Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(forge): fuzz dictionary #731

Closed
wants to merge 17 commits into from
Closed
Show file tree
Hide file tree
Changes from all 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
9 changes: 9 additions & 0 deletions .gitmodules
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[submodule "foundry_seminar2/lib/ds-test"]
path = foundry_seminar2/lib/ds-test
url = https://github.com/dapphub/ds-test
[submodule "foundry_seminar2/lib/solmate"]
path = foundry_seminar2/lib/solmate
url = https://github.com/Rari-Capital/solmate
[submodule "foundry_seminar2/lib/forge-std"]
path = foundry_seminar2/lib/forge-std
url = https://github.com/brockelmore/forge-std
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions cli/src/cmd/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,8 @@ impl Cmd for RunArgs {
Some(evm_opts.sender),
None,
&predeploy_libraries,
0,
0,
);
runner.run_test(&func, needs_setup, Some(&known_contracts))?
}
Expand All @@ -128,6 +130,8 @@ impl Cmd for RunArgs {
Some(evm_opts.sender),
None,
&predeploy_libraries,
0,
0,
);
runner.run_test(&func, needs_setup, Some(&known_contracts))?
}
Expand Down
1 change: 1 addition & 0 deletions evm-adapters/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ serde_json = "1.0.72"
serde = "1.0.130"
ansi_term = "0.12.1"
comfy-table = "5.0.0"
fnv = "1.0.3"

[dev-dependencies]
ethers = { git = "https://github.com/gakonst/ethers-rs", default-features = false, features = ["solc-full", "solc-tests"] }
Expand Down
176 changes: 93 additions & 83 deletions evm-adapters/src/fuzz.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
//! Fuzzing support abstracted over the [`Evm`](crate::Evm) used
use crate::{Evm, ASSUME_MAGIC_RETURN_CODE};

use crate::{
fuzz_strategies::calldata_strategy::fuzz_state_calldata, Evm, ASSUME_MAGIC_RETURN_CODE,
};
use ethers::{
abi::{Abi, Function, ParamType, Token, Tokenizable},
types::{Address, Bytes, I256, U256},
abi::{Abi, Function},
types::{Address, Bytes},
};
use std::{
cell::{RefCell, RefMut},
io::Write,
marker::PhantomData,
rc::Rc,
};

pub use proptest::test_runner::{Config as FuzzConfig, Reason};
Expand All @@ -16,8 +21,6 @@ use proptest::{
};
use serde::{Deserialize, Serialize};

mod strategies;

/// Wrapper around any [`Evm`](crate::Evm) implementor 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 All @@ -29,6 +32,8 @@ pub struct FuzzedExecutor<'a, E, S> {
runner: TestRunner,
state: PhantomData<S>,
sender: Address,
pub state_weight: u32,
pub random_weight: u32,
}

