Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
7f0d2ba
feat(test): ast-seeded fuzzer dictionary
0xrusowsky Oct 8, 2025
2809ba5
test: add unit tests
0xrusowsky Oct 8, 2025
9bea99a
test: add unit tests
0xrusowsky Oct 9, 2025
83542a4
fix: default config test
0xrusowsky Oct 9, 2025
2a6c8c4
fix: merge `sample_values` and `ast_values.words`
0xrusowsky Oct 9, 2025
738a7f6
fix: typos
0xrusowsky Oct 9, 2025
951e0cd
better test
0xrusowsky Oct 9, 2025
07a5dca
chore: move `LiteralsCollector` to the fuzz crate
0xrusowsky Oct 9, 2025
7316df5
feat: bytes support
0xrusowsky Oct 9, 2025
dc063ca
feat: int support
0xrusowsky Oct 10, 2025
fc4f3d4
style: clippy
0xrusowsky Oct 10, 2025
3440429
fix: bump max dict values
0xrusowsky Oct 10, 2025
5ea2f8d
style: simplify tests
0xrusowsky Oct 10, 2025
bf49a49
style: cmnts
0xrusowsky Oct 10, 2025
6477f11
fix: test
0xrusowsky Oct 10, 2025
58e45f9
fix: test
0xrusowsky Oct 10, 2025
e4e5b4d
feat: insert all possible uint types that fit
0xrusowsky Oct 10, 2025
6aaa729
test: turn `unit256` to `uint64` to ensure discovery of smaller uints
0xrusowsky Oct 10, 2025
cba4f57
test: add `LiteralCollector` coverage and size tests
0xrusowsky Oct 10, 2025
b56e2ae
test: simplify
0xrusowsky Oct 10, 2025
c7ff115
style: avoid typo error
0xrusowsky Oct 10, 2025
935c435
test: revert `should_fuzz_literals` changes
0xrusowsky Oct 10, 2025
bc9504e
fix: missing test
0xrusowsky Oct 13, 2025
7ed7476
Merge branch 'master' into rusowsky/ast-fuzz-dict
0xrusowsky Oct 13, 2025
9a05ffb
style: use MB const + use `bool::weighted` + rename `state_clone`
0xrusowsky Oct 14, 2025
5813a5a
refactor: new literals.rs file
0xrusowsky Oct 14, 2025
e7d3159
feat: lazily collect literals
0xrusowsky Oct 14, 2025
544d7ff
fix: default config tests
0xrusowsky Oct 14, 2025
7907e97
Merge branch 'master' into rusowsky/ast-fuzz-dict
0xrusowsky Oct 14, 2025
a0fe257
Merge branch 'master' of github.com:foundry-rs/foundry into rusowsky/…
0xrusowsky Oct 20, 2025
172b63a
fix: merge conflicts
0xrusowsky Oct 20, 2025
8f854be
Merge branch 'master' into rusowsky/ast-fuzz-dict
0xrusowsky Oct 20, 2025
aa48772
fix: more merge conflicts
0xrusowsky Oct 20, 2025
587038c
fix: no need to rmv cache
0xrusowsky Oct 20, 2025
c212567
Merge branch 'master' into rusowsky/ast-fuzz-dict
0xrusowsky Oct 21, 2025
6475317
chore: use alias type for `Arc<Compiler>`
0xrusowsky Oct 21, 2025
959314f
Merge branch 'rusowsky/ast-fuzz-dict' of github.com:foundry-rs/foundr…
0xrusowsky Oct 21, 2025
34d280e
style: rename for consistency
0xrusowsky Oct 21, 2025
4d9ee25
test: prove improvement with new features
0xrusowsky Oct 22, 2025
a4d0c1c
fix: use seed to prevent potential flakiness
0xrusowsky Oct 22, 2025
15856c8
Merge branch 'master' into rusowsky/ast-fuzz-dict
0xrusowsky Oct 22, 2025
5e7847b
fix: assert contract outputs individually
0xrusowsky Oct 22, 2025
a65342c
Merge branch 'rusowsky/ast-fuzz-dict' of github.com:foundry-rs/foundr…
0xrusowsky Oct 22, 2025
4094e81
chore: bump forge-std
0xrusowsky Oct 22, 2025
5f80af8
Merge branch 'master' into rusowsky/ast-fuzz-dict
0xrusowsky Oct 22, 2025
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
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.

