Skip to main content

foundry_evm/executors/fuzz/
mod.rs

1use crate::executors::{
2    DURATION_BETWEEN_METRICS_REPORT, EarlyExit, Executor, FuzzTestTimer, RawCallResult,
3    corpus::{
4        CorpusSyncCoordinator, GlobalCorpusMetrics, ReplayTarget, StatelessReplayTarget,
5        WorkerCorpus,
6    },
7};
8use alloy_dyn_abi::JsonAbiExt;
9use alloy_json_abi::Function;
10use alloy_primitives::{
11    Address, Bytes, Log, U256, keccak256,
12    map::{AddressHashMap, HashMap},
13};
14use eyre::Result;
15use foundry_common::sh_println;
16use foundry_config::FuzzConfig;
17use foundry_evm_core::{
18    Breakpoints,
19    constants::{CHEATCODE_ADDRESS, MAGIC_ASSUME},
20    decode::{RevertDecoder, SkipReason},
21    evm::FoundryEvmNetwork,
22};
23use foundry_evm_coverage::HitMaps;
24use foundry_evm_fuzz::{
25    BaseCounterExample, BasicTxDetails, CallDetails, CounterExample, FuzzCase, FuzzError,
26    FuzzFixtures, FuzzRunMetadata, FuzzTestResult,
27    strategies::{EvmFuzzState, TxGenerator},
28};
29use foundry_evm_traces::SparsedTraceArena;
30use indicatif::ProgressBar;
31use proptest::test_runner::{RngAlgorithm, TestCaseError, TestRng, TestRunner};
32use rayon::iter::{IntoParallelIterator, ParallelIterator};
33use serde_json::json;
34use std::{
35    sync::{
36        Arc, OnceLock,
37        atomic::{AtomicU32, Ordering},
38    },
39    time::{Instant, SystemTime, UNIX_EPOCH},
40};
41
42mod frontier;
43mod types;
44use frontier::{FuzzBranchFrontier, FuzzBranchFrontierArtifact, FuzzFrontierRecorder};
45pub use types::{CaseOutcome, CounterExampleOutcome, FuzzOutcome};
46
47/// Corpus syncs across workers every `SYNC_INTERVAL` runs.
48const SYNC_INTERVAL: u32 = 1000;
49
50/// Minimum number of runs per worker.
51/// This is mainly to reduce the overall number of rayon jobs.
52const MIN_RUNS_PER_WORKER: u32 = 64;
53
54struct WorkerState<FEN: FoundryEvmNetwork> {
55    /// Worker identifier
56    id: usize,
57    /// First fuzz case this worker encountered (with global run number)
58    first_case: Option<(u32, FuzzCase)>,
59    /// Gas usage for all cases this worker ran
60    gas_by_case: Vec<(u64, u64)>,
61    /// Counterexample if this worker found one
62    counterexample: Option<(BasicTxDetails, RawCallResult<FEN>)>,
63    /// Traces collected by this worker
64    ///
65    /// Stores up to `max_traces_to_collect` which is `config.gas_report_samples / num_workers`
66    traces: Vec<SparsedTraceArena>,
67    /// Runtime bytecodes for the last collected trace.
68    debug_bytecodes: AddressHashMap<Bytes>,
69    /// Last breakpoints from this worker
70    breakpoints: Option<Breakpoints>,
71    /// Coverage collected by this worker
72    coverage: Option<HitMaps>,
73    /// Logs from all cases this worker ran
74    logs: Vec<Log>,
75    /// Deprecated cheatcodes seen by this worker
76    deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>,
77    /// Number of runs this worker completed
78    runs: u32,
79    /// Failure reason if this worker failed
80    failure: Option<TestCaseError>,
81    /// Fuzz run metadata that produced the failure.
82    failure_run: Option<FuzzRunMetadata>,
83    /// Last run timestamp in milliseconds
84    ///
85    /// Used to identify which worker ran last and collect its traces and call breakpoints
86    last_run_timestamp: u128,
87    /// Failed corpus replays
88    failed_corpus_replays: usize,
89    /// Branch frontiers captured for symbolic follow-up.
90    frontiers: Vec<FuzzBranchFrontier>,
91}
92
93impl<FEN: FoundryEvmNetwork> WorkerState<FEN> {
94    fn new(worker_id: usize) -> Self {
95        Self {
96            id: worker_id,
97            first_case: None,
98            gas_by_case: Vec::new(),
99            counterexample: None,
100            traces: Vec::new(),
101            debug_bytecodes: HashMap::default(),
102            breakpoints: None,
103            coverage: None,
104            logs: Vec::new(),
105            deprecated_cheatcodes: HashMap::default(),
106            runs: 0,
107            failure: None,
108            failure_run: None,
109            last_run_timestamp: 0,
110            failed_corpus_replays: 0,
111            frontiers: Vec::new(),
112        }
113    }
114}
115
116/// Shared state for coordinating parallel fuzz workers
117struct SharedFuzzState {
118    state: EvmFuzzState,
119    /// Total runs across workers
120    total_runs: Arc<AtomicU32>,
121    /// Found failure
122    ///
123    /// The worker that found the failure sets it's ID.
124    ///
125    /// This ID is then used to correctly extract the failure reason and counterexample.
126    failed_worker_id: OnceLock<usize>,
127    /// Total rejects across workers
128    total_rejects: Arc<AtomicU32>,
129    /// Fuzz timer
130    timer: FuzzTestTimer,
131    /// Global corpus metrics
132    global_corpus_metrics: GlobalCorpusMetrics,
133
134    /// Global test suite early exit.
135    global_early_exit: EarlyExit,
136    /// Local fuzz early exit.
137    local_early_exit: EarlyExit,
138}
139
140impl SharedFuzzState {
141    fn new(state: EvmFuzzState, timeout: Option<u32>, early_exit: EarlyExit) -> Self {
142        Self {
143            state,
144            total_runs: Arc::new(AtomicU32::new(0)),
145            failed_worker_id: OnceLock::new(),
146            total_rejects: Arc::new(AtomicU32::new(0)),
147            timer: FuzzTestTimer::new(timeout),
148            global_corpus_metrics: GlobalCorpusMetrics::default(),
149            global_early_exit: early_exit,
150            local_early_exit: EarlyExit::new(true),
151        }
152    }
153
154    /// Increments the number of runs and returns the new value.
155    fn increment_runs(&self) -> u32 {
156        self.total_runs.fetch_add(1, Ordering::Relaxed) + 1
157    }
158
159    /// Increments and returns the new value of the number of rejected tests.
160    fn increment_rejects(&self) -> u32 {
161        self.total_rejects.fetch_add(1, Ordering::Relaxed) + 1
162    }
163
164    /// Returns `true` if the worker should continue running.
165    fn should_continue(&self) -> bool {
166        !(self.global_early_exit.should_stop()
167            || self.local_early_exit.should_stop()
168            || self.timer.is_timed_out())
169    }
170
171    /// Returns true if the worker was able to claim the failure, false if failure was set by
172    /// another worker
173    fn try_claim_failure(&self, worker_id: usize) -> bool {
174        let mut claimed = false;
175        let _ = self.failed_worker_id.get_or_init(|| {
176            claimed = true;
177            self.local_early_exit.record_failure();
178            worker_id
179        });
180        claimed
181    }
182}
183
184/// Wrapper around an [`Executor`] which provides fuzzing support using [`proptest`].
185///
186/// After instantiation, calling `fuzz` will proceed to hammer the deployed smart contract with
187/// inputs, until it finds a counterexample. The provided [`TestRunner`] contains all the
188/// configuration which can be overridden via [environment variables](proptest::test_runner::Config)
189pub struct FuzzedExecutor<FEN: FoundryEvmNetwork> {
190    /// The EVM executor.
191    executor_f: Executor<FEN>,
192    /// The fuzzer
193    runner: TestRunner,
194    /// The account that calls tests.
195    sender: Address,
196    /// The fuzz configuration.
197    config: FuzzConfig,
198    /// The persisted counterexample to be replayed, if any.
199    persisted_failure: Option<BaseCounterExample>,
200    /// The number of parallel workers.
201    num_workers: usize,
202}
203
204impl<FEN: FoundryEvmNetwork> FuzzedExecutor<FEN> {
205    /// Instantiates a fuzzed executor given a testrunner
206    pub fn new(
207        executor: Executor<FEN>,
208        runner: TestRunner,
209        sender: Address,
210        config: FuzzConfig,
211        persisted_failure: Option<BaseCounterExample>,
212    ) -> Self {
213        let run_limit = if config.run.is_some() { 1 } else { config.runs };
214        let max_workers = if run_limit == 0 {
215            0
216        } else if config.run.is_some() {
217            1
218        } else {
219            Ord::max(1, run_limit / MIN_RUNS_PER_WORKER)
220        };
221        let num_workers = Ord::min(rayon::current_num_threads(), max_workers as usize);
222        Self { executor_f: executor, runner, sender, config, persisted_failure, num_workers }
223    }
224
225    /// Fuzzes the provided function, assuming it is available at the contract at `address`
226    /// If `should_fail` is set to `true`, then it will stop only when there's a success
227    /// test case.
228    ///
229    /// Returns a list of all the consumed gas and calldata of every fuzz case.
230    #[allow(clippy::too_many_arguments)]
231    pub fn fuzz(
232        &mut self,
233        func: &Function,
234        fuzz_fixtures: &FuzzFixtures,
235        state: EvmFuzzState,
236        address: Address,
237        rd: &RevertDecoder,
238        progress: Option<&ProgressBar>,
239        early_exit: &EarlyExit,
240        tokio_handle: &tokio::runtime::Handle,
241    ) -> Result<FuzzTestResult> {
242        let shared_state = SharedFuzzState::new(state, self.config.timeout, early_exit.clone());
243
244        let worker_ids = self.worker_ids();
245        debug!(n = worker_ids.len(), "spawning workers");
246        let final_sync = (worker_ids.len() > 1 && self.config.corpus.corpus_dir.is_some())
247            .then(|| Arc::new(CorpusSyncCoordinator::new(worker_ids.len())));
248        let workers = worker_ids
249            .into_par_iter()
250            .map(|worker_id| {
251                let _guard = tokio_handle.enter();
252                let _guard = info_span!("fuzz_worker", id = worker_id).entered();
253                let timer = Instant::now();
254                let r = self.run_worker(
255                    worker_id,
256                    func,
257                    fuzz_fixtures,
258                    address,
259                    rd,
260                    &shared_state,
261                    progress,
262                    final_sync.as_deref(),
263                );
264                if r.is_err()
265                    && let Some(final_sync) = &final_sync
266                {
267                    final_sync.abort();
268                }
269                debug!("finished in {:?}", timer.elapsed());
270                r
271            })
272            .collect::<Result<Vec<_>>>()?;
273
274        Ok(self.aggregate_results(workers, func, &shared_state))
275    }
276
277    /// Replays the persisted single-call counterexample exactly once.
278    ///
279    /// Unlike [`Self::fuzz`], this never falls through to generated inputs if the persisted
280    /// calldata now succeeds or is rejected by `vm.assume`.
281    pub fn replay_persisted_failure(
282        &mut self,
283        func: &Function,
284        address: Address,
285        rd: &RevertDecoder,
286    ) -> Result<FuzzTestResult> {
287        let Some(failure) = self.persisted_failure.as_ref() else {
288            return Ok(FuzzTestResult {
289                skipped: true,
290                reason: Some("no persisted fuzz failure to replay".to_string()),
291                ..Default::default()
292            });
293        };
294
295        let seed = failure.fuzz.seed.or(self.config.seed);
296        if let Some(cheats) = self.executor_f.inspector_mut().cheatcodes.as_mut()
297            && let Some(seed) = seed
298        {
299            let run = failure.fuzz.run.unwrap_or(1);
300            let worker = failure.fuzz.worker.unwrap_or(0) as usize;
301            cheats.set_seed(Self::fuzz_run_seed(seed, worker, run));
302        }
303
304        let mut tx = BasicTxDetails {
305            warp: None,
306            roll: None,
307            sender: self.sender,
308            call_details: CallDetails {
309                target: address,
310                calldata: failure.calldata.clone(),
311                value: failure.value,
312            },
313        };
314        self.resolve_stateless_tx_with_executor(&self.executor_f, &mut tx)?;
315        let mut call = self.executor_f.call_raw(
316            tx.sender,
317            tx.call_details.target,
318            tx.call_details.calldata.clone(),
319            tx.call_details.value.unwrap_or_default(),
320        )?;
321        if call.result.as_ref() == MAGIC_ASSUME {
322            return Ok(FuzzTestResult {
323                skipped: true,
324                reason: Some("persisted fuzz failure rejected by `vm.assume`".to_string()),
325                ..Default::default()
326            });
327        }
328        if call.reverter == Some(CHEATCODE_ADDRESS)
329            && let Some(reason) = SkipReason::decode(&call.result)
330        {
331            return Ok(FuzzTestResult { skipped: true, reason: reason.0, ..Default::default() });
332        }
333
334        let (breakpoints, deprecated_cheatcodes) =
335            call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| {
336                (cheats.breakpoints.clone(), cheats.deprecated.clone())
337            });
338        let success = if !self.config.fail_on_revert
339            && call
340                .reverter
341                .is_some_and(|reverter| reverter != address && reverter != CHEATCODE_ADDRESS)
342        {
343            true
344        } else {
345            self.executor_f.is_raw_call_mut_success(address, &mut call, false)
346        };
347
348        let mut result = FuzzTestResult {
349            success,
350            labels: call.labels.clone(),
351            traces: call.traces.clone(),
352            debug_bytecodes: call.debug_bytecodes.clone(),
353            breakpoints: Some(breakpoints),
354            deprecated_cheatcodes,
355            ..Default::default()
356        };
357
358        if success {
359            result.first_case = FuzzCase { gas: call.gas_used, stipend: call.stipend };
360            result.gas_by_case.push((call.gas_used, call.stipend));
361            result.line_coverage = call.line_coverage;
362            result.logs = call.logs;
363            result.gas_report_traces.extend(call.traces.into_iter().map(|trace| trace.arena));
364        } else {
365            let reason = if call.reverter == Some(CHEATCODE_ADDRESS) {
366                SkipReason::decode(&call.result)
367                    .map(|reason| reason.to_string())
368                    .or_else(|| rd.maybe_decode(&call.result, call.exit_reason))
369            } else {
370                rd.maybe_decode(&call.result, call.exit_reason)
371            };
372            result.reason = reason;
373            let args = tx
374                .call_details
375                .calldata
376                .get(4..)
377                .map_or_else(Vec::new, |data| func.abi_decode_input(data).unwrap_or_default());
378            result.counterexample = Some(CounterExample::Single(
379                BaseCounterExample::from_fuzz_tx(&tx, args, call.traces).with_fuzz_metadata(
380                    FuzzRunMetadata::new(
381                        seed,
382                        failure.fuzz.run,
383                        Some(failure.fuzz.worker.unwrap_or(0)),
384                    ),
385                ),
386            ));
387            result.logs = call.logs;
388        }
389
390        Ok(result)
391    }
392
393    /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome`
394    /// or a `CounterExampleOutcome`
395    fn single_fuzz(
396        &self,
397        executor: &Executor<FEN>,
398        address: Address,
399        mut tx: BasicTxDetails,
400        coverage_metrics: &mut WorkerCorpus,
401        frontier_recorder: &mut FuzzFrontierRecorder,
402        fuzz_run: Option<&FuzzRunMetadata>,
403    ) -> Result<FuzzOutcome<FEN>, TestCaseError> {
404        tx.sender = self.sender;
405        tx.call_details.target = address;
406        tx.warp = None;
407        tx.roll = None;
408        self.resolve_stateless_tx_with_executor(executor, &mut tx)
409            .map_err(|e| TestCaseError::fail(e.to_string()))?;
410        let mut call = executor
411            .call_raw(
412                tx.sender,
413                tx.call_details.target,
414                tx.call_details.calldata.clone(),
415                tx.call_details.value.unwrap_or_default(),
416            )
417            .map_err(|e| TestCaseError::fail(e.to_string()))?;
418        let cmp_values = call.evm_cmp_values.take().unwrap_or_default();
419        let new_coverage = coverage_metrics.merge_edge_coverage(&mut call);
420        // `new_coverage` is only meaningful when edge coverage is collected; otherwise
421        // `merge_edge_coverage` always returns `false`, so record it as unknown for frontiers.
422        let frontier_new_coverage =
423            self.config.corpus.collect_edge_coverage().then_some(new_coverage);
424        frontier_recorder.capture_stateless_call(fuzz_run, &tx, &cmp_values, frontier_new_coverage);
425        coverage_metrics.process_inputs(&[tx.clone()], &[cmp_values], new_coverage, None);
426
427        // Handle `vm.assume`.
428        if call.result.as_ref() == MAGIC_ASSUME {
429            return Err(TestCaseError::reject(FuzzError::AssumeReject));
430        }
431
432        let (breakpoints, deprecated_cheatcodes) =
433            call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| {
434                (cheats.breakpoints.clone(), cheats.deprecated.clone())
435            });
436
437        // Consider call success if test should not fail on reverts and reverter is not the
438        // cheatcode or test address.
439        let success = if !self.config.fail_on_revert
440            && call
441                .reverter
442                .is_some_and(|reverter| reverter != address && reverter != CHEATCODE_ADDRESS)
443        {
444            true
445        } else {
446            executor.is_raw_call_mut_success(address, &mut call, false)
447        };
448
449        if success {
450            Ok(FuzzOutcome::Case(CaseOutcome {
451                case: FuzzCase { gas: call.gas_used, stipend: call.stipend },
452                traces: call.traces,
453                debug_bytecodes: call.debug_bytecodes,
454                coverage: call.line_coverage,
455                breakpoints,
456                logs: call.logs,
457                deprecated_cheatcodes,
458            }))
459        } else {
460            Ok(FuzzOutcome::CounterExample(CounterExampleOutcome {
461                exit_reason: call.exit_reason,
462                counterexample: (tx, call),
463                breakpoints,
464            }))
465        }
466    }
467
468    fn resolve_stateless_tx_with_executor(
469        &self,
470        executor: &Executor<FEN>,
471        tx: &mut BasicTxDetails,
472    ) -> Result<()> {
473        tx.call_details.value = match tx.call_details.value {
474            Some(requested) if !requested.is_zero() => {
475                let value = requested.min(executor.get_balance(tx.sender)?);
476                (!value.is_zero()).then_some(value)
477            }
478            _ => None,
479        };
480        Ok(())
481    }
482
483    /// Aggregates the results from all workers
484    fn aggregate_results(
485        &self,
486        mut workers: Vec<WorkerState<FEN>>,
487        func: &Function,
488        shared_state: &SharedFuzzState,
489    ) -> FuzzTestResult {
490        self.write_branch_frontiers(&mut workers, func);
491
492        let mut result = FuzzTestResult::default();
493        if workers.is_empty() {
494            result.success = true;
495            return result;
496        }
497
498        // Find first case and last run worker. Set `failed_corpus_replays`.
499        let mut first_case_candidate = None;
500        let mut last_run_worker = None;
501        for (i, worker) in workers.iter().enumerate() {
502            if let Some((run, ref case)) = worker.first_case
503                && first_case_candidate.as_ref().is_none_or(|&(r, _)| run < r)
504            {
505                first_case_candidate = Some((run, case.clone()));
506            }
507
508            if last_run_worker.is_none_or(|(t, _)| worker.last_run_timestamp > t) {
509                last_run_worker = Some((worker.last_run_timestamp, i));
510            }
511
512            // Only set replays from master which is responsible for replaying persisted corpus.
513            if worker.id == 0 {
514                result.failed_corpus_replays = worker.failed_corpus_replays;
515            }
516        }
517        result.first_case = first_case_candidate.map(|(_, case)| case).unwrap_or_default();
518        let (_, last_run_worker_idx) = last_run_worker.expect("at least one worker");
519        let mut output_worker_idx = last_run_worker_idx;
520
521        if let Some(&failed_worker_id) = shared_state.failed_worker_id.get() {
522            result.success = false;
523
524            let failed_worker_idx = workers.iter().position(|w| w.id == failed_worker_id).unwrap();
525            output_worker_idx = failed_worker_idx;
526            let failed_worker = &mut workers[failed_worker_idx];
527
528            let counterexample = failed_worker.counterexample.take();
529            if let Some((_, call)) = &counterexample {
530                result.labels.clone_from(&call.labels);
531                result.traces.clone_from(&call.traces);
532                result.debug_bytecodes.clone_from(&call.debug_bytecodes);
533                result.breakpoints = call.cheatcodes.as_ref().map(|c| c.breakpoints.clone());
534            }
535
536            match &failed_worker.failure {
537                Some(TestCaseError::Fail(reason)) => {
538                    let reason = reason.to_string();
539                    result.reason = (!reason.is_empty()).then_some(reason);
540                    if let Some((tx, call)) = counterexample {
541                        let args =
542                            tx.call_details.calldata.get(4..).map_or_else(Vec::new, |data| {
543                                func.abi_decode_input(data).unwrap_or_default()
544                            });
545                        let fuzz = failed_worker.failure_run.unwrap_or_default();
546                        result.counterexample = Some(CounterExample::Single(
547                            BaseCounterExample::from_fuzz_tx(&tx, args, call.traces)
548                                .with_fuzz_metadata(FuzzRunMetadata::new(
549                                    fuzz.seed.or(self.config.seed),
550                                    fuzz.run,
551                                    fuzz.worker,
552                                )),
553                        ));
554                    }
555                }
556                Some(TestCaseError::Reject(reason)) => {
557                    let reason = reason.to_string();
558                    result.reason = (!reason.is_empty()).then_some(reason);
559                }
560                None => {}
561            }
562        } else {
563            let last_run_worker = &workers[last_run_worker_idx];
564            result.success = true;
565            result.traces = last_run_worker.traces.last().cloned();
566            result.debug_bytecodes.clone_from(&last_run_worker.debug_bytecodes);
567            result.breakpoints = last_run_worker.breakpoints.clone();
568        }
569
570        if !self.config.show_logs {
571            result.logs = workers[output_worker_idx].logs.clone();
572        }
573
574        for mut worker in workers {
575            result.gas_by_case.append(&mut worker.gas_by_case);
576            if self.config.show_logs {
577                result.logs.append(&mut worker.logs);
578            }
579            result.gas_report_traces.extend(worker.traces.into_iter().map(|t| t.arena));
580            HitMaps::merge_opt(&mut result.line_coverage, worker.coverage);
581            result.deprecated_cheatcodes.extend(worker.deprecated_cheatcodes);
582        }
583
584        if let Some(reason) = &result.reason
585            && let Some(reason) = SkipReason::decode_self(reason)
586        {
587            result.skipped = true;
588            result.reason = reason.0;
589        }
590
591        result
592    }
593
594    fn write_branch_frontiers(&self, workers: &mut [WorkerState<FEN>], func: &Function) {
595        let Some(frontier_dir) = &self.config.corpus.frontier_dir else {
596            return;
597        };
598        let limit = self.config.corpus.frontier_limit;
599        if limit == 0 {
600            return;
601        }
602
603        let frontiers = frontier::merge_frontiers(
604            limit,
605            workers.iter_mut().flat_map(|worker| worker.frontiers.drain(..)),
606        );
607        if frontiers.is_empty() {
608            return;
609        }
610
611        let artifact = FuzzBranchFrontierArtifact::new(func, limit, frontiers);
612        if let Err(err) = frontier::write_frontier_artifact(frontier_dir, &artifact) {
613            warn!(%err, path = ?frontier_dir, "failed to write fuzz branch frontier artifact");
614        }
615    }
616
617    /// Runs a single fuzz worker
618    #[allow(clippy::too_many_arguments)]
619    fn run_worker(
620        &self,
621        worker_id: usize,
622        func: &Function,
623        fuzz_fixtures: &FuzzFixtures,
624        address: Address,
625        rd: &RevertDecoder,
626        shared_state: &SharedFuzzState,
627        progress: Option<&ProgressBar>,
628        final_sync: Option<&CorpusSyncCoordinator>,
629    ) -> Result<WorkerState<FEN>> {
630        // Prepare
631        let fuzz_seed = shared_state.state.fork();
632        let generator = TxGenerator::stateless(
633            fuzz_seed.clone(),
634            fuzz_fixtures.clone(),
635            address,
636            self.sender,
637            func.clone(),
638            self.config.dictionary.dictionary_weight,
639            self.config.corpus.payable_value_weight,
640        );
641        let fuzz_state = fuzz_seed.stateless_worker();
642        let generator = foundry_evm_fuzz::sequence::SequenceGenerator::stateless_with_fixtures(
643            generator,
644            fuzz_state.clone(),
645            fuzz_fixtures.clone(),
646            func.clone(),
647            &self.config.corpus,
648        )?;
649
650        let replay_target = ReplayTarget {
651            stateless: Some(StatelessReplayTarget { function: func, address }),
652            fuzzed_contracts: None,
653            dynamic: None,
654        };
655        let mut corpus = WorkerCorpus::new(
656            worker_id,
657            self.config.corpus.clone(),
658            generator,
659            // Master worker replays the persisted corpus using the executor
660            (worker_id == 0).then_some(&self.executor_f),
661            replay_target,
662        )?;
663        let mut executor = self.executor_f.clone();
664        let frontier_limit = if self.config.corpus.capture_branch_frontiers() {
665            self.config.corpus.frontier_limit
666        } else {
667            0
668        };
669        let mut frontier_recorder = FuzzFrontierRecorder::new(frontier_limit);
670
671        let mut worker = WorkerState::new(worker_id);
672        // We want to collect at least one trace which will be displayed to user.
673        let max_traces_to_collect =
674            std::cmp::max(1, self.config.gas_report_samples / self.num_workers as u32);
675
676        let worker_runs = self.runs_per_worker(worker_id);
677        debug!(worker_runs);
678
679        let mut runner_config = self.runner.config().clone();
680        runner_config.cases = worker_runs;
681
682        let mut runner = if let Some(seed) = self.config.seed {
683            let worker_seed = Self::fuzz_worker_seed(seed, worker_id);
684            trace!(target: "forge::test", ?worker_seed, "deterministic seed for worker {worker_id}");
685            let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &worker_seed.to_be_bytes::<32>());
686            TestRunner::new_with_rng(runner_config, rng)
687        } else {
688            TestRunner::new(runner_config)
689        };
690
691        if let Some(target_run) = self.config.run {
692            for _ in 1..target_run {
693                if let Err(err) = corpus.new_sequence(&mut runner) {
694                    worker.failure = Some(TestCaseError::fail(format!(
695                        "failed to generate fuzzed input in worker {}: {err}",
696                        worker.id
697                    )));
698                    shared_state.try_claim_failure(worker_id);
699                    return Ok(worker);
700                }
701            }
702        }
703        let mut generated_inputs = 0;
704
705        let mut persisted_failure =
706            self.persisted_failure.as_ref().filter(|_| worker_id == 0 && self.config.run.is_none());
707
708        // Offset to stagger corpus syncs across workers; so that workers don't sync at the same
709        // time.
710        let sync_offset = (worker_id as u32).saturating_mul(100);
711        let sync_threshold = SYNC_INTERVAL + sync_offset;
712        let mut runs_since_sync = sync_threshold; // Always sync at the start.
713        let mut last_metrics_report = Instant::now();
714        // Continue while:
715        // 1. Global state allows (not timed out, not at global limit, no failure found)
716        // 2. Worker hasn't reached its specific run limit
717        'stop: while shared_state.should_continue() && worker.runs < worker_runs {
718            // If counterexample recorded, replay it first, without incrementing runs.
719            let (input, fuzz_run, is_persisted_replay) = if worker_id == 0
720                && let Some(failure) = persisted_failure.take()
721                && failure.calldata.get(..4).is_some_and(|selector| func.selector() == selector)
722            {
723                let seed = failure.fuzz.seed.or(self.config.seed);
724                if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut()
725                    && let Some(seed) = seed
726                {
727                    let run = failure.fuzz.run.unwrap_or(1);
728                    let worker = failure.fuzz.worker.unwrap_or(worker_id as u32) as usize;
729                    cheats.set_seed(Self::fuzz_run_seed(seed, worker, run));
730                }
731
732                (
733                    BasicTxDetails {
734                        warp: None,
735                        roll: None,
736                        sender: self.sender,
737                        call_details: CallDetails {
738                            target: address,
739                            calldata: failure.calldata.clone(),
740                            value: failure.value,
741                        },
742                    },
743                    Some(FuzzRunMetadata::new(
744                        seed,
745                        failure.fuzz.run,
746                        Some(failure.fuzz.worker.unwrap_or(worker_id as u32)),
747                    )),
748                    true,
749                )
750            } else {
751                runs_since_sync += 1;
752                if runs_since_sync >= sync_threshold {
753                    let timer = Instant::now();
754                    corpus.sync(
755                        self.num_workers,
756                        &executor,
757                        replay_target,
758                        &shared_state.global_corpus_metrics,
759                    )?;
760                    trace!("finished corpus sync in {:?}", timer.elapsed());
761                    runs_since_sync = 0;
762                }
763
764                let fuzz_run = self.config.run.unwrap_or_else(|| {
765                    generated_inputs += 1;
766                    generated_inputs
767                });
768                if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut()
769                    && let Some(seed) = self.config.seed
770                {
771                    cheats.set_seed(Self::fuzz_run_seed(seed, worker_id, fuzz_run));
772                }
773
774                let input = match corpus.new_sequence(&mut runner) {
775                    Ok(plan) => plan.into_first(),
776                    Err(err) => {
777                        worker.failure = Some(TestCaseError::fail(format!(
778                            "failed to generate fuzzed input in worker {}: {err}",
779                            worker.id
780                        )));
781                        shared_state.try_claim_failure(worker_id);
782                        break 'stop;
783                    }
784                };
785
786                (
787                    input,
788                    Some(FuzzRunMetadata::new(
789                        self.config.seed,
790                        Some(fuzz_run),
791                        Some(worker_id as u32),
792                    )),
793                    false,
794                )
795            };
796
797            let mut inc_runs = || {
798                let total_runs = shared_state.increment_runs();
799                debug_assert!(
800                    shared_state.timer.is_enabled()
801                        || total_runs
802                            <= if self.config.run.is_some() { 1 } else { self.config.runs },
803                    "worker runs were not distributed correctly"
804                );
805                worker.runs += 1;
806                if let Some(progress) = progress {
807                    progress.inc(1);
808                }
809                total_runs
810            };
811
812            worker.last_run_timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
813            match self.single_fuzz(
814                &executor,
815                address,
816                input,
817                &mut corpus,
818                &mut frontier_recorder,
819                fuzz_run.as_ref(),
820            ) {
821                Ok(fuzz_outcome) => match fuzz_outcome {
822                    FuzzOutcome::Case(case) => {
823                        if is_persisted_replay {
824                            continue 'stop;
825                        }
826                        let total_runs = inc_runs();
827
828                        if worker_id == 0 && self.config.corpus.collect_edge_coverage() {
829                            if let Some(progress) = progress {
830                                corpus.sync_metrics(&shared_state.global_corpus_metrics);
831                                progress
832                                    .set_message(format!("{}", shared_state.global_corpus_metrics));
833                            } else if last_metrics_report.elapsed()
834                                > DURATION_BETWEEN_METRICS_REPORT
835                            {
836                                corpus.sync_metrics(&shared_state.global_corpus_metrics);
837                                // Display metrics inline.
838                                let metrics = json!({
839                                    "timestamp": SystemTime::now()
840                                        .duration_since(UNIX_EPOCH)?
841                                        .as_secs(),
842                                    "test": func.name,
843                                    "metrics": shared_state.global_corpus_metrics.load(),
844                                });
845                                let _ = sh_println!("{metrics}");
846                                last_metrics_report = Instant::now();
847                            }
848                        }
849
850                        worker.gas_by_case.push((case.case.gas, case.case.stipend));
851
852                        if worker.first_case.is_none() {
853                            worker.first_case = Some((total_runs, case.case));
854                        }
855
856                        if let Some(call_traces) = case.traces {
857                            if worker.traces.len() == max_traces_to_collect as usize {
858                                worker.traces.pop();
859                            }
860                            worker.traces.push(call_traces);
861                            worker.debug_bytecodes = case.debug_bytecodes;
862                            worker.breakpoints = Some(case.breakpoints);
863                        }
864
865                        // Always store logs from the last run in test_data.logs for display at
866                        // verbosity >= 2. When show_logs is true,
867                        // accumulate all logs. When false, only keep the last run's logs.
868                        if self.config.show_logs {
869                            worker.logs.extend(case.logs);
870                        } else {
871                            worker.logs = case.logs;
872                        }
873
874                        HitMaps::merge_opt(&mut worker.coverage, case.coverage);
875                        worker.deprecated_cheatcodes = case.deprecated_cheatcodes;
876                    }
877                    FuzzOutcome::CounterExample(CounterExampleOutcome {
878                        exit_reason: status,
879                        counterexample: outcome,
880                        ..
881                    }) => {
882                        if !is_persisted_replay {
883                            inc_runs();
884                        }
885                        worker.failure_run = fuzz_run;
886
887                        // Only classify magic skip payloads when the revert originates from the
888                        // cheatcode address.
889                        let reason = if outcome.1.reverter == Some(CHEATCODE_ADDRESS) {
890                            SkipReason::decode(&outcome.1.result)
891                                .map(|reason| reason.to_string())
892                                .or_else(|| rd.maybe_decode(&outcome.1.result, status))
893                        } else {
894                            rd.maybe_decode(&outcome.1.result, status)
895                        };
896                        if self.config.show_logs {
897                            worker.logs.extend(outcome.1.logs.clone());
898                        } else {
899                            worker.logs.clone_from(&outcome.1.logs);
900                        }
901                        worker.counterexample = Some(outcome);
902                        worker.failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
903                        shared_state.try_claim_failure(worker_id);
904                        break 'stop;
905                    }
906                },
907                Err(err) => match err {
908                    TestCaseError::Fail(_) => {
909                        worker.failure = Some(err);
910                        shared_state.try_claim_failure(worker_id);
911                        break 'stop;
912                    }
913                    TestCaseError::Reject(_) => {
914                        let max = self.config.max_test_rejects;
915
916                        let total = shared_state.increment_rejects();
917
918                        // Update progress bar to reflect rejected runs.
919                        // TODO(dani): (pre-existing) conflicts with corpus metrics `set_message`
920                        if !self.config.corpus.collect_edge_coverage()
921                            && let Some(progress) = progress
922                        {
923                            progress.set_message(format!("([{total}] rejected)"));
924                        }
925
926                        if max > 0 && total > max {
927                            worker.failure =
928                                Some(TestCaseError::reject(FuzzError::TooManyRejects(max)));
929                            shared_state.try_claim_failure(worker_id);
930                            break 'stop;
931                        }
932                    }
933                },
934            }
935        }
936
937        if let Some(final_sync) = final_sync {
938            corpus.finalize_sync(&executor, replay_target, final_sync)?;
939        }
940
941        if worker_id == 0 {
942            worker.failed_corpus_replays = corpus.failed_replays;
943        }
944        worker.frontiers = frontier_recorder.into_frontiers();
945
946        // Logs stats
947        trace!("worker {worker_id} fuzz stats");
948        fuzz_state.log_stats();
949
950        Ok(worker)
951    }
952
953    /// Determines the number of runs per worker.
954    const fn runs_per_worker(&self, worker_id: usize) -> u32 {
955        let worker_id = worker_id as u32;
956        let total_runs = if self.config.run.is_some() { 1 } else { self.config.runs };
957        let n = self.num_workers as u32;
958        let runs = total_runs / n;
959        let remainder = total_runs % n;
960        // Distribute the remainder evenly among the first `remainder` workers,
961        // assuming `worker_id` is in `0..n`.
962        if worker_id < remainder { runs + 1 } else { runs }
963    }
964
965    /// Returns the worker IDs to execute.
966    fn worker_ids(&self) -> Vec<usize> {
967        if self.config.run.is_some() {
968            vec![self.config.worker.unwrap_or(0) as usize]
969        } else {
970            (0..self.num_workers).collect()
971        }
972    }
973
974    /// Derives the deterministic RNG seed for a fuzz worker.
975    fn fuzz_worker_seed(seed: U256, worker_id: usize) -> U256 {
976        if worker_id == 0 {
977            seed
978        } else {
979            let worker_id = worker_id as u32;
980            let seed_data = [&seed.to_be_bytes::<32>()[..], &worker_id.to_be_bytes()[..]].concat();
981            U256::from_be_bytes(keccak256(seed_data).0)
982        }
983    }
984
985    /// Derives the deterministic RNG seed for cheatcode randomness in a worker-local run.
986    fn fuzz_run_seed(seed: U256, worker_id: usize, run: u32) -> U256 {
987        Self::fuzz_worker_seed(seed, worker_id).wrapping_add(U256::from(run.saturating_sub(1)))
988    }
989}