Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
11 changes: 6 additions & 5 deletions evm/src/executor/inspector/fuzzer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,13 @@ impl Fuzzer {
state.insert(utils::u256_to_h256_be(*slot).into());
}

for index in 0..interpreter.memory.len() / 32 {
let mut slot = [0u8; 32];
slot.clone_from_slice(interpreter.memory.get_slice(index * 32, 32));
// TODO: disabled for now since it's flooding the dictionary
// for index in 0..interpreter.memory.len() / 32 {
// let mut slot = [0u8; 32];
// slot.clone_from_slice(interpreter.memory.get_slice(index * 32, 32));
Comment thread
gakonst marked this conversation as resolved.

state.insert(slot);
}
// state.insert(slot);
// }
}

/// Overrides an external call and tries to call any method of msg.sender.
Expand Down
52 changes: 47 additions & 5 deletions evm/src/fuzz/invariant/executor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ use ethers::{
};
use eyre::ContextCompat;
use foundry_common::contracts::{ContractsByAddress, ContractsByArtifact};
use hashbrown::HashMap;
use parking_lot::{Mutex, RwLock};
use proptest::{
strategy::{BoxedStrategy, Strategy, ValueTree},
Expand Down Expand Up @@ -142,12 +143,14 @@ impl<'a> InvariantExecutor<'a> {
.expect("could not make raw evm call");

// Collect data for fuzzing from the state changeset.
let state_changeset =
let mut state_changeset =
call_result.state_changeset.to_owned().expect("to have a state changeset.");

collect_state_from_call(
&call_result.logs,
&state_changeset,
collect_data(
&mut state_changeset,
sender,
&invariant_contract,
&call_result,
fuzz_state.clone(),
);

Expand Down Expand Up @@ -206,6 +209,8 @@ impl<'a> InvariantExecutor<'a> {
});
}

tracing::trace!(target: "forge::test::invariant::dictionary", "{:?}", fuzz_state.read().iter().map(hex::encode));

let (reverts, invariants) = failures.into_inner().into_inner();

Ok(Some(InvariantFuzzTestResult { invariants, cases: fuzz_cases.into_inner(), reverts }))
Expand All @@ -230,7 +235,8 @@ impl<'a> InvariantExecutor<'a> {
}

// Stores fuzz state for use with [fuzz_calldata_from_state].
let fuzz_state: EvmFuzzState = build_initial_state(self.executor.backend().mem_db());
let fuzz_state: EvmFuzzState =
build_initial_state(invariant_contract.address, self.executor.backend().mem_db());

// During execution, any newly created contract is added here and used through the rest of
// the fuzz run.
Expand Down Expand Up @@ -479,6 +485,42 @@ impl<'a> InvariantExecutor<'a> {
}
}

/// Collects data from call for fuzzing. However, it first verifies that the sender is not an EOA
/// before inserting it into the dictionary. Otherwise, we flood the dictionary with
/// randomly generated addresses.
fn collect_data(
state_changeset: &mut HashMap<Address, revm::Account>,
sender: &Address,
invariant_contract: &InvariantContract,
call_result: &RawCallResult,
fuzz_state: EvmFuzzState,
) {
// Verify it has no code.
let mut has_code = false;
if let Some(Some(code)) = state_changeset.get(sender).map(|account| account.info.code.as_ref())
{
has_code = !code.is_empty();
}

// We keep the nonce changes to apply later.
let mut sender_changeset = None;
if !has_code {
sender_changeset = state_changeset.remove(sender);
}
Comment on lines +491 to +501

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

do we want to do this? what if there's e.g. a smart contract wallet that would make a call and hit e.g. a if isContract check that would trigger a re-entrancy via onERC721/onERC1155 fallback?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

This code only prevents adding this address to the dictionary through the statechangeset. The changes are still applied. So, unless I misunderstood something, it wouldn't prevent that scenario.


collect_state_from_call(
invariant_contract.address,
&call_result.logs,
&*state_changeset,
fuzz_state,
);

// Re-add changes
if let Some(changed) = sender_changeset {
state_changeset.insert(*sender, changed);
}
}