9 changes: 8 additions & 1 deletion crates/config/src/fuzz.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,11 @@ pub struct FuzzDictionaryConfig {
/// Once the fuzzer exceeds this limit, it will start evicting random entries
#[serde(deserialize_with = "crate::deserialize_usize_or_max")]
pub max_fuzz_dictionary_values: usize,
/// How many literal values to seed from the AST, at most.
///
/// This value is independent from the max amount of addresses and values.
#[serde(deserialize_with = "crate::deserialize_usize_or_max")]
pub max_fuzz_dictionary_literals: usize,
}

impl Default for FuzzDictionaryConfig {
Expand All @@ -90,8 +95,10 @@ impl Default for FuzzDictionaryConfig {
include_push_bytes: true,
// limit this to 300MB
max_fuzz_dictionary_addresses: (300 * 1024 * 1024) / 20,
// limit this to 300MB
max_fuzz_dictionary_values: (300 * 1024 * 1024) / 20,
// limit this to 200MB
max_fuzz_dictionary_values: (200 * 1024 * 1024) / 32,
max_fuzz_dictionary_literals: (200 * 1024 * 1024) / 32,
}
}
}
Expand Down
12 changes: 11 additions & 1 deletion crates/evm/evm/src/executors/fuzz/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -362,13 +362,23 @@ impl FuzzedExecutor {

/// Stores fuzz state for use with [fuzz_calldata_from_state]
pub fn build_fuzz_state(&self, deployed_libs: &[Address]) -> EvmFuzzState {
let inspector = self.executor.inspector();

if let Some(fork_db) = self.executor.backend().active_fork_db() {
EvmFuzzState::new(fork_db, self.config.dictionary, deployed_libs)
EvmFuzzState::new(
fork_db,
self.config.dictionary,
deployed_libs,
inspector.analysis.as_ref(),
inspector.paths_config(),
)
} else {
EvmFuzzState::new(
self.executor.backend().mem_db(),
self.config.dictionary,
deployed_libs,
inspector.analysis.as_ref(),
inspector.paths_config(),
)
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/evm/evm/src/executors/invariant/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -566,10 +566,13 @@ impl<'a> InvariantExecutor<'a> {
self.select_contracts_and_senders(invariant_contract.address)?;

// Stores fuzz state for use with [fuzz_calldata_from_state].
let inspector = self.executor.inspector();
let fuzz_state = EvmFuzzState::new(
self.executor.backend().mem_db(),
self.config.dictionary,
deployed_libs,
inspector.analysis.as_ref(),
inspector.paths_config(),
);

// Creates the invariant strategy.
Expand Down
17 changes: 17 additions & 0 deletions crates/evm/evm/src/inspectors/stack.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ use alloy_primitives::{
map::{AddressHashMap, HashMap},
};
use foundry_cheatcodes::{CheatcodeAnalysis, CheatcodesExecutor, Wallets};
use foundry_compilers::ProjectPathsConfig;
use foundry_evm_core::{
ContextExt, Env, InspectorExt,
backend::{DatabaseExt, JournaledState},
Expand Down Expand Up @@ -209,6 +210,7 @@ impl InspectorStackBuilder {
let mut cheatcodes = Cheatcodes::new(config);
// Set analysis capabilities if they are provided
if let Some(analysis) = analysis {
stack.set_analysis(analysis.clone());
cheatcodes.set_analysis(CheatcodeAnalysis::new(analysis));
}
// Set wallets if they are provided
Expand Down Expand Up @@ -308,11 +310,20 @@ pub struct InspectorStack {
pub inner: InspectorStackInner,
}

impl InspectorStack {
pub fn paths_config(&self) -> Option<&ProjectPathsConfig> {
self.cheatcodes.as_ref().map(|c| &c.config.paths)
}
}

/// All used inpectors besides [Cheatcodes].
///
/// See [`InspectorStack`].
#[derive(Default, Clone, Debug)]
pub struct InspectorStackInner {
/// Solar compiler instance, to grant syntactic and semantic analysis capabilities.
pub analysis: Option<Arc<solar::sema::Compiler>>,

// Inspectors.
// These are boxed to reduce the size of the struct and slightly improve performance of the
// `if let Some` checks.
Expand Down Expand Up @@ -388,6 +399,12 @@ impl InspectorStack {
});
}

/// Set the solar compiler instance.
#[inline]
pub fn set_analysis(&mut self, analysis: Arc<solar::sema::Compiler>) {
self.analysis = Some(analysis);
}

/// Set variables from an environment for the relevant inspectors.
#[inline]
pub fn set_env(&mut self, env: &Env) {
Expand Down
2 changes: 2 additions & 0 deletions crates/evm/fuzz/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ foundry-evm-core.workspace = true
foundry-evm-coverage.workspace = true
foundry-evm-traces.workspace = true

solar.workspace = true

alloy-dyn-abi = { workspace = true, features = ["arbitrary", "eip712"] }
alloy-json-abi.workspace = true
alloy-primitives = { workspace = true, features = [
Expand Down
1 change: 1 addition & 0 deletions crates/evm/fuzz/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ pub use error::FuzzError;

pub mod invariant;
pub mod strategies;
pub use strategies::LiteralMaps;

mod inspector;
pub use inspector::Fuzzer;
Expand Down
2 changes: 1 addition & 1 deletion crates/evm/fuzz/src/strategies/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ mod calldata;
pub use calldata::{fuzz_calldata, fuzz_calldata_from_state};

mod state;
pub use state::EvmFuzzState;
pub use state::{EvmFuzzState, LiteralMaps};

mod invariants;
pub use invariants::{fuzz_contract_with_calldata, invariant_strat, override_call_strat};
Expand Down
136 changes: 122 additions & 14 deletions crates/evm/fuzz/src/strategies/param.rs
Original file line number Diff line number Diff line change
Expand Up @@ -170,27 +170,74 @@ pub fn fuzz_param_from_state(
})
.boxed(),
DynSolType::Bool => DynSolValue::type_strategy(param).boxed(),
DynSolType::String => DynSolValue::type_strategy(param)
.prop_map(move |value| {
DynSolValue::String(
value.as_str().unwrap().trim().trim_end_matches('\0').to_string(),
)
})
.boxed(),
DynSolType::String => {
let state_clone = state.clone();
(any::<prop::sample::Index>(), any::<prop::sample::Index>())
.prop_flat_map(move |(use_ast_index, select_index)| {
Copy link
Member

Choose a reason for hiding this comment

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

you can use bool::weighted

Copy link
Contributor Author

Choose a reason for hiding this comment

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

let dict = state_clone.dictionary_read();

// AST string literals available: use 30/70 allocation
Copy link
Collaborator

Choose a reason for hiding this comment

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

IMO this should follow the sample rules, we already have logic / bias to select them

https://github.com/foundry-rs/foundry/pull/12015/files#diff-d37d278bbc4bfc5240900ba4963f1a0f562f98808670ca692658aed9e0fdf624R128-R130

maybe we could reuse same and return DynSolValues from ast analyzed String / bytes here?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

wdym exactly?

bias is a randomly generated bool (50-50) but allocating 50% to ast seeded literals feels like a lot (before it was 0-100).

my idea was that by using Index we can allocate a smaller pct to AST string literals, but we are already using them (30% of the time)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

moved to bool::weighted as suggested in #12015 (comment)

let ast_strings = dict.ast_strings();
if !ast_strings.is_empty() && use_ast_index.index(10) < 3 {
let s = &ast_strings.as_slice()[select_index.index(ast_strings.len())];
return Just(DynSolValue::String(s.clone())).boxed();
}

// Fallback to random string generation
DynSolValue::type_strategy(&DynSolType::String)
.prop_map(|value| {
DynSolValue::String(
value.as_str().unwrap().trim().trim_end_matches('\0').to_string(),
)
})
.boxed()
})
.boxed()
}
DynSolType::Bytes => {
value().prop_map(move |value| DynSolValue::Bytes(value.0.into())).boxed()
let state_clone = state.clone();
(value(), any::<prop::sample::Index>(), any::<prop::sample::Index>())
.prop_map(move |(word, use_ast_index, select_index)| {
let dict = state_clone.dictionary_read();

// Try string literals as bytes (10% chance)
let ast_strings = dict.ast_strings();
if !ast_strings.is_empty() && use_ast_index.index(10) < 1 {
let s = &ast_strings.as_slice()[select_index.index(ast_strings.len())];
return DynSolValue::Bytes(s.as_bytes().to_vec());
}

// Prefer hex literals (20% chance)
let ast_bytes = dict.ast_bytes();
if !ast_bytes.is_empty() && use_ast_index.index(10) < 3 {
let bytes = &ast_bytes.as_slice()[select_index.index(ast_bytes.len())];
return DynSolValue::Bytes(bytes.to_vec());
}

// Fallback to the generated word from the dictionary (70% chance)
DynSolValue::Bytes(word.0.into())
})
.boxed()
}
DynSolType::Int(n @ 8..=256) => match n / 8 {
32 => value()
.prop_map(move |value| DynSolValue::Int(I256::from_raw(value.into()), 256))
.boxed(),
1..=31 => value()
.prop_map(move |value| {
// Generate a uintN in the correct range, then shift it to the range of intN
// by subtracting 2^(N-1)
let uint = U256::from_be_bytes(value.0) % U256::from(1).wrapping_shl(n);
let max_int_plus1 = U256::from(1).wrapping_shl(n - 1);
let num = I256::from_raw(uint.wrapping_sub(max_int_plus1));
// Extract lower N bits
let uint_n = U256::from_be_bytes(value.0) % U256::from(1).wrapping_shl(n);
// Interpret as signed int (two's complement) --> check sign bit (bit N-1).
let sign_bit = U256::from(1) << (n - 1);
let num = if uint_n >= sign_bit {
// Negative number in two's complement
let modulus = U256::from(1) << n;
I256::from_raw(uint_n.wrapping_sub(modulus))
} else {
// Positive number
I256::from_raw(uint_n)
};

DynSolValue::Int(num, n)
})
.boxed(),
Expand Down Expand Up @@ -379,16 +426,18 @@ mod tests {
FuzzFixtures,
strategies::{EvmFuzzState, fuzz_calldata, fuzz_calldata_from_state},
};
use alloy_primitives::B256;
use foundry_common::abi::get_func;
use foundry_config::FuzzDictionaryConfig;
use revm::database::{CacheDB, EmptyDB};
use std::collections::HashSet;

#[test]
fn can_fuzz_array() {
let f = "testArray(uint64[2] calldata values)";
let func = get_func(f).unwrap();
let db = CacheDB::new(EmptyDB::default());
let state = EvmFuzzState::new(&db, FuzzDictionaryConfig::default(), &[]);
let state = EvmFuzzState::new(&db, FuzzDictionaryConfig::default(), &[], None, None);
let strategy = proptest::prop_oneof![
60 => fuzz_calldata(func.clone(), &FuzzFixtures::default()),
40 => fuzz_calldata_from_state(func, &state),
Expand All @@ -397,4 +446,63 @@ mod tests {
let mut runner = proptest::test_runner::TestRunner::new(cfg);
let _ = runner.run(&strategy, |_| Ok(()));
}

#[test]
fn can_fuzz_string_and_bytes_with_ast_literals_and_hashes() {
use super::fuzz_param_from_state;
use crate::strategies::state::LiteralMaps;
use alloy_dyn_abi::DynSolType;
use alloy_primitives::keccak256;
use proptest::strategy::Strategy;

// Seed dict with string values and their hashes --> mimic `CheatcodeAnalysis` behavior.
let mut literals = LiteralMaps::default();
literals.strings.insert("hello".to_string());
literals.strings.insert("world".to_string());
literals.words.entry(DynSolType::FixedBytes(32)).or_default().insert(keccak256("hello"));
literals.words.entry(DynSolType::FixedBytes(32)).or_default().insert(keccak256("world"));

let db = CacheDB::new(EmptyDB::default());
let state = EvmFuzzState::new(&db, FuzzDictionaryConfig::default(), &[], None, None);
state.seed_literals(literals);

let cfg = proptest::test_runner::Config { failure_persistence: None, ..Default::default() };
let mut runner = proptest::test_runner::TestRunner::new(cfg);

// Verify strategies generates the seeded AST literals
let mut generated_bytes = HashSet::new();
let mut generated_hashes = HashSet::new();
let mut generated_strings = HashSet::new();
let bytes_strategy = fuzz_param_from_state(&DynSolType::Bytes, &state);
let string_strategy = fuzz_param_from_state(&DynSolType::String, &state);
let bytes32_strategy = fuzz_param_from_state(&DynSolType::FixedBytes(32), &state);

for _ in 0..256 {
let tree = bytes_strategy.new_tree(&mut runner).unwrap();
if let Some(bytes) = tree.current().as_bytes()
&& let Ok(s) = std::str::from_utf8(bytes)
{
generated_bytes.insert(s.to_string());
}

let tree = string_strategy.new_tree(&mut runner).unwrap();
if let Some(s) = tree.current().as_str() {
generated_strings.insert(s.to_string());
}

let tree = bytes32_strategy.new_tree(&mut runner).unwrap();
if let Some((bytes, size)) = tree.current().as_fixed_bytes()
&& size == 32
{
generated_hashes.insert(B256::from_slice(bytes));
}
}

assert!(generated_bytes.contains("hello"));
assert!(generated_bytes.contains("world"));
assert!(generated_strings.contains("hello"));
assert!(generated_strings.contains("world"));
assert!(generated_hashes.contains(&keccak256("hello")));
assert!(generated_hashes.contains(&keccak256("world")));
}
}
Loading
Loading