foundry_evm/executors/fuzz/
mod.rs

1use crate::executors::{
2    DURATION_BETWEEN_METRICS_REPORT, Executor, FailFast, 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        fail_fast: &FailFast,
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 fail_fast.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                            if self.config.max_test_rejects > 0 {
231                                test_data.rejects += 1;
232                                if test_data.rejects >= self.config.max_test_rejects {
233                                    test_data.failure = Some(err);
234                                    break 'stop;
235                                }
236                            }
237                        }
238                    }
239                }
240            }
241        }
242
243        let (calldata, call) = test_data.counterexample;
244        let mut traces = test_data.traces;
245        let (last_run_traces, last_run_breakpoints) = if test_data.failure.is_none() {
246            (traces.pop(), test_data.breakpoints)
247        } else {
248            (call.traces.clone(), call.cheatcodes.map(|c| c.breakpoints))
249        };
250
251        let mut result = FuzzTestResult {
252            first_case: test_data.first_case.unwrap_or_default(),
253            gas_by_case: test_data.gas_by_case,
254            success: test_data.failure.is_none(),
255            skipped: false,
256            reason: None,
257            counterexample: None,
258            logs: test_data.logs,
259            labels: call.labels,
260            traces: last_run_traces,
261            breakpoints: last_run_breakpoints,
262            gas_report_traces: traces.into_iter().map(|a| a.arena).collect(),
263            line_coverage: test_data.coverage,
264            deprecated_cheatcodes: test_data.deprecated_cheatcodes,
265            failed_corpus_replays: corpus_manager.failed_replays(),
266        };
267
268        match test_data.failure {
269            Some(TestCaseError::Fail(reason)) => {
270                let reason = reason.to_string();
271                result.reason = (!reason.is_empty()).then_some(reason);
272                let args = if let Some(data) = calldata.get(4..) {
273                    func.abi_decode_input(data).unwrap_or_default()
274                } else {
275                    vec![]
276                };
277                result.counterexample = Some(CounterExample::Single(
278                    BaseCounterExample::from_fuzz_call(calldata, args, call.traces),
279                ));
280            }
281            Some(TestCaseError::Reject(reason)) => {
282                let reason = reason.to_string();
283                result.reason = (!reason.is_empty()).then_some(reason);
284            }
285            None => {}
286        }
287
288        if let Some(reason) = &result.reason
289            && let Some(reason) = SkipReason::decode_self(reason)
290        {
291            result.skipped = true;
292            result.reason = reason.0;
293        }
294
295        state.log_stats();
296
297        Ok(result)
298    }
299
300    /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome`
301    /// or a `CounterExampleOutcome`
302    fn single_fuzz(
303        &mut self,
304        address: Address,
305        calldata: Bytes,
306        coverage_metrics: &mut CorpusManager,
307    ) -> Result<FuzzOutcome, TestCaseError> {
308        let mut call = self
309            .executor
310            .call_raw(self.sender, address, calldata.clone(), U256::ZERO)
311            .map_err(|e| TestCaseError::fail(e.to_string()))?;
312        let new_coverage = coverage_metrics.merge_edge_coverage(&mut call);
313        coverage_metrics.process_inputs(
314            &[BasicTxDetails {
315                sender: self.sender,
316                call_details: CallDetails { target: address, calldata: calldata.clone() },
317            }],
318            new_coverage,
319        );
320
321        // Handle `vm.assume`.
322        if call.result.as_ref() == MAGIC_ASSUME {
323            return Err(TestCaseError::reject(FuzzError::TooManyRejects(
324                self.config.max_test_rejects,
325            )));
326        }
327
328        let (breakpoints, deprecated_cheatcodes) =
329            call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| {
330                (cheats.breakpoints.clone(), cheats.deprecated.clone())
331            });
332
333        // Consider call success if test should not fail on reverts and reverter is not the
334        // cheatcode or test address.
335        let success = if !self.config.fail_on_revert
336            && call
337                .reverter
338                .is_some_and(|reverter| reverter != address && reverter != CHEATCODE_ADDRESS)
339        {
340            true
341        } else {
342            self.executor.is_raw_call_mut_success(address, &mut call, false)
343        };
344
345        if success {
346            Ok(FuzzOutcome::Case(CaseOutcome {
347                case: FuzzCase { calldata, gas: call.gas_used, stipend: call.stipend },
348                traces: call.traces,
349                coverage: call.line_coverage,
350                breakpoints,
351                logs: call.logs,
352                deprecated_cheatcodes,
353            }))
354        } else {
355            Ok(FuzzOutcome::CounterExample(CounterExampleOutcome {
356                exit_reason: call.exit_reason,
357                counterexample: (calldata, call),
358                breakpoints,
359            }))
360        }
361    }
362
363    /// Stores fuzz state for use with [fuzz_calldata_from_state]
364    pub fn build_fuzz_state(&self, deployed_libs: &[Address]) -> EvmFuzzState {
365        if let Some(fork_db) = self.executor.backend().active_fork_db() {
366            EvmFuzzState::new(fork_db, self.config.dictionary, deployed_libs)
367        } else {
368            EvmFuzzState::new(
369                self.executor.backend().mem_db(),
370                self.config.dictionary,
371                deployed_libs,
372            )
373        }
374    }
375}