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