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