Skip to content
Merged
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
188 changes: 123 additions & 65 deletions crates/evm/src/fuzz/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::{
coverage::HitMaps,
debug::DebugArena,
decode::{self, decode_console_logs},
executor::{Executor, RawCallResult},
trace::CallTraceArena,
Expand All @@ -10,10 +11,11 @@ use ethers::{
types::{Address, Bytes, Log},
};
use eyre::Result;
use foundry_common::{calc, contracts::ContractsByAddress};
use foundry_common::{calc, contracts::ContractsByAddress, evm::Breakpoints};
use foundry_config::FuzzConfig;
pub use proptest::test_runner::Reason;
use proptest::test_runner::{TestCaseError, TestError, TestRunner};
use revm::interpreter::InstructionResult;
use serde::{Deserialize, Serialize};
use std::{cell::RefCell, collections::BTreeMap, fmt};
use strategies::{
Expand All @@ -25,6 +27,34 @@ pub mod error;
pub mod invariant;
pub mod strategies;

/// Returned by a single fuzz in the case of a successful run
#[derive(Debug)]
pub struct CaseOutcome {
pub case: FuzzCase,
pub gas_used: u64,
pub stipend: u64,
pub traces: Option<CallTraceArena>,
pub coverage: Option<HitMaps>,
pub debug: Option<DebugArena>,
pub breakpoints: Breakpoints,
}

/// Returned by a single fuzz when a counterexample has been discovered
#[derive(Debug)]
pub struct CounterExampleOutcome {
pub counterexample: (ethers::types::Bytes, RawCallResult),
pub exit_reason: InstructionResult,
pub debug: Option<DebugArena>,
pub breakpoints: Breakpoints,
}

/// Outcome of a single fuzz
#[derive(Debug)]
pub enum FuzzOutcome {
Case(CaseOutcome),
CounterExample(CounterExampleOutcome),
}
Comment thread
franfrandev marked this conversation as resolved.
Outdated

/// Wrapper around an [`Executor`] which provides fuzzing support using [`proptest`](https://docs.rs/proptest/1.0.0/proptest/).
///
/// After instantiation, calling `fuzz` will proceed to hammer the deployed smart contract with
Expand Down Expand Up @@ -101,72 +131,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();
if first_case.is_none() {
first_case.replace(case.case);
}
gas_by_case.borrow_mut().push((case.gas_used, case.stipend));

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())));
Comment thread
Evalir marked this conversation as resolved.
} 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(),
))
}
});

Expand Down Expand Up @@ -216,6 +219,61 @@ impl<'a> FuzzedExecutor<'a> {

result
}

pub fn single_fuzz(
Comment thread
franfrandev marked this conversation as resolved.
&self,
state: &EvmFuzzState,
address: Address,
should_fail: bool,
calldata: ethers::types::Bytes,
) -> Result<FuzzOutcome, TestCaseError> {
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(),
Comment thread
Evalir marked this conversation as resolved.
&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.clone().map_or(Default::default(), |cheats| cheats.breakpoints);
Comment thread
franfrandev marked this conversation as resolved.
Outdated

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 },
gas_used: 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,
Comment thread
Evalir marked this conversation as resolved.
}))
}
}
}

#[derive(Clone, Debug, Serialize, Deserialize)]
Expand Down