/// Verifies that the invariant run execution can continue.
fn can_continue(
invariant_contract: &InvariantContract,
Expand Down
7 changes: 4 additions & 3 deletions evm/src/fuzz/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,9 @@ impl<'a> FuzzedExecutor<'a> {

// Stores fuzz state for use with [fuzz_calldata_from_state]
let state: EvmFuzzState = if let Some(fork_db) = self.executor.backend().active_fork_db() {
build_initial_state(fork_db)
build_initial_state(address, fork_db)
} else {
build_initial_state(self.executor.backend().mem_db())
build_initial_state(address, self.executor.backend().mem_db())
};

// TODO: We should have a `FuzzerOpts` struct where we can configure the fuzzer. When we
Expand All @@ -83,7 +83,7 @@ impl<'a> FuzzedExecutor<'a> {
call.state_changeset.as_ref().expect("We should have a state changeset.");

// Build fuzzer state
collect_state_from_call(&call.logs, state_changeset, state.clone());
collect_state_from_call(address, &call.logs, state_changeset, state.clone());

// When assume cheat code is triggered return a special string "FOUNDRY::ASSUME"
if call.result.as_ref() == ASSUME_MAGIC_RETURN_CODE {
Expand Down Expand Up @@ -123,6 +123,7 @@ impl<'a> FuzzedExecutor<'a> {
))
}
});
tracing::trace!(target: "forge::test::fuzz::dictionary", "{:?}", state.read().iter().map(hex::encode));

