-
Notifications
You must be signed in to change notification settings - Fork 2.6k
fix(evm): fuzzing not properly collecting data #2724
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
Changes from 11 commits
cbb2475
95c1f10
b95b84e
96fad72
010c417
de9c728
96c9e76
7e62839
1e0a25a
ccd9d0c
94ec119
a3301e1
d379e2f
a370232
16e072f
fffc554
a952650
6648e8c
236709e
5db4bd4
f942121
7035c3a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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}, | ||
|
|
@@ -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(), | ||
| ); | ||
|
|
||
|
|
@@ -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 })) | ||
|
|
@@ -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. | ||
|
|
@@ -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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This code only prevents adding this address to the dictionary through the |
||
|
|
||
| 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, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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, | ||
|
|
@@ -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()); | ||
|
gakonst marked this conversation as resolved.
|
||
| (sender, fuzz_contract_with_calldata(fuzz_state.clone(), contract, func)) | ||
| }) | ||
| }) | ||
|
|
@@ -86,9 +88,25 @@ fn generate_call( | |
| /// * If `senders` is empty, then it's a completely random address. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @joshieDo same Q, what's the difference between
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. updated the docs to: 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) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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)
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @joshieDo if you really mean
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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()); | ||
|
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); | ||
| } | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -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 { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 | ||
|
|
@@ -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; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.