Skip to content
Open
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
74 changes: 68 additions & 6 deletions crates/evm/fuzz/src/strategies/invariants.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,9 @@ use crate::{
},
};
use alloy_json_abi::Function;
use alloy_primitives::{Address, U256};
use alloy_primitives::{Address, U256, address};
use foundry_config::InvariantConfig;
use foundry_evm_core::constants::CALLER;
use parking_lot::RwLock;
use proptest::prelude::*;
use rand::seq::IteratorRandom;
Expand All @@ -20,6 +21,19 @@ struct PlannedFuzzedCalls {
calls: Vec<BoxedStrategy<CallDetails>>,
}

/// Default invariant senders, modeled after Echidna's fixed sender pool.
///
/// The Foundry default deployer is included because owner/deployer-only paths are common in
/// invariant targets.
const DEFAULT_INVARIANT_SENDERS: [Address; 3] = [
address!("0x0000000000000000000000000000000000010000"),
address!("0x0000000000000000000000000000000000020000"),
CALLER,
];

const RANDOM_SENDER_WEIGHT: u32 = 1;
const DEFAULT_SENDER_WEIGHT: u32 = 99;
Comment on lines +34 to +35

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.

Proposal: split the sender weighting from --invariant-dictionary-weight. After this change, when targetSenders is empty the sender comes 99% from the fixed pool and only 1% from the random/dictionary strategy, so the knob barely affects which address sends the tx anymore.

To check, I counted how many of 400 sender draws were not one of the three new fixed-pool addresses. On master, raising the dictionary weight from 0 to 100 changes that count from 400 down to ~203 — so the knob clearly drives sender selection. On this PR it stays at ~2–3 regardless of the weight.

Maybe we can add a dedicated weight for the fixed-pool-vs-random/dictionary split, and leave dictionary_weight to govern calldata plus the fallback branch. That keeps the new Echidna-style default while still letting users dial sender randomness back up, and avoids making the existing help text (invariant calldata/senders) misleading.


/// Given a target address, we generate random calldata.
pub fn override_call_strat(
fuzz_state: EvmFuzzState,
Expand Down Expand Up @@ -149,17 +163,18 @@ pub fn invariant_strat(
}

/// Strategy to select a sender address:
/// * If `senders` is empty, then it's either a random address or one sampled from the dictionary
/// according to the configured dictionary weight.
/// * If `senders` is empty, then it is usually sampled from Foundry's fixed default sender pool. A
/// random or dictionary address is used rarely to preserve broad exploration.
/// * If `senders` is not empty, a random address is chosen from the list of senders.
fn select_random_sender<S: FuzzStateReader>(
fuzz_state: &S,
senders: Rc<SenderFilters>,
dictionary_weight: u32,
) -> impl Strategy<Value = Address> + use<S> {
if senders.targeted.is_empty() {
let default_senders = default_invariant_senders(&senders);
let dictionary_weight = dictionary_weight.min(100);
proptest::prop_oneof![
let random_sender = proptest::prop_oneof![
100 - dictionary_weight => fuzz_param(&alloy_dyn_abi::DynSolType::Address),
dictionary_weight => fuzz_param_from_state(&alloy_dyn_abi::DynSolType::Address, fuzz_state),
]
Expand All @@ -176,13 +191,30 @@ fn select_random_sender<S: FuzzStateReader>(
addr = Address::random();
}
addr
})
.boxed()
});

if default_senders.is_empty() {
random_sender.boxed()
} else {
proptest::prop_oneof![
DEFAULT_SENDER_WEIGHT => any::<prop::sample::Index>()
.prop_map(move |index| *index.get(&default_senders)),
RANDOM_SENDER_WEIGHT => random_sender,
]
.boxed()
}
} else {
any::<prop::sample::Index>().prop_map(move |index| *index.get(&senders.targeted)).boxed()
}
}

fn default_invariant_senders(senders: &SenderFilters) -> Vec<Address> {
DEFAULT_INVARIANT_SENDERS
.into_iter()
.filter(|sender| !senders.excluded.contains(sender))
.collect()
}

/// Given a function, it returns a proptest strategy which generates valid abi-encoded calldata
/// for that function's input types.
pub fn fuzz_contract_with_calldata<S: FuzzStateReader>(
Expand Down Expand Up @@ -213,3 +245,33 @@ pub fn fuzz_contract_with_calldata<S: FuzzStateReader>(
CallDetails { target, calldata, value }
})
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn default_sender_pool_includes_foundry_deployer() {
let senders = SenderFilters::default();

assert_eq!(
default_invariant_senders(&senders),
vec![
address!("0x0000000000000000000000000000000000010000"),
address!("0x0000000000000000000000000000000000020000"),
CALLER,
]
);
}

#[test]
fn default_sender_pool_respects_exclusions() {
let excluded = address!("0x0000000000000000000000000000000000010000");
let senders = SenderFilters::new(vec![], vec![excluded, CALLER]);

assert_eq!(
default_invariant_senders(&senders),
vec![address!("0x0000000000000000000000000000000000020000")]
);
}
}
Loading