let (calldata, call) = counterexample.into_inner();
let mut result = FuzzTestResult {
Expand Down
26 changes: 22 additions & 4 deletions evm/src/fuzz/strategies/invariants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ use proptest::prelude::*;
pub use proptest::test_runner::Config as FuzzConfig;
use std::sync::Arc;

use super::fuzz_param_from_state;

/// Given a target address, we generate random calldata.
pub fn override_call_strat(
fuzz_state: EvmFuzzState,
Expand Down Expand Up @@ -75,7 +77,7 @@ fn generate_call(
let senders = senders.clone();
let fuzz_state = fuzz_state.clone();
func.prop_flat_map(move |func| {
let sender = select_random_sender(senders.clone());
let sender = select_random_sender(fuzz_state.clone(), senders.clone());
Comment thread
gakonst marked this conversation as resolved.
(sender, fuzz_contract_with_calldata(fuzz_state.clone(), contract, func))
})
})
Expand All @@ -86,9 +88,25 @@ fn generate_call(
/// * If `senders` is empty, then it's a completely random address.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Just to confirm: It's not "completely random" right? But instead is either random OR from the dict, with the same weights as with other fuzz values

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

@joshieDo same Q, what's the difference between fuzz_param_from_state that has a 90% chance of being selected in fuzz_strategy vs the senders: Vec<Address> being passed in the fn?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

updated the docs to:

/// Strategy to select a sender address:
/// * If `senders` is empty, then it's either a random address (10%) or from the dictionary (90%).
/// * If `senders` is not empty, then there's an 80% chance that one from the list is selected. The
///   remaining 20% will either be a random address (10%) or from the dictionary (90%).

does it help?

/// * If `senders` is not empty, then there's an 80% chance that one from the list is selected. The
/// remaining 20% will be random.
fn select_random_sender(senders: Vec<Address>) -> impl Strategy<Value = Address> {
let fuzz_strategy =
fuzz_param(&ParamType::Address).prop_map(move |addr| addr.into_address().unwrap()).boxed();
fn select_random_sender(
fuzz_state: EvmFuzzState,
senders: Vec<Address>,
) -> impl Strategy<Value = Address> {
let fuzz_strategy = proptest::strategy::Union::new_weighted(vec![
(
10,
fuzz_param(&ParamType::Address)
.prop_map(move |addr| addr.into_address().unwrap())
.boxed(),
),
(
90,
fuzz_param_from_state(&ParamType::Address, fuzz_state)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Looks like weights are 90/10 but the comment above this method says 80/20. Probably should be exposed as a config option at some point (not necessarily in this PR)

.prop_map(move |addr| addr.into_address().unwrap())
.boxed(),
),
])
.boxed();

if !senders.is_empty() {
let selector =
Expand Down
13 changes: 10 additions & 3 deletions evm/src/fuzz/strategies/param.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ pub fn fuzz_param_from_state(param: &ParamType, arc_state: EvmFuzzState) -> Boxe
},
ParamType::Bool => value.prop_map(move |value| Token::Bool(value[31] == 1)).boxed(),
ParamType::String => value
.prop_map(move |value| Token::String(String::from_utf8_lossy(&value[..]).to_string()))
.prop_map(move |value| {
Token::String(
String::from_utf8_lossy(&value[..]).trim().trim_end_matches('\0').to_string(),
Comment thread
gakonst marked this conversation as resolved.
)
})
.boxed(),
ParamType::Array(param) => proptest::collection::vec(
fuzz_param_from_state(param, arc_state.clone()),
Expand Down Expand Up @@ -128,7 +132,10 @@ pub fn fuzz_param_from_state(param: &ParamType, arc_state: EvmFuzzState) -> Boxe

#[cfg(test)]
mod tests {
use crate::fuzz::strategies::{build_initial_state, fuzz_calldata, fuzz_calldata_from_state};
use crate::{
fuzz::strategies::{build_initial_state, fuzz_calldata, fuzz_calldata_from_state},
CALLER,
};
use ethers::abi::HumanReadableParser;
use revm::db::{CacheDB, EmptyDB};

Expand All @@ -138,7 +145,7 @@ mod tests {
let func = HumanReadableParser::parse_function(f).unwrap();

let db = CacheDB::new(EmptyDB());
let state = build_initial_state(&db);
let state = build_initial_state(CALLER, &db);

let strat = proptest::strategy::Union::new_weighted(vec![
(60, fuzz_calldata(func.clone())),
Expand Down
46 changes: 32 additions & 14 deletions evm/src/fuzz/strategies/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,20 +53,35 @@ This is a bug, please open an issue: https://github.com/foundry-rs/foundry/issue
}

/// Builds the initial [EvmFuzzState] from a database.
pub fn build_initial_state<DB: DatabaseRef>(db: &CacheDB<DB>) -> EvmFuzzState {
pub fn build_initial_state<DB: DatabaseRef>(
test_address: Address,
db: &CacheDB<DB>,
) -> EvmFuzzState {
let mut state: BTreeSet<[u8; 32]> = BTreeSet::new();
for (address, account) in db.accounts.iter() {
// We don't want to collect data from the test contract.
if *address == test_address {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

hmmm i actually disagree here. A lot of times the test contract will be an owner of a protocol's contract. And people throw relevant state in the test contract as well (esp w/ invariant tests, things like target contracts, etc)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ultimately these should probably be flags exposed in the config—there are cases where collecting data from the test contract will flood your dict, and other times where it may be valuable

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

imo very unlikely it will flood your dict - we collect push bytes, storage and basic account info. a lot of those values will either be duplicate of subcontracts or be relevant to the subcontracts (e.g. their addresses, assertion values etc.). if values are duplicate they don't expand the dictionary since it's a set

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@joshieDo if you really mean CALLER instead of test contract I agree, we dont wan't the caller. But we do want test contract state + address in the dictionary

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

hmm ok so e.g. stack/mem from a large setUp method or test contract helper methods wouldn't be collected? if so then I agree it's not likely to flood and should be ok

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

stack/mem is in a separate file, this file only collects push bytes + storage

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

stack/mem would be collected for invariants (through the inspector) but not normal fuzzing (there's no inspector collector).

Hmm, I see your overall point. I'll revert it. ( i really meant the test contract).

continue
}

let info = db.basic(*address);

// Insert basic account information
state.insert(H256::from(*address).into());
state.insert(utils::u256_to_h256_le(info.balance).into());
state.insert(utils::u256_to_h256_le(U256::from(info.nonce)).into());
state.insert(utils::u256_to_h256_be(info.balance).into());
state.insert(utils::u256_to_h256_be(U256::from(info.nonce)).into());
Comment thread
gakonst marked this conversation as resolved.
Outdated

// Insert storage
for (slot, value) in &account.storage {
state.insert(utils::u256_to_h256_le(*slot).into());
state.insert(utils::u256_to_h256_le(*value).into());
state.insert(utils::u256_to_h256_be(*slot).into());
state.insert(utils::u256_to_h256_be(*value).into());
}

// Insert push bytes
if let Some(code) = &account.info.code {
for push_byte in collect_push_bytes(code.bytes().clone()) {
state.insert(push_byte);
}
}
}

Expand All @@ -82,22 +97,28 @@ pub fn build_initial_state<DB: DatabaseRef>(db: &CacheDB<DB>) -> EvmFuzzState {

/// Collects state changes from a [StateChangeset] and logs into an [EvmFuzzState].
pub fn collect_state_from_call(
test_address: Address,
logs: &[Log],
state_changeset: &StateChangeset,
state: EvmFuzzState,
) {
let mut state = state.write();

for (address, account) in state_changeset {
// We don't want to collect data from the test contract.
if *address == test_address {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

again not sure i agree

continue
}

// Insert basic account information
state.insert(H256::from(*address).into());
state.insert(utils::u256_to_h256_le(account.info.balance).into());
state.insert(utils::u256_to_h256_le(U256::from(account.info.nonce)).into());
state.insert(utils::u256_to_h256_be(account.info.balance).into());
state.insert(utils::u256_to_h256_be(U256::from(account.info.nonce)).into());

// Insert storage
for (slot, value) in &account.storage {
state.insert(utils::u256_to_h256_le(*slot).into());
state.insert(utils::u256_to_h256_le(*value).into());
state.insert(utils::u256_to_h256_be(*slot).into());
state.insert(utils::u256_to_h256_be(*value).into());
}

// Insert push bytes
Expand Down Expand Up @@ -151,11 +172,8 @@ fn collect_push_bytes(code: Bytes) -> Vec<[u8; 32]> {
return bytes
}

let mut buffer: [u8; 32] = [0; 32];
let _ = (&mut buffer[..])
.write(&code[push_start..push_end])
.expect("push was larger than 32 bytes");
bytes.push(buffer);
bytes.push(U256::from_big_endian(&code[push_start..push_end]).into());

i += push_size;
}
i += 1;
Expand Down
10 changes: 9 additions & 1 deletion forge/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,19 @@ pub struct TestOptions {
}

impl TestOptions {
pub fn invariant_fuzzer(&self) -> TestRunner {
self.fuzzer_with_cases(self.invariant_runs)
}

pub fn fuzzer(&self) -> TestRunner {
self.fuzzer_with_cases(self.fuzz_runs)
}

pub fn fuzzer_with_cases(&self, cases: u32) -> TestRunner {
// TODO: Add Options to modify the persistence
let cfg = proptest::test_runner::Config {
failure_persistence: None,
cases: self.fuzz_runs,
cases,
max_local_rejects: self.fuzz_max_local_rejects,
max_global_rejects: self.fuzz_max_global_rejects,
..Default::default()
Expand Down
2 changes: 1 addition & 1 deletion forge/src/runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -302,7 +302,7 @@ impl<'a> ContractRunner<'a> {
.collect();

let results = self.run_invariant_test(
test_options.fuzzer(),
test_options.invariant_fuzzer(),
setup,
test_options,
functions.clone(),
Expand Down
33 changes: 32 additions & 1 deletion forge/tests/it/fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@

use crate::{config::*, test_helpers::filter::Filter};
use forge::result::SuiteResult;

use foundry_evm::decode::decode_console_logs;
use std::collections::BTreeMap;

#[test]
fn test_fuzz() {
Expand Down Expand Up @@ -40,3 +40,34 @@ fn test_fuzz() {
}
}
}

#[test]
fn test_fuzz_collection() {
let mut runner = runner();

let mut opts = TEST_OPTS;
opts.invariant_depth = 200;
opts.fuzz_runs = 1000;
runner.test_options = opts;

let results =
runner.test(&Filter::new(".*", ".*", ".*fuzz/FuzzCollection.t.sol"), None, opts).unwrap();

assert_multiple(
&results,
BTreeMap::from([(
"fuzz/FuzzCollection.t.sol:SampleContractTest",
vec![
("invariantCounter", false, Some("broken counter.".into()), None, None),
(
"testIncrement(address)",
false,
Some("Call did not revert as expected".into()),
None,
None,
),
("testNeedle(uint256)", false, Some("needle found.".into()), None, None),
],
)]),
);
}
Loading