foundry_evm/executors/fuzz/
mod.rs

1use crate::executors::{
2    DURATION_BETWEEN_METRICS_REPORT, EarlyExit, Executor, FuzzTestTimer, RawCallResult,
3};
4use alloy_dyn_abi::JsonAbiExt;
5use alloy_json_abi::Function;
6use alloy_primitives::{Address, Bytes, Log, U256, map::HashMap};
7use eyre::Result;
8use foundry_common::sh_println;
9use foundry_config::FuzzConfig;
10use foundry_evm_core::{
11    Breakpoints,
12    constants::{CHEATCODE_ADDRESS, MAGIC_ASSUME},
13    decode::{RevertDecoder, SkipReason},
14};
15use foundry_evm_coverage::HitMaps;
16use foundry_evm_fuzz::{
17    BaseCounterExample, BasicTxDetails, CallDetails, CounterExample, FuzzCase, FuzzError,
18    FuzzFixtures, FuzzTestResult,
19    strategies::{EvmFuzzState, fuzz_calldata, fuzz_calldata_from_state},
20};
21use foundry_evm_traces::SparsedTraceArena;
22use indicatif::ProgressBar;
23use proptest::{
24    strategy::Strategy,
25    test_runner::{TestCaseError, TestRunner},
26};
27use serde_json::json;
28use std::time::{Instant, SystemTime, UNIX_EPOCH};
29
30mod types;
31use crate::executors::corpus::CorpusManager;
32pub use types::{CaseOutcome, CounterExampleOutcome, FuzzOutcome};
33
34/// Contains data collected during fuzz test runs.
35#[derive(Default)]
36struct FuzzTestData {
37    // Stores the first fuzz case.
38    first_case: Option<FuzzCase>,
39    // Stored gas usage per fuzz case.
40    gas_by_case: Vec<(u64, u64)>,
41    // Stores the result and calldata of the last failed call, if any.
42    counterexample: (Bytes, RawCallResult),
43    // Stores up to `max_traces_to_collect` traces.
44    traces: Vec<SparsedTraceArena>,
45    // Stores breakpoints for the last fuzz case.
46    breakpoints: Option<Breakpoints>,
47    // Stores coverage information for all fuzz cases.
48    coverage: Option<HitMaps>,
49    // Stores logs for all fuzz cases (when show_logs is true) or just the last run (when show_logs
50    // is false)
51    logs: Vec<Log>,
52    // Deprecated cheatcodes mapped to their replacements.
53    deprecated_cheatcodes: HashMap<&'static str, Option<&'static str>>,
54    // Runs performed in fuzz test.
55    runs: u32,
56    // Current assume rejects of the fuzz run.
57    rejects: u32,
58    // Test failure.
59    failure: Option<TestCaseError>,
60}
61
62/// Wrapper around an [`Executor`] which provides fuzzing support using [`proptest`].
63///
64/// After instantiation, calling `fuzz` will proceed to hammer the deployed smart contract with
65/// inputs, until it finds a counterexample. The provided [`TestRunner`] contains all the
66/// configuration which can be overridden via [environment variables](proptest::test_runner::Config)
67pub struct FuzzedExecutor {
68    /// The EVM executor.
69    executor: Executor,
70    /// The fuzzer
71    runner: TestRunner,
72    /// The account that calls tests.
73    sender: Address,
74    /// The fuzz configuration.
75    config: FuzzConfig,
76    /// The persisted counterexample to be replayed, if any.
77    persisted_failure: Option<BaseCounterExample>,
78}
79
80impl FuzzedExecutor {
81    /// Instantiates a fuzzed executor given a testrunner
82    pub fn new(
83        executor: Executor,
84        runner: TestRunner,
85        sender: Address,
86        config: FuzzConfig,
87        persisted_failure: Option<BaseCounterExample>,
88    ) -> Self {
89        Self { executor, runner, sender, config, persisted_failure }
90    }
91
92    /// Fuzzes the provided function, assuming it is available at the contract at `address`
93    /// If `should_fail` is set to `true`, then it will stop only when there's a success
94    /// test case.
95    ///
96    /// Returns a list of all the consumed gas and calldata of every fuzz case.
97    #[allow(clippy::too_many_arguments)]
98    pub fn fuzz(
99        &mut self,
100        func: &Function,
101        fuzz_fixtures: &FuzzFixtures,
102        state: EvmFuzzState,
103        address: Address,
104        rd: &RevertDecoder,
105        progress: Option<&ProgressBar>,
106        early_exit: &EarlyExit,
107    ) -> Result<FuzzTestResult> {
108        let state = &state;
109        // Stores the fuzz test execution data.
110        let mut test_data = FuzzTestData::default();
111        let dictionary_weight = self.config.dictionary.dictionary_weight.min(100);
112        let strategy = proptest::prop_oneof![
113            100 - dictionary_weight => fuzz_calldata(func.clone(), fuzz_fixtures),
114            dictionary_weight => fuzz_calldata_from_state(func.clone(), state),
115        ]
116        .prop_map(move |calldata| BasicTxDetails {
117            warp: None,
118            roll: None,
119            sender: Default::default(),
120            call_details: CallDetails { target: Default::default(), calldata },
121        });
122        // We want to collect at least one trace which will be displayed to user.
123        let max_traces_to_collect = std::cmp::max(1, self.config.gas_report_samples) as usize;
124
125        let mut corpus_manager = CorpusManager::new(
126            self.config.corpus.clone(),
127            strategy.boxed(),
128            &self.executor,
129            Some(func),
130            None,
131        )?;
132
133        // Start timer for this fuzz test.
134        let timer = FuzzTestTimer::new(self.config.timeout);
135        let mut last_metrics_report = Instant::now();
136        let max_runs = self.config.runs;
137        let continue_campaign = |runs: u32| {
138            if early_exit.should_stop() {
139                return false;
140            }
141
142            if timer.is_enabled() { !timer.is_timed_out() } else { runs < max_runs }
143        };
144
145        'stop: while continue_campaign(test_data.runs) {
146            // If counterexample recorded, replay it first, without incrementing runs.
147            let input = if let Some(failure) = self.persisted_failure.take()
148                && failure.calldata.get(..4).is_some_and(|selector| func.selector() == selector)
149            {
150                failure.calldata.clone()
151            } else {
152                // If running with progress, then increment current run.
153                if let Some(progress) = progress {
154                    progress.inc(1);
155                    // Display metrics in progress bar.
156                    if self.config.corpus.collect_edge_coverage() {
157                        progress.set_message(format!("{}", &corpus_manager.metrics));
158                    }
159                } else if self.config.corpus.collect_edge_coverage()
160                    && last_metrics_report.elapsed() > DURATION_BETWEEN_METRICS_REPORT
161                {
162                    // Display metrics inline.
163                    let metrics = json!({
164                        "timestamp": SystemTime::now()
165                            .duration_since(UNIX_EPOCH)?
166                            .as_secs(),
167                        "test": func.name,
168                        "metrics": &corpus_manager.metrics,
169                    });
170                    let _ = sh_println!("{}", serde_json::to_string(&metrics)?);
171                    last_metrics_report = Instant::now();
172                };
173
174                test_data.runs += 1;
175
176                match corpus_manager.new_input(&mut self.runner, state, func) {
177                    Ok(input) => input,
178                    Err(err) => {
179                        test_data.failure = Some(TestCaseError::fail(format!(
180                            "failed to generate fuzzed input: {err}"
181                        )));
182                        break 'stop;
183                    }
184                }
185            };
186
187            match self.single_fuzz(address, input, &mut corpus_manager) {
188                Ok(fuzz_outcome) => match fuzz_outcome {
189                    FuzzOutcome::Case(case) => {
190                        test_data.gas_by_case.push((case.case.gas, case.case.stipend));
191
192                        if test_data.first_case.is_none() {
193                            test_data.first_case.replace(case.case);
194                        }
195
196                        if let Some(call_traces) = case.traces {
197                            if test_data.traces.len() == max_traces_to_collect {
198                                test_data.traces.pop();
199                            }
200                            test_data.traces.push(call_traces);
201                            test_data.breakpoints.replace(case.breakpoints);
202                        }
203
204                        // Always store logs from the last run in test_data.logs for display at
205                        // verbosity >= 2. When show_logs is true,
206                        // accumulate all logs. When false, only keep the last run's logs.
207                        if self.config.show_logs {
208                            test_data.logs.extend(case.logs);
209                        } else {
210                            test_data.logs = case.logs;
211                        }
212
213                        HitMaps::merge_opt(&mut test_data.coverage, case.coverage);
214                        test_data.deprecated_cheatcodes = case.deprecated_cheatcodes;
215                    }
216                    FuzzOutcome::CounterExample(CounterExampleOutcome {
217                        exit_reason: status,
218                        counterexample: outcome,
219                        ..
220                    }) => {
221                        let reason = rd.maybe_decode(&outcome.1.result, status);
222                        test_data.logs.extend(outcome.1.logs.clone());
223                        test_data.counterexample = outcome;
224                        test_data.failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
225                        break 'stop;
226                    }
227                },
228                Err(err) => {
229                    match err {
230                        TestCaseError::Fail(_) => {
231                            test_data.failure = Some(err);
232                            break 'stop;
233                        }
234                        TestCaseError::Reject(_) => {
235                            // Discard run and apply max rejects if configured. Saturate to handle
236                            // the case of replayed failure, which doesn't count as a run.
237                            test_data.runs = test_data.runs.saturating_sub(1);
238                            test_data.rejects += 1;
239
240                            // Update progress bar to reflect rejected runs.
241                            if let Some(progress) = progress {
242                                progress.set_message(format!("([{}] rejected)", test_data.rejects));
243                                progress.dec(1);
244                            }
245
246                            if self.config.max_test_rejects > 0
247                                && test_data.rejects >= self.config.max_test_rejects
248                            {
249                                test_data.failure = Some(TestCaseError::reject(
250                                    FuzzError::TooManyRejects(self.config.max_test_rejects),
251                                ));
252                                break 'stop;
253                            }
254                        }
255                    }
256                }
257            }
258        }
259
260        let (calldata, call) = test_data.counterexample;
261        let mut traces = test_data.traces;
262        let (last_run_traces, last_run_breakpoints) = if test_data.failure.is_none() {
263            (traces.pop(), test_data.breakpoints)
264        } else {
265            (call.traces.clone(), call.cheatcodes.map(|c| c.breakpoints))
266        };
267
268        // test_data.logs already contains the appropriate logs:
269        // - For failed tests: logs from the counterexample
270        // - For successful tests with show_logs=true: all logs from all runs
271        // - For successful tests with show_logs=false: logs from the last run only
272        let result_logs = test_data.logs;
273
274        let mut result = FuzzTestResult {
275            first_case: test_data.first_case.unwrap_or_default(),
276            gas_by_case: test_data.gas_by_case,
277            success: test_data.failure.is_none(),
278            skipped: false,
279            reason: None,
280            counterexample: None,
281            logs: result_logs,
282            labels: call.labels,
283            traces: last_run_traces,
284            breakpoints: last_run_breakpoints,
285            gas_report_traces: traces.into_iter().map(|a| a.arena).collect(),
286            line_coverage: test_data.coverage,
287            deprecated_cheatcodes: test_data.deprecated_cheatcodes,
288            failed_corpus_replays: corpus_manager.failed_replays(),
289        };
290
291        match test_data.failure {
292            Some(TestCaseError::Fail(reason)) => {
293                let reason = reason.to_string();
294                result.reason = (!reason.is_empty()).then_some(reason);
295                let args = if let Some(data) = calldata.get(4..) {
296                    func.abi_decode_input(data).unwrap_or_default()
297                } else {
298                    vec![]
299                };
300                result.counterexample = Some(CounterExample::Single(
301                    BaseCounterExample::from_fuzz_call(calldata, args, call.traces),
302                ));
303            }
304            Some(TestCaseError::Reject(reason)) => {
305                let reason = reason.to_string();
306                result.reason = (!reason.is_empty()).then_some(reason);
307            }
308            None => {}
309        }
310
311        if let Some(reason) = &result.reason
312            && let Some(reason) = SkipReason::decode_self(reason)
313        {
314            result.skipped = true;
315            result.reason = reason.0;
316        }
317
318        state.log_stats();
319
320        Ok(result)
321    }
322
323    /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome`
324    /// or a `CounterExampleOutcome`
325    fn single_fuzz(
326        &mut self,
327        address: Address,
328        calldata: Bytes,
329        coverage_metrics: &mut CorpusManager,
330    ) -> Result<FuzzOutcome, TestCaseError> {
331        let mut call = self
332            .executor
333            .call_raw(self.sender, address, calldata.clone(), U256::ZERO)
334            .map_err(|e| TestCaseError::fail(e.to_string()))?;
335        let new_coverage = coverage_metrics.merge_edge_coverage(&mut call);
336        coverage_metrics.process_inputs(
337            &[BasicTxDetails {
338                warp: None,
339                roll: None,
340                sender: self.sender,
341                call_details: CallDetails { target: address, calldata: calldata.clone() },
342            }],
343            new_coverage,
344        );
345
346        // Handle `vm.assume`.
347        if call.result.as_ref() == MAGIC_ASSUME {
348            return Err(TestCaseError::reject(FuzzError::AssumeReject));
349        }
350
351        let (breakpoints, deprecated_cheatcodes) =
352            call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| {
353                (cheats.breakpoints.clone(), cheats.deprecated.clone())
354            });
355
356        // Consider call success if test should not fail on reverts and reverter is not the
357        // cheatcode or test address.
358        let success = if !self.config.fail_on_revert
359            && call
360                .reverter
361                .is_some_and(|reverter| reverter != address && reverter != CHEATCODE_ADDRESS)
362        {
363            true
364        } else {
365            self.executor.is_raw_call_mut_success(address, &mut call, false)
366        };
367
368        if success {
369            Ok(FuzzOutcome::Case(CaseOutcome {
370                case: FuzzCase { calldata, gas: call.gas_used, stipend: call.stipend },
371                traces: call.traces,
372                coverage: call.line_coverage,
373                breakpoints,
374                logs: call.logs,
375                deprecated_cheatcodes,
376            }))
377        } else {
378            Ok(FuzzOutcome::CounterExample(CounterExampleOutcome {
379                exit_reason: call.exit_reason,
380                counterexample: (calldata, call),
381                breakpoints,
382            }))
383        }
384    }
385}