impl<'a, S, E: Evm<S>> FuzzedExecutor<'a, E, S> {
Expand All @@ -42,8 +47,21 @@ impl<'a, S, E: Evm<S>> FuzzedExecutor<'a, E, S> {
}

/// Instantiates a fuzzed executor EVM given a testrunner
pub fn new(evm: &'a mut E, runner: TestRunner, sender: Address) -> Self {
Self { evm: RefCell::new(evm), runner, state: PhantomData, sender }
pub fn new(
evm: &'a mut E,
runner: TestRunner,
sender: Address,
state_weight: u32,
random_weight: u32,
) -> Self {
Self {
evm: RefCell::new(evm),
runner,
state: PhantomData,
sender,
state_weight,
random_weight,
}
}

/// Fuzzes the provided function, assuming it is available at the contract at `address`
Expand All @@ -64,23 +82,45 @@ impl<'a, S, E: Evm<S>> FuzzedExecutor<'a, E, S> {
// fuzz test run.
S: Clone,
{
let strat = fuzz_calldata(func);
let mut strats = Vec::new();
if self.random_weight > 0 {
strats.push((self.random_weight, fuzz_state_calldata(func.clone(), None).boxed()))
}

// Snapshot the state before the test starts running
let pre_test_state = self.evm.borrow().state().clone();
let flattened_state = Rc::new(RefCell::new(self.evm.borrow().flatten_state()));

// we dont shrink for state strategy
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// we dont shrink for state strategy
// we dont shrink for state strategy because shrinking in a collection moves the iterator
// whereas here it doesn't make sense to shrink, given everything is a concrete value

if self.state_weight > 0 {
strats.push((
self.state_weight,
fuzz_state_calldata(func.clone(), Some(flattened_state.clone()))
.no_shrink()
.boxed(),
))
}

if strats.is_empty() {
panic!("Fuzz strategy weights were all 0. Please set at least one strategy weight to be above 0");
}

// stores the consumed gas and calldata of every successful fuzz call
let fuzz_cases: RefCell<Vec<FuzzCase>> = RefCell::new(Default::default());

let combined_strat = proptest::strategy::Union::new_weighted(strats);

// stores the latest reason of a test call, this will hold the return reason of failed test
// case if the runner failed
let return_reason: RefCell<Option<E::ReturnReason>> = RefCell::new(None);
let revert_reason = RefCell::new(None);

let mut runner = self.runner.clone();
tracing::debug!(func = ?func.name, should_fail, "fuzzing");
let ret_calldata = RefCell::new(None);

let test_error = runner
.run(&strat, |calldata| {
.run(&combined_strat, |calldata| {
let mut evm = self.evm.borrow_mut();
// Before each test, we must reset to the initial state
evm.reset(pre_test_state.clone());
Expand Down Expand Up @@ -108,6 +148,13 @@ impl<'a, S, E: Evm<S>> FuzzedExecutor<'a, E, S> {
let revert =
foundry_utils::decode_revert(returndata.as_ref(), abi).unwrap_or_default();
let _ = revert_reason.borrow_mut().insert(revert);

// because of how we do state selector, (totally random)
// we have to manually set the test_error data. Otherwise
// the way proptest works, makes it so the failing calldata wouldnt be the same
// as the test_error calldata. so we do this instead
let mut cd = ret_calldata.borrow_mut();
*cd = Some(calldata.clone());
}

// This will panic and get caught by the executor
Expand All @@ -122,14 +169,33 @@ impl<'a, S, E: Evm<S>> FuzzedExecutor<'a, E, S> {
}
);

{
let mut t = flattened_state.borrow_mut();
(*t).extend(evm.flatten_state());

returndata.as_ref().chunks(32).for_each(|chunk| {
let mut to_fill: [u8; 32] = [0; 32];
let _ = (&mut to_fill[..])
.write(chunk)
.expect("Chunk cannot be greater than 32 bytes");
(*t).insert(to_fill);
});
}

// push test case to the case set
fuzz_cases.borrow_mut().push(FuzzCase { calldata, gas });

Ok(())
})
.err()
.map(|test_error| FuzzError {
test_error,
// selector strategy isnt reproducible, so we hack around that by using a refcell
test_error: match test_error {
TestError::Abort(msg) => TestError::Abort(msg),
TestError::Fail(msg, _cd) => {
TestError::Fail(msg, ret_calldata.into_inner().expect("Calldata must be set"))
}
},
return_reason: return_reason.into_inner().expect("Reason must be set"),
revert_reason: revert_reason.into_inner().expect("Revert error string must be set"),
});
Expand Down Expand Up @@ -231,79 +297,6 @@ pub struct FuzzCase {
pub gas: u64,
}

/// Given a function, it returns a proptest strategy which generates valid abi-encoded calldata
/// for that function's input types.
pub fn fuzz_calldata(func: &Function) -> impl Strategy<Value = Bytes> + '_ {
// We need to compose all the strategies generated for each parameter in all
// possible combinations
let strats = func.inputs.iter().map(|input| fuzz_param(&input.kind)).collect::<Vec<_>>();

strats.prop_map(move |tokens| {
tracing::trace!(input = ?tokens);
func.encode_input(&tokens).unwrap().into()
})
}

/// The max length of arrays we fuzz for is 256.
const MAX_ARRAY_LEN: usize = 256;

/// Given an ethabi parameter type, returns a proptest strategy for generating values for that
/// datatype. Works with ABI Encoder v2 tuples.
fn fuzz_param(param: &ParamType) -> impl Strategy<Value = Token> {
match param {
ParamType::Address => {
// The key to making this work is the `boxed()` call which type erases everything
// https://altsysrq.github.io/proptest-book/proptest/tutorial/transforming-strategies.html
any::<[u8; 20]>().prop_map(|x| Address::from_slice(&x).into_token()).boxed()
}
ParamType::Bytes => any::<Vec<u8>>().prop_map(|x| Bytes::from(x).into_token()).boxed(),
// For ints and uints we sample from a U256, then wrap it to the correct size with a
// modulo operation. Note that this introduces modulo bias, but it can be removed with
// rejection sampling if it's determined the bias is too severe. Rejection sampling may
// slow down tests as it resamples bad values, so may want to benchmark the performance
// hit and weigh that against the current bias before implementing
ParamType::Int(n) => match n / 8 {
32 => any::<[u8; 32]>()
.prop_map(move |x| I256::from_raw(U256::from(&x)).into_token())
.boxed(),
y @ 1..=31 => any::<[u8; 32]>()
.prop_map(move |x| {
// Generate a uintN in the correct range, then shift it to the range of intN
// by subtracting 2^(N-1)
let uint = U256::from(&x) % U256::from(2).pow(U256::from(y * 8));
let max_int_plus1 = U256::from(2).pow(U256::from(y * 8 - 1));
let num = I256::from_raw(uint.overflowing_sub(max_int_plus1).0);
num.into_token()
})
.boxed(),
_ => panic!("unsupported solidity type int{}", n),
},
ParamType::Uint(n) => {
strategies::UintStrategy::new(*n, vec![]).prop_map(|x| x.into_token()).boxed()
}
ParamType::Bool => any::<bool>().prop_map(|x| x.into_token()).boxed(),
ParamType::String => any::<Vec<u8>>()
.prop_map(|x| Token::String(unsafe { std::str::from_utf8_unchecked(&x).to_string() }))
.boxed(),
ParamType::Array(param) => proptest::collection::vec(fuzz_param(param), 0..MAX_ARRAY_LEN)
.prop_map(Token::Array)
.boxed(),
ParamType::FixedBytes(size) => (0..*size as u64)
.map(|_| any::<u8>())
.collect::<Vec<_>>()
.prop_map(Token::FixedBytes)
.boxed(),
ParamType::FixedArray(param, size) => (0..*size as u64)
.map(|_| fuzz_param(param).prop_map(|param| param.into_token()))
.collect::<Vec<_>>()
.prop_map(Token::FixedArray)
.boxed(),
ParamType::Tuple(params) => {
params.iter().map(fuzz_param).collect::<Vec<_>>().prop_map(Token::Tuple).boxed()
}
}
}

#[cfg(test)]
#[cfg(feature = "sputnik")]
mod tests {
Expand Down Expand Up @@ -331,4 +324,21 @@ mod tests {
let revert_reason = error.revert_reason;
assert_eq!(revert_reason, "fuzztest-revert");
}

#[test]
fn finds_fuzzed_state_revert() {
let mut evm = vm();

let compiled = COMPILED.find("FuzzTests").expect("could not find contract");
let (addr, _, _, _) =
evm.deploy(Address::zero(), compiled.bytecode().unwrap().clone(), 0.into()).unwrap();

let evm = fuzzvm(&mut evm);

let func = compiled.abi.unwrap().function("testFuzzedStateRevert").unwrap();
let res = evm.fuzz(func, addr, false, compiled.abi);
let error = res.test_error.unwrap();
let revert_reason = error.revert_reason;
assert_eq!(revert_reason, "fuzzstate-revert");
}
}
Loading