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                if let Some(cheats) = self.executor.inspector_mut().cheatcodes.as_mut()
175                    && let Some(seed) = self.config.seed
176                {
177                    cheats.set_seed(seed.wrapping_add(U256::from(test_data.runs)));
178                }
179                test_data.runs += 1;
180
181                match corpus_manager.new_input(&mut self.runner, state, func) {
182                    Ok(input) => input,
183                    Err(err) => {
184                        test_data.failure = Some(TestCaseError::fail(format!(
185                            "failed to generate fuzzed input: {err}"
186                        )));
187                        break 'stop;
188                    }
189                }
190            };
191
192            match self.single_fuzz(address, input, &mut corpus_manager) {
193                Ok(fuzz_outcome) => match fuzz_outcome {
194                    FuzzOutcome::Case(case) => {
195                        test_data.gas_by_case.push((case.case.gas, case.case.stipend));
196
197                        if test_data.first_case.is_none() {
198                            test_data.first_case.replace(case.case);
199                        }
200
201                        if let Some(call_traces) = case.traces {
202                            if test_data.traces.len() == max_traces_to_collect {
203                                test_data.traces.pop();
204                            }
205                            test_data.traces.push(call_traces);
206                            test_data.breakpoints.replace(case.breakpoints);
207                        }
208
209                        // Always store logs from the last run in test_data.logs for display at
210                        // verbosity >= 2. When show_logs is true,
211                        // accumulate all logs. When false, only keep the last run's logs.
212                        if self.config.show_logs {
213                            test_data.logs.extend(case.logs);
214                        } else {
215                            test_data.logs = case.logs;
216                        }
217
218                        HitMaps::merge_opt(&mut test_data.coverage, case.coverage);
219                        test_data.deprecated_cheatcodes = case.deprecated_cheatcodes;
220                    }
221                    FuzzOutcome::CounterExample(CounterExampleOutcome {
222                        exit_reason: status,
223                        counterexample: outcome,
224                        ..
225                    }) => {
226                        let reason = rd.maybe_decode(&outcome.1.result, status);
227                        test_data.logs.extend(outcome.1.logs.clone());
228                        test_data.counterexample = outcome;
229                        test_data.failure = Some(TestCaseError::fail(reason.unwrap_or_default()));
230                        break 'stop;
231                    }
232                },
233                Err(err) => {
234                    match err {
235                        TestCaseError::Fail(_) => {
236                            test_data.failure = Some(err);
237                            break 'stop;
238                        }
239                        TestCaseError::Reject(_) => {
240                            // Discard run and apply max rejects if configured. Saturate to handle
241                            // the case of replayed failure, which doesn't count as a run.
242                            test_data.runs = test_data.runs.saturating_sub(1);
243                            test_data.rejects += 1;
244
245                            // Update progress bar to reflect rejected runs.
246                            if let Some(progress) = progress {
247                                progress.set_message(format!("([{}] rejected)", test_data.rejects));
248                                progress.dec(1);
249                            }
250
251                            if self.config.max_test_rejects > 0
252                                && test_data.rejects >= self.config.max_test_rejects
253                            {
254                                test_data.failure = Some(TestCaseError::reject(
255                                    FuzzError::TooManyRejects(self.config.max_test_rejects),
256                                ));
257                                break 'stop;
258                            }
259                        }
260                    }
261                }
262            }
263        }
264
265        let (calldata, call) = test_data.counterexample;
266        let mut traces = test_data.traces;
267        let (last_run_traces, last_run_breakpoints) = if test_data.failure.is_none() {
268            (traces.pop(), test_data.breakpoints)
269        } else {
270            (call.traces.clone(), call.cheatcodes.map(|c| c.breakpoints))
271        };
272
273        // test_data.logs already contains the appropriate logs:
274        // - For failed tests: logs from the counterexample
275        // - For successful tests with show_logs=true: all logs from all runs
276        // - For successful tests with show_logs=false: logs from the last run only
277        let result_logs = test_data.logs;
278
279        let mut result = FuzzTestResult {
280            first_case: test_data.first_case.unwrap_or_default(),
281            gas_by_case: test_data.gas_by_case,
282            success: test_data.failure.is_none(),
283            skipped: false,
284            reason: None,
285            counterexample: None,
286            logs: result_logs,
287            labels: call.labels,
288            traces: last_run_traces,
289            breakpoints: last_run_breakpoints,
290            gas_report_traces: traces.into_iter().map(|a| a.arena).collect(),
291            line_coverage: test_data.coverage,
292            deprecated_cheatcodes: test_data.deprecated_cheatcodes,
293            failed_corpus_replays: corpus_manager.failed_replays(),
294        };
295
296        match test_data.failure {
297            Some(TestCaseError::Fail(reason)) => {
298                let reason = reason.to_string();
299                result.reason = (!reason.is_empty()).then_some(reason);
300                let args = if let Some(data) = calldata.get(4..) {
301                    func.abi_decode_input(data).unwrap_or_default()
302                } else {
303                    vec![]
304                };
305                result.counterexample = Some(CounterExample::Single(
306                    BaseCounterExample::from_fuzz_call(calldata, args, call.traces),
307                ));
308            }
309            Some(TestCaseError::Reject(reason)) => {
310                let reason = reason.to_string();
311                result.reason = (!reason.is_empty()).then_some(reason);
312            }
313            None => {}
314        }
315
316        if let Some(reason) = &result.reason
317            && let Some(reason) = SkipReason::decode_self(reason)
318        {
319            result.skipped = true;
320            result.reason = reason.0;
321        }
322
323        state.log_stats();
324
325        Ok(result)
326    }
327
328    /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome`
329    /// or a `CounterExampleOutcome`
330    fn single_fuzz(
331        &mut self,
332        address: Address,
333        calldata: Bytes,
334        coverage_metrics: &mut CorpusManager,
335    ) -> Result<FuzzOutcome, TestCaseError> {
336        let mut call = self
337            .executor
338            .call_raw(self.sender, address, calldata.clone(), U256::ZERO)
339            .map_err(|e| TestCaseError::fail(e.to_string()))?;
340        let new_coverage = coverage_metrics.merge_edge_coverage(&mut call);
341        coverage_metrics.process_inputs(
342            &[BasicTxDetails {
343                warp: None,
344                roll: None,
345                sender: self.sender,
346                call_details: CallDetails { target: address, calldata: calldata.clone() },
347            }],
348            new_coverage,
349        );
350
351        // Handle `vm.assume`.
352        if call.result.as_ref() == MAGIC_ASSUME {
353            return Err(TestCaseError::reject(FuzzError::AssumeReject));
354        }
355
356        let (breakpoints, deprecated_cheatcodes) =
357            call.cheatcodes.as_ref().map_or_else(Default::default, |cheats| {
358                (cheats.breakpoints.clone(), cheats.deprecated.clone())
359            });
360
361        // Consider call success if test should not fail on reverts and reverter is not the
362        // cheatcode or test address.
363        let success = if !self.config.fail_on_revert
364            && call
365                .reverter
366                .is_some_and(|reverter| reverter != address && reverter != CHEATCODE_ADDRESS)
367        {
368            true
369        } else {
370            self.executor.is_raw_call_mut_success(address, &mut call, false)
371        };
372
373        if success {
374            Ok(FuzzOutcome::Case(CaseOutcome {
375                case: FuzzCase { calldata, gas: call.gas_used, stipend: call.stipend },
376                traces: call.traces,
377                coverage: call.line_coverage,
378                breakpoints,
379                logs: call.logs,
380                deprecated_cheatcodes,
381            }))
382        } else {
383            Ok(FuzzOutcome::CounterExample(CounterExampleOutcome {
384                exit_reason: call.exit_reason,
385                counterexample: (calldata, call),
386                breakpoints,
387            }))
388        }
389    }
390}