Skip to main content

foundry_evm/executors/fuzz/
mod.rs

1use crate::executors::{
2    DURATION_BETWEEN_METRICS_REPORT, EarlyExit, Executor, FuzzTestTimer, RawCallResult,
3    corpus::{GlobalCorpusMetrics, ReplayTarget, StatelessReplayTarget, WorkerCorpus},
4};
5use alloy_dyn_abi::JsonAbiExt;
6use alloy_json_abi::Function;
7use alloy_primitives::{
8    Address, Bytes, Log, U256, keccak256,
9    map::{AddressHashMap, HashMap},
10};
11use eyre::Result;
12use foundry_common::sh_println;
13use foundry_config::FuzzConfig;
14use foundry_evm_core::{
15    Breakpoints,
16    constants::{CHEATCODE_ADDRESS, MAGIC_ASSUME},
17    decode::{RevertDecoder, SkipReason},
18    evm::FoundryEvmNetwork,
19};
20use foundry_evm_coverage::HitMaps;
21use foundry_evm_fuzz::{
22    BaseCounterExample, BasicTxDetails, CallDetails, CounterExample, FuzzCase, FuzzError,
23    FuzzFixtures, FuzzRunMetadata, FuzzTestResult,
24    strategies::{EvmFuzzState, fuzz_calldata, fuzz_calldata_from_state, fuzz_msg_value},
25};
26use foundry_evm_traces::SparsedTraceArena;
27use indicatif::ProgressBar;
28use proptest::{
29    strategy::{Just, Strategy},
30    test_runner::{RngAlgorithm, TestCaseError, TestRng, TestRunner},
31};
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: (Bytes, 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: (Bytes::new(), RawCallResult::default()),
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 workers = worker_ids
247            .into_par_iter()
248            .map(|worker_id| {
249                let _guard = tokio_handle.enter();
250                let _guard = info_span!("fuzz_worker", id = worker_id).entered();
251                let timer = Instant::now();
252                let r = self.run_worker(
253                    worker_id,
254                    func,
255                    fuzz_fixtures,
256                    address,
257                    rd,
258                    &shared_state,
259                    progress,
260                );
261                debug!("finished in {:?}", timer.elapsed());
262                r
263            })
264            .collect::<Result<Vec<_>>>()?;
265
266        Ok(self.aggregate_results(workers, func, &shared_state))
267    }
268
269    /// Replays the persisted single-call counterexample exactly once.
270    ///
271    /// Unlike [`Self::fuzz`], this never falls through to generated inputs if the persisted
272    /// calldata now succeeds or is rejected by `vm.assume`.
273    pub fn replay_persisted_failure(
274        &mut self,
275        func: &Function,
276        address: Address,
277        rd: &RevertDecoder,
278    ) -> Result<FuzzTestResult> {
279        let Some(failure) = self.persisted_failure.as_ref() else {
280            return Ok(FuzzTestResult {
281                skipped: true,
282                reason: Some("no persisted fuzz failure to replay".to_string()),
283                ..Default::default()
284            });
285        };
286
287        let seed = failure.fuzz.seed.or(self.config.seed);
288        if let Some(cheats) = self.executor_f.inspector_mut().cheatcodes.as_mut()
289            && let Some(seed) = seed
290        {
291            let run = failure.fuzz.run.unwrap_or(1);
292            let worker = failure.fuzz.worker.unwrap_or(0) as usize;
293            cheats.set_seed(Self::fuzz_run_seed(seed, worker, run));
294        }
295
296        let calldata = failure.calldata.clone();
297        let mut call =
298            self.executor_f.call_raw(self.sender, address, calldata.clone(), U256::ZERO)?;
299        if call.result.as_ref() == MAGIC_ASSUME {
300            return Ok(FuzzTestResult {
301                skipped: true,
302                reason: Some("persisted fuzz failure rejected by `vm.assume`".to_string()),
303                ..Default::default()
304            });
305        }
306        if call.reverter == Some(CHEATCODE_ADDRESS)
307            && let Some(reason) = SkipReason::decode(&call.result)
308        {
309            return Ok(FuzzTestResult { skipped: true, reason: reason.0, ..Default::default() });
310        }
311
312        let (breakpoints, deprecated_cheatcodes) =
313            call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| {
314                (cheats.breakpoints.clone(), cheats.deprecated.clone())
315            });
316        let success = if !self.config.fail_on_revert
317            && call
318                .reverter
319                .is_some_and(|reverter| reverter != address && reverter != CHEATCODE_ADDRESS)
320        {
321            true
322        } else {
323            self.executor_f.is_raw_call_mut_success(address, &mut call, false)
324        };
325
326        let mut result = FuzzTestResult {
327            success,
328            labels: call.labels.clone(),
329            traces: call.traces.clone(),
330            debug_bytecodes: call.debug_bytecodes.clone(),
331            breakpoints: Some(breakpoints),
332            deprecated_cheatcodes,
333            ..Default::default()
334        };
335
336        if success {
337            result.first_case = FuzzCase { gas: call.gas_used, stipend: call.stipend };
338            result.gas_by_case.push((call.gas_used, call.stipend));
339            result.line_coverage = call.line_coverage;
340            result.logs = call.logs;
341            result.gas_report_traces.extend(call.traces.into_iter().map(|trace| trace.arena));
342        } else {
343            let reason = if call.reverter == Some(CHEATCODE_ADDRESS) {
344                SkipReason::decode(&call.result)
345                    .map(|reason| reason.to_string())
346                    .or_else(|| rd.maybe_decode(&call.result, call.exit_reason))
347            } else {
348                rd.maybe_decode(&call.result, call.exit_reason)
349            };
350            result.reason = reason;
351            let args = calldata
352                .get(4..)
353                .map_or_else(Vec::new, |data| func.abi_decode_input(data).unwrap_or_default());
354            result.counterexample = Some(CounterExample::Single(
355                BaseCounterExample::from_fuzz_call(calldata, args, call.traces).with_fuzz_metadata(
356                    FuzzRunMetadata::new(
357                        seed,
358                        failure.fuzz.run,
359                        Some(failure.fuzz.worker.unwrap_or(0)),
360                    ),
361                ),
362            ));
363            result.logs = call.logs;
364        }
365
366        Ok(result)
367    }
368
369    /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome`
370    /// or a `CounterExampleOutcome`
371    fn single_fuzz(
372        &self,
373        executor: &Executor<FEN>,
374        address: Address,
375        calldata: Bytes,
376        coverage_metrics: &mut WorkerCorpus,
377        frontier_recorder: &mut FuzzFrontierRecorder,
378        fuzz_run: Option<&FuzzRunMetadata>,
379    ) -> Result<FuzzOutcome<FEN>, TestCaseError> {
380        let mut call = executor
381            .call_raw(self.sender, address, calldata.clone(), U256::ZERO)
382            .map_err(|e| TestCaseError::fail(e.to_string()))?;
383        let cmp_values = call.evm_cmp_values.take().unwrap_or_default();
384        let new_coverage = coverage_metrics.merge_edge_coverage(&mut call);
385        // `new_coverage` is only meaningful when edge coverage is collected; otherwise
386        // `merge_edge_coverage` always returns `false`, so record it as unknown for frontiers.
387        let frontier_new_coverage =
388            self.config.corpus.collect_edge_coverage().then_some(new_coverage);
389        frontier_recorder.capture_stateless_call(
390            fuzz_run,
391            self.sender,
392            address,
393            &calldata,
394            &cmp_values,
395            frontier_new_coverage,
396        );
397        coverage_metrics.process_inputs(
398            &[BasicTxDetails {
399                warp: None,
400                roll: None,
401                sender: self.sender,
402                call_details: CallDetails {
403                    target: address,
404                    calldata: calldata.clone(),
405                    value: None,
406                },
407            }],
408            &[cmp_values],
409            new_coverage,
410            None,
411        );
412
413        // Handle `vm.assume`.
414        if call.result.as_ref() == MAGIC_ASSUME {
415            return Err(TestCaseError::reject(FuzzError::AssumeReject));
416        }
417
418        let (breakpoints, deprecated_cheatcodes) =
419            call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| {
420                (cheats.breakpoints.clone(), cheats.deprecated.clone())
421            });
422
423        // Consider call success if test should not fail on reverts and reverter is not the
424        // cheatcode or test address.
425        let success = if !self.config.fail_on_revert
426            && call
427                .reverter
428                .is_some_and(|reverter| reverter != address && reverter != CHEATCODE_ADDRESS)
429        {
430            true
431        } else {
432            executor.is_raw_call_mut_success(address, &mut call, false)
433        };
434
435        if success {
436            Ok(FuzzOutcome::Case(CaseOutcome {
437                case: FuzzCase { gas: call.gas_used, stipend: call.stipend },
438                traces: call.traces,
439                debug_bytecodes: call.debug_bytecodes,
440                coverage: call.line_coverage,
441                breakpoints,
442                logs: call.logs,
443                deprecated_cheatcodes,
444            }))
445        } else {
446            Ok(FuzzOutcome::CounterExample(CounterExampleOutcome {
447                exit_reason: call.exit_reason,
448                counterexample: (calldata, call),
449                breakpoints,
450            }))
451        }
452    }
453
454    /// Aggregates the results from all workers
455    fn aggregate_results(
456        &self,
457        mut workers: Vec<WorkerState<FEN>>,
458        func: &Function,
459        shared_state: &SharedFuzzState,
460    ) -> FuzzTestResult {
461        self.write_branch_frontiers(&mut workers, func);
462
463        let mut result = FuzzTestResult::default();
464        if workers.is_empty() {
465            result.success = true;
466            return result;
467        }
468
469        // Find first case and last run worker. Set `failed_corpus_replays`.
470        let mut first_case_candidate = None;
471        let mut last_run_worker = None;
472        for (i, worker) in workers.iter().enumerate() {
473            if let Some((run, ref case)) = worker.first_case
474                && first_case_candidate.as_ref().is_none_or(|&(r, _)| run < r)
475            {
476                first_case_candidate = Some((run, case.clone()));
477            }
478
479            if last_run_worker.is_none_or(|(t, _)| worker.last_run_timestamp > t) {
480                last_run_worker = Some((worker.last_run_timestamp, i));
481            }
482
483            // Only set replays from master which is responsible for replaying persisted corpus.
484            if worker.id == 0 {
485                result.failed_corpus_replays = worker.failed_corpus_replays;
486            }
487        }
488        result.first_case = first_case_candidate.map(|(_, case)| case).unwrap_or_default();
489        let (_, last_run_worker_idx) = last_run_worker.expect("at least one worker");
490
491        if let Some(&failed_worker_id) = shared_state.failed_worker_id.get() {
492            result.success = false;
493
494            let failed_worker_idx = workers.iter().position(|w| w.id == failed_worker_id).unwrap();
495            let failed_worker = &mut workers[failed_worker_idx];
496
497            let (calldata, call) = std::mem::take(&mut failed_worker.counterexample);
498            result.labels = call.labels;
499            result.traces = call.traces.clone();
500            result.debug_bytecodes = call.debug_bytecodes.clone();
501            result.breakpoints = call.cheatcodes.map(|c| c.breakpoints);
502
503            match &failed_worker.failure {
504                Some(TestCaseError::Fail(reason)) => {
505                    let reason = reason.to_string();
506                    result.reason = (!reason.is_empty()).then_some(reason);
507                    let args = if let Some(data) = calldata.get(4..) {
508                        func.abi_decode_input(data).unwrap_or_default()
509                    } else {
510                        vec![]
511                    };
512                    let fuzz = failed_worker.failure_run.unwrap_or_default();
513                    result.counterexample = Some(CounterExample::Single(
514                        BaseCounterExample::from_fuzz_call(calldata, args, call.traces)
515                            .with_fuzz_metadata(FuzzRunMetadata::new(
516                                fuzz.seed.or(self.config.seed),
517                                fuzz.run,
518                                fuzz.worker,
519                            )),
520                    ));
521                }
522                Some(TestCaseError::Reject(reason)) => {
523                    let reason = reason.to_string();
524                    result.reason = (!reason.is_empty()).then_some(reason);
525                }
526                None => {}
527            }
528        } else {
529            let last_run_worker = &workers[last_run_worker_idx];
530            result.success = true;
531            result.traces = last_run_worker.traces.last().cloned();
532            result.debug_bytecodes.clone_from(&last_run_worker.debug_bytecodes);
533            result.breakpoints = last_run_worker.breakpoints.clone();
534        }
535
536        if !self.config.show_logs {
537            result.logs = workers[last_run_worker_idx].logs.clone();
538        }
539
540        for mut worker in workers {
541            result.gas_by_case.append(&mut worker.gas_by_case);
542            if self.config.show_logs {
543                result.logs.append(&mut worker.logs);
544            }
545            result.gas_report_traces.extend(worker.traces.into_iter().map(|t| t.arena));
546            HitMaps::merge_opt(&mut result.line_coverage, worker.coverage);
547            result.deprecated_cheatcodes.extend(worker.deprecated_cheatcodes);
548        }
549
550        if let Some(reason) = &result.reason
551            && let Some(reason) = SkipReason::decode_self(reason)
552        {
553            result.skipped = true;
554            result.reason = reason.0;
555        }
556
557        result
558    }
559
560    fn write_branch_frontiers(&self, workers: &mut [WorkerState<FEN>], func: &Function) {
561        let Some(frontier_dir) = &self.config.corpus.frontier_dir else {
562            return;
563        };
564        let limit = self.config.corpus.frontier_limit;
565        if limit == 0 {
566            return;
567        }
568
569        let frontiers = frontier::merge_frontiers(
570            limit,
571            workers.iter_mut().flat_map(|worker| worker.frontiers.drain(..)),
572        );
573        if frontiers.is_empty() {
574            return;
575        }
576
577        let artifact = FuzzBranchFrontierArtifact::new(func, limit, frontiers);
578        if let Err(err) = frontier::write_frontier_artifact(frontier_dir, &artifact) {
579            warn!(%err, path = ?frontier_dir, "failed to write fuzz branch frontier artifact");
580        }
581    }
582
583    /// Runs a single fuzz worker
584    #[allow(clippy::too_many_arguments)]
585    fn run_worker(
586        &self,
587        worker_id: usize,
588        func: &Function,
589        fuzz_fixtures: &FuzzFixtures,
590        address: Address,
591        rd: &RevertDecoder,
592        shared_state: &SharedFuzzState,
593        progress: Option<&ProgressBar>,
594    ) -> Result<WorkerState<FEN>> {
595        // Prepare
596        let fuzz_state = shared_state.state.fork();
597        let dictionary_weight = self.config.dictionary.dictionary_weight.min(100);
598        let calldata_strategy = proptest::prop_oneof![
599            100 - dictionary_weight => fuzz_calldata(func.clone(), fuzz_fixtures),
600            dictionary_weight => fuzz_calldata_from_state(func.clone(), &fuzz_state, fuzz_fixtures),
601        ];
602        let value_strategy = if func.state_mutability == alloy_json_abi::StateMutability::Payable {
603            fuzz_msg_value(self.config.corpus.payable_value_weight).boxed()
604        } else {
605            Just(None).boxed()
606        };
607        let strategy =
608            (calldata_strategy, value_strategy).prop_map(move |(calldata, value)| BasicTxDetails {
609                warp: None,
610                roll: None,
611                sender: Default::default(),
612                call_details: CallDetails { target: Default::default(), calldata, value },
613            });
614
615        let replay_target = ReplayTarget {
616            stateless: Some(StatelessReplayTarget { function: func, address }),
617            fuzzed_contracts: None,
618            dynamic: None,
619        };
620        let mut corpus = WorkerCorpus::new(
621            worker_id,
622            self.config.corpus.clone(),
623            strategy.boxed(),
624            // Master worker replays the persisted corpus using the executor
625            (worker_id == 0).then_some(&self.executor_f),
626            replay_target,
627        )?;
628        let mut executor = self.executor_f.clone();
629        let frontier_limit = if self.config.corpus.capture_branch_frontiers() {
630            self.config.corpus.frontier_limit
631        } else {
632            0
633        };
634        let mut frontier_recorder = FuzzFrontierRecorder::new(frontier_limit);
635
636        let mut worker = WorkerState::new(worker_id);
637        // We want to collect at least one trace which will be displayed to user.
638        let max_traces_to_collect =
639            std::cmp::max(1, self.config.gas_report_samples / self.num_workers as u32);
640
641        let worker_runs = self.runs_per_worker(worker_id);
642        debug!(worker_runs);
643
644        let mut runner_config = self.runner.config().clone();
645        runner_config.cases = worker_runs;
646
647        let mut runner = if let Some(seed) = self.config.seed {
648            let worker_seed = Self::fuzz_worker_seed(seed, worker_id);
649            trace!(target: "forge::test", ?worker_seed, "deterministic seed for worker {worker_id}");
650            let rng = TestRng::from_seed(RngAlgorithm::ChaCha, &worker_seed.to_be_bytes::<32>());
651            TestRunner::new_with_rng(runner_config, rng)
652        } else {
653            TestRunner::new(runner_config)
654        };
655
656        if let Some(target_run) = self.config.run {
657            for _ in 1..target_run {
658                if let Err(err) = corpus.new_input(&mut runner, &fuzz_state, func) {
659                    worker.failure = Some(TestCaseError::fail(format!(
660                        "failed to generate fuzzed input in worker {}: {err}",
661                        worker.id
662                    )));
663                    shared_state.try_claim_failure(worker_id);
664                    return Ok(worker);
665                }
666            }
667        }
668
669        let mut persisted_failure =
670            self.persisted_failure.as_ref().filter(|_| worker_id == 0 && self.config.run.is_none());
671
672        // Offset to stagger corpus syncs across workers; so that workers don't sync at the same
673        // time.
674        let sync_offset = (worker_id as u32).saturating_mul(100);
675        let sync_threshold = SYNC_INTERVAL + sync_offset;
676        let mut runs_since_sync = sync_threshold; // Always sync at the start.
677        let mut last_metrics_report = Instant::now();
678        // Continue while:
679        // 1. Global state allows (not timed out, not at global limit, no failure found)
680        // 2. Worker hasn't reached its specific run limit
681        'stop: while shared_state.should_continue() && worker.runs < worker_runs {
682            // If counterexample recorded, replay it first, without incrementing runs.
683            let (input, fuzz_run) = if worker_id == 0
684                && let Some(failure) = persisted_failure.take()
685                && failure.calldata.get(..4).is_some_and(|selector| func.selector() == selector)
686            {
687                let seed = failure.fuzz.seed.or(self.config.seed);
688                if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut()
689                    && let Some(seed) = seed
690                {
691                    let run = failure.fuzz.run.unwrap_or(1);
692                    let worker = failure.fuzz.worker.unwrap_or(worker_id as u32) as usize;
693                    cheats.set_seed(Self::fuzz_run_seed(seed, worker, run));
694                }
695
696                (
697                    failure.calldata.clone(),
698                    Some(FuzzRunMetadata::new(
699                        seed,
700                        failure.fuzz.run,
701                        Some(failure.fuzz.worker.unwrap_or(worker_id as u32)),
702                    )),
703                )
704            } else {
705                runs_since_sync += 1;
706                if runs_since_sync >= sync_threshold {
707                    let timer = Instant::now();
708                    corpus.sync(
709                        self.num_workers,
710                        &executor,
711                        replay_target,
712                        &shared_state.global_corpus_metrics,
713                    )?;
714                    trace!("finished corpus sync in {:?}", timer.elapsed());
715                    runs_since_sync = 0;
716                }
717
718                let fuzz_run = self.config.run.unwrap_or(worker.runs + 1);
719                if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut()
720                    && let Some(seed) = self.config.seed
721                {
722                    cheats.set_seed(Self::fuzz_run_seed(seed, worker_id, fuzz_run));
723                }
724
725                let input = match corpus.new_input(&mut runner, &fuzz_state, func) {
726                    Ok(input) => input,
727                    Err(err) => {
728                        worker.failure = Some(TestCaseError::fail(format!(
729                            "failed to generate fuzzed input in worker {}: {err}",
730                            worker.id
731                        )));
732                        shared_state.try_claim_failure(worker_id);
733                        break 'stop;
734                    }
735                };
736
737                (
738                    input,
739                    Some(FuzzRunMetadata::new(
740                        self.config.seed,
741                        Some(fuzz_run),
742                        Some(worker_id as u32),
743                    )),
744                )
745            };
746
747            let mut inc_runs = || {
748                let total_runs = shared_state.increment_runs();
749                debug_assert!(
750                    shared_state.timer.is_enabled()
751                        || total_runs
752                            <= if self.config.run.is_some() { 1 } else { self.config.runs },
753                    "worker runs were not distributed correctly"
754                );
755                worker.runs += 1;
756                if let Some(progress) = progress {
757                    progress.inc(1);
758                }
759                total_runs
760            };
761
762            worker.last_run_timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
763            match self.single_fuzz(
764                &executor,
765                address,
766                input,
767                &mut corpus,
768                &mut frontier_recorder,
769                fuzz_run.as_ref(),
770            ) {
771                Ok(fuzz_outcome) => match fuzz_outcome {
772                    FuzzOutcome::Case(case) => {
773                        let total_runs = inc_runs();
774
775                        if worker_id == 0 && self.config.corpus.collect_edge_coverage() {
776                            if let Some(progress) = progress {
777                                corpus.sync_metrics(&shared_state.global_corpus_metrics);
778                                progress
779                                    .set_message(format!("{}", shared_state.global_corpus_metrics));
780                            } else if last_metrics_report.elapsed()
781                                > DURATION_BETWEEN_METRICS_REPORT
782                            {
783                                corpus.sync_metrics(&shared_state.global_corpus_metrics);
784                                // Display metrics inline.
785                                let metrics = json!({
786                                    "timestamp": SystemTime::now()
787                                        .duration_since(UNIX_EPOCH)?
788                                        .as_secs(),
789                                    "test": func.name,
790                                    "metrics": shared_state.global_corpus_metrics.load(),
791                                });
792                                let _ = sh_println!("{metrics}");
793                                last_metrics_report = Instant::now();
794                            }
795                        }
796
797                        worker.gas_by_case.push((case.case.gas, case.case.stipend));
798
799                        if worker.first_case.is_none() {
800                            worker.first_case = Some((total_runs, case.case));
801                        }
802
803                        if let Some(call_traces) = case.traces {
804                            if worker.traces.len() == max_traces_to_collect as usize {
805                                worker.traces.pop();
806                            }
807                            worker.traces.push(call_traces);
808                            worker.debug_bytecodes = case.debug_bytecodes;
809                            worker.breakpoints = Some(case.breakpoints);
810                        }
811
812                        // Always store logs from the last run in test_data.logs for display at
813                        // verbosity >= 2. When show_logs is true,
814                        // accumulate all logs. When false, only keep the last run's logs.
815                        if self.config.show_logs {
816                            worker.logs.extend(case.logs);
817                        } else {
818                            worker.logs = case.logs;
819                        }
820
821                        HitMaps::merge_opt(&mut worker.coverage, case.coverage);
822                        worker.deprecated_cheatcodes = case.deprecated_cheatcodes;
823                    }
824                    FuzzOutcome::CounterExample(CounterExampleOutcome {
825                        exit_reason: status,
826                        counterexample: outcome,
827                        ..
828                    }) => {
829                        inc_runs();
830                        worker.failure_run = fuzz_run;
831
832                        // Only classify magic skip payloads when the revert originates from the
833                        // cheatcode address.
834                        let reason = if outcome.1.reverter == Some(CHEATCODE_ADDRESS) {
835                            SkipReason::decode(&outcome.1.result)
836                                .map(|reason| reason.to_string())
837                                .or_else(|| rd.maybe_decode(&outcome.1.result, status))
838                        } else {
839                            rd.maybe_decode(&outcome.1.result, status)
840                        };
841                        worker.logs.extend(outcome.1.logs.clone());
842                        worker.counterexample = outcome;
843                        worker.failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
844                        shared_state.try_claim_failure(worker_id);
845                        break 'stop;
846                    }
847                },
848                Err(err) => match err {
849                    TestCaseError::Fail(_) => {
850                        worker.failure = Some(err);
851                        shared_state.try_claim_failure(worker_id);
852                        break 'stop;
853                    }
854                    TestCaseError::Reject(_) => {
855                        let max = self.config.max_test_rejects;
856
857                        let total = shared_state.increment_rejects();
858
859                        // Update progress bar to reflect rejected runs.
860                        // TODO(dani): (pre-existing) conflicts with corpus metrics `set_message`
861                        if !self.config.corpus.collect_edge_coverage()
862                            && let Some(progress) = progress
863                        {
864                            progress.set_message(format!("([{total}] rejected)"));
865                        }
866
867                        if max > 0 && total > max {
868                            worker.failure =
869                                Some(TestCaseError::reject(FuzzError::TooManyRejects(max)));
870                            shared_state.try_claim_failure(worker_id);
871                            break 'stop;
872                        }
873                    }
874                },
875            }
876        }
877
878        if worker_id == 0 {
879            worker.failed_corpus_replays = corpus.failed_replays;
880        }
881        worker.frontiers = frontier_recorder.into_frontiers();
882
883        // Logs stats
884        trace!("worker {worker_id} fuzz stats");
885        fuzz_state.log_stats();
886
887        Ok(worker)
888    }
889
890    /// Determines the number of runs per worker.
891    const fn runs_per_worker(&self, worker_id: usize) -> u32 {
892        let worker_id = worker_id as u32;
893        let total_runs = if self.config.run.is_some() { 1 } else { self.config.runs };
894        let n = self.num_workers as u32;
895        let runs = total_runs / n;
896        let remainder = total_runs % n;
897        // Distribute the remainder evenly among the first `remainder` workers,
898        // assuming `worker_id` is in `0..n`.
899        if worker_id < remainder { runs + 1 } else { runs }
900    }
901
902    /// Returns the worker IDs to execute.
903    fn worker_ids(&self) -> Vec<usize> {
904        if self.config.run.is_some() {
905            vec![self.config.worker.unwrap_or(0) as usize]
906        } else {
907            (0..self.num_workers).collect()
908        }
909    }
910
911    /// Derives the deterministic RNG seed for a fuzz worker.
912    fn fuzz_worker_seed(seed: U256, worker_id: usize) -> U256 {
913        if worker_id == 0 {
914            seed
915        } else {
916            let worker_id = worker_id as u32;
917            let seed_data = [&seed.to_be_bytes::<32>()[..], &worker_id.to_be_bytes()[..]].concat();
918            U256::from_be_bytes(keccak256(seed_data).0)
919        }
920    }
921
922    /// Derives the deterministic RNG seed for cheatcode randomness in a worker-local run.
923    fn fuzz_run_seed(seed: U256, worker_id: usize, run: u32) -> U256 {
924        Self::fuzz_worker_seed(seed, worker_id).wrapping_add(U256::from(run.saturating_sub(1)))
925    }
926}