Skip to main content

foundry_evm/executors/fuzz/
mod.rs

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