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