foundry_evm/executors/fuzz/
mod.rs

1use crate::executors::{
2    DURATION_BETWEEN_METRICS_REPORT, EarlyExit, Executor, FuzzTestTimer, RawCallResult,
3    corpus::{GlobalCorpusMetrics, WorkerCorpus},
4};
5use alloy_dyn_abi::JsonAbiExt;
6use alloy_json_abi::Function;
7use alloy_primitives::{Address, Bytes, Log, U256, keccak256, map::HashMap};
8use eyre::Result;
9use foundry_common::sh_println;
10use foundry_config::FuzzConfig;
11use foundry_evm_core::{
12    Breakpoints,
13    constants::{CHEATCODE_ADDRESS, MAGIC_ASSUME},
14    decode::{RevertDecoder, SkipReason},
15};
16use foundry_evm_coverage::HitMaps;
17use foundry_evm_fuzz::{
18    BaseCounterExample, BasicTxDetails, CallDetails, CounterExample, FuzzCase, FuzzError,
19    FuzzFixtures, FuzzTestResult,
20    strategies::{EvmFuzzState, fuzz_calldata, fuzz_calldata_from_state},
21};
22use foundry_evm_traces::SparsedTraceArena;
23use indicatif::ProgressBar;
24use proptest::{
25    strategy::Strategy,
26    test_runner::{RngAlgorithm, TestCaseError, TestRng, TestRunner},
27};
28use rayon::iter::{IntoParallelIterator, ParallelIterator};
29use serde_json::json;
30use std::{
31    sync::{
32        Arc, OnceLock,
33        atomic::{AtomicU32, Ordering},
34    },
35    time::{Instant, SystemTime, UNIX_EPOCH},
36};
37
38mod types;
39pub use types::{CaseOutcome, CounterExampleOutcome, FuzzOutcome};
40
41/// Corpus syncs across workers every `SYNC_INTERVAL` runs.
42const SYNC_INTERVAL: u32 = 1000;
43
44/// Minimum number of runs per worker.
45/// This is mainly to reduce the overall number of rayon jobs.
46const MIN_RUNS_PER_WORKER: u32 = 64;
47
48#[derive(Default)]
49struct WorkerState {
50    /// Worker identifier
51    id: usize,
52    /// First fuzz case this worker encountered (with global run number)
53    first_case: Option<(u32, FuzzCase)>,
54    /// Gas usage for all cases this worker ran
55    gas_by_case: Vec<(u64, u64)>,
56    /// Counterexample if this worker found one
57    counterexample: (Bytes, RawCallResult),
58    /// Traces collected by this worker
59    ///
60    /// Stores up to `max_traces_to_collect` which is `config.gas_report_samples / num_workers`
61    traces: Vec<SparsedTraceArena>,
62    /// Last breakpoints from this worker
63    breakpoints: Option<Breakpoints>,
64    /// Coverage collected by this worker
65    coverage: Option<HitMaps>,
66    /// Logs from all cases this worker ran
67    logs: Vec<Log>,
68    /// Deprecated cheatcodes seen by this worker
69    deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>,
70    /// Number of runs this worker completed
71    runs: u32,
72    /// Failure reason if this worker failed
73    failure: Option<TestCaseError>,
74    /// Last run timestamp in milliseconds
75    ///
76    /// Used to identify which worker ran last and collect its traces and call breakpoints
77    last_run_timestamp: u128,
78    /// Failed corpus replays
79    failed_corpus_replays: usize,
80}
81
82impl WorkerState {
83    fn new(worker_id: usize) -> Self {
84        Self { id: worker_id, ..Default::default() }
85    }
86}
87
88/// Shared state for coordinating parallel fuzz workers
89struct SharedFuzzState {
90    state: EvmFuzzState,
91    /// Total runs across workers
92    total_runs: Arc<AtomicU32>,
93    /// Found failure
94    ///
95    /// The worker that found the failure sets it's ID.
96    ///
97    /// This ID is then used to correctly extract the failure reason and counterexample.
98    failed_worker_id: OnceLock<usize>,
99    /// Total rejects across workers
100    total_rejects: Arc<AtomicU32>,
101    /// Fuzz timer
102    timer: FuzzTestTimer,
103    /// Global corpus metrics
104    global_corpus_metrics: GlobalCorpusMetrics,
105
106    /// Global test suite early exit.
107    global_early_exit: EarlyExit,
108    /// Local fuzz early exit.
109    local_early_exit: EarlyExit,
110}
111
112impl SharedFuzzState {
113    fn new(state: EvmFuzzState, timeout: Option<u32>, early_exit: EarlyExit) -> Self {
114        Self {
115            state,
116            total_runs: Arc::new(AtomicU32::new(0)),
117            failed_worker_id: OnceLock::new(),
118            total_rejects: Arc::new(AtomicU32::new(0)),
119            timer: FuzzTestTimer::new(timeout),
120            global_corpus_metrics: GlobalCorpusMetrics::default(),
121            global_early_exit: early_exit,
122            local_early_exit: EarlyExit::new(true),
123        }
124    }
125
126    /// Increments the number of runs and returns the new value.
127    fn increment_runs(&self) -> u32 {
128        self.total_runs.fetch_add(1, Ordering::Relaxed) + 1
129    }
130
131    /// Increments and returns the new value of the number of rejected tests.
132    fn increment_rejects(&self) -> u32 {
133        self.total_rejects.fetch_add(1, Ordering::Relaxed) + 1
134    }
135
136    /// Returns `true` if the worker should continue running.
137    fn should_continue(&self) -> bool {
138        !(self.global_early_exit.should_stop()
139            || self.local_early_exit.should_stop()
140            || self.timer.is_timed_out())
141    }
142
143    /// Returns true if the worker was able to claim the failure, false if failure was set by
144    /// another worker
145    fn try_claim_failure(&self, worker_id: usize) -> bool {
146        let mut claimed = false;
147        let _ = self.failed_worker_id.get_or_init(|| {
148            claimed = true;
149            self.local_early_exit.record_failure();
150            worker_id
151        });
152        claimed
153    }
154}
155
156/// Wrapper around an [`Executor`] which provides fuzzing support using [`proptest`].
157///
158/// After instantiation, calling `fuzz` will proceed to hammer the deployed smart contract with
159/// inputs, until it finds a counterexample. The provided [`TestRunner`] contains all the
160/// configuration which can be overridden via [environment variables](proptest::test_runner::Config)
161pub struct FuzzedExecutor {
162    /// The EVM executor.
163    executor_f: Executor,
164    /// The fuzzer
165    runner: TestRunner,
166    /// The account that calls tests.
167    sender: Address,
168    /// The fuzz configuration.
169    config: FuzzConfig,
170    /// The persisted counterexample to be replayed, if any.
171    persisted_failure: Option<BaseCounterExample>,
172    /// The number of parallel workers.
173    num_workers: usize,
174}
175
176impl FuzzedExecutor {
177    /// Instantiates a fuzzed executor given a testrunner
178    pub fn new(
179        executor: Executor,
180        runner: TestRunner,
181        sender: Address,
182        config: FuzzConfig,
183        persisted_failure: Option<BaseCounterExample>,
184    ) -> Self {
185        let mut max_workers = Ord::max(1, config.runs / MIN_RUNS_PER_WORKER);
186        if config.runs == 0 {
187            max_workers = 0;
188        }
189        let num_workers = Ord::min(rayon::current_num_threads(), max_workers as usize);
190        Self { executor_f: executor, runner, sender, config, persisted_failure, num_workers }
191    }
192
193    /// Fuzzes the provided function, assuming it is available at the contract at `address`
194    /// If `should_fail` is set to `true`, then it will stop only when there's a success
195    /// test case.
196    ///
197    /// Returns a list of all the consumed gas and calldata of every fuzz case.
198    #[allow(clippy::too_many_arguments)]
199    pub fn fuzz(
200        &mut self,
201        func: &Function,
202        fuzz_fixtures: &FuzzFixtures,
203        state: EvmFuzzState,
204        address: Address,
205        rd: &RevertDecoder,
206        progress: Option<&ProgressBar>,
207        early_exit: &EarlyExit,
208        tokio_handle: &tokio::runtime::Handle,
209    ) -> Result<FuzzTestResult> {
210        let shared_state = SharedFuzzState::new(state, self.config.timeout, early_exit.clone());
211
212        debug!(n = self.num_workers, "spawning workers");
213        let workers = (0..self.num_workers)
214            .into_par_iter()
215            .map(|worker_id| {
216                let _guard = tokio_handle.enter();
217                let _guard = info_span!("fuzz_worker", id = worker_id).entered();
218                let timer = Instant::now();
219                let r = self.run_worker(
220                    worker_id,
221                    func,
222                    fuzz_fixtures,
223                    address,
224                    rd,
225                    &shared_state,
226                    progress,
227                );
228                debug!("finished in {:?}", timer.elapsed());
229                r
230            })
231            .collect::<Result<Vec<_>>>()?;
232
233        Ok(self.aggregate_results(workers, func, &shared_state))
234    }
235
236    /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome`
237    /// or a `CounterExampleOutcome`
238    fn single_fuzz(
239        &self,
240        executor: &Executor,
241        address: Address,
242        calldata: Bytes,
243        coverage_metrics: &mut WorkerCorpus,
244    ) -> Result<FuzzOutcome, TestCaseError> {
245        let mut call = executor
246            .call_raw(self.sender, address, calldata.clone(), U256::ZERO)
247            .map_err(|e| TestCaseError::fail(e.to_string()))?;
248        let new_coverage = coverage_metrics.merge_edge_coverage(&mut call);
249        coverage_metrics.process_inputs(
250            &[BasicTxDetails {
251                warp: None,
252                roll: None,
253                sender: self.sender,
254                call_details: CallDetails { target: address, calldata: calldata.clone() },
255            }],
256            new_coverage,
257        );
258
259        // Handle `vm.assume`.
260        if call.result.as_ref() == MAGIC_ASSUME {
261            return Err(TestCaseError::reject(FuzzError::AssumeReject));
262        }
263
264        let (breakpoints, deprecated_cheatcodes) =
265            call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| {
266                (cheats.breakpoints.clone(), cheats.deprecated.clone())
267            });
268
269        // Consider call success if test should not fail on reverts and reverter is not the
270        // cheatcode or test address.
271        let success = if !self.config.fail_on_revert
272            && call
273                .reverter
274                .is_some_and(|reverter| reverter != address && reverter != CHEATCODE_ADDRESS)
275        {
276            true
277        } else {
278            executor.is_raw_call_mut_success(address, &mut call, false)
279        };
280
281        if success {
282            Ok(FuzzOutcome::Case(CaseOutcome {
283                case: FuzzCase { calldata, gas: call.gas_used, stipend: call.stipend },
284                traces: call.traces,
285                coverage: call.line_coverage,
286                breakpoints,
287                logs: call.logs,
288                deprecated_cheatcodes,
289            }))
290        } else {
291            Ok(FuzzOutcome::CounterExample(CounterExampleOutcome {
292                exit_reason: call.exit_reason,
293                counterexample: (calldata, call),
294                breakpoints,
295            }))
296        }
297    }
298
299    /// Aggregates the results from all workers
300    fn aggregate_results(
301        &self,
302        mut workers: Vec<WorkerState>,
303        func: &Function,
304        shared_state: &SharedFuzzState,
305    ) -> FuzzTestResult {
306        let mut result = FuzzTestResult::default();
307        if workers.is_empty() {
308            result.success = true;
309            return result;
310        }
311
312        // Find first case and last run worker. Set `failed_corpus_replays`.
313        let mut first_case_candidate = None;
314        let mut last_run_worker = None;
315        for (i, worker) in workers.iter().enumerate() {
316            if let Some((run, ref case)) = worker.first_case
317                && first_case_candidate.as_ref().is_none_or(|&(r, _)| run < r)
318            {
319                first_case_candidate = Some((run, case.clone()));
320            }
321
322            if last_run_worker.is_none_or(|(t, _)| worker.last_run_timestamp > t) {
323                last_run_worker = Some((worker.last_run_timestamp, i));
324            }
325
326            // Only set replays from master which is responsible for replaying persisted corpus.
327            if worker.id == 0 {
328                result.failed_corpus_replays = worker.failed_corpus_replays;
329            }
330        }
331        result.first_case = first_case_candidate.map(|(_, case)| case).unwrap_or_default();
332        let (_, last_run_worker_idx) = last_run_worker.expect("at least one worker");
333
334        if let Some(&failed_worker_id) = shared_state.failed_worker_id.get() {
335            result.success = false;
336
337            let failed_worker_idx = workers.iter().position(|w| w.id == failed_worker_id).unwrap();
338            let failed_worker = &mut workers[failed_worker_idx];
339
340            let (calldata, call) = std::mem::take(&mut failed_worker.counterexample);
341            result.labels = call.labels;
342            result.traces = call.traces.clone();
343            result.breakpoints = call.cheatcodes.map(|c| c.breakpoints);
344
345            match &failed_worker.failure {
346                Some(TestCaseError::Fail(reason)) => {
347                    let reason = reason.to_string();
348                    result.reason = (!reason.is_empty()).then_some(reason);
349                    let args = if let Some(data) = calldata.get(4..) {
350                        func.abi_decode_input(data).unwrap_or_default()
351                    } else {
352                        vec![]
353                    };
354                    result.counterexample = Some(CounterExample::Single(
355                        BaseCounterExample::from_fuzz_call(calldata, args, call.traces),
356                    ));
357                }
358                Some(TestCaseError::Reject(reason)) => {
359                    let reason = reason.to_string();
360                    result.reason = (!reason.is_empty()).then_some(reason);
361                }
362                None => {}
363            }
364        } else {
365            let last_run_worker = &workers[last_run_worker_idx];
366            result.success = true;
367            result.traces = last_run_worker.traces.last().cloned();
368            result.breakpoints = last_run_worker.breakpoints.clone();
369        }
370
371        if !self.config.show_logs {
372            result.logs = workers[last_run_worker_idx].logs.clone();
373        }
374
375        for mut worker in workers {
376            result.gas_by_case.append(&mut worker.gas_by_case);
377            if self.config.show_logs {
378                result.logs.append(&mut worker.logs);
379            }
380            result.gas_report_traces.extend(worker.traces.into_iter().map(|t| t.arena));
381            HitMaps::merge_opt(&mut result.line_coverage, worker.coverage);
382            result.deprecated_cheatcodes.extend(worker.deprecated_cheatcodes);
383        }
384
385        if let Some(reason) = &result.reason
386            && let Some(reason) = SkipReason::decode_self(reason)
387        {
388            result.skipped = true;
389            result.reason = reason.0;
390        }
391
392        result
393    }
394
395    /// Runs a single fuzz worker
396    #[allow(clippy::too_many_arguments)]
397    fn run_worker(
398        &self,
399        worker_id: usize,
400        func: &Function,
401        fuzz_fixtures: &FuzzFixtures,
402        address: Address,
403        rd: &RevertDecoder,
404        shared_state: &SharedFuzzState,
405        progress: Option<&ProgressBar>,
406    ) -> Result<WorkerState> {
407        // Prepare
408        let dictionary_weight = self.config.dictionary.dictionary_weight.min(100);
409        let strategy = proptest::prop_oneof![
410            100 - dictionary_weight => fuzz_calldata(func.clone(), fuzz_fixtures),
411            dictionary_weight => fuzz_calldata_from_state(func.clone(), &shared_state.state),
412        ]
413        .prop_map(move |calldata| BasicTxDetails {
414            warp: None,
415            roll: None,
416            sender: Default::default(),
417            call_details: CallDetails { target: Default::default(), calldata },
418        });
419
420        let mut corpus = WorkerCorpus::new(
421            worker_id,
422            self.config.corpus.clone(),
423            strategy.boxed(),
424            // Master worker replays the persisted corpus using the executor
425            if worker_id == 0 { Some(&self.executor_f) } else { None },
426            Some(func),
427            None, // fuzzed_contracts for invariant tests
428        )?;
429        let mut executor = self.executor_f.clone();
430
431        let mut worker = WorkerState::new(worker_id);
432        // We want to collect at least one trace which will be displayed to user.
433        let max_traces_to_collect =
434            std::cmp::max(1, self.config.gas_report_samples / self.num_workers as u32);
435
436        let worker_runs = self.runs_per_worker(worker_id);
437        debug!(worker_runs);
438
439        let mut runner_config = self.runner.config().clone();
440        runner_config.cases = worker_runs;
441
442        let mut runner = if let Some(seed) = self.config.seed {
443            // For deterministic parallel fuzzing, derive a unique seed for each worker
444            let worker_seed = if worker_id == 0 {
445                // Master worker uses the provided seed as is.
446                seed
447            } else {
448                // Derive a worker-specific seed using keccak256(seed || worker_id)
449                let seed_data =
450                    [&seed.to_be_bytes::<32>()[..], &worker_id.to_be_bytes()[..]].concat();
451                U256::from_be_bytes(keccak256(seed_data).0)
452            };
453            trace!(target: "forge::test", ?worker_seed, "deterministic seed for worker {worker_id}");
454            let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &worker_seed.to_be_bytes::<32>());
455            TestRunner::new_with_rng(runner_config, rng)
456        } else {
457            TestRunner::new(runner_config)
458        };
459
460        let mut persisted_failure = self.persisted_failure.as_ref().filter(|_| worker_id == 0);
461
462        // Offset to stagger corpus syncs across workers; so that workers don't sync at the same
463        // time.
464        let sync_offset = worker_id as u32 * 100;
465        let sync_threshold = SYNC_INTERVAL + sync_offset;
466        let mut runs_since_sync = sync_threshold; // Always sync at the start.
467        let mut last_metrics_report = Instant::now();
468        // Continue while:
469        // 1. Global state allows (not timed out, not at global limit, no failure found)
470        // 2. Worker hasn't reached its specific run limit
471        'stop: while shared_state.should_continue() && worker.runs < worker_runs {
472            // If counterexample recorded, replay it first, without incrementing runs.
473            let input = if worker_id == 0
474                && let Some(failure) = persisted_failure.take()
475                && failure.calldata.get(..4).is_some_and(|selector| func.selector() == selector)
476            {
477                failure.calldata.clone()
478            } else {
479                runs_since_sync += 1;
480                if runs_since_sync >= sync_threshold {
481                    let timer = Instant::now();
482                    corpus.sync(
483                        self.num_workers,
484                        &executor,
485                        Some(func),
486                        None,
487                        &shared_state.global_corpus_metrics,
488                    )?;
489                    trace!("finished corpus sync in {:?}", timer.elapsed());
490                    runs_since_sync = 0;
491                }
492
493                if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut()
494                    && let Some(seed) = self.config.seed
495                {
496                    cheats.set_seed(seed.wrapping_add(U256::from(worker.runs)));
497                }
498
499                match corpus.new_input(&mut runner, &shared_state.state, func) {
500                    Ok(input) => input,
501                    Err(err) => {
502                        worker.failure = Some(TestCaseError::fail(format!(
503                            "failed to generate fuzzed input in worker {}: {err}",
504                            worker.id
505                        )));
506                        shared_state.try_claim_failure(worker_id);
507                        break 'stop;
508                    }
509                }
510            };
511
512            let mut inc_runs = || {
513                let total_runs = shared_state.increment_runs();
514                debug_assert!(
515                    shared_state.timer.is_enabled() || total_runs <= self.config.runs,
516                    "worker runs were not distributed correctly"
517                );
518                worker.runs += 1;
519                if let Some(progress) = progress {
520                    progress.inc(1);
521                }
522                total_runs
523            };
524
525            worker.last_run_timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
526            match self.single_fuzz(&executor, address, input, &mut corpus) {
527                Ok(fuzz_outcome) => match fuzz_outcome {
528                    FuzzOutcome::Case(case) => {
529                        let total_runs = inc_runs();
530
531                        if worker_id == 0 && self.config.corpus.collect_edge_coverage() {
532                            if let Some(progress) = progress {
533                                corpus.sync_metrics(&shared_state.global_corpus_metrics);
534                                progress
535                                    .set_message(format!("{}", shared_state.global_corpus_metrics));
536                            } else if last_metrics_report.elapsed()
537                                > DURATION_BETWEEN_METRICS_REPORT
538                            {
539                                corpus.sync_metrics(&shared_state.global_corpus_metrics);
540                                // Display metrics inline.
541                                let metrics = json!({
542                                    "timestamp": SystemTime::now()
543                                        .duration_since(UNIX_EPOCH)?
544                                        .as_secs(),
545                                    "test": func.name,
546                                    "metrics": shared_state.global_corpus_metrics.load(),
547                                });
548                                let _ = sh_println!("{metrics}");
549                                last_metrics_report = Instant::now();
550                            }
551                        }
552
553                        worker.gas_by_case.push((case.case.gas, case.case.stipend));
554
555                        if worker.first_case.is_none() {
556                            worker.first_case = Some((total_runs, case.case));
557                        }
558
559                        if let Some(call_traces) = case.traces {
560                            if worker.traces.len() == max_traces_to_collect as usize {
561                                worker.traces.pop();
562                            }
563                            worker.traces.push(call_traces);
564                            worker.breakpoints = Some(case.breakpoints);
565                        }
566
567                        // Always store logs from the last run in test_data.logs for display at
568                        // verbosity >= 2. When show_logs is true,
569                        // accumulate all logs. When false, only keep the last run's logs.
570                        if self.config.show_logs {
571                            worker.logs.extend(case.logs);
572                        } else {
573                            worker.logs = case.logs;
574                        }
575
576                        HitMaps::merge_opt(&mut worker.coverage, case.coverage);
577                        worker.deprecated_cheatcodes = case.deprecated_cheatcodes;
578                    }
579                    FuzzOutcome::CounterExample(CounterExampleOutcome {
580                        exit_reason: status,
581                        counterexample: outcome,
582                        ..
583                    }) => {
584                        inc_runs();
585
586                        let reason = rd.maybe_decode(&outcome.1.result, status);
587                        worker.logs.extend(outcome.1.logs.clone());
588                        worker.counterexample = outcome;
589                        worker.failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
590                        shared_state.try_claim_failure(worker_id);
591                        break 'stop;
592                    }
593                },
594                Err(err) => match err {
595                    TestCaseError::Fail(_) => {
596                        worker.failure = Some(err);
597                        shared_state.try_claim_failure(worker_id);
598                        break 'stop;
599                    }
600                    TestCaseError::Reject(_) => {
601                        let max = self.config.max_test_rejects;
602
603                        let total = shared_state.increment_rejects();
604
605                        // Update progress bar to reflect rejected runs.
606                        // TODO(dani): (pre-existing) conflicts with corpus metrics `set_message`
607                        if !self.config.corpus.collect_edge_coverage()
608                            && let Some(progress) = progress
609                        {
610                            progress.set_message(format!("([{total}] rejected)"));
611                        }
612
613                        if max > 0 && total > max {
614                            worker.failure =
615                                Some(TestCaseError::reject(FuzzError::TooManyRejects(max)));
616                            shared_state.try_claim_failure(worker_id);
617                            break 'stop;
618                        }
619                    }
620                },
621            }
622        }
623
624        if worker_id == 0 {
625            worker.failed_corpus_replays = corpus.failed_replays;
626        }
627
628        // Logs stats
629        trace!("worker {worker_id} fuzz stats");
630        shared_state.state.log_stats();
631
632        Ok(worker)
633    }
634
635    /// Determines the number of runs per worker.
636    fn runs_per_worker(&self, worker_id: usize) -> u32 {
637        let worker_id = worker_id as u32;
638        let total_runs = self.config.runs;
639        let n = self.num_workers as u32;
640        let runs = total_runs / n;
641        let remainder = total_runs % n;
642        // Distribute the remainder evenly among the first `remainder` workers,
643        // assuming `worker_id` is in `0..n`.
644        if worker_id < remainder { runs + 1 } else { runs }
645    }
646}