diff --git a/crates/evm/src/fuzz/mod.rs b/crates/evm/src/fuzz/mod.rs index 3d7f0c559aaad..e0dc9e466fbba 100644 --- a/crates/evm/src/fuzz/mod.rs +++ b/crates/evm/src/fuzz/mod.rs @@ -20,10 +20,12 @@ use strategies::{ build_initial_state, collect_state_from_call, fuzz_calldata, fuzz_calldata_from_state, EvmFuzzState, }; +use types::{CaseOutcome, CounterExampleOutcome, FuzzCase, FuzzOutcome}; pub mod error; pub mod invariant; pub mod strategies; +pub mod types; /// Wrapper around an [`Executor`] which provides fuzzing support using [`proptest`](https://docs.rs/proptest/1.0.0/proptest/). /// @@ -101,72 +103,45 @@ impl<'a> FuzzedExecutor<'a> { let strat = proptest::strategy::Union::new_weighted(weights); debug!(func = ?func.name, should_fail, "fuzzing"); let run_result = self.runner.clone().run(&strat, |calldata| { - let call = self - .executor - .call_raw(self.sender, address, calldata.0.clone(), 0.into()) - .map_err(|_| TestCaseError::fail(FuzzError::FailedContractCall))?; - let state_changeset = call - .state_changeset - .as_ref() - .ok_or_else(|| TestCaseError::fail(FuzzError::EmptyChangeset))?; - - // Build fuzzer state - collect_state_from_call( - &call.logs, - state_changeset, - state.clone(), - &self.config.dictionary, - ); - - // When assume cheat code is triggered return a special string "FOUNDRY::ASSUME" - if call.result.as_ref() == ASSUME_MAGIC_RETURN_CODE { - return Err(TestCaseError::reject(FuzzError::AssumeReject)) - } - - let success = self.executor.is_success( - address, - call.reverted, - state_changeset.clone(), - should_fail, - ); - - if success { - let mut first_case = first_case.borrow_mut(); - if first_case.is_none() { - first_case.replace(FuzzCase { - calldata, - gas: call.gas_used, - stipend: call.stipend, - }); + let fuzz_res = self.single_fuzz(&state, address, should_fail, calldata)?; + + match fuzz_res { + FuzzOutcome::Case(case) => { + let mut first_case = first_case.borrow_mut(); + gas_by_case.borrow_mut().push((case.case.gas, case.case.stipend)); + if first_case.is_none() { + first_case.replace(case.case); + } + + traces.replace(case.traces); + + if let Some(prev) = coverage.take() { + // Safety: If `Option::or` evaluates to `Some`, then `call.coverage` must + // necessarily also be `Some` + coverage.replace(Some(prev.merge(case.coverage.unwrap()))); + } else { + coverage.replace(case.coverage); + } + + Ok(()) } - gas_by_case.borrow_mut().push((call.gas_used, call.stipend)); - - traces.replace(call.traces); - - if let Some(prev) = coverage.take() { - // Safety: If `Option::or` evaluates to `Some`, then `call.coverage` must - // necessarily also be `Some` - coverage.replace(Some(prev.merge(call.coverage.unwrap()))); - } else { - coverage.replace(call.coverage); + FuzzOutcome::CounterExample(CounterExampleOutcome { + exit_reason, + counterexample: _counterexample, + .. + }) => { + let status = exit_reason; + // We cannot use the calldata returned by the test runner in `TestError::Fail`, + // since that input represents the last run case, which may not correspond with + // our failure - when a fuzz case fails, proptest will try + // to run at least one more case to find a minimal failure + // case. + let call_res = _counterexample.1.result.clone(); + *counterexample.borrow_mut() = _counterexample; + Err(TestCaseError::fail( + decode::decode_revert(&call_res, errors, Some(status)).unwrap_or_default(), + )) } - - Ok(()) - } else { - let status = call.exit_reason; - // We cannot use the calldata returned by the test runner in `TestError::Fail`, - // since that input represents the last run case, which may not correspond with our - // failure - when a fuzz case fails, proptest will try to run at least one more - // case to find a minimal failure case. - *counterexample.borrow_mut() = (calldata, call); - Err(TestCaseError::fail( - decode::decode_revert( - counterexample.borrow().1.result.as_ref(), - errors, - Some(status), - ) - .unwrap_or_default(), - )) } }); @@ -216,6 +191,63 @@ impl<'a> FuzzedExecutor<'a> { result } + + /// Granular and single-step function that runs only one fuzz and returns either a `CaseOutcome` + /// or a `CounterExampleOutcome` + pub fn single_fuzz( + &self, + state: &EvmFuzzState, + address: Address, + should_fail: bool, + calldata: ethers::types::Bytes, + ) -> Result { + let call = self + .executor + .call_raw(self.sender, address, calldata.0.clone(), 0.into()) + .map_err(|_| TestCaseError::fail(FuzzError::FailedContractCall))?; + let state_changeset = call + .state_changeset + .as_ref() + .ok_or_else(|| TestCaseError::fail(FuzzError::EmptyChangeset))?; + + // Build fuzzer state + collect_state_from_call( + &call.logs, + state_changeset, + state.clone(), + &self.config.dictionary, + ); + + // When assume cheat code is triggered return a special string "FOUNDRY::ASSUME" + if call.result.as_ref() == ASSUME_MAGIC_RETURN_CODE { + return Err(TestCaseError::reject(FuzzError::AssumeReject)) + } + + let breakpoints = call + .cheatcodes + .as_ref() + .map_or_else(Default::default, |cheats| cheats.breakpoints.clone()); + + let success = + self.executor.is_success(address, call.reverted, state_changeset.clone(), should_fail); + + if success { + Ok(FuzzOutcome::Case(CaseOutcome { + case: FuzzCase { calldata, gas: call.gas_used, stipend: call.stipend }, + traces: call.traces, + coverage: call.coverage, + debug: call.debug, + breakpoints, + })) + } else { + Ok(FuzzOutcome::CounterExample(CounterExampleOutcome { + debug: call.debug.clone(), + exit_reason: call.exit_reason, + counterexample: (calldata, call), + breakpoints, + })) + } + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -444,14 +476,3 @@ impl FuzzedCases { self.lowest().map(|c| c.gas).unwrap_or_default() } } - -/// Data of a single fuzz test case -#[derive(Clone, Debug, Default, Serialize, Deserialize)] -pub struct FuzzCase { - /// The calldata used for this fuzz test - pub calldata: Bytes, - /// Consumed gas - pub gas: u64, - /// The initial gas stipend for the transaction - pub stipend: u64, -} diff --git a/crates/evm/src/fuzz/types.rs b/crates/evm/src/fuzz/types.rs new file mode 100644 index 0000000000000..7178376a67a74 --- /dev/null +++ b/crates/evm/src/fuzz/types.rs @@ -0,0 +1,51 @@ +use crate::{coverage::HitMaps, debug::DebugArena, executor::RawCallResult, trace::CallTraceArena}; +use ethers::types::Bytes; +use foundry_common::evm::Breakpoints; +use revm::interpreter::InstructionResult; +use serde::{Deserialize, Serialize}; + +/// Data of a single fuzz test case +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +pub struct FuzzCase { + /// The calldata used for this fuzz test + pub calldata: Bytes, + /// Consumed gas + pub gas: u64, + /// The initial gas stipend for the transaction + pub stipend: u64, +} + +/// Returned by a single fuzz in the case of a successful run +#[derive(Debug)] +pub struct CaseOutcome { + /// Data of a single fuzz test case + pub case: FuzzCase, + /// The traces of the call + pub traces: Option, + /// The coverage info collected during the call + pub coverage: Option, + /// The debug nodes of the call + pub debug: Option, + /// Breakpoints char pc map + pub breakpoints: Breakpoints, +} + +/// Returned by a single fuzz when a counterexample has been discovered +#[derive(Debug)] +pub struct CounterExampleOutcome { + /// Minimal reproduction test case for failing test + pub counterexample: (ethers::types::Bytes, RawCallResult), + /// The status of the call + pub exit_reason: InstructionResult, + /// The debug nodes of the call + pub debug: Option, + /// Breakpoints char pc map + pub breakpoints: Breakpoints, +} + +/// Outcome of a single fuzz +#[derive(Debug)] +pub enum FuzzOutcome { + Case(CaseOutcome), + CounterExample(CounterExampleOutcome), +} diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index c6363e0695ce2..ca717e61b6484 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -6,7 +6,7 @@ use foundry_common::evm::Breakpoints; use foundry_evm::{ coverage::HitMaps, executor::EvmError, - fuzz::{CounterExample, FuzzCase}, + fuzz::{types::FuzzCase, CounterExample}, trace::{TraceKind, Traces}, }; use serde::{Deserialize, Serialize};