From 7f0d2ba7724d0373a829a15b7e8ac0a65fc2eb9f Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 8 Oct 2025 09:59:36 +0200 Subject: [PATCH 01/36] feat(test): ast-seeded fuzzer dictionary --- crates/cheatcodes/src/inspector.rs | 2 +- crates/cheatcodes/src/inspector/analysis.rs | 136 +++++++++++++++++- crates/config/src/fuzz.rs | 7 + crates/evm/evm/src/executors/fuzz/mod.rs | 12 +- crates/evm/evm/src/executors/invariant/mod.rs | 8 ++ crates/evm/evm/src/inspectors/stack.rs | 11 ++ crates/evm/fuzz/src/lib.rs | 1 + crates/evm/fuzz/src/strategies/mod.rs | 2 +- crates/evm/fuzz/src/strategies/param.rs | 122 +++++++++++++--- crates/evm/fuzz/src/strategies/state.rs | 43 +++++- crates/forge/tests/it/test_helpers.rs | 2 + 11 files changed, 319 insertions(+), 27 deletions(-) diff --git a/crates/cheatcodes/src/inspector.rs b/crates/cheatcodes/src/inspector.rs index 43b33ad673d72..8a210d89463d0 100644 --- a/crates/cheatcodes/src/inspector.rs +++ b/crates/cheatcodes/src/inspector.rs @@ -77,7 +77,7 @@ use std::{ mod utils; pub mod analysis; -pub use analysis::CheatcodeAnalysis; +pub use analysis::*; pub type Ecx<'a, 'b, 'c> = &'a mut EthEvmContext<&'b mut (dyn DatabaseExt + 'c)>; diff --git a/crates/cheatcodes/src/inspector/analysis.rs b/crates/cheatcodes/src/inspector/analysis.rs index 1e73fc3df783a..3006fe8193905 100644 --- a/crates/cheatcodes/src/inspector/analysis.rs +++ b/crates/cheatcodes/src/inspector/analysis.rs @@ -1,8 +1,22 @@ //! Cheatcode information, extracted from the syntactic and semantic analysis of the sources. +use alloy_dyn_abi::DynSolType; +use alloy_primitives::{ + B256, keccak256, + map::{B256IndexSet, HashMap, IndexSet}, +}; use foundry_common::fmt::{StructDefinitions, TypeDefMap}; -use solar::sema::{self, Compiler, Gcx, hir}; -use std::sync::{Arc, OnceLock}; +use foundry_compilers::ProjectPathsConfig; +use foundry_evm_fuzz::LiteralMaps; +use solar::{ + ast::{self, Visit}, + interface::source_map::FileName, + sema::{self, Compiler, Gcx, hir}, +}; +use std::{ + ops::ControlFlow, + sync::{Arc, OnceLock}, +}; use thiserror::Error; /// Represents a failure in one of the lazy analysis steps. @@ -40,6 +54,10 @@ pub struct CheatcodeAnalysis { /// Cached struct definitions in the sources. /// Used to keep field order when parsing JSON values. struct_defs: OnceLock>, + + /// Cached literal values defined in the sources. + /// Used to seed the fuzzer dictionary at initialization. + ast_literals: OnceLock, AnalysisError>>, } impl std::fmt::Debug for CheatcodeAnalysis { @@ -53,7 +71,7 @@ impl std::fmt::Debug for CheatcodeAnalysis { impl CheatcodeAnalysis { pub fn new(compiler: Arc) -> Self { - Self { compiler, struct_defs: OnceLock::new() } + Self { compiler, struct_defs: OnceLock::new(), ast_literals: OnceLock::new() } } /// Lazily initializes and returns the struct definitions. @@ -68,6 +86,118 @@ impl CheatcodeAnalysis { }) .as_ref() } + + /// Lazily initializes and returns the AST literals. + pub fn ast_literals( + &self, + max_values: usize, + paths_config: Option<&ProjectPathsConfig>, + ) -> Result, &AnalysisError> { + self.ast_literals + .get_or_init(|| { + self.compiler.enter(|compiler| { + let mut literals_collector = LiteralsCollector::new(max_values); + for source in compiler.sources().iter() { + if let Some(paths) = paths_config + && let FileName::Real(source_path) = &source.file.name + && paths.is_test_or_script(source_path) + { + continue; + } + + if let Some(ref ast) = source.ast { + let _ = literals_collector.visit_source_unit(ast); + } + } + + Ok(Arc::new(LiteralMaps { + words: literals_collector.words, + strings: literals_collector.strings, + })) + }) + }) + .as_ref() + .map(Arc::clone) + } +} + +// -- AST LITERALS ------------------------------------------------------------- + +enum LitTy { + Word(B256), + Str(String), +} + +#[derive(Debug, Default)] +struct LiteralsCollector { + words: HashMap, + strings: IndexSet, + max_values: usize, + total_values: usize, +} + +impl LiteralsCollector { + fn new(max_values: usize) -> Self { + Self { max_values, ..Default::default() } + } +} + +impl<'ast> ast::Visit<'ast> for LiteralsCollector { + type BreakValue = (); + + fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow<()> { + // Stop early if we've hit the limit + if self.total_values >= self.max_values { + return ControlFlow::Break(()); + } + + if let ast::ExprKind::Lit(lit, _) = &expr.kind + && let Some((ty, value)) = convert_literal(lit) + { + let is_new = match value { + LitTy::Word(v) => self.words.entry(ty).or_default().insert(v), + LitTy::Str(v) => { + // For strings, also store the hashed version + let hash = keccak256(v.as_bytes()); + if self.words.entry(DynSolType::FixedBytes(32)).or_default().insert(hash) { + self.total_values += 1; + } + self.strings.insert(v) + } + }; + + if is_new { + self.total_values += 1; + } + } + + self.walk_expr(expr) + } +} + +fn convert_literal(lit: &ast::Lit) -> Option<(DynSolType, LitTy)> { + use ast::LitKind; + + match &lit.kind { + LitKind::Number(n) => Some((DynSolType::Uint(256), LitTy::Word(B256::from(*n)))), + LitKind::Address(addr) => Some((DynSolType::Address, LitTy::Word(addr.into_word()))), + // Hex strings: store short as right-padded B256, and ignore long ones. + LitKind::Str(ast::StrKind::Hex, bytes, _) => { + let byte_slice = bytes.as_byte_str(); + if byte_slice.len() <= 32 { + Some((DynSolType::Bytes, LitTy::Word(B256::right_padding_from(byte_slice)))) + } else { + None + } + } + // Regular and unicode strings: always store as dynamic + LitKind::Str(_, bytes, _) => Some(( + DynSolType::String, + LitTy::Str(String::from_utf8_lossy(bytes.as_byte_str()).into_owned()), + )), + // Skip + LitKind::Bool(_) | LitKind::Rational(_) | LitKind::Err(_) => None, + } } // -- STRUCT DEFINITIONS ------------------------------------------------------- diff --git a/crates/config/src/fuzz.rs b/crates/config/src/fuzz.rs index ccb8cf45b632b..886f31f6dfd65 100644 --- a/crates/config/src/fuzz.rs +++ b/crates/config/src/fuzz.rs @@ -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 { @@ -92,6 +97,8 @@ impl Default for FuzzDictionaryConfig { max_fuzz_dictionary_addresses: (300 * 1024 * 1024) / 20, // limit this to 200MB max_fuzz_dictionary_values: (200 * 1024 * 1024) / 32, + // limit this to 200MB + max_fuzz_dictionary_literals: (200 * 1024 * 1024) / 32, } } } diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index cc90e0670fae6..a85e4c0d6bdcf 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -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(); + let analysis = inspector.analysis().and_then(|a| { + a.ast_literals( + self.config.dictionary.max_fuzz_dictionary_literals, + inspector.paths_config(), + ) + .ok() + }); + 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, analysis) } else { EvmFuzzState::new( self.executor.backend().mem_db(), self.config.dictionary, deployed_libs, + analysis, ) } } diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 9361dca926549..7083cbbbd28b9 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -566,10 +566,18 @@ 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().and_then(|a| { + a.ast_literals( + self.config.dictionary.max_fuzz_dictionary_literals, + inspector.paths_config(), + ) + .ok() + }), ); // Creates the invariant strategy. diff --git a/crates/evm/evm/src/inspectors/stack.rs b/crates/evm/evm/src/inspectors/stack.rs index 226564a35a409..fa4765bcc8406 100644 --- a/crates/evm/evm/src/inspectors/stack.rs +++ b/crates/evm/evm/src/inspectors/stack.rs @@ -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}, @@ -308,6 +309,16 @@ pub struct InspectorStack { pub inner: InspectorStackInner, } +impl InspectorStack { + pub fn analysis(&self) -> Option<&CheatcodeAnalysis> { + self.cheatcodes.as_ref().and_then(|c| c.analysis.as_ref()) + } + + pub fn paths_config(&self) -> Option<&ProjectPathsConfig> { + self.cheatcodes.as_ref().map(|c| &c.config.paths) + } +} + /// All used inpectors besides [Cheatcodes]. /// /// See [`InspectorStack`]. diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index e0347dc5088a9..780ade34edbba 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -28,6 +28,7 @@ pub use error::FuzzError; pub mod invariant; pub mod strategies; +pub use strategies::LiteralMaps; mod inspector; pub use inspector::Fuzzer; diff --git a/crates/evm/fuzz/src/strategies/mod.rs b/crates/evm/fuzz/src/strategies/mod.rs index e96ebc5443c8b..19a9baff27802 100644 --- a/crates/evm/fuzz/src/strategies/mod.rs +++ b/crates/evm/fuzz/src/strategies/mod.rs @@ -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}; diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index ea330b1fcb102..8cec8ba6c06fd 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -116,20 +116,34 @@ pub fn fuzz_param_from_state( param: &DynSolType, state: &EvmFuzzState, ) -> BoxedStrategy { - // Value strategy that uses the state. + // Value strategy that uses the state with AST literal support with the following allocations: + // - default: 20% AST literals, 40% DB state, 40% runtime samples (when AST available) + // - fallback (when no AST literals): 50% samples, 50% DB state let value = || { let state = state.clone(); let param = param.clone(); - // Generate a bias and use it to pick samples or non-persistent values (50 / 50). - // Use `Index` instead of `Selector` when selecting a value to avoid iterating over the - // entire dictionary. - any::<(bool, prop::sample::Index)>().prop_map(move |(bias, index)| { - let state = state.dictionary_read(); - let values = if bias { state.samples(¶m) } else { None } - .unwrap_or_else(|| state.values()) - .as_slice(); - values[index.index(values.len())] - }) + (any::(), any::()).prop_map( + move |(bias_index, select_index)| { + let bias = bias_index.index(10); + let dict = state.dictionary_read(); + let values = match dict.ast_word(¶m) { + // AST literals available: use 20/40/40 allocation + Some(ast_pool) if !ast_pool.is_empty() => { + if bias < 2 { + ast_pool + } else if bias < 6 { + dict.values() + } else { + dict.samples(¶m).unwrap_or_else(|| dict.values()) + } + } + // No AST literals: use 50/50 allocation + _ => if bias.is_multiple_of(2) { dict.samples(¶m) } else { None } + .unwrap_or_else(|| dict.values()), + }; + values.as_slice()[select_index.index(values.len())] + }, + ) }; // Convert the value based on the parameter type @@ -170,13 +184,32 @@ 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::(), any::()) + .prop_flat_map(move |(use_ast_index, select_index)| { + let dict = state_clone.dictionary_read(); + + // AST string literals available: use 30/70 allocation + if let Some(ast_strings) = dict.ast_string() + && !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() } @@ -379,16 +412,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); let strategy = proptest::prop_oneof![ 60 => fuzz_calldata(func.clone(), &FuzzFixtures::default()), 40 => fuzz_calldata_from_state(func, &state), @@ -397,4 +432,53 @@ mod tests { let mut runner = proptest::test_runner::TestRunner::new(cfg); let _ = runner.run(&strategy, |_| Ok(())); } + + /// Verifies that AST string literals and their keccak256 hashes are available in the fuzzer. + #[test] + fn can_fuzz_string_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; + use std::sync::Arc; + + // 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(), &[], Some(Arc::new(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_hashes = HashSet::new(); + let mut generated_strings = HashSet::new(); + 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 = 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_strings.contains("hello")); + assert!(generated_strings.contains("world")); + assert!(generated_hashes.contains(&keccak256("hello"))); + assert!(generated_hashes.contains(&keccak256("world"))); + } } diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index 92121e46d337e..7f96cc6781ca9 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -3,7 +3,7 @@ use alloy_dyn_abi::{DynSolType, DynSolValue, EventExt, FunctionExt}; use alloy_json_abi::{Function, JsonAbi}; use alloy_primitives::{ Address, B256, Bytes, Log, U256, - map::{AddressIndexSet, AddressMap, B256IndexSet, HashMap}, + map::{AddressIndexSet, AddressMap, B256IndexSet, HashMap, IndexSet}, }; use foundry_common::{ ignore_metadata_hash, mapping_slots::MappingSlots, slot_identifier::SlotIdentifier, @@ -44,6 +44,7 @@ impl EvmFuzzState { db: &CacheDB, config: FuzzDictionaryConfig, deployed_libs: &[Address], + analysis: Option>, ) -> Self { // Sort accounts to ensure deterministic dictionary generation from the same setUp state. let mut accs = db.cache.accounts.iter().collect::>(); @@ -52,6 +53,13 @@ impl EvmFuzzState { // Create fuzz dictionary and insert values from db state. let mut dictionary = FuzzDictionary::new(config); dictionary.insert_db_values(accs); + + // Seed dict with AST literals if analysis is available. + if let Some(literals) = analysis { + dictionary.ast_values = Some(literals); + trace!("inserted AST literals into fuzz dictionary"); + } + Self { inner: Arc::new(RwLock::new(dictionary)), deployed_libs: deployed_libs.to_vec(), @@ -132,13 +140,21 @@ pub struct FuzzDictionary { /// Number of address values initially collected from db. /// Used to revert new collected addresses at the end of each run. db_addresses: usize, - /// Sample typed values that are collected from call result and used across invariant runs. + /// Literal values collected from source code. Never reverted. + ast_values: Option>, + /// Typed runtime sample values persisted across invariant runs. sample_values: HashMap, misses: usize, hits: usize, } +#[derive(Clone, Default)] +pub struct LiteralMaps { + pub words: HashMap, + pub strings: IndexSet, +} + impl fmt::Debug for FuzzDictionary { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("FuzzDictionary") @@ -412,6 +428,18 @@ impl FuzzDictionary { self.sample_values.get(param_type) } + /// Returns the collectec AST static values (fit in a single evm word) for a given type + #[inline] + pub fn ast_word(&self, param_type: &DynSolType) -> Option<&B256IndexSet> { + self.ast_values.as_ref()?.words.get(param_type) + } + + /// Returns the collected AST strings. + #[inline] + pub fn ast_string(&self) -> Option<&IndexSet> { + self.ast_values.as_ref().map(|v| &v.strings) + } + #[inline] pub fn addresses(&self) -> &AddressIndexSet { &self.addresses @@ -424,8 +452,19 @@ impl FuzzDictionary { } pub fn log_stats(&self) { + let (ast_word_count, ast_string_count) = self + .ast_values + .as_ref() + .map(|v| { + let words: usize = v.words.values().map(|vals| vals.len()).sum(); + let strings = v.strings.len(); + (words, strings) + }) + .unwrap_or((0, 0)); trace!( addresses.len = self.addresses.len(), + ast_words.len = ast_word_count, + ast_string.len = ast_string_count, sample.len = self.sample_values.len(), state.len = self.state_values.len(), state.misses = self.misses, diff --git a/crates/forge/tests/it/test_helpers.rs b/crates/forge/tests/it/test_helpers.rs index 280e6f483abb5..9eb5c15f37704 100644 --- a/crates/forge/tests/it/test_helpers.rs +++ b/crates/forge/tests/it/test_helpers.rs @@ -115,6 +115,7 @@ impl ForgeTestProfile { dictionary_weight: 40, max_fuzz_dictionary_addresses: 10_000, max_fuzz_dictionary_values: 10_000, + max_fuzz_dictionary_literals: 10_000, }, gas_report_samples: 256, corpus: FuzzCorpusConfig::default(), @@ -133,6 +134,7 @@ impl ForgeTestProfile { include_push_bytes: true, max_fuzz_dictionary_addresses: 10_000, max_fuzz_dictionary_values: 10_000, + max_fuzz_dictionary_literals: 10_000, }, shrink_run_limit: 5000, max_assume_rejects: 65536, From 2809ba5c261eb4089724c9466bda38b9b5057927 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 8 Oct 2025 12:10:47 +0200 Subject: [PATCH 02/36] test: add unit tests --- crates/cheatcodes/src/inspector/analysis.rs | 3 +- crates/forge/tests/cli/test_cmd.rs | 67 +++++++++++++++++++++ 2 files changed, 69 insertions(+), 1 deletion(-) diff --git a/crates/cheatcodes/src/inspector/analysis.rs b/crates/cheatcodes/src/inspector/analysis.rs index 3006fe8193905..a1da2d0e24f9d 100644 --- a/crates/cheatcodes/src/inspector/analysis.rs +++ b/crates/cheatcodes/src/inspector/analysis.rs @@ -98,9 +98,10 @@ impl CheatcodeAnalysis { self.compiler.enter(|compiler| { let mut literals_collector = LiteralsCollector::new(max_values); for source in compiler.sources().iter() { + // Ignore tests, scripts, and libs if let Some(paths) = paths_config && let FileName::Real(source_path) = &source.file.name - && paths.is_test_or_script(source_path) + && !source_path.starts_with(&paths.sources) { continue; } diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 43c5a71578f8a..3e1dc2cc7c646 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -4154,3 +4154,70 @@ Tip: Run `forge test --rerun` to retry only the 2 failed tests "#]]); }); + +forgetest_init!(should_fuzz_literals, |prj, cmd| { + prj.wipe_contracts(); + + // Add a source with magic values + prj.add_source( + "Hasher.sol", + r#" +contract Hasher { + string constant HELLO = "hello"; + bytes32 forbidden = keccak256("foo"); + + function hash(bytes memory v) external view returns (bytes32) { + bytes32 hashed = keccak256(v); + + assert(hashed != keccak256(bytes(HELLO))); + assert(hashed != keccak256("world")); + assert(hashed != forbidden); + return hashed; + } +} +"#, + ); + + prj.add_test( + "HasherFuzz.t.sol", + r#" + import {Test} from "forge-std/Test.sol"; + import {Hasher} from "src/Hasher.sol"; + + contract HasherTest is Test { + Hasher public hasher; + function setUp() public { hasher = new Hasher(); } + + function testFuzz_Hash(string memory s) public view { hasher.hash(bytes(s)); } + } + "#, + ); + + // the fuzzer is ABLE to find a breaking input when seeding from the AST + prj.update_config(|config| { + config.fuzz.runs = 10; + config.fuzz.seed = Some(U256::from(100)); + config.fuzz.dictionary.max_fuzz_dictionary_literals = 10_000; + }); + cmd.forge_fuse().args(["test"]).assert_failure().stdout_eq( +r#"[COMPILING_FILES] with [SOLC_VERSION] +[SOLC_VERSION] [ELAPSED] +Compiler run successful! + +Ran 1 test for test/HasherFuzz.t.sol:HasherTest +[FAIL: panic: assertion failed (0x01); counterexample: calldata=[..] args=["foo"]] testFuzz_Hash(string) (runs: 4, [AVG_GAS]) +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +... +Encountered a total of 1 failing tests, 0 tests succeeded +"#); + + // the fuzzer is UNABLE to find a breaking input when NOT seeding from the AST + prj.update_config(|config| { + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; + }); + cmd.args(["test"]).assert_success(); +}); From 9bea99a43adb0ae405aa49c5b41b5a2c57b6744b Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 9 Oct 2025 08:16:45 +0200 Subject: [PATCH 03/36] test: add unit tests --- crates/forge/tests/cli/test_cmd.rs | 87 +++++++++++++++++------------- crates/test-utils/src/util.rs | 5 ++ 2 files changed, 54 insertions(+), 38 deletions(-) diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 3e1dc2cc7c646..194844e6d820d 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -4162,50 +4162,42 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { prj.add_source( "Hasher.sol", r#" -contract Hasher { - string constant HELLO = "hello"; - bytes32 forbidden = keccak256("foo"); + contract Hasher { + string constant HELLO = "hello"; + bytes32 forbidden = keccak256("foo"); - function hash(bytes memory v) external view returns (bytes32) { - bytes32 hashed = keccak256(v); + function hash(bytes memory v) external view returns (bytes32) { + bytes32 hashed = keccak256(v); - assert(hashed != keccak256(bytes(HELLO))); - assert(hashed != keccak256("world")); - assert(hashed != forbidden); - return hashed; - } -} -"#, + assert(hashed != keccak256(bytes(HELLO))); + assert(hashed != keccak256("world")); + assert(hashed != forbidden); + return hashed; + } + } + "#, ); prj.add_test( "HasherFuzz.t.sol", r#" - import {Test} from "forge-std/Test.sol"; - import {Hasher} from "src/Hasher.sol"; + import {Test} from "forge-std/Test.sol"; + import {Hasher} from "src/Hasher.sol"; - contract HasherTest is Test { - Hasher public hasher; - function setUp() public { hasher = new Hasher(); } + contract HasherTest is Test { + Hasher public hasher; + function setUp() public { hasher = new Hasher(); } - function testFuzz_Hash(string memory s) public view { hasher.hash(bytes(s)); } - } - "#, + function testFuzz_Hash(string memory s) public view { hasher.hash(bytes(s)); } + } + "#, ); - // the fuzzer is ABLE to find a breaking input when seeding from the AST - prj.update_config(|config| { - config.fuzz.runs = 10; - config.fuzz.seed = Some(U256::from(100)); - config.fuzz.dictionary.max_fuzz_dictionary_literals = 10_000; - }); - cmd.forge_fuse().args(["test"]).assert_failure().stdout_eq( -r#"[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! + const EXPECTED_PREV: &str = r#"No files changed, compilation skipped Ran 1 test for test/HasherFuzz.t.sol:HasherTest -[FAIL: panic: assertion failed (0x01); counterexample: calldata=[..] args=["foo"]] testFuzz_Hash(string) (runs: 4, [AVG_GAS]) +[FAIL: panic: assertion failed (0x01); counterexample: calldata=[..] args=[""#; + const EXPECTED_POST: &str = r#", [AVG_GAS]) Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) @@ -4213,11 +4205,30 @@ Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: ... Encountered a total of 1 failing tests, 0 tests succeeded -"#); - - // the fuzzer is UNABLE to find a breaking input when NOT seeding from the AST - prj.update_config(|config| { - config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; - }); - cmd.args(["test"]).assert_success(); +"#; + let expected = |word: &'static str, runs: u32| -> String { + format!("{EXPECTED_PREV}{word}\"]] testFuzz_Hash(string) (runs: {runs}{EXPECTED_POST}") + }; + let mut fuzz_with_seed = |seed: u32, expected_word: &'static str, expected_runs: u32| { + prj.clear_cache_dir(); + + // the fuzzer is UNABLE to find a breaking input when NOT seeding from the AST + prj.update_config(|config| { + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; + config.fuzz.seed = Some(U256::from(seed)); + }); + cmd.forge_fuse().args(["test"]).assert_success(); + + // the fuzzer is ABLE to find a breaking input when seeding from the AST + prj.update_config(|config| { + config.fuzz.dictionary.max_fuzz_dictionary_literals = 10_000; + }); + + let expected_output = expected(expected_word, expected_runs); + cmd.forge_fuse().args(["test"]).assert_failure().stdout_eq(expected_output); + }; + + fuzz_with_seed(999, "hello", 5); + fuzz_with_seed(300, "world", 5); + fuzz_with_seed(100, "foo", 4); }); diff --git a/crates/test-utils/src/util.rs b/crates/test-utils/src/util.rs index 9be9cb798811e..51dc385ee29c6 100644 --- a/crates/test-utils/src/util.rs +++ b/crates/test-utils/src/util.rs @@ -624,6 +624,11 @@ impl TestProject { let _ = fs::remove_dir_all(self.artifacts()); } + /// Removes the entire cache directory (including fuzz, invariant, and test-failures caches). + pub fn clear_cache_dir(&self) { + let _ = fs::remove_dir_all(self.root().join("cache")); + } + /// Updates the project's config with the given function. pub fn update_config(&self, f: impl FnOnce(&mut Config)) { self._update_config(Box::new(f)); From 83542a4fab96ce190bde614a74e84984d8812380 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 9 Oct 2025 10:05:27 +0200 Subject: [PATCH 04/36] fix: default config test --- crates/forge/tests/cli/config.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index b34cb810ffafc..ef4c6dc5311d8 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -165,6 +165,7 @@ include_storage = true include_push_bytes = true max_fuzz_dictionary_addresses = 15728640 max_fuzz_dictionary_values = 6553600 +max_fuzz_dictionary_literals = 6553600 gas_report_samples = 256 corpus_gzip = true corpus_min_mutations = 5 @@ -183,6 +184,7 @@ include_storage = true include_push_bytes = true max_fuzz_dictionary_addresses = 15728640 max_fuzz_dictionary_values = 6553600 +max_fuzz_dictionary_literals = 6553600 shrink_run_limit = 5000 max_assume_rejects = 65536 gas_report_samples = 256 @@ -1222,6 +1224,7 @@ forgetest_init!(test_default_config, |prj, cmd| { "include_push_bytes": true, "max_fuzz_dictionary_addresses": 15728640, "max_fuzz_dictionary_values": 6553600, + "max_fuzz_dictionary_literals": 6553600, "gas_report_samples": 256, "corpus_dir": null, "corpus_gzip": true, @@ -1242,6 +1245,7 @@ forgetest_init!(test_default_config, |prj, cmd| { "include_push_bytes": true, "max_fuzz_dictionary_addresses": 15728640, "max_fuzz_dictionary_values": 6553600, + "max_fuzz_dictionary_literals": 6553600, "shrink_run_limit": 5000, "max_assume_rejects": 65536, "gas_report_samples": 256, From 2a6c8c426f2c4b43de2768a6a511f33255ff4748 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 9 Oct 2025 11:46:14 +0200 Subject: [PATCH 05/36] fix: merge `sample_values` and `ast_values.words` --- crates/cheatcodes/src/inspector/analysis.rs | 13 ++-- crates/evm/fuzz/src/strategies/param.rs | 67 +++++++++------------ crates/evm/fuzz/src/strategies/state.rs | 36 ++++------- 3 files changed, 46 insertions(+), 70 deletions(-) diff --git a/crates/cheatcodes/src/inspector/analysis.rs b/crates/cheatcodes/src/inspector/analysis.rs index a1da2d0e24f9d..3bd28784a78cc 100644 --- a/crates/cheatcodes/src/inspector/analysis.rs +++ b/crates/cheatcodes/src/inspector/analysis.rs @@ -57,7 +57,7 @@ pub struct CheatcodeAnalysis { /// Cached literal values defined in the sources. /// Used to seed the fuzzer dictionary at initialization. - ast_literals: OnceLock, AnalysisError>>, + ast_literals: OnceLock>, } impl std::fmt::Debug for CheatcodeAnalysis { @@ -92,7 +92,7 @@ impl CheatcodeAnalysis { &self, max_values: usize, paths_config: Option<&ProjectPathsConfig>, - ) -> Result, &AnalysisError> { + ) -> Result<&LiteralMaps, &AnalysisError> { self.ast_literals .get_or_init(|| { self.compiler.enter(|compiler| { @@ -111,14 +111,13 @@ impl CheatcodeAnalysis { } } - Ok(Arc::new(LiteralMaps { - words: literals_collector.words, - strings: literals_collector.strings, - })) + Ok(LiteralMaps { + words: Arc::new(literals_collector.words), + strings: Arc::new(literals_collector.strings), + }) }) }) .as_ref() - .map(Arc::clone) } } diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 8cec8ba6c06fd..9481a61cf22cb 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -116,34 +116,20 @@ pub fn fuzz_param_from_state( param: &DynSolType, state: &EvmFuzzState, ) -> BoxedStrategy { - // Value strategy that uses the state with AST literal support with the following allocations: - // - default: 20% AST literals, 40% DB state, 40% runtime samples (when AST available) - // - fallback (when no AST literals): 50% samples, 50% DB state + // Value strategy that uses the state. let value = || { let state = state.clone(); let param = param.clone(); - (any::(), any::()).prop_map( - move |(bias_index, select_index)| { - let bias = bias_index.index(10); - let dict = state.dictionary_read(); - let values = match dict.ast_word(¶m) { - // AST literals available: use 20/40/40 allocation - Some(ast_pool) if !ast_pool.is_empty() => { - if bias < 2 { - ast_pool - } else if bias < 6 { - dict.values() - } else { - dict.samples(¶m).unwrap_or_else(|| dict.values()) - } - } - // No AST literals: use 50/50 allocation - _ => if bias.is_multiple_of(2) { dict.samples(¶m) } else { None } - .unwrap_or_else(|| dict.values()), - }; - values.as_slice()[select_index.index(values.len())] - }, - ) + // Generate a bias and use it to pick samples or non-persistent values (50 / 50). + // Use `Index` instead of `Selector` when selecting a value to avoid iterating over the + // entire dictionary. + any::<(bool, prop::sample::Index)>().prop_map(move |(bias, index)| { + let state = state.dictionary_read(); + let values = if bias { state.samples(¶m) } else { None } + .unwrap_or_else(|| state.values()) + .as_slice(); + values[index.index(values.len())] + }) }; // Convert the value based on the parameter type @@ -191,10 +177,8 @@ pub fn fuzz_param_from_state( let dict = state_clone.dictionary_read(); // AST string literals available: use 30/70 allocation - if let Some(ast_strings) = dict.ast_string() - && !ast_strings.is_empty() - && use_ast_index.index(10) < 3 - { + let ast_strings = dict.ast_string(); + 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(); } @@ -412,10 +396,16 @@ mod tests { FuzzFixtures, strategies::{EvmFuzzState, fuzz_calldata, fuzz_calldata_from_state}, }; - use alloy_primitives::B256; + use alloy_primitives::{ + B256, + map::{B256IndexSet, IndexSet}, + }; use foundry_common::abi::get_func; use foundry_config::FuzzDictionaryConfig; - use revm::database::{CacheDB, EmptyDB}; + use revm::{ + database::{CacheDB, EmptyDB}, + primitives::HashMap, + }; use std::collections::HashSet; #[test] @@ -444,15 +434,16 @@ mod tests { use std::sync::Arc; // 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 mut strings = IndexSet::default(); + strings.insert("hello".to_string()); + strings.insert("world".to_string()); + let mut words = HashMap::::default(); + words.entry(DynSolType::FixedBytes(32)).or_default().insert(keccak256("hello")); + words.entry(DynSolType::FixedBytes(32)).or_default().insert(keccak256("world")); + let literals = LiteralMaps { words: Arc::new(words), strings: Arc::new(strings) }; let db = CacheDB::new(EmptyDB::default()); - let state = - EvmFuzzState::new(&db, FuzzDictionaryConfig::default(), &[], Some(Arc::new(literals))); + let state = EvmFuzzState::new(&db, FuzzDictionaryConfig::default(), &[], Some(&literals)); let cfg = proptest::test_runner::Config { failure_persistence: None, ..Default::default() }; let mut runner = proptest::test_runner::TestRunner::new(cfg); diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index 7f96cc6781ca9..8f8022710812e 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -44,7 +44,7 @@ impl EvmFuzzState { db: &CacheDB, config: FuzzDictionaryConfig, deployed_libs: &[Address], - analysis: Option>, + analysis: Option<&LiteralMaps>, ) -> Self { // Sort accounts to ensure deterministic dictionary generation from the same setUp state. let mut accs = db.cache.accounts.iter().collect::>(); @@ -56,7 +56,8 @@ impl EvmFuzzState { // Seed dict with AST literals if analysis is available. if let Some(literals) = analysis { - dictionary.ast_values = Some(literals); + dictionary.sample_values = literals.words.as_ref().clone(); + dictionary.string_literals = literals.strings.clone(); trace!("inserted AST literals into fuzz dictionary"); } @@ -140,10 +141,11 @@ pub struct FuzzDictionary { /// Number of address values initially collected from db. /// Used to revert new collected addresses at the end of each run. db_addresses: usize, - /// Literal values collected from source code. Never reverted. - ast_values: Option>, /// Typed runtime sample values persisted across invariant runs. + /// Intially seeded with literal values collected from the source code. sample_values: HashMap, + /// String literals collected from source code. Never reverted. + string_literals: Arc>, misses: usize, hits: usize, @@ -151,8 +153,8 @@ pub struct FuzzDictionary { #[derive(Clone, Default)] pub struct LiteralMaps { - pub words: HashMap, - pub strings: IndexSet, + pub words: Arc>, + pub strings: Arc>, } impl fmt::Debug for FuzzDictionary { @@ -428,16 +430,10 @@ impl FuzzDictionary { self.sample_values.get(param_type) } - /// Returns the collectec AST static values (fit in a single evm word) for a given type - #[inline] - pub fn ast_word(&self, param_type: &DynSolType) -> Option<&B256IndexSet> { - self.ast_values.as_ref()?.words.get(param_type) - } - /// Returns the collected AST strings. #[inline] - pub fn ast_string(&self) -> Option<&IndexSet> { - self.ast_values.as_ref().map(|v| &v.strings) + pub fn ast_string(&self) -> &IndexSet { + self.string_literals.as_ref() } #[inline] @@ -452,19 +448,9 @@ impl FuzzDictionary { } pub fn log_stats(&self) { - let (ast_word_count, ast_string_count) = self - .ast_values - .as_ref() - .map(|v| { - let words: usize = v.words.values().map(|vals| vals.len()).sum(); - let strings = v.strings.len(); - (words, strings) - }) - .unwrap_or((0, 0)); trace!( addresses.len = self.addresses.len(), - ast_words.len = ast_word_count, - ast_string.len = ast_string_count, + ast_string.len = self.string_literals.as_ref().len(), sample.len = self.sample_values.len(), state.len = self.state_values.len(), state.misses = self.misses, From 738a7f690aefd1b760a5195b067766efba5dc612 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 9 Oct 2025 12:09:31 +0200 Subject: [PATCH 06/36] fix: typos --- crates/evm/fuzz/src/strategies/param.rs | 2 +- crates/evm/fuzz/src/strategies/state.rs | 4 ++-- crates/forge/tests/cli/test_cmd.rs | 2 ++ 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 9481a61cf22cb..a8549bde25e69 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -177,7 +177,7 @@ pub fn fuzz_param_from_state( let dict = state_clone.dictionary_read(); // AST string literals available: use 30/70 allocation - let ast_strings = dict.ast_string(); + 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(); diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index 8f8022710812e..39b2c43dd08b4 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -142,7 +142,7 @@ pub struct FuzzDictionary { /// Used to revert new collected addresses at the end of each run. db_addresses: usize, /// Typed runtime sample values persisted across invariant runs. - /// Intially seeded with literal values collected from the source code. + /// Initially seeded with literal values collected from the source code. sample_values: HashMap, /// String literals collected from source code. Never reverted. string_literals: Arc>, @@ -432,7 +432,7 @@ impl FuzzDictionary { /// Returns the collected AST strings. #[inline] - pub fn ast_string(&self) -> &IndexSet { + pub fn ast_strings(&self) -> &IndexSet { self.string_literals.as_ref() } diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 194844e6d820d..8c178f53ecabd 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -2396,6 +2396,7 @@ contract Dummy { forgetest_init!(test_assume_no_revert_with_data, |prj, cmd| { prj.update_config(|config| { config.fuzz.seed = Some(U256::from(111)); + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; }); prj.add_source( @@ -4205,6 +4206,7 @@ Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: ... Encountered a total of 1 failing tests, 0 tests succeeded +... "#; let expected = |word: &'static str, runs: u32| -> String { format!("{EXPECTED_PREV}{word}\"]] testFuzz_Hash(string) (runs: {runs}{EXPECTED_POST}") From 951e0cd2160114173f92e33d948535aa86062fab Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 9 Oct 2025 14:12:27 +0200 Subject: [PATCH 07/36] better test --- crates/forge/tests/cli/test_cmd.rs | 104 ++++++++++++++++++----------- 1 file changed, 66 insertions(+), 38 deletions(-) diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 8c178f53ecabd..75aa4c72dbeb7 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -4157,49 +4157,59 @@ Tip: Run `forge test --rerun` to retry only the 2 failed tests }); forgetest_init!(should_fuzz_literals, |prj, cmd| { + prj.clear_cache_dir(); prj.wipe_contracts(); - // Add a source with magic values + // Add a source with magic (literal) values prj.add_source( - "Hasher.sol", + "Magic.sol", r#" - contract Hasher { - string constant HELLO = "hello"; - bytes32 forbidden = keccak256("foo"); - - function hash(bytes memory v) external view returns (bytes32) { - bytes32 hashed = keccak256(v); - - assert(hashed != keccak256(bytes(HELLO))); - assert(hashed != keccak256("world")); - assert(hashed != forbidden); - return hashed; - } + contract Magic { + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + uint256 constant MAGIC_NUMBER = 1122334455; + int32 constant MAGIC_INT = -777; + bytes32 constant MAGIC_WORD = 0x00000000000000000000000000000000000000000000000000000000deadbeef; + bytes constant MAGIC_BYTES = hex"deadbeef"; + string constant MAGIC_STRING = "xyzzy"; + + function checkAddr(address v) external pure { assert(v != DAI); } + function checkWord(bytes32 v) external pure { assert(v != MAGIC_WORD); } + function checkNumber(uint256 v) external pure { assert(v != MAGIC_NUMBER); } + function checkInteger(int32 v) external pure { assert(v != MAGIC_INT); } + function checkBytes(bytes memory v) external pure { assert(keccak256(v) != keccak256(MAGIC_BYTES)); } + function checkString(string memory v) external pure { assert(keccak256(abi.encodePacked(v)) != keccak256(abi.encodePacked(MAGIC_STRING))); } } "#, ); prj.add_test( - "HasherFuzz.t.sol", + "MagicFuzz.t.sol", r#" import {Test} from "forge-std/Test.sol"; - import {Hasher} from "src/Hasher.sol"; - - contract HasherTest is Test { - Hasher public hasher; - function setUp() public { hasher = new Hasher(); } - - function testFuzz_Hash(string memory s) public view { hasher.hash(bytes(s)); } + import {Magic} from "src/Magic.sol"; + + contract MagicTest is Test { + Magic public magic; + function setUp() public { magic = new Magic(); } + + function testFuzz_Addr(address v) public view { magic.checkAddr(v); } + function testFuzz_Number(uint256 v) public view { magic.checkNumber(v); } + function testFuzz_Integer(int32 v) public view { magic.checkInteger(v); } + function testFuzz_Word(bytes32 v) public view { magic.checkWord(v); } + function testFuzz_Bytes(bytes memory v) public view { magic.checkBytes(v); } + function testFuzz_String(string memory v) public view { magic.checkString(v); } } - "#, + "#, ); - const EXPECTED_PREV: &str = r#"No files changed, compilation skipped + // Helper to create expected output for a test failure + let expected_fail = |test_name: &str, type_sig: &str, value: &str, runs: u32| -> String { + format!( + r#"No files changed, compilation skipped -Ran 1 test for test/HasherFuzz.t.sol:HasherTest -[FAIL: panic: assertion failed (0x01); counterexample: calldata=[..] args=[""#; - const EXPECTED_POST: &str = r#", [AVG_GAS]) -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +Ran 1 test for test/MagicFuzz.t.sol:MagicTest +[FAIL: panic: assertion failed (0x01); counterexample: calldata=[..] args=[{value}]] {test_name}({type_sig}) (runs: {runs}, [AVG_GAS]) +[..] Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) @@ -4207,30 +4217,48 @@ Failing tests: ... Encountered a total of 1 failing tests, 0 tests succeeded ... -"#; - let expected = |word: &'static str, runs: u32| -> String { - format!("{EXPECTED_PREV}{word}\"]] testFuzz_Hash(string) (runs: {runs}{EXPECTED_POST}") +"# + ) }; - let mut fuzz_with_seed = |seed: u32, expected_word: &'static str, expected_runs: u32| { + + // Test address literal fuzzing + let mut test_literal = |seed: u32, + test_name: &'static str, + type_sig: &'static str, + expected_value: &'static str, + expected_runs: u32| { prj.clear_cache_dir(); // the fuzzer is UNABLE to find a breaking input when NOT seeding from the AST prj.update_config(|config| { + config.fuzz.runs = 100; config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; config.fuzz.seed = Some(U256::from(seed)); }); - cmd.forge_fuse().args(["test"]).assert_success(); + cmd.forge_fuse().args(["test", "--match-test", test_name]).assert_success(); // the fuzzer is ABLE to find a breaking input when seeding from the AST prj.update_config(|config| { config.fuzz.dictionary.max_fuzz_dictionary_literals = 10_000; }); - let expected_output = expected(expected_word, expected_runs); - cmd.forge_fuse().args(["test"]).assert_failure().stdout_eq(expected_output); + let expected_output = expected_fail(test_name, type_sig, expected_value, expected_runs); + cmd.forge_fuse() + .args(["test", "--match-test", test_name]) + .assert_failure() + .stdout_eq(expected_output); }; - fuzz_with_seed(999, "hello", 5); - fuzz_with_seed(300, "world", 5); - fuzz_with_seed(100, "foo", 4); + test_literal(100, "testFuzz_Addr", "address", "0x6B175474E89094C44Da98b954EedeAC495271d0F", 28); + test_literal(200, "testFuzz_Number", "uint256", "1122334455 [1.122e9]", 5); + // test_literal(300, "testFuzz_Integer", "int32", "-777", 4); + // test_literal( + // 400, + // "testFuzz_Word", + // "bytes32", + // "0x00000000000000000000000000000000000000000000000000000000deadbeef", + // 0, + // ); + // test_literal(500, "testFuzz_Bytes", "bytes", "0xdeadbeef", 4); + test_literal(600, "testFuzz_String", "string", "\"xyzzy\"", 35); }); From 07a5dcaffe49c32a49524623b8dd3b23bda62185 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 9 Oct 2025 17:09:20 +0200 Subject: [PATCH 08/36] chore: move `LiteralsCollector` to the fuzz crate --- Cargo.lock | 1 + crates/cheatcodes/src/inspector/analysis.rs | 136 +-------------- crates/evm/evm/src/executors/fuzz/mod.rs | 18 +- crates/evm/evm/src/executors/invariant/mod.rs | 9 +- crates/evm/evm/src/inspectors/stack.rs | 14 +- crates/evm/fuzz/Cargo.toml | 2 + crates/evm/fuzz/src/strategies/param.rs | 29 ++-- crates/evm/fuzz/src/strategies/state.rs | 160 ++++++++++++++++-- 8 files changed, 182 insertions(+), 187 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 4738f49d04624..314e925017664 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4838,6 +4838,7 @@ dependencies = [ "rand 0.9.2", "revm", "serde", + "solar-compiler", "thiserror 2.0.17", "tracing", ] diff --git a/crates/cheatcodes/src/inspector/analysis.rs b/crates/cheatcodes/src/inspector/analysis.rs index 3bd28784a78cc..1e73fc3df783a 100644 --- a/crates/cheatcodes/src/inspector/analysis.rs +++ b/crates/cheatcodes/src/inspector/analysis.rs @@ -1,22 +1,8 @@ //! Cheatcode information, extracted from the syntactic and semantic analysis of the sources. -use alloy_dyn_abi::DynSolType; -use alloy_primitives::{ - B256, keccak256, - map::{B256IndexSet, HashMap, IndexSet}, -}; use foundry_common::fmt::{StructDefinitions, TypeDefMap}; -use foundry_compilers::ProjectPathsConfig; -use foundry_evm_fuzz::LiteralMaps; -use solar::{ - ast::{self, Visit}, - interface::source_map::FileName, - sema::{self, Compiler, Gcx, hir}, -}; -use std::{ - ops::ControlFlow, - sync::{Arc, OnceLock}, -}; +use solar::sema::{self, Compiler, Gcx, hir}; +use std::sync::{Arc, OnceLock}; use thiserror::Error; /// Represents a failure in one of the lazy analysis steps. @@ -54,10 +40,6 @@ pub struct CheatcodeAnalysis { /// Cached struct definitions in the sources. /// Used to keep field order when parsing JSON values. struct_defs: OnceLock>, - - /// Cached literal values defined in the sources. - /// Used to seed the fuzzer dictionary at initialization. - ast_literals: OnceLock>, } impl std::fmt::Debug for CheatcodeAnalysis { @@ -71,7 +53,7 @@ impl std::fmt::Debug for CheatcodeAnalysis { impl CheatcodeAnalysis { pub fn new(compiler: Arc) -> Self { - Self { compiler, struct_defs: OnceLock::new(), ast_literals: OnceLock::new() } + Self { compiler, struct_defs: OnceLock::new() } } /// Lazily initializes and returns the struct definitions. @@ -86,118 +68,6 @@ impl CheatcodeAnalysis { }) .as_ref() } - - /// Lazily initializes and returns the AST literals. - pub fn ast_literals( - &self, - max_values: usize, - paths_config: Option<&ProjectPathsConfig>, - ) -> Result<&LiteralMaps, &AnalysisError> { - self.ast_literals - .get_or_init(|| { - self.compiler.enter(|compiler| { - let mut literals_collector = LiteralsCollector::new(max_values); - for source in compiler.sources().iter() { - // Ignore tests, scripts, and libs - if let Some(paths) = paths_config - && let FileName::Real(source_path) = &source.file.name - && !source_path.starts_with(&paths.sources) - { - continue; - } - - if let Some(ref ast) = source.ast { - let _ = literals_collector.visit_source_unit(ast); - } - } - - Ok(LiteralMaps { - words: Arc::new(literals_collector.words), - strings: Arc::new(literals_collector.strings), - }) - }) - }) - .as_ref() - } -} - -// -- AST LITERALS ------------------------------------------------------------- - -enum LitTy { - Word(B256), - Str(String), -} - -#[derive(Debug, Default)] -struct LiteralsCollector { - words: HashMap, - strings: IndexSet, - max_values: usize, - total_values: usize, -} - -impl LiteralsCollector { - fn new(max_values: usize) -> Self { - Self { max_values, ..Default::default() } - } -} - -impl<'ast> ast::Visit<'ast> for LiteralsCollector { - type BreakValue = (); - - fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow<()> { - // Stop early if we've hit the limit - if self.total_values >= self.max_values { - return ControlFlow::Break(()); - } - - if let ast::ExprKind::Lit(lit, _) = &expr.kind - && let Some((ty, value)) = convert_literal(lit) - { - let is_new = match value { - LitTy::Word(v) => self.words.entry(ty).or_default().insert(v), - LitTy::Str(v) => { - // For strings, also store the hashed version - let hash = keccak256(v.as_bytes()); - if self.words.entry(DynSolType::FixedBytes(32)).or_default().insert(hash) { - self.total_values += 1; - } - self.strings.insert(v) - } - }; - - if is_new { - self.total_values += 1; - } - } - - self.walk_expr(expr) - } -} - -fn convert_literal(lit: &ast::Lit) -> Option<(DynSolType, LitTy)> { - use ast::LitKind; - - match &lit.kind { - LitKind::Number(n) => Some((DynSolType::Uint(256), LitTy::Word(B256::from(*n)))), - LitKind::Address(addr) => Some((DynSolType::Address, LitTy::Word(addr.into_word()))), - // Hex strings: store short as right-padded B256, and ignore long ones. - LitKind::Str(ast::StrKind::Hex, bytes, _) => { - let byte_slice = bytes.as_byte_str(); - if byte_slice.len() <= 32 { - Some((DynSolType::Bytes, LitTy::Word(B256::right_padding_from(byte_slice)))) - } else { - None - } - } - // Regular and unicode strings: always store as dynamic - LitKind::Str(_, bytes, _) => Some(( - DynSolType::String, - LitTy::Str(String::from_utf8_lossy(bytes.as_byte_str()).into_owned()), - )), - // Skip - LitKind::Bool(_) | LitKind::Rational(_) | LitKind::Err(_) => None, - } } // -- STRUCT DEFINITIONS ------------------------------------------------------- diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index a85e4c0d6bdcf..9b65928d9a4fb 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -363,22 +363,22 @@ 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(); - let analysis = inspector.analysis().and_then(|a| { - a.ast_literals( - self.config.dictionary.max_fuzz_dictionary_literals, - inspector.paths_config(), - ) - .ok() - }); if let Some(fork_db) = self.executor.backend().active_fork_db() { - EvmFuzzState::new(fork_db, self.config.dictionary, deployed_libs, analysis) + 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, - analysis, + inspector.analysis.as_ref(), + inspector.paths_config(), ) } } diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 7083cbbbd28b9..ee5997eb16f0c 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -571,13 +571,8 @@ impl<'a> InvariantExecutor<'a> { self.executor.backend().mem_db(), self.config.dictionary, deployed_libs, - inspector.analysis().and_then(|a| { - a.ast_literals( - self.config.dictionary.max_fuzz_dictionary_literals, - inspector.paths_config(), - ) - .ok() - }), + inspector.analysis.as_ref(), + inspector.paths_config(), ); // Creates the invariant strategy. diff --git a/crates/evm/evm/src/inspectors/stack.rs b/crates/evm/evm/src/inspectors/stack.rs index fa4765bcc8406..6da4f8f5e72a1 100644 --- a/crates/evm/evm/src/inspectors/stack.rs +++ b/crates/evm/evm/src/inspectors/stack.rs @@ -210,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 @@ -310,10 +311,6 @@ pub struct InspectorStack { } impl InspectorStack { - pub fn analysis(&self) -> Option<&CheatcodeAnalysis> { - self.cheatcodes.as_ref().and_then(|c| c.analysis.as_ref()) - } - pub fn paths_config(&self) -> Option<&ProjectPathsConfig> { self.cheatcodes.as_ref().map(|c| &c.config.paths) } @@ -324,6 +321,9 @@ impl InspectorStack { /// See [`InspectorStack`]. #[derive(Default, Clone, Debug)] pub struct InspectorStackInner { + /// Solar compiler instance, to grant syntactic and semantic analysis capabilities. + pub analysis: Option>, + // Inspectors. // These are boxed to reduce the size of the struct and slightly improve performance of the // `if let Some` checks. @@ -399,6 +399,12 @@ impl InspectorStack { }); } + /// Set the solar compiler instance. + #[inline] + pub fn set_analysis(&mut self, analysis: Arc) { + self.analysis = Some(analysis); + } + /// Set variables from an environment for the relevant inspectors. #[inline] pub fn set_env(&mut self, env: &Env) { diff --git a/crates/evm/fuzz/Cargo.toml b/crates/evm/fuzz/Cargo.toml index 452eb49790f9e..62e4e80a73674 100644 --- a/crates/evm/fuzz/Cargo.toml +++ b/crates/evm/fuzz/Cargo.toml @@ -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 = [ diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index a8549bde25e69..5b75bfef35f24 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -396,16 +396,10 @@ mod tests { FuzzFixtures, strategies::{EvmFuzzState, fuzz_calldata, fuzz_calldata_from_state}, }; - use alloy_primitives::{ - B256, - map::{B256IndexSet, IndexSet}, - }; + use alloy_primitives::B256; use foundry_common::abi::get_func; use foundry_config::FuzzDictionaryConfig; - use revm::{ - database::{CacheDB, EmptyDB}, - primitives::HashMap, - }; + use revm::database::{CacheDB, EmptyDB}; use std::collections::HashSet; #[test] @@ -413,7 +407,7 @@ mod tests { 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(), &[], None); + 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), @@ -431,19 +425,18 @@ mod tests { use alloy_dyn_abi::DynSolType; use alloy_primitives::keccak256; use proptest::strategy::Strategy; - use std::sync::Arc; // Seed dict with string values and their hashes --> mimic `CheatcodeAnalysis` behavior. - let mut strings = IndexSet::default(); - strings.insert("hello".to_string()); - strings.insert("world".to_string()); - let mut words = HashMap::::default(); - words.entry(DynSolType::FixedBytes(32)).or_default().insert(keccak256("hello")); - words.entry(DynSolType::FixedBytes(32)).or_default().insert(keccak256("world")); - let literals = LiteralMaps { words: Arc::new(words), strings: Arc::new(strings) }; + 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(), &[], Some(&literals)); + 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); diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index 39b2c43dd08b4..20014153cbd58 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -2,13 +2,13 @@ use crate::{BasicTxDetails, invariant::FuzzRunIdentifiedContracts}; use alloy_dyn_abi::{DynSolType, DynSolValue, EventExt, FunctionExt}; use alloy_json_abi::{Function, JsonAbi}; use alloy_primitives::{ - Address, B256, Bytes, Log, U256, + Address, B256, Bytes, Log, U256, keccak256, map::{AddressIndexSet, AddressMap, B256IndexSet, HashMap, IndexSet}, }; use foundry_common::{ ignore_metadata_hash, mapping_slots::MappingSlots, slot_identifier::SlotIdentifier, }; -use foundry_compilers::artifacts::StorageLayout; +use foundry_compilers::{ProjectPathsConfig, artifacts::StorageLayout}; use foundry_config::FuzzDictionaryConfig; use foundry_evm_core::{bytecode::InstIter, utils::StateChangeset}; use parking_lot::{RawRwLock, RwLock, lock_api::RwLockReadGuard}; @@ -16,7 +16,12 @@ use revm::{ database::{CacheDB, DatabaseRef, DbAccount}, state::AccountInfo, }; -use std::{collections::BTreeMap, fmt, sync::Arc}; +use solar::{ + ast::{self, Visit}, + interface::source_map::FileName, + sema::Compiler, +}; +use std::{collections::BTreeMap, fmt, ops::ControlFlow, sync::Arc}; /// The maximum number of bytes we will look at in bytecodes to find push bytes (24 KiB). /// @@ -44,7 +49,8 @@ impl EvmFuzzState { db: &CacheDB, config: FuzzDictionaryConfig, deployed_libs: &[Address], - analysis: Option<&LiteralMaps>, + analysis: Option<&Arc>, + paths_config: Option<&ProjectPathsConfig>, ) -> Self { // Sort accounts to ensure deterministic dictionary generation from the same setUp state. let mut accs = db.cache.accounts.iter().collect::>(); @@ -55,9 +61,14 @@ impl EvmFuzzState { dictionary.insert_db_values(accs); // Seed dict with AST literals if analysis is available. - if let Some(literals) = analysis { - dictionary.sample_values = literals.words.as_ref().clone(); - dictionary.string_literals = literals.strings.clone(); + if let Some(compiler) = analysis { + let literals = LiteralsCollector::process( + compiler, + paths_config, + config.max_fuzz_dictionary_literals, + ); + dictionary.sample_values = literals.words; + dictionary.string_literals = literals.strings; trace!("inserted AST literals into fuzz dictionary"); } @@ -123,6 +134,12 @@ impl EvmFuzzState { pub fn log_stats(&self) { self.inner.read().log_stats(); } + + #[cfg(test)] + /// Test-only helper to seed the dictionary with literal values. + pub(crate) fn seed_literals(&self, map: LiteralMaps) { + self.inner.write().seed_literals(map); + } } // We're using `IndexSet` to have a stable element order when restoring persisted state, as well as @@ -145,18 +162,12 @@ pub struct FuzzDictionary { /// Initially seeded with literal values collected from the source code. sample_values: HashMap, /// String literals collected from source code. Never reverted. - string_literals: Arc>, + string_literals: IndexSet, misses: usize, hits: usize, } -#[derive(Clone, Default)] -pub struct LiteralMaps { - pub words: Arc>, - pub strings: Arc>, -} - impl fmt::Debug for FuzzDictionary { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("FuzzDictionary") @@ -433,7 +444,7 @@ impl FuzzDictionary { /// Returns the collected AST strings. #[inline] pub fn ast_strings(&self) -> &IndexSet { - self.string_literals.as_ref() + &self.string_literals } #[inline] @@ -450,7 +461,7 @@ impl FuzzDictionary { pub fn log_stats(&self) { trace!( addresses.len = self.addresses.len(), - ast_string.len = self.string_literals.as_ref().len(), + ast_string.len = self.string_literals.len(), sample.len = self.sample_values.len(), state.len = self.state_values.len(), state.misses = self.misses, @@ -458,4 +469,121 @@ impl FuzzDictionary { "FuzzDictionary stats", ); } + + #[cfg(test)] + /// Test-only helper to seed the dictionary with literal values. + pub(crate) fn seed_literals(&mut self, map: LiteralMaps) { + self.string_literals = map.strings; + self.sample_values = map.words; + } +} + +// -- AST LITERALS COLLECTOR --------------------------------------------------- + +enum LitTy { + Word(B256), + Str(String), +} + +#[derive(Clone, Default, Debug)] +pub struct LiteralMaps { + pub words: HashMap, + pub strings: IndexSet, +} + +#[derive(Debug, Default)] +struct LiteralsCollector { + max_values: usize, + total_values: usize, + output: LiteralMaps, +} + +impl LiteralsCollector { + fn new(max_values: usize) -> Self { + Self { max_values, ..Default::default() } + } + + fn process( + compiler: &Arc, + paths_config: Option<&ProjectPathsConfig>, + max_values: usize, + ) -> LiteralMaps { + compiler.enter(|compiler| { + let mut literals_collector = Self::new(max_values); + for source in compiler.sources().iter() { + // Ignore scripts, and libs + if let Some(paths) = paths_config + && let FileName::Real(source_path) = &source.file.name + && !(source_path.starts_with(&paths.sources) || paths.is_test(source_path)) + { + continue; + } + + if let Some(ref ast) = source.ast { + let _ = literals_collector.visit_source_unit(ast); + } + } + + literals_collector.output + }) + } +} + +impl<'ast> ast::Visit<'ast> for LiteralsCollector { + type BreakValue = (); + + fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow<()> { + // Stop early if we've hit the limit + if self.total_values >= self.max_values { + return ControlFlow::Break(()); + } + + if let ast::ExprKind::Lit(lit, _) = &expr.kind + && let Some((ty, value)) = convert_literal(lit) + { + let is_new = match value { + LitTy::Word(v) => self.output.words.entry(ty).or_default().insert(v), + LitTy::Str(v) => { + // For strings, also store the hashed version + let hash = keccak256(v.as_bytes()); + if self.output.words.entry(DynSolType::FixedBytes(32)).or_default().insert(hash) + { + self.total_values += 1; + } + self.output.strings.insert(v) + } + }; + + if is_new { + self.total_values += 1; + } + } + + self.walk_expr(expr) + } +} + +fn convert_literal(lit: &ast::Lit<'_>) -> Option<(DynSolType, LitTy)> { + use ast::LitKind; + + match &lit.kind { + LitKind::Number(n) => Some((DynSolType::Uint(256), LitTy::Word(B256::from(*n)))), + LitKind::Address(addr) => Some((DynSolType::Address, LitTy::Word(addr.into_word()))), + // Hex strings: store short as right-padded B256, and ignore long ones. + LitKind::Str(ast::StrKind::Hex, bytes, _) => { + let byte_slice = bytes.as_byte_str(); + if byte_slice.len() <= 32 { + Some((DynSolType::Bytes, LitTy::Word(B256::right_padding_from(byte_slice)))) + } else { + None + } + } + // Regular and unicode strings: always store as dynamic + LitKind::Str(_, bytes, _) => Some(( + DynSolType::String, + LitTy::Str(String::from_utf8_lossy(bytes.as_byte_str()).into_owned()), + )), + // Skip + LitKind::Bool(_) | LitKind::Rational(_) | LitKind::Err(_) => None, + } } From 7316df5ead88602167b57020f02678e7d689a0f2 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Thu, 9 Oct 2025 20:29:24 +0200 Subject: [PATCH 09/36] feat: bytes support --- crates/evm/fuzz/src/strategies/param.rs | 61 ++++++++++++++++++++++++- crates/evm/fuzz/src/strategies/state.rs | 35 +++++++++++--- crates/forge/tests/cli/test_cmd.rs | 23 ++++++---- 3 files changed, 100 insertions(+), 19 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 5b75bfef35f24..886f06b88f8da 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -195,7 +195,29 @@ pub fn fuzz_param_from_state( .boxed() } DynSolType::Bytes => { - value().prop_map(move |value| DynSolValue::Bytes(value.0.into())).boxed() + let state_clone = state.clone(); + (value(), any::(), any::()) + .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() @@ -417,7 +439,6 @@ mod tests { let _ = runner.run(&strategy, |_| Ok(())); } - /// Verifies that AST string literals and their keccak256 hashes are available in the fuzzer. #[test] fn can_fuzz_string_with_ast_literals_and_hashes() { use super::fuzz_param_from_state; @@ -465,4 +486,40 @@ mod tests { assert!(generated_hashes.contains(&keccak256("hello"))); assert!(generated_hashes.contains(&keccak256("world"))); } + + #[test] + fn can_fuzz_bytes_with_string_literals_and_hashes() { + use super::fuzz_param_from_state; + use crate::strategies::state::LiteralMaps; + use alloy_dyn_abi::DynSolType; + 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()); + + 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 bytes_strategy = fuzz_param_from_state(&DynSolType::Bytes, &state); + + for _ in 0..256 { + let tree = bytes_strategy.new_tree(&mut runner).unwrap(); + if let Some(bytes) = tree.current().as_bytes() { + if let Ok(s) = std::str::from_utf8(bytes) { + generated_bytes.insert(s.to_string()); + } + } + } + + assert!(generated_bytes.contains("hello")); + assert!(generated_bytes.contains("world")); + } } diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index 20014153cbd58..3424e6ceaccd1 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -2,7 +2,7 @@ use crate::{BasicTxDetails, invariant::FuzzRunIdentifiedContracts}; use alloy_dyn_abi::{DynSolType, DynSolValue, EventExt, FunctionExt}; use alloy_json_abi::{Function, JsonAbi}; use alloy_primitives::{ - Address, B256, Bytes, Log, U256, keccak256, + Address, B256, Bytes, I256, Log, U256, keccak256, map::{AddressIndexSet, AddressMap, B256IndexSet, HashMap, IndexSet}, }; use foundry_common::{ @@ -69,6 +69,7 @@ impl EvmFuzzState { ); dictionary.sample_values = literals.words; dictionary.string_literals = literals.strings; + dictionary.byte_literals = literals.bytes; trace!("inserted AST literals into fuzz dictionary"); } @@ -163,6 +164,8 @@ pub struct FuzzDictionary { sample_values: HashMap, /// String literals collected from source code. Never reverted. string_literals: IndexSet, + /// Byte literals (hex"...") collected from source code. Never reverted. + byte_literals: IndexSet, misses: usize, hits: usize, @@ -447,6 +450,12 @@ impl FuzzDictionary { &self.string_literals } + /// Returns the collected AST bytes (hex literals). + #[inline] + pub fn ast_bytes(&self) -> &IndexSet { + &self.byte_literals + } + #[inline] pub fn addresses(&self) -> &AddressIndexSet { &self.addresses @@ -474,6 +483,7 @@ impl FuzzDictionary { /// Test-only helper to seed the dictionary with literal values. pub(crate) fn seed_literals(&mut self, map: LiteralMaps) { self.string_literals = map.strings; + self.byte_literals = map.bytes; self.sample_values = map.words; } } @@ -483,12 +493,14 @@ impl FuzzDictionary { enum LitTy { Word(B256), Str(String), + Bytes(Bytes), } #[derive(Clone, Default, Debug)] pub struct LiteralMaps { pub words: HashMap, pub strings: IndexSet, + pub bytes: IndexSet, } #[derive(Debug, Default)] @@ -550,8 +562,22 @@ impl<'ast> ast::Visit<'ast> for LiteralsCollector { { self.total_values += 1; } + // And the right-padded version if it fits. + if v.len() <= 32 { + let padded = B256::right_padding_from(v.as_bytes()); + if self + .output + .words + .entry(DynSolType::FixedBytes(32)) + .or_default() + .insert(padded) + { + self.total_values += 1; + } + } self.output.strings.insert(v) } + LitTy::Bytes(v) => self.output.bytes.insert(v), }; if is_new { @@ -569,14 +595,9 @@ fn convert_literal(lit: &ast::Lit<'_>) -> Option<(DynSolType, LitTy)> { match &lit.kind { LitKind::Number(n) => Some((DynSolType::Uint(256), LitTy::Word(B256::from(*n)))), LitKind::Address(addr) => Some((DynSolType::Address, LitTy::Word(addr.into_word()))), - // Hex strings: store short as right-padded B256, and ignore long ones. LitKind::Str(ast::StrKind::Hex, bytes, _) => { let byte_slice = bytes.as_byte_str(); - if byte_slice.len() <= 32 { - Some((DynSolType::Bytes, LitTy::Word(B256::right_padding_from(byte_slice)))) - } else { - None - } + Some((DynSolType::Bytes, LitTy::Bytes(Bytes::copy_from_slice(byte_slice)))) } // Regular and unicode strings: always store as dynamic LitKind::Str(_, bytes, _) => Some(( diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 75aa4c72dbeb7..ab7b978352f34 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -4168,7 +4168,7 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; uint256 constant MAGIC_NUMBER = 1122334455; int32 constant MAGIC_INT = -777; - bytes32 constant MAGIC_WORD = 0x00000000000000000000000000000000000000000000000000000000deadbeef; + bytes32 constant MAGIC_WORD = "abcd1234"; bytes constant MAGIC_BYTES = hex"deadbeef"; string constant MAGIC_STRING = "xyzzy"; @@ -4178,6 +4178,7 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { function checkInteger(int32 v) external pure { assert(v != MAGIC_INT); } function checkBytes(bytes memory v) external pure { assert(keccak256(v) != keccak256(MAGIC_BYTES)); } function checkString(string memory v) external pure { assert(keccak256(abi.encodePacked(v)) != keccak256(abi.encodePacked(MAGIC_STRING))); } + function checkBytesFromString(bytes memory v) external pure { assert(keccak256(v) != keccak256(abi.encodePacked(MAGIC_STRING))); } } "#, ); @@ -4196,7 +4197,8 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { function testFuzz_Number(uint256 v) public view { magic.checkNumber(v); } function testFuzz_Integer(int32 v) public view { magic.checkInteger(v); } function testFuzz_Word(bytes32 v) public view { magic.checkWord(v); } - function testFuzz_Bytes(bytes memory v) public view { magic.checkBytes(v); } + function testFuzz_BytesFromHex(bytes memory v) public view { magic.checkBytes(v); } + function testFuzz_BytesFromString(bytes memory v) public view { magic.checkBytesFromString(v); } function testFuzz_String(string memory v) public view { magic.checkString(v); } } "#, @@ -4252,13 +4254,14 @@ Encountered a total of 1 failing tests, 0 tests succeeded test_literal(100, "testFuzz_Addr", "address", "0x6B175474E89094C44Da98b954EedeAC495271d0F", 28); test_literal(200, "testFuzz_Number", "uint256", "1122334455 [1.122e9]", 5); // test_literal(300, "testFuzz_Integer", "int32", "-777", 4); - // test_literal( - // 400, - // "testFuzz_Word", - // "bytes32", - // "0x00000000000000000000000000000000000000000000000000000000deadbeef", - // 0, - // ); - // test_literal(500, "testFuzz_Bytes", "bytes", "0xdeadbeef", 4); + test_literal( + 400, + "testFuzz_Word", + "bytes32", + "0x6162636431323334000000000000000000000000000000000000000000000000", /* bytes32("abcd1234") */ + 7, + ); + test_literal(500, "testFuzz_BytesFromHex", "bytes", "0xdeadbeef", 1); test_literal(600, "testFuzz_String", "string", "\"xyzzy\"", 35); + test_literal(999, "testFuzz_BytesFromString", "bytes", "0x78797a7a79", 53); // abi.encodePacked("xyzzy") }); From dc063cacfb61ad61fb0c989967ce4351c2506811 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 07:37:38 +0200 Subject: [PATCH 10/36] feat: int support --- crates/evm/fuzz/src/strategies/param.rs | 21 ++++++++++--- crates/evm/fuzz/src/strategies/state.rs | 42 +++++++++++++++++++++++++ crates/forge/tests/cli/test_cmd.rs | 10 +++--- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 886f06b88f8da..2af1c4f69f20a 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -225,11 +225,22 @@ pub fn fuzz_param_from_state( .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 integer (two's complement) + // Check if the sign bit (bit N-1) is set + let sign_bit = U256::from(1) << (n - 1); + let num = if uint_n >= sign_bit { + // Negative number in two's complement + // Map [2^(N-1), 2^N) to [-2^(N-1), 0) by subtracting 2^N + 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(), diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index 3424e6ceaccd1..b1bb5a8f3b1ea 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -550,6 +550,37 @@ impl<'ast> ast::Visit<'ast> for LiteralsCollector { return ControlFlow::Break(()); } + // Handle unary negation of number literals + if let ast::ExprKind::Unary(un_op, inner_expr) = &expr.kind + && un_op.kind == ast::UnOpKind::Neg + && let ast::ExprKind::Lit(lit, _) = &inner_expr.kind + && let ast::LitKind::Number(n) = &lit.kind + { + // Compute the negative I256 value + if let Ok(pos_i256) = I256::try_from(*n) { + let neg_value = -pos_i256; + let neg_b256 = B256::from(neg_value.into_raw()); + + // Store under all intN sizes that can represent this value + for bits in [16, 32, 64, 128, 256] { + if can_fit_in_int(neg_value, bits) { + if self + .output + .words + .entry(DynSolType::Int(bits)) + .or_default() + .insert(neg_b256) + { + self.total_values += 1; + } + } + } + } + + // Continue walking the expression + return self.walk_expr(expr); + } + if let ast::ExprKind::Lit(lit, _) = &expr.kind && let Some((ty, value)) = convert_literal(lit) { @@ -589,6 +620,17 @@ impl<'ast> ast::Visit<'ast> for LiteralsCollector { } } +/// Checks if a signed integer value can fit in intN type. +fn can_fit_in_int(value: I256, bits: usize) -> bool { + // Calculate the maximum positive value for intN: 2^(N-1) - 1 + let max_val = I256::try_from((U256::from(1) << (bits - 1)) - U256::from(1)) + .expect("max value should fit in I256"); + // Calculate the minimum negative value for intN: -2^(N-1) + let min_val = -max_val - I256::ONE; + + value >= min_val && value <= max_val +} + fn convert_literal(lit: &ast::Lit<'_>) -> Option<(DynSolType, LitTy)> { use ast::LitKind; diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index ab7b978352f34..8c4be11bfac5f 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -4176,8 +4176,8 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { function checkWord(bytes32 v) external pure { assert(v != MAGIC_WORD); } function checkNumber(uint256 v) external pure { assert(v != MAGIC_NUMBER); } function checkInteger(int32 v) external pure { assert(v != MAGIC_INT); } - function checkBytes(bytes memory v) external pure { assert(keccak256(v) != keccak256(MAGIC_BYTES)); } function checkString(string memory v) external pure { assert(keccak256(abi.encodePacked(v)) != keccak256(abi.encodePacked(MAGIC_STRING))); } + function checkBytesFromHex(bytes memory v) external pure { assert(keccak256(v) != keccak256(MAGIC_BYTES)); } function checkBytesFromString(bytes memory v) external pure { assert(keccak256(v) != keccak256(abi.encodePacked(MAGIC_STRING))); } } "#, @@ -4197,9 +4197,9 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { function testFuzz_Number(uint256 v) public view { magic.checkNumber(v); } function testFuzz_Integer(int32 v) public view { magic.checkInteger(v); } function testFuzz_Word(bytes32 v) public view { magic.checkWord(v); } - function testFuzz_BytesFromHex(bytes memory v) public view { magic.checkBytes(v); } - function testFuzz_BytesFromString(bytes memory v) public view { magic.checkBytesFromString(v); } function testFuzz_String(string memory v) public view { magic.checkString(v); } + function testFuzz_BytesFromHex(bytes memory v) public view { magic.checkBytesFromHex(v); } + function testFuzz_BytesFromString(bytes memory v) public view { magic.checkBytesFromString(v); } } "#, ); @@ -4231,7 +4231,7 @@ Encountered a total of 1 failing tests, 0 tests succeeded expected_runs: u32| { prj.clear_cache_dir(); - // the fuzzer is UNABLE to find a breaking input when NOT seeding from the AST + // the fuzzer is UNABLE to find a breaking input (fast) when NOT seeding from the AST prj.update_config(|config| { config.fuzz.runs = 100; config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; @@ -4253,7 +4253,7 @@ Encountered a total of 1 failing tests, 0 tests succeeded test_literal(100, "testFuzz_Addr", "address", "0x6B175474E89094C44Da98b954EedeAC495271d0F", 28); test_literal(200, "testFuzz_Number", "uint256", "1122334455 [1.122e9]", 5); - // test_literal(300, "testFuzz_Integer", "int32", "-777", 4); + test_literal(300, "testFuzz_Integer", "int32", "-777", 0); test_literal( 400, "testFuzz_Word", From fc4f3d4b9f416c0ff1ad10799efd80bf61c769d1 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 07:46:02 +0200 Subject: [PATCH 11/36] style: clippy --- crates/evm/fuzz/src/strategies/param.rs | 8 ++++---- crates/evm/fuzz/src/strategies/state.rs | 9 ++++----- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 2af1c4f69f20a..3bdaa6aca7d6c 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -523,10 +523,10 @@ mod tests { for _ in 0..256 { let tree = bytes_strategy.new_tree(&mut runner).unwrap(); - if let Some(bytes) = tree.current().as_bytes() { - if let Ok(s) = std::str::from_utf8(bytes) { - generated_bytes.insert(s.to_string()); - } + if let Some(bytes) = tree.current().as_bytes() + && let Ok(s) = std::str::from_utf8(bytes) + { + generated_bytes.insert(s.to_string()); } } diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index b1bb5a8f3b1ea..82b791f0eb369 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -563,16 +563,15 @@ impl<'ast> ast::Visit<'ast> for LiteralsCollector { // Store under all intN sizes that can represent this value for bits in [16, 32, 64, 128, 256] { - if can_fit_in_int(neg_value, bits) { - if self + if can_fit_in_int(neg_value, bits) + && self .output .words .entry(DynSolType::Int(bits)) .or_default() .insert(neg_b256) - { - self.total_values += 1; - } + { + self.total_values += 1; } } } From 3440429d4a3cd2fe69ca8665074027ebb5b4dc87 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 07:50:14 +0200 Subject: [PATCH 12/36] fix: bump max dict values --- crates/cheatcodes/src/inspector.rs | 2 +- crates/config/src/fuzz.rs | 4 ++-- crates/forge/tests/cli/config.rs | 8 ++++---- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/crates/cheatcodes/src/inspector.rs b/crates/cheatcodes/src/inspector.rs index 8a210d89463d0..43b33ad673d72 100644 --- a/crates/cheatcodes/src/inspector.rs +++ b/crates/cheatcodes/src/inspector.rs @@ -77,7 +77,7 @@ use std::{ mod utils; pub mod analysis; -pub use analysis::*; +pub use analysis::CheatcodeAnalysis; pub type Ecx<'a, 'b, 'c> = &'a mut EthEvmContext<&'b mut (dyn DatabaseExt + 'c)>; diff --git a/crates/config/src/fuzz.rs b/crates/config/src/fuzz.rs index 886f31f6dfd65..f5e4cac73aa58 100644 --- a/crates/config/src/fuzz.rs +++ b/crates/config/src/fuzz.rs @@ -95,8 +95,8 @@ impl Default for FuzzDictionaryConfig { include_push_bytes: true, // limit this to 300MB max_fuzz_dictionary_addresses: (300 * 1024 * 1024) / 20, - // limit this to 200MB - max_fuzz_dictionary_values: (200 * 1024 * 1024) / 32, + // limit this to 300MB + max_fuzz_dictionary_values: (300 * 1024 * 1024) / 20, // limit this to 200MB max_fuzz_dictionary_literals: (200 * 1024 * 1024) / 32, } diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index ef4c6dc5311d8..4cc4edd0a47f1 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -164,7 +164,7 @@ dictionary_weight = 40 include_storage = true include_push_bytes = true max_fuzz_dictionary_addresses = 15728640 -max_fuzz_dictionary_values = 6553600 +max_fuzz_dictionary_values = 15728640 max_fuzz_dictionary_literals = 6553600 gas_report_samples = 256 corpus_gzip = true @@ -183,7 +183,7 @@ dictionary_weight = 80 include_storage = true include_push_bytes = true max_fuzz_dictionary_addresses = 15728640 -max_fuzz_dictionary_values = 6553600 +max_fuzz_dictionary_values = 15728640 max_fuzz_dictionary_literals = 6553600 shrink_run_limit = 5000 max_assume_rejects = 65536 @@ -1223,7 +1223,7 @@ forgetest_init!(test_default_config, |prj, cmd| { "include_storage": true, "include_push_bytes": true, "max_fuzz_dictionary_addresses": 15728640, - "max_fuzz_dictionary_values": 6553600, + "max_fuzz_dictionary_values": 15728640, "max_fuzz_dictionary_literals": 6553600, "gas_report_samples": 256, "corpus_dir": null, @@ -1244,7 +1244,7 @@ forgetest_init!(test_default_config, |prj, cmd| { "include_storage": true, "include_push_bytes": true, "max_fuzz_dictionary_addresses": 15728640, - "max_fuzz_dictionary_values": 6553600, + "max_fuzz_dictionary_values": 15728640, "max_fuzz_dictionary_literals": 6553600, "shrink_run_limit": 5000, "max_assume_rejects": 65536, From 5ea2f8d7dcd4a796c5b21b2fe785efa1275b4b7d Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 07:58:21 +0200 Subject: [PATCH 13/36] style: simplify tests --- crates/evm/fuzz/src/strategies/param.rs | 49 ++++++------------------- crates/evm/fuzz/src/strategies/state.rs | 1 - 2 files changed, 12 insertions(+), 38 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 3bdaa6aca7d6c..5312248dca735 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -451,7 +451,7 @@ mod tests { } #[test] - fn can_fuzz_string_with_ast_literals_and_hashes() { + 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; @@ -473,12 +473,21 @@ mod tests { 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()); @@ -492,45 +501,11 @@ mod tests { } } + 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"))); } - - #[test] - fn can_fuzz_bytes_with_string_literals_and_hashes() { - use super::fuzz_param_from_state; - use crate::strategies::state::LiteralMaps; - use alloy_dyn_abi::DynSolType; - 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()); - - 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 bytes_strategy = fuzz_param_from_state(&DynSolType::Bytes, &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()); - } - } - - assert!(generated_bytes.contains("hello")); - assert!(generated_bytes.contains("world")); - } } diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index 82b791f0eb369..df09790433a65 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -640,7 +640,6 @@ fn convert_literal(lit: &ast::Lit<'_>) -> Option<(DynSolType, LitTy)> { let byte_slice = bytes.as_byte_str(); Some((DynSolType::Bytes, LitTy::Bytes(Bytes::copy_from_slice(byte_slice)))) } - // Regular and unicode strings: always store as dynamic LitKind::Str(_, bytes, _) => Some(( DynSolType::String, LitTy::Str(String::from_utf8_lossy(bytes.as_byte_str()).into_owned()), From bf49a49b123d736da8ae949a11b767c1fcb39f4e Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 08:00:01 +0200 Subject: [PATCH 14/36] style: cmnts --- crates/evm/fuzz/src/strategies/param.rs | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 5312248dca735..208b9a549e03d 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -227,13 +227,10 @@ pub fn fuzz_param_from_state( .prop_map(move |value| { // Extract lower N bits let uint_n = U256::from_be_bytes(value.0) % U256::from(1).wrapping_shl(n); - - // Interpret as signed integer (two's complement) - // Check if the sign bit (bit N-1) is set + // 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 - // Map [2^(N-1), 2^N) to [-2^(N-1), 0) by subtracting 2^N let modulus = U256::from(1) << n; I256::from_raw(uint_n.wrapping_sub(modulus)) } else { From 6477f11db9ffac7d317f12bd3de5ecacff5c0534 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 08:25:17 +0200 Subject: [PATCH 15/36] fix: test --- crates/forge/tests/it/fuzz.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/forge/tests/it/fuzz.rs b/crates/forge/tests/it/fuzz.rs index 616b19a17f805..4ac88fc02879f 100644 --- a/crates/forge/tests/it/fuzz.rs +++ b/crates/forge/tests/it/fuzz.rs @@ -395,7 +395,10 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) // Test that counterexample is not replayed if test changes. // forgetest_init!(test_fuzz_replay_with_changed_test, |prj, cmd| { - prj.update_config(|config| config.fuzz.seed = Some(U256::from(100u32))); + prj.update_config(|config| { + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; + config.fuzz.seed = Some(U256::from(100u32)) + }); prj.add_test( "Counter.t.sol", r#" From 58e45f9705138e2da6e0a4cb4c2ca5dccf5cc54d Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 08:39:51 +0200 Subject: [PATCH 16/36] fix: test --- crates/forge/tests/cli/failure_assertions.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/forge/tests/cli/failure_assertions.rs b/crates/forge/tests/cli/failure_assertions.rs index 619c3b71da11d..bd580a5f65687 100644 --- a/crates/forge/tests/cli/failure_assertions.rs +++ b/crates/forge/tests/cli/failure_assertions.rs @@ -241,6 +241,9 @@ Suite result: FAILED. 0 passed; 5 failed; 0 skipped; [ELAPSED] forgetest!(expect_emit_params_tests_should_fail, |prj, cmd| { prj.insert_ds_test(); prj.insert_vm(); + prj.update_config(|config| { + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; + }); let expect_emit_failure_src = include_str!("../fixtures/ExpectEmitParamHarness.sol"); let expect_emit_failure_tests = include_str!("../fixtures/ExpectEmitParamFailures.t.sol"); @@ -426,7 +429,7 @@ forgetest!(multiple_setups, |prj, cmd| { prj.add_source( "MultipleSetupsTest.t.sol", r#" - + import "./test.sol"; contract MultipleSetup is DSTest { From e4e5b4d699a500ec12183055c1015d10c4162219 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 09:59:04 +0200 Subject: [PATCH 17/36] feat: insert all possible uint types that fit --- crates/evm/fuzz/src/strategies/state.rs | 86 ++++++++++++++----------- 1 file changed, 50 insertions(+), 36 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index df09790433a65..2558f2f4dd8e1 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -490,13 +490,7 @@ impl FuzzDictionary { // -- AST LITERALS COLLECTOR --------------------------------------------------- -enum LitTy { - Word(B256), - Str(String), - Bytes(Bytes), -} - -#[derive(Clone, Default, Debug)] +#[derive(Debug, Default)] pub struct LiteralMaps { pub words: HashMap, pub strings: IndexSet, @@ -563,7 +557,7 @@ impl<'ast> ast::Visit<'ast> for LiteralsCollector { // Store under all intN sizes that can represent this value for bits in [16, 32, 64, 128, 256] { - if can_fit_in_int(neg_value, bits) + if can_fit_int(neg_value, bits) && self .output .words @@ -580,21 +574,48 @@ impl<'ast> ast::Visit<'ast> for LiteralsCollector { return self.walk_expr(expr); } - if let ast::ExprKind::Lit(lit, _) = &expr.kind - && let Some((ty, value)) = convert_literal(lit) - { - let is_new = match value { - LitTy::Word(v) => self.output.words.entry(ty).or_default().insert(v), - LitTy::Str(v) => { + // Handle literals + if let ast::ExprKind::Lit(lit, _) = &expr.kind { + let is_new = match &lit.kind { + ast::LitKind::Number(n) => { + let pos_value = U256::from(*n); + let pos_b256 = B256::from(pos_value); + + // Store under all uintN sizes that can represent this value + for bits in [8, 16, 32, 64, 128, 256] { + if can_fit_uint(pos_value, bits) + && self + .output + .words + .entry(DynSolType::Uint(bits)) + .or_default() + .insert(pos_b256) + { + self.total_values += 1; + } + } + false // already handled inserts individually + } + ast::LitKind::Address(addr) => self + .output + .words + .entry(DynSolType::Address) + .or_default() + .insert(addr.into_word()), + ast::LitKind::Str(ast::StrKind::Hex, sym, _) => { + self.output.bytes.insert(Bytes::copy_from_slice(sym.as_byte_str())) + } + ast::LitKind::Str(_, sym, _) => { + let s = String::from_utf8_lossy(sym.as_byte_str()).into_owned(); // For strings, also store the hashed version - let hash = keccak256(v.as_bytes()); + let hash = keccak256(s.as_bytes()); if self.output.words.entry(DynSolType::FixedBytes(32)).or_default().insert(hash) { self.total_values += 1; } // And the right-padded version if it fits. - if v.len() <= 32 { - let padded = B256::right_padding_from(v.as_bytes()); + if s.len() <= 32 { + let padded = B256::right_padding_from(s.as_bytes()); if self .output .words @@ -605,9 +626,11 @@ impl<'ast> ast::Visit<'ast> for LiteralsCollector { self.total_values += 1; } } - self.output.strings.insert(v) + self.output.strings.insert(s) + } + ast::LitKind::Bool(..) | ast::LitKind::Rational(..) | ast::LitKind::Err(..) => { + false // ignore } - LitTy::Bytes(v) => self.output.bytes.insert(v), }; if is_new { @@ -620,7 +643,7 @@ impl<'ast> ast::Visit<'ast> for LiteralsCollector { } /// Checks if a signed integer value can fit in intN type. -fn can_fit_in_int(value: I256, bits: usize) -> bool { +fn can_fit_int(value: I256, bits: usize) -> bool { // Calculate the maximum positive value for intN: 2^(N-1) - 1 let max_val = I256::try_from((U256::from(1) << (bits - 1)) - U256::from(1)) .expect("max value should fit in I256"); @@ -630,21 +653,12 @@ fn can_fit_in_int(value: I256, bits: usize) -> bool { value >= min_val && value <= max_val } -fn convert_literal(lit: &ast::Lit<'_>) -> Option<(DynSolType, LitTy)> { - use ast::LitKind; - - match &lit.kind { - LitKind::Number(n) => Some((DynSolType::Uint(256), LitTy::Word(B256::from(*n)))), - LitKind::Address(addr) => Some((DynSolType::Address, LitTy::Word(addr.into_word()))), - LitKind::Str(ast::StrKind::Hex, bytes, _) => { - let byte_slice = bytes.as_byte_str(); - Some((DynSolType::Bytes, LitTy::Bytes(Bytes::copy_from_slice(byte_slice)))) - } - LitKind::Str(_, bytes, _) => Some(( - DynSolType::String, - LitTy::Str(String::from_utf8_lossy(bytes.as_byte_str()).into_owned()), - )), - // Skip - LitKind::Bool(_) | LitKind::Rational(_) | LitKind::Err(_) => None, +/// Checks if an unsigned integer value can fit in uintN type. +fn can_fit_uint(value: U256, bits: usize) -> bool { + if bits == 256 { + return true; } + // Calculate the maximum value for uintN: 2^N - 1 + let max_val = (U256::from(1) << bits) - U256::from(1); + value <= max_val } From 6aaa729dcac7afb904781e8d584457fa4154219c Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 10:17:07 +0200 Subject: [PATCH 18/36] test: turn `unit256` to `uint64` to ensure discovery of smaller uints works --- crates/forge/tests/cli/test_cmd.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 8c4be11bfac5f..04e2bc6a095ad 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -4166,7 +4166,7 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { r#" contract Magic { address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; - uint256 constant MAGIC_NUMBER = 1122334455; + uint64 constant MAGIC_NUMBER = 1122334455; int32 constant MAGIC_INT = -777; bytes32 constant MAGIC_WORD = "abcd1234"; bytes constant MAGIC_BYTES = hex"deadbeef"; @@ -4174,7 +4174,7 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { function checkAddr(address v) external pure { assert(v != DAI); } function checkWord(bytes32 v) external pure { assert(v != MAGIC_WORD); } - function checkNumber(uint256 v) external pure { assert(v != MAGIC_NUMBER); } + function checkNumber(uint64 v) external pure { assert(v != MAGIC_NUMBER); } function checkInteger(int32 v) external pure { assert(v != MAGIC_INT); } function checkString(string memory v) external pure { assert(keccak256(abi.encodePacked(v)) != keccak256(abi.encodePacked(MAGIC_STRING))); } function checkBytesFromHex(bytes memory v) external pure { assert(keccak256(v) != keccak256(MAGIC_BYTES)); } @@ -4194,7 +4194,7 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { function setUp() public { magic = new Magic(); } function testFuzz_Addr(address v) public view { magic.checkAddr(v); } - function testFuzz_Number(uint256 v) public view { magic.checkNumber(v); } + function testFuzz_Number(uint64 v) public view { magic.checkNumber(v); } function testFuzz_Integer(int32 v) public view { magic.checkInteger(v); } function testFuzz_Word(bytes32 v) public view { magic.checkWord(v); } function testFuzz_String(string memory v) public view { magic.checkString(v); } @@ -4252,7 +4252,7 @@ Encountered a total of 1 failing tests, 0 tests succeeded }; test_literal(100, "testFuzz_Addr", "address", "0x6B175474E89094C44Da98b954EedeAC495271d0F", 28); - test_literal(200, "testFuzz_Number", "uint256", "1122334455 [1.122e9]", 5); + test_literal(200, "testFuzz_Number", "uint64", "1122334455 [1.122e9]", 5); test_literal(300, "testFuzz_Integer", "int32", "-777", 0); test_literal( 400, From cba4f57256998004608ba2726744479b1f42a1e5 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 15:18:59 +0200 Subject: [PATCH 19/36] test: add `LiteralCollector` coverage and size tests --- crates/evm/fuzz/src/strategies/state.rs | 137 ++++++++++++++++++++++++ crates/forge/tests/cli/test_cmd.rs | 6 ++ 2 files changed, 143 insertions(+) diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index 2558f2f4dd8e1..e496f6c3c9169 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -662,3 +662,140 @@ fn can_fit_uint(value: U256, bits: usize) -> bool { let max_val = (U256::from(1) << bits) - U256::from(1); value <= max_val } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + + const SOURCE: &str = r#" + contract Magic { + // plain literals + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + uint64 constant MAGIC_NUMBER = 1122334455; + int32 constant MAGIC_INT = -777; + bytes32 constant MAGIC_WORD = "abcd1234"; + bytes constant MAGIC_BYTES = hex"deadbeef"; + string constant MAGIC_STRING = "xyzzy"; + + // constant exprs with folding + uint256 constant NEG_FOLDING = uint(-2); + uint256 constant BIN_FOLDING = 2 * 2 ether; + bytes32 constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1); + }"#; + + /// Helper to compile Solidity source and return the solar Compiler. + fn compile_test_source(source: &str) -> eyre::Result> { + // Create a new solar Compiler instance + let mut compiler = solar::sema::Compiler::new( + solar::interface::Session::builder().with_stderr_emitter().build(), + ); + + // Parse the source directly using stdin (in-memory source) + compiler.enter_mut(|c| -> eyre::Result<()> { + let mut pcx = c.parse(); + pcx.set_resolve_imports(false); + + // Create source file from string using Stdin file name + let sf = c + .sess() + .source_map() + .new_source_file(solar::interface::source_map::FileName::Stdin, source) + .map_err(|e| eyre::eyre!("Failed to create source file: {}", e))?; + + pcx.add_file(sf); + pcx.parse(); + let _ = c.lower_asts(); + + Ok(()) + })?; + + Ok(Arc::new(compiler)) + } + + #[test] + fn test_literals_collector_coverage() { + // Compile the test source and process literals + let compiler = compile_test_source(SOURCE).expect("Failed to compile test source"); + let literals = LiteralsCollector::process(&compiler, None, usize::MAX); + + // Expected values from the SOURCE contract + let dai_address = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F").into_word(); + let magic_number = B256::from(U256::from(1122334455u64)); + let magic_int = B256::from(I256::try_from(-777i32).unwrap().into_raw()); + let magic_word = B256::right_padding_from(b"abcd1234"); + let deadbeef_bytes = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); + + assert!( + literals.words.get(&DynSolType::Address).is_some_and(|set| set.contains(&dai_address)), + "Expected DAI in address set" + ); + assert!( + literals + .words + .get(&DynSolType::Uint(64)) + .is_some_and(|set| set.contains(&magic_number)), + "Expected MAGIC_NUMBER in uint64 set" + ); + assert!( + literals.words.get(&DynSolType::Int(32)).is_some_and(|set| set.contains(&magic_int)), + "Expected MAGIC_INT in int32 set" + ); + assert!( + literals + .words + .get(&DynSolType::FixedBytes(32)) + .is_some_and(|set| set.contains(&magic_word)), + "Expected MAGIC_WORD in bytes32 set" + ); + assert!(literals.strings.contains("xyzzy"), "Expected MAGIC_STRING to be collected"); + assert!( + literals.strings.contains("eip1967.proxy.implementation"), + "Expected IMPLEMENTATION_SLOT in string set" + ); + assert!(literals.bytes.contains(&deadbeef_bytes), "Expected MAGIC_BYTES in bytes set"); + } + + #[test] + fn test_literals_collector_size() { + // Compile the test source and process literals + let compiler = compile_test_source(SOURCE).expect("Failed to compile test source"); + let literals = LiteralsCollector::process(&compiler, None, usize::MAX); + + // Helper to get count for a type, returns 0 if not present + let count = |ty: DynSolType| literals.words.get(&ty).map_or(0, |set| set.len()); + + assert_eq!(count(DynSolType::Address), 1, "Address literal count mismatch"); + + // Unsigned integers - MAGIC_NUMBER (1122334455) appears in multiple sizes + assert_eq!(count(DynSolType::Uint(8)), 2, "Uint(8) count mismatch"); + assert_eq!(count(DynSolType::Uint(16)), 3, "Uint(16) count mismatch"); + assert_eq!(count(DynSolType::Uint(32)), 4, "Uint(32) count mismatch"); + assert_eq!(count(DynSolType::Uint(64)), 5, "Uint(64) count mismatch"); + assert_eq!(count(DynSolType::Uint(128)), 5, "Uint(128) count mismatch"); + assert_eq!(count(DynSolType::Uint(256)), 5, "Uint(256) count mismatch"); + + // Signed integers - MAGIC_INT (-777) appears in multiple sizes + assert_eq!(count(DynSolType::Int(16)), 2, "Int(16) count mismatch"); + assert_eq!(count(DynSolType::Int(32)), 2, "Int(32) count mismatch"); + assert_eq!(count(DynSolType::Int(64)), 2, "Int(64) count mismatch"); + assert_eq!(count(DynSolType::Int(128)), 2, "Int(128) count mismatch"); + assert_eq!(count(DynSolType::Int(256)), 2, "Int(256) count mismatch"); + + // FixedBytes(32) includes: + // - MAGIC_WORD + // - String literals (hashed and right-padded versions) + assert_eq!(count(DynSolType::FixedBytes(32)), 6, "FixedBytes(32) count mismatch"); + + // String and byte literals + assert_eq!(literals.strings.len(), 3, "String literals count mismatch"); + assert_eq!(literals.bytes.len(), 1, "Byte literals count mismatch"); + + // Total count check + assert_eq!( + literals.words.values().map(|set| set.len()).sum::(), + 41, + "Total word values count mismatch" + ); + } +} diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 04e2bc6a095ad..6bc1da10d4776 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -4165,6 +4165,7 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { "Magic.sol", r#" contract Magic { + // plain literals address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; uint64 constant MAGIC_NUMBER = 1122334455; int32 constant MAGIC_INT = -777; @@ -4172,6 +4173,11 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { bytes constant MAGIC_BYTES = hex"deadbeef"; string constant MAGIC_STRING = "xyzzy"; + // constant exprs with folding + uint256 constant NEG_FOLDING = uint(-2); + uint256 constant BIN_FOLDING = 2 * 2 ether; + bytes32 constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1); + function checkAddr(address v) external pure { assert(v != DAI); } function checkWord(bytes32 v) external pure { assert(v != MAGIC_WORD); } function checkNumber(uint64 v) external pure { assert(v != MAGIC_NUMBER); } From b56e2aec1c0832925032ced5327cb83ccf50b56c Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 15:37:39 +0200 Subject: [PATCH 20/36] test: simplify --- crates/evm/fuzz/src/strategies/state.rs | 113 +++++++++--------------- 1 file changed, 43 insertions(+), 70 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index e496f6c3c9169..e35d2be2e97c4 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -667,6 +667,7 @@ fn can_fit_uint(value: U256, bits: usize) -> bool { mod tests { use super::*; use alloy_primitives::address; + use solar::interface::{Session, source_map}; const SOURCE: &str = r#" contract Magic { @@ -684,88 +685,39 @@ mod tests { bytes32 constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1); }"#; - /// Helper to compile Solidity source and return the solar Compiler. - fn compile_test_source(source: &str) -> eyre::Result> { - // Create a new solar Compiler instance - let mut compiler = solar::sema::Compiler::new( - solar::interface::Session::builder().with_stderr_emitter().build(), - ); - - // Parse the source directly using stdin (in-memory source) - compiler.enter_mut(|c| -> eyre::Result<()> { - let mut pcx = c.parse(); - pcx.set_resolve_imports(false); - - // Create source file from string using Stdin file name - let sf = c - .sess() - .source_map() - .new_source_file(solar::interface::source_map::FileName::Stdin, source) - .map_err(|e| eyre::eyre!("Failed to create source file: {}", e))?; - - pcx.add_file(sf); - pcx.parse(); - let _ = c.lower_asts(); - - Ok(()) - })?; - - Ok(Arc::new(compiler)) - } - #[test] fn test_literals_collector_coverage() { - // Compile the test source and process literals - let compiler = compile_test_source(SOURCE).expect("Failed to compile test source"); - let literals = LiteralsCollector::process(&compiler, None, usize::MAX); + let lits = process_source_literals(SOURCE); // Expected values from the SOURCE contract - let dai_address = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F").into_word(); - let magic_number = B256::from(U256::from(1122334455u64)); - let magic_int = B256::from(I256::try_from(-777i32).unwrap().into_raw()); - let magic_word = B256::right_padding_from(b"abcd1234"); - let deadbeef_bytes = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); - - assert!( - literals.words.get(&DynSolType::Address).is_some_and(|set| set.contains(&dai_address)), - "Expected DAI in address set" - ); - assert!( - literals - .words - .get(&DynSolType::Uint(64)) - .is_some_and(|set| set.contains(&magic_number)), - "Expected MAGIC_NUMBER in uint64 set" - ); - assert!( - literals.words.get(&DynSolType::Int(32)).is_some_and(|set| set.contains(&magic_int)), - "Expected MAGIC_INT in int32 set" - ); + let addr = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F").into_word(); + let num = B256::from(U256::from(1122334455u64)); + let int = B256::from(I256::try_from(-777i32).unwrap().into_raw()); + let word = B256::right_padding_from(b"abcd1234"); + let dyn_bytes = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); + + assert_word(&lits, DynSolType::Address, addr, "Expected DAI in address set"); + assert_word(&lits, DynSolType::Uint(64), num, "Expected MAGIC_NUMBER in uint64 set"); + assert_word(&lits, DynSolType::Int(32), int, "Expected MAGIC_INT in int32 set"); + assert_word(&lits, DynSolType::FixedBytes(32), word, "Expected MAGIC_WORD in bytes32 set"); + assert!(lits.strings.contains("xyzzy"), "Expected MAGIC_STRING to be collected"); assert!( - literals - .words - .get(&DynSolType::FixedBytes(32)) - .is_some_and(|set| set.contains(&magic_word)), - "Expected MAGIC_WORD in bytes32 set" - ); - assert!(literals.strings.contains("xyzzy"), "Expected MAGIC_STRING to be collected"); - assert!( - literals.strings.contains("eip1967.proxy.implementation"), + lits.strings.contains("eip1967.proxy.implementation"), "Expected IMPLEMENTATION_SLOT in string set" ); - assert!(literals.bytes.contains(&deadbeef_bytes), "Expected MAGIC_BYTES in bytes set"); + assert!(lits.bytes.contains(&dyn_bytes), "Expected MAGIC_BYTES in bytes set"); } #[test] fn test_literals_collector_size() { - // Compile the test source and process literals - let compiler = compile_test_source(SOURCE).expect("Failed to compile test source"); - let literals = LiteralsCollector::process(&compiler, None, usize::MAX); + let literals = process_source_literals(SOURCE); // Helper to get count for a type, returns 0 if not present let count = |ty: DynSolType| literals.words.get(&ty).map_or(0, |set| set.len()); assert_eq!(count(DynSolType::Address), 1, "Address literal count mismatch"); + assert_eq!(literals.strings.len(), 3, "String literals count mismatch"); + assert_eq!(literals.bytes.len(), 1, "Byte literals count mismatch"); // Unsigned integers - MAGIC_NUMBER (1122334455) appears in multiple sizes assert_eq!(count(DynSolType::Uint(8)), 2, "Uint(8) count mismatch"); @@ -787,10 +739,6 @@ mod tests { // - String literals (hashed and right-padded versions) assert_eq!(count(DynSolType::FixedBytes(32)), 6, "FixedBytes(32) count mismatch"); - // String and byte literals - assert_eq!(literals.strings.len(), 3, "String literals count mismatch"); - assert_eq!(literals.bytes.len(), 1, "Byte literals count mismatch"); - // Total count check assert_eq!( literals.words.values().map(|set| set.len()).sum::(), @@ -798,4 +746,29 @@ mod tests { "Total word values count mismatch" ); } + + // -- TEST HELPERS --------------------------------------------------------- + + fn process_source_literals(source: &str) -> LiteralMaps { + let mut compiler = Compiler::new(Session::builder().with_stderr_emitter().build()); + compiler + .enter_mut(|c| -> std::io::Result<()> { + let mut pcx = c.parse(); + pcx.set_resolve_imports(false); + + pcx.add_file( + c.sess().source_map().new_source_file(source_map::FileName::Stdin, source)?, + ); + pcx.parse(); + let _ = c.lower_asts(); + Ok(()) + }) + .expect("Failed to compile test source"); + + LiteralsCollector::process(&Arc::new(compiler), None, usize::MAX) + } + + fn assert_word(literals: &LiteralMaps, ty: DynSolType, value: B256, msg: &str) { + assert!(literals.words.get(&ty).is_some_and(|set| set.contains(&value)), "{}", msg); + } } From c7ff115c839de8939c5b1052d6fd6574058de876 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 15:39:13 +0200 Subject: [PATCH 21/36] style: avoid typo error --- crates/evm/fuzz/src/strategies/state.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index e35d2be2e97c4..af9d446ce134a 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -687,7 +687,7 @@ mod tests { #[test] fn test_literals_collector_coverage() { - let lits = process_source_literals(SOURCE); + let map = process_source_literals(SOURCE); // Expected values from the SOURCE contract let addr = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F").into_word(); @@ -696,16 +696,16 @@ mod tests { let word = B256::right_padding_from(b"abcd1234"); let dyn_bytes = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); - assert_word(&lits, DynSolType::Address, addr, "Expected DAI in address set"); - assert_word(&lits, DynSolType::Uint(64), num, "Expected MAGIC_NUMBER in uint64 set"); - assert_word(&lits, DynSolType::Int(32), int, "Expected MAGIC_INT in int32 set"); - assert_word(&lits, DynSolType::FixedBytes(32), word, "Expected MAGIC_WORD in bytes32 set"); - assert!(lits.strings.contains("xyzzy"), "Expected MAGIC_STRING to be collected"); + assert_word(&map, DynSolType::Address, addr, "Expected DAI in address set"); + assert_word(&map, DynSolType::Uint(64), num, "Expected MAGIC_NUMBER in uint64 set"); + assert_word(&map, DynSolType::Int(32), int, "Expected MAGIC_INT in int32 set"); + assert_word(&map, DynSolType::FixedBytes(32), word, "Expected MAGIC_WORD in bytes32 set"); + assert!(map.strings.contains("xyzzy"), "Expected MAGIC_STRING to be collected"); assert!( - lits.strings.contains("eip1967.proxy.implementation"), + map.strings.contains("eip1967.proxy.implementation"), "Expected IMPLEMENTATION_SLOT in string set" ); - assert!(lits.bytes.contains(&dyn_bytes), "Expected MAGIC_BYTES in bytes set"); + assert!(map.bytes.contains(&dyn_bytes), "Expected MAGIC_BYTES in bytes set"); } #[test] From 935c435d97da1503dce91a799b7bf0c762f17eb8 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Fri, 10 Oct 2025 15:52:55 +0200 Subject: [PATCH 22/36] test: revert `should_fuzz_literals` changes --- crates/forge/tests/cli/test_cmd.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 6bc1da10d4776..5bc7cd85c4d94 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -4173,11 +4173,6 @@ forgetest_init!(should_fuzz_literals, |prj, cmd| { bytes constant MAGIC_BYTES = hex"deadbeef"; string constant MAGIC_STRING = "xyzzy"; - // constant exprs with folding - uint256 constant NEG_FOLDING = uint(-2); - uint256 constant BIN_FOLDING = 2 * 2 ether; - bytes32 constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1); - function checkAddr(address v) external pure { assert(v != DAI); } function checkWord(bytes32 v) external pure { assert(v != MAGIC_WORD); } function checkNumber(uint64 v) external pure { assert(v != MAGIC_NUMBER); } From bc9504e4e1e38d1e44267e85ce01ad865abd0022 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 13 Oct 2025 07:41:12 +0200 Subject: [PATCH 23/36] fix: missing test --- crates/forge/tests/it/fuzz.rs | 5 ++++- testdata/default/repros/Issue2851.t.sol | 1 + 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/crates/forge/tests/it/fuzz.rs b/crates/forge/tests/it/fuzz.rs index 4ac88fc02879f..781a8d781e579 100644 --- a/crates/forge/tests/it/fuzz.rs +++ b/crates/forge/tests/it/fuzz.rs @@ -276,7 +276,10 @@ Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) forgetest_init!(test_fuzz_fail_on_revert, |prj, cmd| { prj.wipe_contracts(); - prj.update_config(|config| config.fuzz.fail_on_revert = false); + prj.update_config(|config| { + config.fuzz.fail_on_revert = false; + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; + }); prj.add_source( "Counter.sol", r#" diff --git a/testdata/default/repros/Issue2851.t.sol b/testdata/default/repros/Issue2851.t.sol index 7742af4b708e0..86d470eba2668 100644 --- a/testdata/default/repros/Issue2851.t.sol +++ b/testdata/default/repros/Issue2851.t.sol @@ -22,6 +22,7 @@ contract Issue2851Test is DSTest { back = new Backdoor(); } + /// forge-config: default.fuzz.dictionary.max_fuzz_dictionary_literals = 0 /// forge-config: default.fuzz.seed = '111' function invariantNotZero() public { assertEq(back.number(), 1); From 9a05ffb24ddef901d75a9e42eaabe5a0dd85db1c Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Oct 2025 08:37:20 +0200 Subject: [PATCH 24/36] style: use MB const + use `bool::weighted` + rename `state_clone` --- crates/config/src/fuzz.rs | 11 ++++----- crates/evm/fuzz/src/strategies/param.rs | 31 ++++++++++++++----------- 2 files changed, 23 insertions(+), 19 deletions(-) diff --git a/crates/config/src/fuzz.rs b/crates/config/src/fuzz.rs index f5e4cac73aa58..9f2ba141897a8 100644 --- a/crates/config/src/fuzz.rs +++ b/crates/config/src/fuzz.rs @@ -89,16 +89,15 @@ pub struct FuzzDictionaryConfig { impl Default for FuzzDictionaryConfig { fn default() -> Self { + const MB: usize = 1024 * 1024; + Self { dictionary_weight: 40, include_storage: true, 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_literals: (200 * 1024 * 1024) / 32, + max_fuzz_dictionary_addresses: 300 * MB / 20, + max_fuzz_dictionary_values: 300 * MB / 32, + max_fuzz_dictionary_literals: 200 * MB / 32, } } } diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 208b9a549e03d..51de9aebc2daf 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -171,14 +171,14 @@ pub fn fuzz_param_from_state( .boxed(), DynSolType::Bool => DynSolValue::type_strategy(param).boxed(), DynSolType::String => { - let state_clone = state.clone(); - (any::(), any::()) - .prop_flat_map(move |(use_ast_index, select_index)| { - let dict = state_clone.dictionary_read(); + let state = state.clone(); + (proptest::bool::weighted(0.3), any::()) + .prop_flat_map(move |(use_ast, select_index)| { + let dict = state.dictionary_read(); - // AST string literals available: use 30/70 allocation + // AST string literals available: 30% probability let ast_strings = dict.ast_strings(); - if !ast_strings.is_empty() && use_ast_index.index(10) < 3 { + if use_ast && !ast_strings.is_empty() { let s = &ast_strings.as_slice()[select_index.index(ast_strings.len())]; return Just(DynSolValue::String(s.clone())).boxed(); } @@ -196,25 +196,30 @@ pub fn fuzz_param_from_state( } DynSolType::Bytes => { let state_clone = state.clone(); - (value(), any::(), any::()) - .prop_map(move |(word, use_ast_index, select_index)| { + ( + value(), + proptest::bool::weighted(0.1), + proptest::bool::weighted(0.2), + any::(), + ) + .prop_map(move |(word, use_ast_string, use_ast_bytes, select_index)| { let dict = state_clone.dictionary_read(); - // Try string literals as bytes (10% chance) + // Try string literals as bytes: 10% chance let ast_strings = dict.ast_strings(); - if !ast_strings.is_empty() && use_ast_index.index(10) < 1 { + if use_ast_string && !ast_strings.is_empty() { 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) + // Try hex literals: 20% chance let ast_bytes = dict.ast_bytes(); - if !ast_bytes.is_empty() && use_ast_index.index(10) < 3 { + if use_ast_bytes && !ast_bytes.is_empty() { 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) + // Fallback to the generated word from the dictionary: 70% chance DynSolValue::Bytes(word.0.into()) }) .boxed() From 5813a5adacdb3a5d19d6466d2e4c033116464db1 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Oct 2025 08:50:27 +0200 Subject: [PATCH 25/36] refactor: new literals.rs file --- crates/evm/fuzz/src/strategies/literals.rs | 296 ++++++++++++++++++++ crates/evm/fuzz/src/strategies/mod.rs | 5 +- crates/evm/fuzz/src/strategies/param.rs | 2 +- crates/evm/fuzz/src/strategies/state.rs | 301 +-------------------- 4 files changed, 307 insertions(+), 297 deletions(-) create mode 100644 crates/evm/fuzz/src/strategies/literals.rs diff --git a/crates/evm/fuzz/src/strategies/literals.rs b/crates/evm/fuzz/src/strategies/literals.rs new file mode 100644 index 0000000000000..2c734ebdea6cc --- /dev/null +++ b/crates/evm/fuzz/src/strategies/literals.rs @@ -0,0 +1,296 @@ +use alloy_dyn_abi::DynSolType; +use alloy_primitives::{ + B256, Bytes, I256, U256, keccak256, + map::{B256IndexSet, HashMap, IndexSet}, +}; +use foundry_compilers::ProjectPathsConfig; +use parking_lot::{RawRwLock, RwLock, lock_api::RwLockReadGuard}; +use solar::{ + ast::{self, Visit}, + interface::source_map::FileName, + sema::Compiler, +}; +use std::{ops::ControlFlow, sync::Arc}; + +#[derive(Debug, Default)] +pub struct LiteralMaps { + pub words: HashMap, + pub strings: IndexSet, + pub bytes: IndexSet, +} + +#[derive(Debug, Default)] +pub struct LiteralsCollector { + max_values: usize, + total_values: usize, + output: LiteralMaps, +} + +impl LiteralsCollector { + fn new(max_values: usize) -> Self { + Self { max_values, ..Default::default() } + } + + pub fn process( + compiler: &Arc, + paths_config: Option<&ProjectPathsConfig>, + max_values: usize, + ) -> LiteralMaps { + compiler.enter(|compiler| { + let mut literals_collector = Self::new(max_values); + for source in compiler.sources().iter() { + // Ignore scripts, and libs + if let Some(paths) = paths_config + && let FileName::Real(source_path) = &source.file.name + && !(source_path.starts_with(&paths.sources) || paths.is_test(source_path)) + { + continue; + } + + if let Some(ref ast) = source.ast { + let _ = literals_collector.visit_source_unit(ast); + } + } + + literals_collector.output + }) + } +} + +impl<'ast> ast::Visit<'ast> for LiteralsCollector { + type BreakValue = (); + + fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow<()> { + // Stop early if we've hit the limit + if self.total_values >= self.max_values { + return ControlFlow::Break(()); + } + + // Handle unary negation of number literals + if let ast::ExprKind::Unary(un_op, inner_expr) = &expr.kind + && un_op.kind == ast::UnOpKind::Neg + && let ast::ExprKind::Lit(lit, _) = &inner_expr.kind + && let ast::LitKind::Number(n) = &lit.kind + { + // Compute the negative I256 value + if let Ok(pos_i256) = I256::try_from(*n) { + let neg_value = -pos_i256; + let neg_b256 = B256::from(neg_value.into_raw()); + + // Store under all intN sizes that can represent this value + for bits in [16, 32, 64, 128, 256] { + if can_fit_int(neg_value, bits) + && self + .output + .words + .entry(DynSolType::Int(bits)) + .or_default() + .insert(neg_b256) + { + self.total_values += 1; + } + } + } + + // Continue walking the expression + return self.walk_expr(expr); + } + + // Handle literals + if let ast::ExprKind::Lit(lit, _) = &expr.kind { + let is_new = match &lit.kind { + ast::LitKind::Number(n) => { + let pos_value = U256::from(*n); + let pos_b256 = B256::from(pos_value); + + // Store under all uintN sizes that can represent this value + for bits in [8, 16, 32, 64, 128, 256] { + if can_fit_uint(pos_value, bits) + && self + .output + .words + .entry(DynSolType::Uint(bits)) + .or_default() + .insert(pos_b256) + { + self.total_values += 1; + } + } + false // already handled inserts individually + } + ast::LitKind::Address(addr) => self + .output + .words + .entry(DynSolType::Address) + .or_default() + .insert(addr.into_word()), + ast::LitKind::Str(ast::StrKind::Hex, sym, _) => { + self.output.bytes.insert(Bytes::copy_from_slice(sym.as_byte_str())) + } + ast::LitKind::Str(_, sym, _) => { + let s = String::from_utf8_lossy(sym.as_byte_str()).into_owned(); + // For strings, also store the hashed version + let hash = keccak256(s.as_bytes()); + if self.output.words.entry(DynSolType::FixedBytes(32)).or_default().insert(hash) + { + self.total_values += 1; + } + // And the right-padded version if it fits. + if s.len() <= 32 { + let padded = B256::right_padding_from(s.as_bytes()); + if self + .output + .words + .entry(DynSolType::FixedBytes(32)) + .or_default() + .insert(padded) + { + self.total_values += 1; + } + } + self.output.strings.insert(s) + } + ast::LitKind::Bool(..) | ast::LitKind::Rational(..) | ast::LitKind::Err(..) => { + false // ignore + } + }; + + if is_new { + self.total_values += 1; + } + } + + self.walk_expr(expr) + } +} + +/// Checks if a signed integer value can fit in intN type. +fn can_fit_int(value: I256, bits: usize) -> bool { + // Calculate the maximum positive value for intN: 2^(N-1) - 1 + let max_val = I256::try_from((U256::from(1) << (bits - 1)) - U256::from(1)) + .expect("max value should fit in I256"); + // Calculate the minimum negative value for intN: -2^(N-1) + let min_val = -max_val - I256::ONE; + + value >= min_val && value <= max_val +} + +/// Checks if an unsigned integer value can fit in uintN type. +fn can_fit_uint(value: U256, bits: usize) -> bool { + if bits == 256 { + return true; + } + // Calculate the maximum value for uintN: 2^N - 1 + let max_val = (U256::from(1) << bits) - U256::from(1); + value <= max_val +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + use solar::interface::{Session, source_map}; + + const SOURCE: &str = r#" + contract Magic { + // plain literals + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + uint64 constant MAGIC_NUMBER = 1122334455; + int32 constant MAGIC_INT = -777; + bytes32 constant MAGIC_WORD = "abcd1234"; + bytes constant MAGIC_BYTES = hex"deadbeef"; + string constant MAGIC_STRING = "xyzzy"; + + // constant exprs with folding + uint256 constant NEG_FOLDING = uint(-2); + uint256 constant BIN_FOLDING = 2 * 2 ether; + bytes32 constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1); + }"#; + + #[test] + fn test_literals_collector_coverage() { + let map = process_source_literals(SOURCE); + + // Expected values from the SOURCE contract + let addr = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F").into_word(); + let num = B256::from(U256::from(1122334455u64)); + let int = B256::from(I256::try_from(-777i32).unwrap().into_raw()); + let word = B256::right_padding_from(b"abcd1234"); + let dyn_bytes = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); + + assert_word(&map, DynSolType::Address, addr, "Expected DAI in address set"); + assert_word(&map, DynSolType::Uint(64), num, "Expected MAGIC_NUMBER in uint64 set"); + assert_word(&map, DynSolType::Int(32), int, "Expected MAGIC_INT in int32 set"); + assert_word(&map, DynSolType::FixedBytes(32), word, "Expected MAGIC_WORD in bytes32 set"); + assert!(map.strings.contains("xyzzy"), "Expected MAGIC_STRING to be collected"); + assert!( + map.strings.contains("eip1967.proxy.implementation"), + "Expected IMPLEMENTATION_SLOT in string set" + ); + assert!(map.bytes.contains(&dyn_bytes), "Expected MAGIC_BYTES in bytes set"); + } + + #[test] + fn test_literals_collector_size() { + let literals = process_source_literals(SOURCE); + + // Helper to get count for a type, returns 0 if not present + let count = |ty: DynSolType| literals.words.get(&ty).map_or(0, |set| set.len()); + + assert_eq!(count(DynSolType::Address), 1, "Address literal count mismatch"); + assert_eq!(literals.strings.len(), 3, "String literals count mismatch"); + assert_eq!(literals.bytes.len(), 1, "Byte literals count mismatch"); + + // Unsigned integers - MAGIC_NUMBER (1122334455) appears in multiple sizes + assert_eq!(count(DynSolType::Uint(8)), 2, "Uint(8) count mismatch"); + assert_eq!(count(DynSolType::Uint(16)), 3, "Uint(16) count mismatch"); + assert_eq!(count(DynSolType::Uint(32)), 4, "Uint(32) count mismatch"); + assert_eq!(count(DynSolType::Uint(64)), 5, "Uint(64) count mismatch"); + assert_eq!(count(DynSolType::Uint(128)), 5, "Uint(128) count mismatch"); + assert_eq!(count(DynSolType::Uint(256)), 5, "Uint(256) count mismatch"); + + // Signed integers - MAGIC_INT (-777) appears in multiple sizes + assert_eq!(count(DynSolType::Int(16)), 2, "Int(16) count mismatch"); + assert_eq!(count(DynSolType::Int(32)), 2, "Int(32) count mismatch"); + assert_eq!(count(DynSolType::Int(64)), 2, "Int(64) count mismatch"); + assert_eq!(count(DynSolType::Int(128)), 2, "Int(128) count mismatch"); + assert_eq!(count(DynSolType::Int(256)), 2, "Int(256) count mismatch"); + + // FixedBytes(32) includes: + // - MAGIC_WORD + // - String literals (hashed and right-padded versions) + assert_eq!(count(DynSolType::FixedBytes(32)), 6, "FixedBytes(32) count mismatch"); + + // Total count check + assert_eq!( + literals.words.values().map(|set| set.len()).sum::(), + 41, + "Total word values count mismatch" + ); + } + + // -- TEST HELPERS --------------------------------------------------------- + + fn process_source_literals(source: &str) -> LiteralMaps { + let mut compiler = Compiler::new(Session::builder().with_stderr_emitter().build()); + compiler + .enter_mut(|c| -> std::io::Result<()> { + let mut pcx = c.parse(); + pcx.set_resolve_imports(false); + + pcx.add_file( + c.sess().source_map().new_source_file(source_map::FileName::Stdin, source)?, + ); + pcx.parse(); + let _ = c.lower_asts(); + Ok(()) + }) + .expect("Failed to compile test source"); + + LiteralsCollector::process(&Arc::new(compiler), None, usize::MAX) + } + + fn assert_word(literals: &LiteralMaps, ty: DynSolType, value: B256, msg: &str) { + assert!(literals.words.get(&ty).is_some_and(|set| set.contains(&value)), "{}", msg); + } +} diff --git a/crates/evm/fuzz/src/strategies/mod.rs b/crates/evm/fuzz/src/strategies/mod.rs index 19a9baff27802..06cec62230d7b 100644 --- a/crates/evm/fuzz/src/strategies/mod.rs +++ b/crates/evm/fuzz/src/strategies/mod.rs @@ -11,10 +11,13 @@ mod calldata; pub use calldata::{fuzz_calldata, fuzz_calldata_from_state}; mod state; -pub use state::{EvmFuzzState, LiteralMaps}; +pub use state::EvmFuzzState; mod invariants; pub use invariants::{fuzz_contract_with_calldata, invariant_strat, override_call_strat}; mod mutators; pub use mutators::BoundMutator; + +mod literals; +pub use literals::{LiteralMaps, LiteralsCollector}; diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 51de9aebc2daf..7c8aaa59b6d3e 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -455,7 +455,7 @@ mod tests { #[test] fn can_fuzz_string_and_bytes_with_ast_literals_and_hashes() { use super::fuzz_param_from_state; - use crate::strategies::state::LiteralMaps; + use crate::strategies::LiteralMaps; use alloy_dyn_abi::DynSolType; use alloy_primitives::keccak256; use proptest::strategy::Strategy; diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index af9d446ce134a..db762cda9e721 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -2,7 +2,7 @@ use crate::{BasicTxDetails, invariant::FuzzRunIdentifiedContracts}; use alloy_dyn_abi::{DynSolType, DynSolValue, EventExt, FunctionExt}; use alloy_json_abi::{Function, JsonAbi}; use alloy_primitives::{ - Address, B256, Bytes, I256, Log, U256, keccak256, + Address, B256, Bytes, Log, U256, map::{AddressIndexSet, AddressMap, B256IndexSet, HashMap, IndexSet}, }; use foundry_common::{ @@ -16,12 +16,8 @@ use revm::{ database::{CacheDB, DatabaseRef, DbAccount}, state::AccountInfo, }; -use solar::{ - ast::{self, Visit}, - interface::source_map::FileName, - sema::Compiler, -}; -use std::{collections::BTreeMap, fmt, ops::ControlFlow, sync::Arc}; +use solar::sema::Compiler; +use std::{collections::BTreeMap, fmt, sync::Arc}; /// The maximum number of bytes we will look at in bytecodes to find push bytes (24 KiB). /// @@ -62,7 +58,7 @@ impl EvmFuzzState { // Seed dict with AST literals if analysis is available. if let Some(compiler) = analysis { - let literals = LiteralsCollector::process( + let literals = super::LiteralsCollector::process( compiler, paths_config, config.max_fuzz_dictionary_literals, @@ -138,7 +134,7 @@ impl EvmFuzzState { #[cfg(test)] /// Test-only helper to seed the dictionary with literal values. - pub(crate) fn seed_literals(&self, map: LiteralMaps) { + pub(crate) fn seed_literals(&self, map: super::LiteralMaps) { self.inner.write().seed_literals(map); } } @@ -481,294 +477,9 @@ impl FuzzDictionary { #[cfg(test)] /// Test-only helper to seed the dictionary with literal values. - pub(crate) fn seed_literals(&mut self, map: LiteralMaps) { + pub(crate) fn seed_literals(&mut self, map: super::LiteralMaps) { self.string_literals = map.strings; self.byte_literals = map.bytes; self.sample_values = map.words; } } - -// -- AST LITERALS COLLECTOR --------------------------------------------------- - -#[derive(Debug, Default)] -pub struct LiteralMaps { - pub words: HashMap, - pub strings: IndexSet, - pub bytes: IndexSet, -} - -#[derive(Debug, Default)] -struct LiteralsCollector { - max_values: usize, - total_values: usize, - output: LiteralMaps, -} - -impl LiteralsCollector { - fn new(max_values: usize) -> Self { - Self { max_values, ..Default::default() } - } - - fn process( - compiler: &Arc, - paths_config: Option<&ProjectPathsConfig>, - max_values: usize, - ) -> LiteralMaps { - compiler.enter(|compiler| { - let mut literals_collector = Self::new(max_values); - for source in compiler.sources().iter() { - // Ignore scripts, and libs - if let Some(paths) = paths_config - && let FileName::Real(source_path) = &source.file.name - && !(source_path.starts_with(&paths.sources) || paths.is_test(source_path)) - { - continue; - } - - if let Some(ref ast) = source.ast { - let _ = literals_collector.visit_source_unit(ast); - } - } - - literals_collector.output - }) - } -} - -impl<'ast> ast::Visit<'ast> for LiteralsCollector { - type BreakValue = (); - - fn visit_expr(&mut self, expr: &'ast ast::Expr<'ast>) -> ControlFlow<()> { - // Stop early if we've hit the limit - if self.total_values >= self.max_values { - return ControlFlow::Break(()); - } - - // Handle unary negation of number literals - if let ast::ExprKind::Unary(un_op, inner_expr) = &expr.kind - && un_op.kind == ast::UnOpKind::Neg - && let ast::ExprKind::Lit(lit, _) = &inner_expr.kind - && let ast::LitKind::Number(n) = &lit.kind - { - // Compute the negative I256 value - if let Ok(pos_i256) = I256::try_from(*n) { - let neg_value = -pos_i256; - let neg_b256 = B256::from(neg_value.into_raw()); - - // Store under all intN sizes that can represent this value - for bits in [16, 32, 64, 128, 256] { - if can_fit_int(neg_value, bits) - && self - .output - .words - .entry(DynSolType::Int(bits)) - .or_default() - .insert(neg_b256) - { - self.total_values += 1; - } - } - } - - // Continue walking the expression - return self.walk_expr(expr); - } - - // Handle literals - if let ast::ExprKind::Lit(lit, _) = &expr.kind { - let is_new = match &lit.kind { - ast::LitKind::Number(n) => { - let pos_value = U256::from(*n); - let pos_b256 = B256::from(pos_value); - - // Store under all uintN sizes that can represent this value - for bits in [8, 16, 32, 64, 128, 256] { - if can_fit_uint(pos_value, bits) - && self - .output - .words - .entry(DynSolType::Uint(bits)) - .or_default() - .insert(pos_b256) - { - self.total_values += 1; - } - } - false // already handled inserts individually - } - ast::LitKind::Address(addr) => self - .output - .words - .entry(DynSolType::Address) - .or_default() - .insert(addr.into_word()), - ast::LitKind::Str(ast::StrKind::Hex, sym, _) => { - self.output.bytes.insert(Bytes::copy_from_slice(sym.as_byte_str())) - } - ast::LitKind::Str(_, sym, _) => { - let s = String::from_utf8_lossy(sym.as_byte_str()).into_owned(); - // For strings, also store the hashed version - let hash = keccak256(s.as_bytes()); - if self.output.words.entry(DynSolType::FixedBytes(32)).or_default().insert(hash) - { - self.total_values += 1; - } - // And the right-padded version if it fits. - if s.len() <= 32 { - let padded = B256::right_padding_from(s.as_bytes()); - if self - .output - .words - .entry(DynSolType::FixedBytes(32)) - .or_default() - .insert(padded) - { - self.total_values += 1; - } - } - self.output.strings.insert(s) - } - ast::LitKind::Bool(..) | ast::LitKind::Rational(..) | ast::LitKind::Err(..) => { - false // ignore - } - }; - - if is_new { - self.total_values += 1; - } - } - - self.walk_expr(expr) - } -} - -/// Checks if a signed integer value can fit in intN type. -fn can_fit_int(value: I256, bits: usize) -> bool { - // Calculate the maximum positive value for intN: 2^(N-1) - 1 - let max_val = I256::try_from((U256::from(1) << (bits - 1)) - U256::from(1)) - .expect("max value should fit in I256"); - // Calculate the minimum negative value for intN: -2^(N-1) - let min_val = -max_val - I256::ONE; - - value >= min_val && value <= max_val -} - -/// Checks if an unsigned integer value can fit in uintN type. -fn can_fit_uint(value: U256, bits: usize) -> bool { - if bits == 256 { - return true; - } - // Calculate the maximum value for uintN: 2^N - 1 - let max_val = (U256::from(1) << bits) - U256::from(1); - value <= max_val -} - -#[cfg(test)] -mod tests { - use super::*; - use alloy_primitives::address; - use solar::interface::{Session, source_map}; - - const SOURCE: &str = r#" - contract Magic { - // plain literals - address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; - uint64 constant MAGIC_NUMBER = 1122334455; - int32 constant MAGIC_INT = -777; - bytes32 constant MAGIC_WORD = "abcd1234"; - bytes constant MAGIC_BYTES = hex"deadbeef"; - string constant MAGIC_STRING = "xyzzy"; - - // constant exprs with folding - uint256 constant NEG_FOLDING = uint(-2); - uint256 constant BIN_FOLDING = 2 * 2 ether; - bytes32 constant IMPLEMENTATION_SLOT = bytes32(uint256(keccak256('eip1967.proxy.implementation')) - 1); - }"#; - - #[test] - fn test_literals_collector_coverage() { - let map = process_source_literals(SOURCE); - - // Expected values from the SOURCE contract - let addr = address!("0x6B175474E89094C44Da98b954EedeAC495271d0F").into_word(); - let num = B256::from(U256::from(1122334455u64)); - let int = B256::from(I256::try_from(-777i32).unwrap().into_raw()); - let word = B256::right_padding_from(b"abcd1234"); - let dyn_bytes = Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]); - - assert_word(&map, DynSolType::Address, addr, "Expected DAI in address set"); - assert_word(&map, DynSolType::Uint(64), num, "Expected MAGIC_NUMBER in uint64 set"); - assert_word(&map, DynSolType::Int(32), int, "Expected MAGIC_INT in int32 set"); - assert_word(&map, DynSolType::FixedBytes(32), word, "Expected MAGIC_WORD in bytes32 set"); - assert!(map.strings.contains("xyzzy"), "Expected MAGIC_STRING to be collected"); - assert!( - map.strings.contains("eip1967.proxy.implementation"), - "Expected IMPLEMENTATION_SLOT in string set" - ); - assert!(map.bytes.contains(&dyn_bytes), "Expected MAGIC_BYTES in bytes set"); - } - - #[test] - fn test_literals_collector_size() { - let literals = process_source_literals(SOURCE); - - // Helper to get count for a type, returns 0 if not present - let count = |ty: DynSolType| literals.words.get(&ty).map_or(0, |set| set.len()); - - assert_eq!(count(DynSolType::Address), 1, "Address literal count mismatch"); - assert_eq!(literals.strings.len(), 3, "String literals count mismatch"); - assert_eq!(literals.bytes.len(), 1, "Byte literals count mismatch"); - - // Unsigned integers - MAGIC_NUMBER (1122334455) appears in multiple sizes - assert_eq!(count(DynSolType::Uint(8)), 2, "Uint(8) count mismatch"); - assert_eq!(count(DynSolType::Uint(16)), 3, "Uint(16) count mismatch"); - assert_eq!(count(DynSolType::Uint(32)), 4, "Uint(32) count mismatch"); - assert_eq!(count(DynSolType::Uint(64)), 5, "Uint(64) count mismatch"); - assert_eq!(count(DynSolType::Uint(128)), 5, "Uint(128) count mismatch"); - assert_eq!(count(DynSolType::Uint(256)), 5, "Uint(256) count mismatch"); - - // Signed integers - MAGIC_INT (-777) appears in multiple sizes - assert_eq!(count(DynSolType::Int(16)), 2, "Int(16) count mismatch"); - assert_eq!(count(DynSolType::Int(32)), 2, "Int(32) count mismatch"); - assert_eq!(count(DynSolType::Int(64)), 2, "Int(64) count mismatch"); - assert_eq!(count(DynSolType::Int(128)), 2, "Int(128) count mismatch"); - assert_eq!(count(DynSolType::Int(256)), 2, "Int(256) count mismatch"); - - // FixedBytes(32) includes: - // - MAGIC_WORD - // - String literals (hashed and right-padded versions) - assert_eq!(count(DynSolType::FixedBytes(32)), 6, "FixedBytes(32) count mismatch"); - - // Total count check - assert_eq!( - literals.words.values().map(|set| set.len()).sum::(), - 41, - "Total word values count mismatch" - ); - } - - // -- TEST HELPERS --------------------------------------------------------- - - fn process_source_literals(source: &str) -> LiteralMaps { - let mut compiler = Compiler::new(Session::builder().with_stderr_emitter().build()); - compiler - .enter_mut(|c| -> std::io::Result<()> { - let mut pcx = c.parse(); - pcx.set_resolve_imports(false); - - pcx.add_file( - c.sess().source_map().new_source_file(source_map::FileName::Stdin, source)?, - ); - pcx.parse(); - let _ = c.lower_asts(); - Ok(()) - }) - .expect("Failed to compile test source"); - - LiteralsCollector::process(&Arc::new(compiler), None, usize::MAX) - } - - fn assert_word(literals: &LiteralMaps, ty: DynSolType, value: B256, msg: &str) { - assert!(literals.words.get(&ty).is_some_and(|set| set.contains(&value)), "{}", msg); - } -} From e7d3159e0b78bf45685fd771bfb2cdde17e3a367 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Oct 2025 11:54:39 +0200 Subject: [PATCH 26/36] feat: lazily collect literals --- crates/evm/fuzz/src/strategies/literals.rs | 62 ++++++++++++++++++- crates/evm/fuzz/src/strategies/state.rs | 72 ++++++++++++++-------- crates/forge/tests/cli/test_cmd.rs | 4 +- 3 files changed, 107 insertions(+), 31 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/literals.rs b/crates/evm/fuzz/src/strategies/literals.rs index 2c734ebdea6cc..8aca09d259288 100644 --- a/crates/evm/fuzz/src/strategies/literals.rs +++ b/crates/evm/fuzz/src/strategies/literals.rs @@ -4,13 +4,71 @@ use alloy_primitives::{ map::{B256IndexSet, HashMap, IndexSet}, }; use foundry_compilers::ProjectPathsConfig; -use parking_lot::{RawRwLock, RwLock, lock_api::RwLockReadGuard}; use solar::{ ast::{self, Visit}, interface::source_map::FileName, sema::Compiler, }; -use std::{ops::ControlFlow, sync::Arc}; +use std::{ + ops::ControlFlow, + sync::{Arc, OnceLock}, +}; + +#[derive(Debug, Default)] +pub struct LiteralsDictionary { + /// Data required for initialization, captured from `EvmFuzzState::new`. + compiler: Option>, + paths_config: Option, + max_values: usize, + + /// Lazy initialized literal maps. + maps: OnceLock, +} + +impl LiteralsDictionary { + pub fn new( + compiler: Option>, + paths_config: Option, + max_values: usize, + ) -> Self { + Self { compiler, paths_config, max_values, maps: OnceLock::default() } + } + + /// Returns a reference to the `LiteralMaps`, initializing them on the first call. + pub fn get(&self) -> &LiteralMaps { + self.maps.get_or_init(|| { + if let Some(compiler) = &self.compiler { + let literals = LiteralsCollector::process( + compiler, + self.paths_config.as_ref(), + self.max_values, + ); + trace!( + words = literals.words.values().map(|set| set.len()).sum::(), + strings = literals.strings.len(), + bytes = literals.bytes.len(), + "collected source code literals for fuzz dictionary" + ); + literals + } else { + LiteralMaps::default() + } + }) + } + + /// Takes ownership of the dictionary words, leaving an empty map in their place. + /// Ensures the map is initialized before taking its contents. + pub fn take_words(&mut self) -> HashMap { + let _ = self.get(); + self.maps.get_mut().map(|m| std::mem::take(&mut m.words)).unwrap_or_default() + } + + #[cfg(test)] + /// Test-only helper to seed the dictionary with literal values. + pub(crate) fn set(&mut self, map: super::LiteralMaps) { + let _ = self.maps.set(map); + } +} #[derive(Debug, Default)] pub struct LiteralMaps { diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index db762cda9e721..b23c72abf02cf 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -1,4 +1,6 @@ -use crate::{BasicTxDetails, invariant::FuzzRunIdentifiedContracts}; +use crate::{ + BasicTxDetails, invariant::FuzzRunIdentifiedContracts, strategies::literals::LiteralsDictionary, +}; use alloy_dyn_abi::{DynSolType, DynSolValue, EventExt, FunctionExt}; use alloy_json_abi::{Function, JsonAbi}; use alloy_primitives::{ @@ -55,19 +57,11 @@ impl EvmFuzzState { // Create fuzz dictionary and insert values from db state. let mut dictionary = FuzzDictionary::new(config); dictionary.insert_db_values(accs); - - // Seed dict with AST literals if analysis is available. - if let Some(compiler) = analysis { - let literals = super::LiteralsCollector::process( - compiler, - paths_config, - config.max_fuzz_dictionary_literals, - ); - dictionary.sample_values = literals.words; - dictionary.string_literals = literals.strings; - dictionary.byte_literals = literals.bytes; - trace!("inserted AST literals into fuzz dictionary"); - } + dictionary.literal_values = LiteralsDictionary::new( + analysis.cloned(), + paths_config.cloned(), + config.max_fuzz_dictionary_literals, + ); Self { inner: Arc::new(RwLock::new(dictionary)), @@ -158,10 +152,13 @@ pub struct FuzzDictionary { /// Typed runtime sample values persisted across invariant runs. /// Initially seeded with literal values collected from the source code. sample_values: HashMap, - /// String literals collected from source code. Never reverted. - string_literals: IndexSet, - /// Byte literals (hex"...") collected from source code. Never reverted. - byte_literals: IndexSet, + /// Lazily initialized dictionary of literal values collected from the source code. + literal_values: LiteralsDictionary, + /// Tracks whether literals from `literal_values` have been merged into `sample_values`. + /// + /// Set to `true` on first call to `seed_samples()`. Before seeding, `samples()` checks both + /// maps separately. After seeding, literals are merged in, so only `sample_values` is checked. + samples_seeded: bool, misses: usize, hits: usize, @@ -178,7 +175,7 @@ impl fmt::Debug for FuzzDictionary { impl FuzzDictionary { pub fn new(config: FuzzDictionaryConfig) -> Self { - let mut dictionary = Self { config, ..Default::default() }; + let mut dictionary = Self { config, samples_seeded: false, ..Default::default() }; dictionary.prefill(); dictionary } @@ -188,6 +185,15 @@ impl FuzzDictionary { self.insert_value(B256::ZERO); } + /// Seeds `sample_values` with all words from the [`LiteralsDictionary`]. + /// Should only be called once per dictionary lifetime. + #[cold] + fn seed_samples(&mut self) { + trace!("seeding `sample_values` from literal dictionary"); + self.sample_values.extend(self.literal_values.take_words()); + self.samples_seeded = true; + } + /// Insert values from initial db state into fuzz dictionary. /// These values are persisted across invariant runs. fn insert_db_values(&mut self, db_state: Vec<(&Address, &DbAccount)>) { @@ -358,6 +364,9 @@ impl FuzzDictionary { && let Some(slot_info) = slot_identifier.identify(&slot, mapping_slots) && slot_info.decode(value).is_some() { trace!(?slot_info, "inserting typed storage value"); + if !self.samples_seeded { + self.seed_samples(); + } self.sample_values.entry(slot_info.slot_type.dyn_sol_type).or_default().insert(value); } else { self.insert_value_u256(value.into()); @@ -407,6 +416,9 @@ impl FuzzDictionary { sample_values: impl IntoIterator, limit: u32, ) { + if !self.samples_seeded { + self.seed_samples(); + } for sample in sample_values { if let (Some(sample_type), Some(sample_value)) = (sample.as_type(), sample.as_word()) { if let Some(values) = self.sample_values.get_mut(&sample_type) { @@ -435,21 +447,30 @@ impl FuzzDictionary { self.state_values.is_empty() } + /// Returns sample values for a given type, checking both runtime samples and literals. + /// + /// Before `seed_samples()` is called, checks both `literal_values` and `sample_values` + /// separately. After seeding, all literal values are merged into `sample_values`. #[inline] pub fn samples(&self, param_type: &DynSolType) -> Option<&B256IndexSet> { + // If not seeded yet, return literals + if !self.samples_seeded { + return self.literal_values.get().words.get(param_type); + } + self.sample_values.get(param_type) } - /// Returns the collected AST strings. + /// Returns the collected literal strings, triggering initialization if needed. #[inline] pub fn ast_strings(&self) -> &IndexSet { - &self.string_literals + &self.literal_values.get().strings } - /// Returns the collected AST bytes (hex literals). + /// Returns the collected literal bytes (hex strings), triggering initialization if needed. #[inline] pub fn ast_bytes(&self) -> &IndexSet { - &self.byte_literals + &self.literal_values.get().bytes } #[inline] @@ -466,7 +487,6 @@ impl FuzzDictionary { pub fn log_stats(&self) { trace!( addresses.len = self.addresses.len(), - ast_string.len = self.string_literals.len(), sample.len = self.sample_values.len(), state.len = self.state_values.len(), state.misses = self.misses, @@ -478,8 +498,6 @@ impl FuzzDictionary { #[cfg(test)] /// Test-only helper to seed the dictionary with literal values. pub(crate) fn seed_literals(&mut self, map: super::LiteralMaps) { - self.string_literals = map.strings; - self.byte_literals = map.bytes; - self.sample_values = map.words; + self.literal_values.set(map); } } diff --git a/crates/forge/tests/cli/test_cmd.rs b/crates/forge/tests/cli/test_cmd.rs index 5bc7cd85c4d94..06de7f06e9d00 100644 --- a/crates/forge/tests/cli/test_cmd.rs +++ b/crates/forge/tests/cli/test_cmd.rs @@ -4262,7 +4262,7 @@ Encountered a total of 1 failing tests, 0 tests succeeded "0x6162636431323334000000000000000000000000000000000000000000000000", /* bytes32("abcd1234") */ 7, ); - test_literal(500, "testFuzz_BytesFromHex", "bytes", "0xdeadbeef", 1); + test_literal(500, "testFuzz_BytesFromHex", "bytes", "0xdeadbeef", 5); test_literal(600, "testFuzz_String", "string", "\"xyzzy\"", 35); - test_literal(999, "testFuzz_BytesFromString", "bytes", "0x78797a7a79", 53); // abi.encodePacked("xyzzy") + test_literal(999, "testFuzz_BytesFromString", "bytes", "0x78797a7a79", 19); // abi.encodePacked("xyzzy") }); From 544d7ff20e59fdff31835a7ad36f87d57a18a469 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 14 Oct 2025 12:25:46 +0200 Subject: [PATCH 27/36] fix: default config tests --- crates/forge/tests/cli/config.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index 4cc4edd0a47f1..c84fc51f75e7f 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -164,7 +164,7 @@ dictionary_weight = 40 include_storage = true include_push_bytes = true max_fuzz_dictionary_addresses = 15728640 -max_fuzz_dictionary_values = 15728640 +max_fuzz_dictionary_values = 9830400 max_fuzz_dictionary_literals = 6553600 gas_report_samples = 256 corpus_gzip = true @@ -183,7 +183,7 @@ dictionary_weight = 80 include_storage = true include_push_bytes = true max_fuzz_dictionary_addresses = 15728640 -max_fuzz_dictionary_values = 15728640 +max_fuzz_dictionary_values = 9830400 max_fuzz_dictionary_literals = 6553600 shrink_run_limit = 5000 max_assume_rejects = 65536 @@ -1223,7 +1223,7 @@ forgetest_init!(test_default_config, |prj, cmd| { "include_storage": true, "include_push_bytes": true, "max_fuzz_dictionary_addresses": 15728640, - "max_fuzz_dictionary_values": 15728640, + "max_fuzz_dictionary_values": 9830400, "max_fuzz_dictionary_literals": 6553600, "gas_report_samples": 256, "corpus_dir": null, @@ -1244,7 +1244,7 @@ forgetest_init!(test_default_config, |prj, cmd| { "include_storage": true, "include_push_bytes": true, "max_fuzz_dictionary_addresses": 15728640, - "max_fuzz_dictionary_values": 15728640, + "max_fuzz_dictionary_values": 9830400, "max_fuzz_dictionary_literals": 6553600, "shrink_run_limit": 5000, "max_assume_rejects": 65536, From 172b63a409e4b10f3ad42aed30953f558398f974 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 20 Oct 2025 08:33:14 +0200 Subject: [PATCH 28/36] fix: merge conflicts --- crates/forge/tests/cli/test_cmd/fuzz.rs | 10 +- .../tests/cli/test_cmd/invariant/common.rs | 12 +- .../tests/cli/test_cmd/invariant/target.rs | 52 +- crates/forge/tests/it/fuzz.rs | 502 ------------------ crates/forge/tests/it/test_helpers.rs | 285 ---------- 5 files changed, 34 insertions(+), 827 deletions(-) delete mode 100644 crates/forge/tests/it/fuzz.rs delete mode 100644 crates/forge/tests/it/test_helpers.rs diff --git a/crates/forge/tests/cli/test_cmd/fuzz.rs b/crates/forge/tests/cli/test_cmd/fuzz.rs index aaa8fe687d369..77c7d80260266 100644 --- a/crates/forge/tests/cli/test_cmd/fuzz.rs +++ b/crates/forge/tests/cli/test_cmd/fuzz.rs @@ -249,12 +249,12 @@ contract CounterTest is Test { } "#, ); - // Tests should fail and record counterexample with value 2. + // Tests should fail and record counterexample with value 200. cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" ... Failing tests: Encountered 1 failing test in test/Counter.t.sol:CounterTest -[FAIL: EvmError: Revert; counterexample: calldata=0x5c7f60d70000000000000000000000000000000000000000000000000000000000000002 args=[2]] testFuzz_SetNumber(uint256) (runs: 19, [AVG_GAS]) +[FAIL: EvmError: Revert; counterexample: calldata=0x5c7f60d700000000000000000000000000000000000000000000000000000000000000c8 args=[200]] testFuzz_SetNumber(uint256) (runs: 6, [AVG_GAS]) ... "#]]); @@ -267,7 +267,7 @@ import {Test} from "forge-std/Test.sol"; contract CounterTest is Test { function testFuzz_SetNumber(uint256 x) public pure { - vm.assume(x != 2); + vm.assume(x != 200); } } "#, @@ -325,12 +325,12 @@ contract CounterTest is Test { } "#, ); - // Test should fail with replayed counterexample 2 (0 runs). + // Test should fail with replayed counterexample 200 (0 runs). cmd.forge_fuse().args(["test"]).assert_failure().stdout_eq(str![[r#" ... Failing tests: Encountered 1 failing test in test/Counter.t.sol:CounterTest -[FAIL: EvmError: Revert; counterexample: calldata=0x5c7f60d70000000000000000000000000000000000000000000000000000000000000002 args=[2]] testFuzz_SetNumber(uint256) (runs: 0, [AVG_GAS]) +[FAIL: EvmError: Revert; counterexample: calldata=0x5c7f60d700000000000000000000000000000000000000000000000000000000000000c8 args=[200]] testFuzz_SetNumber(uint256) (runs: 0, [AVG_GAS]) ... "#]]); diff --git a/crates/forge/tests/cli/test_cmd/invariant/common.rs b/crates/forge/tests/cli/test_cmd/invariant/common.rs index 4e32bf143fb58..cdf1a1d86a945 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/common.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/common.rs @@ -965,19 +965,19 @@ contract InvariantRollForkStateTest is Test { assert_invariant(cmd.args(["test", "-j1"])).failure().stdout_eq(str![[r#" ... -Ran 1 test for test/InvariantRollFork.t.sol:InvariantRollForkBlockTest -[FAIL: too many blocks mined] +Ran 1 test for test/InvariantRollFork.t.sol:InvariantRollForkStateTest +[FAIL: wrong supply] [SEQUENCE] - invariant_fork_handler_block() ([RUNS]) + invariant_fork_handler_state() ([RUNS]) [STATS] Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/InvariantRollFork.t.sol:InvariantRollForkStateTest -[FAIL: wrong supply] +Ran 1 test for test/InvariantRollFork.t.sol:InvariantRollForkBlockTest +[FAIL: too many blocks mined] [SEQUENCE] - invariant_fork_handler_state() ([RUNS]) + invariant_fork_handler_block() ([RUNS]) [STATS] diff --git a/crates/forge/tests/cli/test_cmd/invariant/target.rs b/crates/forge/tests/cli/test_cmd/invariant/target.rs index 31e684d4f307d..021f91312b419 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/target.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/target.rs @@ -546,20 +546,6 @@ Ran 1 test for test/ExcludeArtifacts.t.sol:ExcludeArtifacts Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/ExcludeContracts.t.sol:ExcludeContracts -[PASS] invariantTrueWorld() ([RUNS]) - -[STATS] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test for test/ExcludeSelectors.t.sol:ExcludeSelectors -[PASS] invariantFalseWorld() ([RUNS]) - -[STATS] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - Ran 1 test for test/ExcludeSenders.t.sol:ExcludeSenders [PASS] invariantTrueWorld() ([RUNS]) @@ -574,14 +560,12 @@ Ran 1 test for test/TargetArtifactSelectors.t.sol:TargetArtifactSelectors Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/TargetArtifactSelectors2.t.sol:TargetArtifactSelectors2 -[FAIL: it's false] - [SEQUENCE] - invariantShouldFail() ([RUNS]) +Ran 1 test for test/ExcludeSelectors.t.sol:ExcludeSelectors +[PASS] invariantFalseWorld() ([RUNS]) [STATS] -Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] Ran 2 tests for test/TargetArtifacts.t.sol:TargetArtifacts [FAIL: false world] @@ -603,10 +587,10 @@ Ran 1 test for test/TargetContracts.t.sol:TargetContracts Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/TargetInterfaces.t.sol:TargetWorldInterfaces -[FAIL: false world] +Ran 1 test for test/TargetArtifactSelectors2.t.sol:TargetArtifactSelectors2 +[FAIL: it's false] [SEQUENCE] - invariantTrueWorld() ([RUNS]) + invariantShouldFail() ([RUNS]) [STATS] @@ -628,6 +612,15 @@ Ran 1 test for test/TargetSenders.t.sol:TargetSenders Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +Ran 1 test for test/TargetInterfaces.t.sol:TargetWorldInterfaces +[FAIL: false world] + [SEQUENCE] + invariantTrueWorld() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + Ran 11 test suites [ELAPSED]: 8 tests passed, 4 failed, 0 skipped (12 total tests) Failing tests: @@ -658,6 +651,7 @@ Tip: Run `forge test --rerun` to retry only the 4 failed tests "#]]); }); +// TODO(rusowsky): figure out why it is flaky // https://github.com/foundry-rs/foundry/issues/5625 // https://github.com/foundry-rs/foundry/issues/6166 // `Target.wrongSelector` is not called when handler added as `targetContract` @@ -734,6 +728,13 @@ contract DynamicTargetContract is Test { assert_invariant(cmd.args(["test", "-j1"])).failure().stdout_eq(str![[r#" ... +Ran 1 test for test/FuzzedTargetContracts.t.sol:ExplicitTargetContract +[PASS] invariant_explicit_target() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + Ran 1 test for test/FuzzedTargetContracts.t.sol:DynamicTargetContract [FAIL: wrong target selector called] [SEQUENCE] @@ -743,13 +744,6 @@ Ran 1 test for test/FuzzedTargetContracts.t.sol:DynamicTargetContract Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/FuzzedTargetContracts.t.sol:ExplicitTargetContract -[PASS] invariant_explicit_target() ([RUNS]) - -[STATS] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - Ran 2 test suites [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) Failing tests: diff --git a/crates/forge/tests/it/fuzz.rs b/crates/forge/tests/it/fuzz.rs deleted file mode 100644 index 781a8d781e579..0000000000000 --- a/crates/forge/tests/it/fuzz.rs +++ /dev/null @@ -1,502 +0,0 @@ -//! Fuzz tests. - -use crate::{config::*, test_helpers::TEST_DATA_DEFAULT}; -use alloy_primitives::{Bytes, U256}; -use forge::{ - decode::decode_console_logs, - fuzz::CounterExample, - result::{SuiteResult, TestStatus}, -}; -use foundry_test_utils::{Filter, forgetest_init, str}; -use std::collections::BTreeMap; - -#[tokio::test(flavor = "multi_thread")] -async fn test_fuzz() { - let filter = Filter::new(".*", ".*", ".*fuzz/") - .exclude_tests(r"invariantCounter|testIncrement\(address\)|testNeedle\(uint256\)|testSuccessChecker\(uint256\)|testSuccessChecker2\(int256\)|testSuccessChecker3\(uint32\)|testStorageOwner\(address\)|testImmutableOwner\(address\)") - .exclude_paths("invariant"); - let mut runner = TEST_DATA_DEFAULT.runner(); - let suite_result = runner.test_collect(&filter).unwrap(); - - assert!(!suite_result.is_empty()); - - for (_, SuiteResult { test_results, .. }) in suite_result { - for (test_name, result) in test_results { - match test_name.as_str() { - "testPositive(uint256)" - | "testPositive(int256)" - | "testSuccessfulFuzz(uint128,uint128)" - | "testToStringFuzz(bytes32)" => assert_eq!( - result.status, - TestStatus::Success, - "Test {} did not pass as expected.\nReason: {:?}\nLogs:\n{}", - test_name, - result.reason, - decode_console_logs(&result.logs).join("\n") - ), - _ => assert_eq!( - result.status, - TestStatus::Failure, - "Test {} did not fail as expected.\nReason: {:?}\nLogs:\n{}", - test_name, - result.reason, - decode_console_logs(&result.logs).join("\n") - ), - } - } - } -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_successful_fuzz_cases() { - let filter = Filter::new(".*", ".*", ".*fuzz/FuzzPositive") - .exclude_tests(r"invariantCounter|testIncrement\(address\)|testNeedle\(uint256\)") - .exclude_paths("invariant"); - let mut runner = TEST_DATA_DEFAULT.runner(); - let suite_result = runner.test_collect(&filter).unwrap(); - - assert!(!suite_result.is_empty()); - - for (_, SuiteResult { test_results, .. }) in suite_result { - for (test_name, result) in test_results { - match test_name.as_str() { - "testSuccessChecker(uint256)" - | "testSuccessChecker2(int256)" - | "testSuccessChecker3(uint32)" => assert_eq!( - result.status, - TestStatus::Success, - "Test {} did not pass as expected.\nReason: {:?}\nLogs:\n{}", - test_name, - result.reason, - decode_console_logs(&result.logs).join("\n") - ), - _ => {} - } - } - } -} - -/// Test that showcases PUSH collection on normal fuzzing. Ignored until we collect them in a -/// smarter way. -#[tokio::test(flavor = "multi_thread")] -#[ignore] -async fn test_fuzz_collection() { - let filter = Filter::new(".*", ".*", ".*fuzz/FuzzCollection.t.sol"); - let mut runner = TEST_DATA_DEFAULT.runner_with(|config| { - config.invariant.depth = 100; - config.invariant.runs = 1000; - config.fuzz.runs = 1000; - config.fuzz.seed = Some(U256::from(6u32)); - }); - let results = runner.test_collect(&filter).unwrap(); - - assert_multiple( - &results, - BTreeMap::from([( - "default/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), - ], - )]), - ); -} - -#[tokio::test(flavor = "multi_thread")] -async fn test_persist_fuzz_failure() { - let filter = Filter::new(".*", ".*", ".*fuzz/FuzzFailurePersist.t.sol"); - - macro_rules! run_fail { - () => { run_fail!(|config| {}) }; - (|$config:ident| $e:expr) => {{ - let mut runner = TEST_DATA_DEFAULT.runner_with(|$config| { - $config.fuzz.runs = 1000; - $e - }); - runner - .test_collect(&filter) - .unwrap() - .get("default/fuzz/FuzzFailurePersist.t.sol:FuzzFailurePersistTest") - .unwrap() - .test_results - .get("test_persist_fuzzed_failure(uint256,int256,address,bool,string,(address,uint256),address[])") - .unwrap() - .counterexample - .clone() - }}; - } - - // record initial counterexample calldata - let initial_counterexample = run_fail!(); - let initial_calldata = match initial_counterexample { - Some(CounterExample::Single(counterexample)) => counterexample.calldata, - _ => Bytes::new(), - }; - - // run several times and compare counterexamples calldata - for i in 0..10 { - let new_calldata = match run_fail!() { - Some(CounterExample::Single(counterexample)) => counterexample.calldata, - _ => Bytes::new(), - }; - // calldata should be the same with the initial one - assert_eq!(initial_calldata, new_calldata, "run {i}"); - } - - // write new failure in different dir. - let persist_dir = tempfile::tempdir().unwrap().keep(); - let new_calldata = match run_fail!(|config| config.fuzz.failure_persist_dir = Some(persist_dir)) - { - Some(CounterExample::Single(counterexample)) => counterexample.calldata, - _ => Bytes::new(), - }; - // empty file is used to load failure so new calldata is generated - assert_ne!(initial_calldata, new_calldata); -} - -forgetest_init!(test_can_scrape_bytecode, |prj, cmd| { - prj.update_config(|config| config.optimizer = Some(true)); - prj.add_source( - "FuzzerDict.sol", - r#" -// https://github.com/foundry-rs/foundry/issues/1168 -contract FuzzerDict { - // Immutables should get added to the dictionary. - address public immutable immutableOwner; - // Regular storage variables should also get added to the dictionary. - address public storageOwner; - - constructor(address _immutableOwner, address _storageOwner) { - immutableOwner = _immutableOwner; - storageOwner = _storageOwner; - } -} - "#, - ); - - prj.add_test( - "FuzzerDictTest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; -import "src/FuzzerDict.sol"; - -contract FuzzerDictTest is Test { - FuzzerDict fuzzerDict; - - function setUp() public { - fuzzerDict = new FuzzerDict(address(100), address(200)); - } - - /// forge-config: default.fuzz.runs = 2000 - function testImmutableOwner(address who) public { - assertTrue(who != fuzzerDict.immutableOwner()); - } - - /// forge-config: default.fuzz.runs = 2000 - function testStorageOwner(address who) public { - assertTrue(who != fuzzerDict.storageOwner()); - } -} - "#, - ); - - // Test that immutable address is used as fuzzed input, causing test to fail. - cmd.args(["test", "--fuzz-seed", "119", "--mt", "testImmutableOwner"]).assert_failure(); - // Test that storage address is used as fuzzed input, causing test to fail. - cmd.forge_fuse() - .args(["test", "--fuzz-seed", "119", "--mt", "testStorageOwner"]) - .assert_failure(); -}); - -// tests that inline max-test-rejects config is properly applied -forgetest_init!(test_inline_max_test_rejects, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_test( - "Contract.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract InlineMaxRejectsTest is Test { - /// forge-config: default.fuzz.max-test-rejects = 1 - function test_fuzz_bound(uint256 a) public { - vm.assume(false); - } -} - "#, - ); - - cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" -... -[FAIL: `vm.assume` rejected too many inputs (1 allowed)] test_fuzz_bound(uint256) (runs: 0, [AVG_GAS]) -... -"#]]); -}); - -// Tests that test timeout config is properly applied. -// If test doesn't timeout after one second, then test will fail with `rejected too many inputs`. -forgetest_init!(test_fuzz_timeout, |prj, cmd| { - prj.wipe_contracts(); - - prj.add_test( - "Contract.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract FuzzTimeoutTest is Test { - /// forge-config: default.fuzz.max-test-rejects = 50000 - /// forge-config: default.fuzz.timeout = 1 - function test_fuzz_bound(uint256 a) public pure { - vm.assume(a == 0); - } -} - "#, - ); - - cmd.args(["test"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/Contract.t.sol:FuzzTimeoutTest -[PASS] test_fuzz_bound(uint256) (runs: [..], [AVG_GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -forgetest_init!(test_fuzz_fail_on_revert, |prj, cmd| { - prj.wipe_contracts(); - prj.update_config(|config| { - config.fuzz.fail_on_revert = false; - config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; - }); - prj.add_source( - "Counter.sol", - r#" -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - require(number > 10000000000, "low number"); - number = newNumber; - } -} - "#, - ); - - prj.add_test( - "CounterTest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; -import "src/Counter.sol"; - -contract CounterTest is Test { - Counter public counter; - - function setUp() public { - counter = new Counter(); - } - - function testFuzz_SetNumberRequire(uint256 x) public { - counter.setNumber(x); - require(counter.number() == 1); - } - - function testFuzz_SetNumberAssert(uint256 x) public { - counter.setNumber(x); - assertEq(counter.number(), 1); - } -} - "#, - ); - - // Tests should not fail as revert happens in Counter contract. - cmd.args(["test", "--mc", "CounterTest"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 2 tests for test/CounterTest.t.sol:CounterTest -[PASS] testFuzz_SetNumberAssert(uint256) (runs: 256, [AVG_GAS]) -[PASS] testFuzz_SetNumberRequire(uint256) (runs: 256, [AVG_GAS]) -Suite result: ok. 2 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 2 tests passed, 0 failed, 0 skipped (2 total tests) - -"#]]); - - // Tested contract does not revert. - prj.add_source( - "Counter.sol", - r#" -contract Counter { - uint256 public number; - - function setNumber(uint256 newNumber) public { - number = newNumber; - } -} - "#, - ); - - // Tests should fail as revert happens in cheatcode (assert) and test (require) contract. - cmd.assert_failure().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 2 tests for test/CounterTest.t.sol:CounterTest -[FAIL: assertion failed: [..]] testFuzz_SetNumberAssert(uint256) (runs: 0, [AVG_GAS]) -[FAIL: EvmError: Revert; [..]] testFuzz_SetNumberRequire(uint256) (runs: 0, [AVG_GAS]) -Suite result: FAILED. 0 passed; 2 failed; 0 skipped; [ELAPSED] -... - -"#]]); -}); - -// Test 256 runs regardless number of test rejects. -// -forgetest_init!(test_fuzz_runs_with_rejects, |prj, cmd| { - prj.add_test( - "FuzzWithRejectsTest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract FuzzWithRejectsTest is Test { - function testFuzzWithRejects(uint256 x) public pure { - vm.assume(x < 1_000_000); - } -} - "#, - ); - - // Tests should not fail as revert happens in Counter contract. - cmd.args(["test", "--mc", "FuzzWithRejectsTest"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/FuzzWithRejectsTest.t.sol:FuzzWithRejectsTest -[PASS] testFuzzWithRejects(uint256) (runs: 256, [AVG_GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); -}); - -// Test that counterexample is not replayed if test changes. -// -forgetest_init!(test_fuzz_replay_with_changed_test, |prj, cmd| { - prj.update_config(|config| { - config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; - config.fuzz.seed = Some(U256::from(100u32)) - }); - prj.add_test( - "Counter.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract CounterTest is Test { - function testFuzz_SetNumber(uint256 x) public pure { - require(x > 200); - } -} - "#, - ); - // Tests should fail and record counterexample with value 2. - cmd.args(["test"]).assert_failure().stdout_eq(str![[r#" -... -Failing tests: -Encountered 1 failing test in test/Counter.t.sol:CounterTest -[FAIL: EvmError: Revert; counterexample: calldata=0x5c7f60d70000000000000000000000000000000000000000000000000000000000000002 args=[2]] testFuzz_SetNumber(uint256) (runs: 19, [AVG_GAS]) -... - -"#]]); - - // Change test to assume counterexample 2 is discarded. - prj.add_test( - "Counter.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract CounterTest is Test { - function testFuzz_SetNumber(uint256 x) public pure { - vm.assume(x != 2); - } -} - "#, - ); - // Test should pass when replay failure with changed assume logic. - cmd.forge_fuse().args(["test"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/Counter.t.sol:CounterTest -[PASS] testFuzz_SetNumber(uint256) (runs: 256, [AVG_GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); - - // Change test signature. - prj.add_test( - "Counter.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract CounterTest is Test { - function testFuzz_SetNumber(uint8 x) public pure { - } -} - "#, - ); - // Test should pass when replay failure with changed function signature. - cmd.forge_fuse().args(["test"]).assert_success().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/Counter.t.sol:CounterTest -[PASS] testFuzz_SetNumber(uint8) (runs: 256, [AVG_GAS]) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) - -"#]]); - - // Change test back to the original one that produced the counterexample. - prj.add_test( - "Counter.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract CounterTest is Test { - function testFuzz_SetNumber(uint256 x) public pure { - require(x > 200); - } -} - "#, - ); - // Test should fail with replayed counterexample 2 (0 runs). - cmd.forge_fuse().args(["test"]).assert_failure().stdout_eq(str![[r#" -... -Failing tests: -Encountered 1 failing test in test/Counter.t.sol:CounterTest -[FAIL: EvmError: Revert; counterexample: calldata=0x5c7f60d70000000000000000000000000000000000000000000000000000000000000002 args=[2]] testFuzz_SetNumber(uint256) (runs: 0, [AVG_GAS]) -... - -"#]]); -}); diff --git a/crates/forge/tests/it/test_helpers.rs b/crates/forge/tests/it/test_helpers.rs deleted file mode 100644 index 9eb5c15f37704..0000000000000 --- a/crates/forge/tests/it/test_helpers.rs +++ /dev/null @@ -1,285 +0,0 @@ -//! Test helpers for Forge integration tests. - -use alloy_chains::NamedChain; -use alloy_primitives::U256; -use forge::{MultiContractRunner, MultiContractRunnerBuilder}; -use foundry_cli::utils::install_crypto_provider; -use foundry_compilers::{ - Project, ProjectCompileOutput, SolcConfig, - artifacts::{EvmVersion, Settings}, - compilers::multi::MultiCompiler, -}; -use foundry_config::{ - Config, FsPermissions, FuzzConfig, FuzzCorpusConfig, FuzzDictionaryConfig, InvariantConfig, - RpcEndpointUrl, RpcEndpoints, fs_permissions::PathPermission, -}; -use foundry_evm::{constants::CALLER, opts::EvmOpts}; -use foundry_test_utils::{ - init_tracing, - rpc::{next_http_archive_rpc_url, next_rpc_endpoint}, - util::get_compiled, -}; -use revm::primitives::hardfork::SpecId; -use std::{ - env, fmt, - path::{Path, PathBuf}, - sync::{Arc, LazyLock}, -}; - -pub const RE_PATH_SEPARATOR: &str = "/"; -const TESTDATA: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../../testdata"); - -/// Profile for the tests group. Used to configure separate configurations for test runs. -pub enum ForgeTestProfile { - Default, - Paris, - MultiVersion, -} - -impl fmt::Display for ForgeTestProfile { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match self { - Self::Default => write!(f, "default"), - Self::Paris => write!(f, "paris"), - Self::MultiVersion => write!(f, "multi-version"), - } - } -} - -impl ForgeTestProfile { - /// Returns true if the profile is Paris. - pub fn is_paris(&self) -> bool { - matches!(self, Self::Paris) - } - - pub fn root(&self) -> PathBuf { - PathBuf::from(TESTDATA) - } - - /// Configures the solc settings for the test profile. - pub fn solc_config(&self) -> SolcConfig { - let mut settings = Settings::default(); - - if matches!(self, Self::Paris) { - settings.evm_version = Some(EvmVersion::Paris); - } - - let settings = SolcConfig::builder().settings(settings).build(); - SolcConfig { settings } - } - - /// Build [Config] for test profile. - /// - /// Project source files are read from testdata/{profile_name} - /// Project output files are written to testdata/out/{profile_name} - /// Cache is written to testdata/cache/{profile_name} - /// - /// AST output is enabled by default to support inline configs. - pub fn config(&self) -> Config { - let mut config = Config::with_root(self.root()); - - config.ast = true; - config.src = self.root().join(self.to_string()); - config.out = self.root().join("out").join(self.to_string()); - config.cache_path = self.root().join("cache").join(self.to_string()); - - config.prompt_timeout = 0; - - config.optimizer = Some(true); - config.optimizer_runs = Some(200); - - config.gas_limit = u64::MAX.into(); - config.chain = None; - config.tx_origin = CALLER; - config.block_number = U256::from(1); - config.block_timestamp = U256::from(1); - - config.sender = CALLER; - config.initial_balance = U256::MAX; - config.ffi = true; - config.verbosity = 3; - config.memory_limit = 1 << 26; - - if self.is_paris() { - config.evm_version = EvmVersion::Paris; - } - - config.fuzz = FuzzConfig { - runs: 256, - fail_on_revert: true, - max_test_rejects: 65536, - seed: None, - dictionary: FuzzDictionaryConfig { - include_storage: true, - include_push_bytes: true, - dictionary_weight: 40, - max_fuzz_dictionary_addresses: 10_000, - max_fuzz_dictionary_values: 10_000, - max_fuzz_dictionary_literals: 10_000, - }, - gas_report_samples: 256, - corpus: FuzzCorpusConfig::default(), - failure_persist_dir: Some(tempfile::tempdir().unwrap().keep()), - show_logs: false, - timeout: None, - }; - config.invariant = InvariantConfig { - runs: 256, - depth: 15, - fail_on_revert: false, - call_override: false, - dictionary: FuzzDictionaryConfig { - dictionary_weight: 80, - include_storage: true, - include_push_bytes: true, - max_fuzz_dictionary_addresses: 10_000, - max_fuzz_dictionary_values: 10_000, - max_fuzz_dictionary_literals: 10_000, - }, - shrink_run_limit: 5000, - max_assume_rejects: 65536, - gas_report_samples: 256, - corpus: FuzzCorpusConfig::default(), - failure_persist_dir: Some( - tempfile::Builder::new() - .prefix(&format!("foundry-{self}")) - .tempdir() - .unwrap() - .keep(), - ), - show_metrics: true, - timeout: None, - show_solidity: false, - }; - - config.sanitized() - } -} - -/// Container for test data for a specific test profile. -pub struct ForgeTestData { - pub project: Project, - pub output: ProjectCompileOutput, - pub config: Arc, - pub profile: ForgeTestProfile, -} - -impl ForgeTestData { - /// Builds [ForgeTestData] for the given [ForgeTestProfile]. - /// - /// Uses [get_compiled] to lazily compile the project. - pub fn new(profile: ForgeTestProfile) -> Self { - install_crypto_provider(); - init_tracing(); - let config = Arc::new(profile.config()); - let mut project = config.project().unwrap(); - let output = get_compiled(&mut project); - Self { project, output, config, profile } - } - - /// Builds a base runner - pub fn base_runner(&self) -> MultiContractRunnerBuilder { - init_tracing(); - let config = self.config.clone(); - let mut runner = MultiContractRunnerBuilder::new(config).sender(self.config.sender); - if self.profile.is_paris() { - runner = runner.evm_spec(SpecId::MERGE); - } - runner - } - - /// Builds a non-tracing runner - pub fn runner(&self) -> MultiContractRunner { - self.runner_with(|_| {}) - } - - /// Builds a non-tracing runner - pub fn runner_with(&self, modify: impl FnOnce(&mut Config)) -> MultiContractRunner { - let mut config = (*self.config).clone(); - modify(&mut config); - self.runner_with_config(config) - } - - fn runner_with_config(&self, mut config: Config) -> MultiContractRunner { - config.rpc_endpoints = rpc_endpoints(); - config.allow_paths.push(manifest_root().to_path_buf()); - - if config.fs_permissions.is_empty() { - config.fs_permissions = - FsPermissions::new(vec![PathPermission::read_write(manifest_root())]); - } - - let opts = config_evm_opts(&config); - - let mut builder = self.base_runner(); - let config = Arc::new(config); - builder.config = config.clone(); - builder - .enable_isolation(opts.isolate) - .sender(config.sender) - .build::(&self.output, opts.local_evm_env(), opts) - .unwrap() - } - - /// Builds a tracing runner - pub fn tracing_runner(&self) -> MultiContractRunner { - let mut opts = config_evm_opts(&self.config); - opts.verbosity = 5; - self.base_runner().build::(&self.output, opts.local_evm_env(), opts).unwrap() - } - - /// Builds a runner that runs against forked state - pub async fn forked_runner(&self, rpc: &str) -> MultiContractRunner { - let mut opts = config_evm_opts(&self.config); - - opts.env.chain_id = None; // clear chain id so the correct one gets fetched from the RPC - opts.fork_url = Some(rpc.to_string()); - - let env = opts.evm_env().await.expect("Could not instantiate fork environment"); - let fork = opts.get_fork(&Default::default(), env.clone()); - - self.base_runner().with_fork(fork).build::(&self.output, env, opts).unwrap() - } -} - -/// Default data for the tests group. -pub static TEST_DATA_DEFAULT: LazyLock = - LazyLock::new(|| ForgeTestData::new(ForgeTestProfile::Default)); - -/// Data for tests requiring Paris support on Solc and EVM level. -pub static TEST_DATA_PARIS: LazyLock = - LazyLock::new(|| ForgeTestData::new(ForgeTestProfile::Paris)); - -/// Data for tests requiring no specific version on Solc and EVM level. -pub static TEST_DATA_MULTI_VERSION: LazyLock = - LazyLock::new(|| ForgeTestData::new(ForgeTestProfile::MultiVersion)); - -pub fn manifest_root() -> &'static Path { - let mut root = Path::new(env!("CARGO_MANIFEST_DIR")); - // need to check here where we're executing the test from, if in `forge` we need to also allow - // `testdata` - if root.ends_with("forge") { - root = root.parent().unwrap(); - } - root -} - -/// the RPC endpoints used during tests -pub fn rpc_endpoints() -> RpcEndpoints { - RpcEndpoints::new([ - ("mainnet", RpcEndpointUrl::Url(next_http_archive_rpc_url())), - ("mainnet2", RpcEndpointUrl::Url(next_http_archive_rpc_url())), - ("sepolia", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Sepolia))), - ("optimism", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Optimism))), - ("arbitrum", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Arbitrum))), - ("polygon", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::Polygon))), - ("bsc", RpcEndpointUrl::Url(next_rpc_endpoint(NamedChain::BinanceSmartChain))), - ("avaxTestnet", RpcEndpointUrl::Url("https://api.avax-test.network/ext/bc/C/rpc".into())), - ("moonbeam", RpcEndpointUrl::Url("https://moonbeam-rpc.publicnode.com".into())), - ("rpcEnvAlias", RpcEndpointUrl::Env("${RPC_ENV_ALIAS}".into())), - ]) -} - -fn config_evm_opts(config: &Config) -> EvmOpts { - config.to_figment(foundry_config::FigmentProviders::None).extract().unwrap() -} From aa48772383e6361db1eb5d583baa7598749c183b Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 20 Oct 2025 08:49:02 +0200 Subject: [PATCH 29/36] fix: more merge conflicts --- crates/forge/tests/cli/test_cmd/invariant/common.rs | 8 ++++---- testdata/default/repros/Issue2851.t.sol | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/forge/tests/cli/test_cmd/invariant/common.rs b/crates/forge/tests/cli/test_cmd/invariant/common.rs index cdf1a1d86a945..2bfb099e7a919 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/common.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/common.rs @@ -1082,8 +1082,8 @@ contract FindFromLogValueTest is Test { assert_invariant(cmd.args(["test", "-j1"])).failure().stdout_eq(str![[r#" ... -Ran 1 test for test/InvariantScrapeValues.t.sol:FindFromLogValueTest -[FAIL: value from logs found] +Ran 1 test for test/InvariantScrapeValues.t.sol:FindFromReturnValueTest +[FAIL: value from return found] [SEQUENCE] invariant_value_not_found() ([RUNS]) @@ -1091,8 +1091,8 @@ Ran 1 test for test/InvariantScrapeValues.t.sol:FindFromLogValueTest Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/InvariantScrapeValues.t.sol:FindFromReturnValueTest -[FAIL: value from return found] +Ran 1 test for test/InvariantScrapeValues.t.sol:FindFromLogValueTest +[FAIL: value from logs found] [SEQUENCE] invariant_value_not_found() ([RUNS]) diff --git a/testdata/default/repros/Issue2851.t.sol b/testdata/default/repros/Issue2851.t.sol index 86d470eba2668..bafa7c0ed0d07 100644 --- a/testdata/default/repros/Issue2851.t.sol +++ b/testdata/default/repros/Issue2851.t.sol @@ -1,7 +1,7 @@ // SPDX-License-Identifier: MIT OR Apache-2.0 pragma solidity ^0.8.1; -import "ds-test/test.sol"; +import "utils/Test.sol"; contract Backdoor { uint256 public number = 1; @@ -15,7 +15,7 @@ contract Backdoor { } // https://github.com/foundry-rs/foundry/issues/2851 -contract Issue2851Test is DSTest { +contract Issue2851Test is Test { Backdoor back; function setUp() public { From 587038cad833a56449c28af4024dccf6702022b1 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Mon, 20 Oct 2025 08:59:22 +0200 Subject: [PATCH 30/36] fix: no need to rmv cache --- crates/forge/tests/cli/test_cmd/mod.rs | 3 --- 1 file changed, 3 deletions(-) diff --git a/crates/forge/tests/cli/test_cmd/mod.rs b/crates/forge/tests/cli/test_cmd/mod.rs index ebf3627bd8cb5..154befa90ded4 100644 --- a/crates/forge/tests/cli/test_cmd/mod.rs +++ b/crates/forge/tests/cli/test_cmd/mod.rs @@ -4209,7 +4209,6 @@ Tip: Run `forge test --rerun` to retry only the 2 failed tests }); forgetest_init!(should_fuzz_literals, |prj, cmd| { - prj.clear_cache_dir(); prj.wipe_contracts(); // Add a source with magic (literal) values @@ -4282,8 +4281,6 @@ Encountered a total of 1 failing tests, 0 tests succeeded type_sig: &'static str, expected_value: &'static str, expected_runs: u32| { - prj.clear_cache_dir(); - // the fuzzer is UNABLE to find a breaking input (fast) when NOT seeding from the AST prj.update_config(|config| { config.fuzz.runs = 100; From 6475317b50053be36cf04f0a5dd28d9b08298562 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 21 Oct 2025 15:45:43 +0200 Subject: [PATCH 31/36] chore: use alias type for `Arc` --- Cargo.lock | 1 - crates/common/src/compile.rs | 4 ++++ crates/common/src/lib.rs | 1 + crates/evm/evm/Cargo.toml | 2 -- crates/evm/evm/src/inspectors/stack.rs | 11 ++++++----- crates/evm/fuzz/src/strategies/state.rs | 6 +++--- 6 files changed, 14 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e04018a5cad99..d2d039feabcc2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4746,7 +4746,6 @@ dependencies = [ "revm-inspectors", "serde", "serde_json", - "solar-compiler", "thiserror 2.0.17", "tracing", "uuid 1.18.1", diff --git a/crates/common/src/compile.rs b/crates/common/src/compile.rs index 021a442514fba..1be6dca58bb5f 100644 --- a/crates/common/src/compile.rs +++ b/crates/common/src/compile.rs @@ -26,9 +26,13 @@ use std::{ io::IsTerminal, path::{Path, PathBuf}, str::FromStr, + sync::Arc, time::Instant, }; +/// A Solar compiler instance, to grant syntactic and semantic analysis capabilities. +pub type Analysis = Arc; + /// Builder type to configure how to compile a project. /// /// This is merely a wrapper for [`Project::compile()`] which also prints to stdout depending on its diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index 97954cc4821f3..476dc4b779c3f 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -38,6 +38,7 @@ pub mod transactions; mod utils; pub mod version; +pub use compile::Analysis; pub use constants::*; pub use contracts::*; pub use io::{Shell, shell, stdin}; diff --git a/crates/evm/evm/Cargo.toml b/crates/evm/evm/Cargo.toml index 083ae8856b651..b3e9553f40304 100644 --- a/crates/evm/evm/Cargo.toml +++ b/crates/evm/evm/Cargo.toml @@ -24,8 +24,6 @@ foundry-evm-fuzz.workspace = true foundry-evm-networks.workspace = true foundry-evm-traces.workspace = true -solar.workspace = true - alloy-dyn-abi = { workspace = true, features = ["arbitrary", "eip712"] } alloy-evm.workspace = true alloy-json-abi.workspace = true diff --git a/crates/evm/evm/src/inspectors/stack.rs b/crates/evm/evm/src/inspectors/stack.rs index 2338a16e9fda0..868d7404d6f85 100644 --- a/crates/evm/evm/src/inspectors/stack.rs +++ b/crates/evm/evm/src/inspectors/stack.rs @@ -8,6 +8,7 @@ use alloy_primitives::{ map::{AddressHashMap, HashMap}, }; use foundry_cheatcodes::{CheatcodeAnalysis, CheatcodesExecutor, Wallets}; +use foundry_common::compile::Analysis; use foundry_compilers::ProjectPathsConfig; use foundry_evm_core::{ ContextExt, Env, InspectorExt, @@ -39,8 +40,8 @@ use std::{ #[derive(Clone, Debug, Default)] #[must_use = "builders do nothing unless you call `build` on them"] pub struct InspectorStackBuilder { - /// Solar compiler instance, to grant syntactic and semantic analysis capabilities - pub analysis: Option>, + /// Solar compiler instance, to grant syntactic and semantic analysis capabilities. + pub analysis: Option, /// The block environment. /// /// Used in the cheatcode handler to overwrite the block environment separately from the @@ -86,7 +87,7 @@ impl InspectorStackBuilder { /// Set the solar compiler instance that grants syntactic and semantic analysis capabilities #[inline] - pub fn set_analysis(mut self, analysis: Arc) -> Self { + pub fn set_analysis(mut self, analysis: Analysis) -> Self { self.analysis = Some(analysis); self } @@ -322,7 +323,7 @@ impl InspectorStack { #[derive(Default, Clone, Debug)] pub struct InspectorStackInner { /// Solar compiler instance, to grant syntactic and semantic analysis capabilities. - pub analysis: Option>, + pub analysis: Option, // Inspectors. // These are boxed to reduce the size of the struct and slightly improve performance of the @@ -401,7 +402,7 @@ impl InspectorStack { /// Set the solar compiler instance. #[inline] - pub fn set_analysis(&mut self, analysis: Arc) { + pub fn set_analysis(&mut self, analysis: Analysis) { self.analysis = Some(analysis); } diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index b23c72abf02cf..e9360e406ca39 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -8,7 +8,8 @@ use alloy_primitives::{ map::{AddressIndexSet, AddressMap, B256IndexSet, HashMap, IndexSet}, }; use foundry_common::{ - ignore_metadata_hash, mapping_slots::MappingSlots, slot_identifier::SlotIdentifier, + compile::Analysis, ignore_metadata_hash, mapping_slots::MappingSlots, + slot_identifier::SlotIdentifier, }; use foundry_compilers::{ProjectPathsConfig, artifacts::StorageLayout}; use foundry_config::FuzzDictionaryConfig; @@ -18,7 +19,6 @@ use revm::{ database::{CacheDB, DatabaseRef, DbAccount}, state::AccountInfo, }; -use solar::sema::Compiler; use std::{collections::BTreeMap, fmt, sync::Arc}; /// The maximum number of bytes we will look at in bytecodes to find push bytes (24 KiB). @@ -47,7 +47,7 @@ impl EvmFuzzState { db: &CacheDB, config: FuzzDictionaryConfig, deployed_libs: &[Address], - analysis: Option<&Arc>, + analysis: Option<&Analysis>, paths_config: Option<&ProjectPathsConfig>, ) -> Self { // Sort accounts to ensure deterministic dictionary generation from the same setUp state. From 34d280e2589599c31b9eb6250456f7cb54cc2b60 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Tue, 21 Oct 2025 15:52:17 +0200 Subject: [PATCH 32/36] style: rename for consistency --- crates/evm/fuzz/src/strategies/literals.rs | 26 ++++++++++------------ 1 file changed, 12 insertions(+), 14 deletions(-) diff --git a/crates/evm/fuzz/src/strategies/literals.rs b/crates/evm/fuzz/src/strategies/literals.rs index 8aca09d259288..16f6e3c79097f 100644 --- a/crates/evm/fuzz/src/strategies/literals.rs +++ b/crates/evm/fuzz/src/strategies/literals.rs @@ -3,21 +3,18 @@ use alloy_primitives::{ B256, Bytes, I256, U256, keccak256, map::{B256IndexSet, HashMap, IndexSet}, }; +use foundry_common::Analysis; use foundry_compilers::ProjectPathsConfig; use solar::{ ast::{self, Visit}, interface::source_map::FileName, - sema::Compiler, -}; -use std::{ - ops::ControlFlow, - sync::{Arc, OnceLock}, }; +use std::{ops::ControlFlow, sync::OnceLock}; #[derive(Debug, Default)] pub struct LiteralsDictionary { /// Data required for initialization, captured from `EvmFuzzState::new`. - compiler: Option>, + analysis: Option, paths_config: Option, max_values: usize, @@ -27,19 +24,19 @@ pub struct LiteralsDictionary { impl LiteralsDictionary { pub fn new( - compiler: Option>, + analysis: Option, paths_config: Option, max_values: usize, ) -> Self { - Self { compiler, paths_config, max_values, maps: OnceLock::default() } + Self { analysis, paths_config, max_values, maps: OnceLock::default() } } /// Returns a reference to the `LiteralMaps`, initializing them on the first call. pub fn get(&self) -> &LiteralMaps { self.maps.get_or_init(|| { - if let Some(compiler) = &self.compiler { + if let Some(analysis) = &self.analysis { let literals = LiteralsCollector::process( - compiler, + analysis, self.paths_config.as_ref(), self.max_values, ); @@ -90,11 +87,11 @@ impl LiteralsCollector { } pub fn process( - compiler: &Arc, + analysis: &Analysis, paths_config: Option<&ProjectPathsConfig>, max_values: usize, ) -> LiteralMaps { - compiler.enter(|compiler| { + analysis.enter(|compiler| { let mut literals_collector = Self::new(max_values); for source in compiler.sources().iter() { // Ignore scripts, and libs @@ -330,7 +327,8 @@ mod tests { // -- TEST HELPERS --------------------------------------------------------- fn process_source_literals(source: &str) -> LiteralMaps { - let mut compiler = Compiler::new(Session::builder().with_stderr_emitter().build()); + let mut compiler = + solar::sema::Compiler::new(Session::builder().with_stderr_emitter().build()); compiler .enter_mut(|c| -> std::io::Result<()> { let mut pcx = c.parse(); @@ -345,7 +343,7 @@ mod tests { }) .expect("Failed to compile test source"); - LiteralsCollector::process(&Arc::new(compiler), None, usize::MAX) + LiteralsCollector::process(&std::sync::Arc::new(compiler), None, usize::MAX) } fn assert_word(literals: &LiteralMaps, ty: DynSolType, value: B256, msg: &str) { From 4d9ee254c9851444b0813c9030242938ccd93df3 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 22 Oct 2025 08:00:12 +0200 Subject: [PATCH 33/36] test: prove improvement with new features --- crates/forge/tests/cli/test_cmd/fuzz.rs | 108 ++++++++++++++++++ .../tests/cli/test_cmd/invariant/common.rs | 88 ++++++++++++++ .../tests/cli/test_cmd/invariant/storage.rs | 36 +++--- .../tests/cli/test_cmd/invariant/target.rs | 1 - crates/forge/tests/cli/test_cmd/mod.rs | 108 ------------------ 5 files changed, 217 insertions(+), 124 deletions(-) diff --git a/crates/forge/tests/cli/test_cmd/fuzz.rs b/crates/forge/tests/cli/test_cmd/fuzz.rs index 77c7d80260266..a91a48399a9b7 100644 --- a/crates/forge/tests/cli/test_cmd/fuzz.rs +++ b/crates/forge/tests/cli/test_cmd/fuzz.rs @@ -733,3 +733,111 @@ Suite result: FAILED. 1 passed; 6 failed; 0 skipped; [ELAPSED] ... "#]]); }); + +forgetest_init!(should_fuzz_literals, |prj, cmd| { + prj.wipe_contracts(); + + // Add a source with magic (literal) values + prj.add_source( + "Magic.sol", + r#" + contract Magic { + // plain literals + address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; + uint64 constant MAGIC_NUMBER = 1122334455; + int32 constant MAGIC_INT = -777; + bytes32 constant MAGIC_WORD = "abcd1234"; + bytes constant MAGIC_BYTES = hex"deadbeef"; + string constant MAGIC_STRING = "xyzzy"; + + function checkAddr(address v) external pure { assert(v != DAI); } + function checkWord(bytes32 v) external pure { assert(v != MAGIC_WORD); } + function checkNumber(uint64 v) external pure { assert(v != MAGIC_NUMBER); } + function checkInteger(int32 v) external pure { assert(v != MAGIC_INT); } + function checkString(string memory v) external pure { assert(keccak256(abi.encodePacked(v)) != keccak256(abi.encodePacked(MAGIC_STRING))); } + function checkBytesFromHex(bytes memory v) external pure { assert(keccak256(v) != keccak256(MAGIC_BYTES)); } + function checkBytesFromString(bytes memory v) external pure { assert(keccak256(v) != keccak256(abi.encodePacked(MAGIC_STRING))); } + } + "#, + ); + + prj.add_test( + "MagicFuzz.t.sol", + r#" + import {Test} from "forge-std/Test.sol"; + import {Magic} from "src/Magic.sol"; + + contract MagicTest is Test { + Magic public magic; + function setUp() public { magic = new Magic(); } + + function testFuzz_Addr(address v) public view { magic.checkAddr(v); } + function testFuzz_Number(uint64 v) public view { magic.checkNumber(v); } + function testFuzz_Integer(int32 v) public view { magic.checkInteger(v); } + function testFuzz_Word(bytes32 v) public view { magic.checkWord(v); } + function testFuzz_String(string memory v) public view { magic.checkString(v); } + function testFuzz_BytesFromHex(bytes memory v) public view { magic.checkBytesFromHex(v); } + function testFuzz_BytesFromString(bytes memory v) public view { magic.checkBytesFromString(v); } + } + "#, + ); + + // Helper to create expected output for a test failure + let expected_fail = |test_name: &str, type_sig: &str, value: &str, runs: u32| -> String { + format!( + r#"No files changed, compilation skipped + +Ran 1 test for test/MagicFuzz.t.sol:MagicTest +[FAIL: panic: assertion failed (0x01); counterexample: calldata=[..] args=[{value}]] {test_name}({type_sig}) (runs: {runs}, [AVG_GAS]) +[..] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +... +Encountered a total of 1 failing tests, 0 tests succeeded +... +"# + ) + }; + + // Test address literal fuzzing + let mut test_literal = |seed: u32, + test_name: &'static str, + type_sig: &'static str, + expected_value: &'static str, + expected_runs: u32| { + // the fuzzer is UNABLE to find a breaking input (fast) when NOT seeding from the AST + prj.update_config(|config| { + config.fuzz.runs = 100; + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; + config.fuzz.seed = Some(U256::from(seed)); + }); + cmd.forge_fuse().args(["test", "--match-test", test_name]).assert_success(); + + // the fuzzer is ABLE to find a breaking input when seeding from the AST + prj.update_config(|config| { + config.fuzz.dictionary.max_fuzz_dictionary_literals = 10_000; + }); + + let expected_output = expected_fail(test_name, type_sig, expected_value, expected_runs); + cmd.forge_fuse() + .args(["test", "--match-test", test_name]) + .assert_failure() + .stdout_eq(expected_output); + }; + + test_literal(100, "testFuzz_Addr", "address", "0x6B175474E89094C44Da98b954EedeAC495271d0F", 28); + test_literal(200, "testFuzz_Number", "uint64", "1122334455 [1.122e9]", 5); + test_literal(300, "testFuzz_Integer", "int32", "-777", 0); + test_literal( + 400, + "testFuzz_Word", + "bytes32", + "0x6162636431323334000000000000000000000000000000000000000000000000", /* bytes32("abcd1234") */ + 7, + ); + test_literal(500, "testFuzz_BytesFromHex", "bytes", "0xdeadbeef", 5); + test_literal(600, "testFuzz_String", "string", "\"xyzzy\"", 35); + test_literal(999, "testFuzz_BytesFromString", "bytes", "0x78797a7a79", 19); // abi.encodePacked("xyzzy") +}); diff --git a/crates/forge/tests/cli/test_cmd/invariant/common.rs b/crates/forge/tests/cli/test_cmd/invariant/common.rs index fdb9f767a3d54..5311494f40827 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/common.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/common.rs @@ -442,6 +442,8 @@ forgetest_init!(invariant_fixtures, |prj, cmd| { prj.update_config(|config| { config.invariant.runs = 1; config.invariant.depth = 100; + // disable literals to test fixtures + config.invariant.dictionary.max_fuzz_dictionary_literals = 0; }); prj.add_test( @@ -550,6 +552,92 @@ Tip: Run `forge test --rerun` to retry only the 1 failed test "#]]); }); +forgetest_init!(invariant_breaks_without_fixtures, |prj, cmd| { + prj.wipe_contracts(); + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 100; + }); + + prj.add_test( + "InvariantLiterals.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Target { + bool ownerFound; + bool amountFound; + bool magicFound; + bool keyFound; + bool backupFound; + bool extraStringFound; + + function fuzzWithoutFixtures( + address owner_, + uint256 _amount, + int32 magic, + bytes32 key, + bytes memory backup, + string memory extra + ) external { + if (owner_ == address(0x6B175474E89094C44Da98b954EedeAC495271d0F)) { + ownerFound = true; + } + if (_amount == 1122334455) amountFound = true; + if (magic == -777) magicFound = true; + if (key == "abcd1234") keyFound = true; + if (keccak256(backup) == keccak256("qwerty1234")) backupFound = true; + if (keccak256(abi.encodePacked(extra)) == keccak256(abi.encodePacked("112233aabbccdd"))) { + extraStringFound = true; + } + } + + function isCompromised() public view returns (bool) { + return ownerFound && amountFound && magicFound && keyFound && backupFound && extraStringFound; + } +} + +/// Try to compromise target contract by finding all accepted values without using fixtures. +contract InvariantLiterals is Test { + Target target; + + function setUp() public { + target = new Target(); + } + + function invariant_target_not_compromised() public { + assertEq(target.isCompromised(), false); + } +} +"#, + ); + + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Ran 1 test for test/InvariantLiterals.t.sol:InvariantLiterals +[FAIL: assertion failed: true != false] + [SEQUENCE] + invariant_target_not_compromised() ([RUNS]) + +[STATS] + +Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/InvariantLiterals.t.sol:InvariantLiterals +[FAIL: assertion failed: true != false] + [SEQUENCE] + invariant_target_not_compromised() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); +}); + forgetest!(invariant_handler_failure, |prj, cmd| { prj.insert_utils(); prj.update_config(|config| { diff --git a/crates/forge/tests/cli/test_cmd/invariant/storage.rs b/crates/forge/tests/cli/test_cmd/invariant/storage.rs index 83c4f72bca93d..61f3a08b4a775 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/storage.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/storage.rs @@ -1,12 +1,10 @@ use super::*; -forgetest_init!( - #[ignore = "slow"] - storage, - |prj, cmd| { - prj.add_test( - "name", - r#" +forgetest_init!(storage, |prj, cmd| { + prj.wipe_contracts(); + prj.add_test( + "name", + r#" import "forge-std/Test.sol"; contract Contract { @@ -47,25 +45,33 @@ contract InvariantStorageTest is Test { c = new Contract(); } - function invariantChangeAddress() public { + function invariantChangeAddress() public view { require(c.addr() == address(0xbeef), "changedAddr"); } - function invariantChangeString() public { + function invariantChangeString() public view { require(keccak256(bytes(c.str())) == keccak256(bytes("hello")), "changedStr"); } - function invariantChangeUint() public { + function invariantChangeUint() public view { require(c.num() == 1337, "changedUint"); } - function invariantPush() public { + function invariantPush() public view { require(c.pushNum() == 0, "pushUint"); } } "#, - ); + ); - assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#""#]]); - } -); + assert_invariant(cmd.args(["test"])).failure().stdout_eq(str![[r#" +... +Suite result: FAILED. 0 passed; 4 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 0 tests passed, 4 failed, 0 skipped (4 total tests) + +Failing tests: +Encountered 4 failing tests in test/name.sol:InvariantStorageTest +... +"#]]); +}); diff --git a/crates/forge/tests/cli/test_cmd/invariant/target.rs b/crates/forge/tests/cli/test_cmd/invariant/target.rs index 021f91312b419..71529c62416f6 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/target.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/target.rs @@ -651,7 +651,6 @@ Tip: Run `forge test --rerun` to retry only the 4 failed tests "#]]); }); -// TODO(rusowsky): figure out why it is flaky // https://github.com/foundry-rs/foundry/issues/5625 // https://github.com/foundry-rs/foundry/issues/6166 // `Target.wrongSelector` is not called when handler added as `targetContract` diff --git a/crates/forge/tests/cli/test_cmd/mod.rs b/crates/forge/tests/cli/test_cmd/mod.rs index 154befa90ded4..359c09b06f790 100644 --- a/crates/forge/tests/cli/test_cmd/mod.rs +++ b/crates/forge/tests/cli/test_cmd/mod.rs @@ -4207,111 +4207,3 @@ Tip: Run `forge test --rerun` to retry only the 2 failed tests "#]]); }); - -forgetest_init!(should_fuzz_literals, |prj, cmd| { - prj.wipe_contracts(); - - // Add a source with magic (literal) values - prj.add_source( - "Magic.sol", - r#" - contract Magic { - // plain literals - address constant DAI = 0x6B175474E89094C44Da98b954EedeAC495271d0F; - uint64 constant MAGIC_NUMBER = 1122334455; - int32 constant MAGIC_INT = -777; - bytes32 constant MAGIC_WORD = "abcd1234"; - bytes constant MAGIC_BYTES = hex"deadbeef"; - string constant MAGIC_STRING = "xyzzy"; - - function checkAddr(address v) external pure { assert(v != DAI); } - function checkWord(bytes32 v) external pure { assert(v != MAGIC_WORD); } - function checkNumber(uint64 v) external pure { assert(v != MAGIC_NUMBER); } - function checkInteger(int32 v) external pure { assert(v != MAGIC_INT); } - function checkString(string memory v) external pure { assert(keccak256(abi.encodePacked(v)) != keccak256(abi.encodePacked(MAGIC_STRING))); } - function checkBytesFromHex(bytes memory v) external pure { assert(keccak256(v) != keccak256(MAGIC_BYTES)); } - function checkBytesFromString(bytes memory v) external pure { assert(keccak256(v) != keccak256(abi.encodePacked(MAGIC_STRING))); } - } - "#, - ); - - prj.add_test( - "MagicFuzz.t.sol", - r#" - import {Test} from "forge-std/Test.sol"; - import {Magic} from "src/Magic.sol"; - - contract MagicTest is Test { - Magic public magic; - function setUp() public { magic = new Magic(); } - - function testFuzz_Addr(address v) public view { magic.checkAddr(v); } - function testFuzz_Number(uint64 v) public view { magic.checkNumber(v); } - function testFuzz_Integer(int32 v) public view { magic.checkInteger(v); } - function testFuzz_Word(bytes32 v) public view { magic.checkWord(v); } - function testFuzz_String(string memory v) public view { magic.checkString(v); } - function testFuzz_BytesFromHex(bytes memory v) public view { magic.checkBytesFromHex(v); } - function testFuzz_BytesFromString(bytes memory v) public view { magic.checkBytesFromString(v); } - } - "#, - ); - - // Helper to create expected output for a test failure - let expected_fail = |test_name: &str, type_sig: &str, value: &str, runs: u32| -> String { - format!( - r#"No files changed, compilation skipped - -Ran 1 test for test/MagicFuzz.t.sol:MagicTest -[FAIL: panic: assertion failed (0x01); counterexample: calldata=[..] args=[{value}]] {test_name}({type_sig}) (runs: {runs}, [AVG_GAS]) -[..] - -Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) - -Failing tests: -... -Encountered a total of 1 failing tests, 0 tests succeeded -... -"# - ) - }; - - // Test address literal fuzzing - let mut test_literal = |seed: u32, - test_name: &'static str, - type_sig: &'static str, - expected_value: &'static str, - expected_runs: u32| { - // the fuzzer is UNABLE to find a breaking input (fast) when NOT seeding from the AST - prj.update_config(|config| { - config.fuzz.runs = 100; - config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; - config.fuzz.seed = Some(U256::from(seed)); - }); - cmd.forge_fuse().args(["test", "--match-test", test_name]).assert_success(); - - // the fuzzer is ABLE to find a breaking input when seeding from the AST - prj.update_config(|config| { - config.fuzz.dictionary.max_fuzz_dictionary_literals = 10_000; - }); - - let expected_output = expected_fail(test_name, type_sig, expected_value, expected_runs); - cmd.forge_fuse() - .args(["test", "--match-test", test_name]) - .assert_failure() - .stdout_eq(expected_output); - }; - - test_literal(100, "testFuzz_Addr", "address", "0x6B175474E89094C44Da98b954EedeAC495271d0F", 28); - test_literal(200, "testFuzz_Number", "uint64", "1122334455 [1.122e9]", 5); - test_literal(300, "testFuzz_Integer", "int32", "-777", 0); - test_literal( - 400, - "testFuzz_Word", - "bytes32", - "0x6162636431323334000000000000000000000000000000000000000000000000", /* bytes32("abcd1234") */ - 7, - ); - test_literal(500, "testFuzz_BytesFromHex", "bytes", "0xdeadbeef", 5); - test_literal(600, "testFuzz_String", "string", "\"xyzzy\"", 35); - test_literal(999, "testFuzz_BytesFromString", "bytes", "0x78797a7a79", 19); // abi.encodePacked("xyzzy") -}); From a4d0c1ce51e2e9d481d6fbcecdb466a4a3158987 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 22 Oct 2025 08:13:54 +0200 Subject: [PATCH 34/36] fix: use seed to prevent potential flakiness --- crates/forge/tests/cli/test_cmd/invariant/common.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/forge/tests/cli/test_cmd/invariant/common.rs b/crates/forge/tests/cli/test_cmd/invariant/common.rs index 5311494f40827..4e3f62752e4bd 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/common.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/common.rs @@ -444,6 +444,7 @@ forgetest_init!(invariant_fixtures, |prj, cmd| { config.invariant.depth = 100; // disable literals to test fixtures config.invariant.dictionary.max_fuzz_dictionary_literals = 0; + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; }); prj.add_test( @@ -555,6 +556,7 @@ Tip: Run `forge test --rerun` to retry only the 1 failed test forgetest_init!(invariant_breaks_without_fixtures, |prj, cmd| { prj.wipe_contracts(); prj.update_config(|config| { + config.fuzz.seed = Some(U256::from(1)); config.invariant.runs = 1; config.invariant.depth = 100; }); From 5e7847ba882ec8d6e93d42f35b9561a042139464 Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 22 Oct 2025 09:17:46 +0200 Subject: [PATCH 35/36] fix: assert contract outputs individually --- .../tests/cli/test_cmd/invariant/target.rs | 185 ++++++++++++++---- 1 file changed, 150 insertions(+), 35 deletions(-) diff --git a/crates/forge/tests/cli/test_cmd/invariant/target.rs b/crates/forge/tests/cli/test_cmd/invariant/target.rs index 71529c62416f6..8929c9d5dc398 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/target.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/target.rs @@ -537,29 +537,27 @@ contract TargetArtifacts is Test { "#, ); - assert_invariant(cmd.args(["test", "-j1"])).failure().stdout_eq(str![[r#" + // Test ExcludeContracts + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "ExcludeContracts"])) + .success() + .stdout_eq(str![[r#" ... -Ran 1 test for test/ExcludeArtifacts.t.sol:ExcludeArtifacts -[PASS] invariantShouldPass() ([RUNS]) - -[STATS] - -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] - -Ran 1 test for test/ExcludeSenders.t.sol:ExcludeSenders +Ran 1 test for test/ExcludeContracts.t.sol:ExcludeContracts [PASS] invariantTrueWorld() ([RUNS]) [STATS] Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/TargetArtifactSelectors.t.sol:TargetArtifactSelectors -[PASS] invariantShouldPass() ([RUNS]) - -[STATS] +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) -Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +"#]]); + // Test ExcludeSelectors + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "ExcludeSelectors"])) + .success() + .stdout_eq(str![[r#" +... Ran 1 test for test/ExcludeSelectors.t.sol:ExcludeSelectors [PASS] invariantFalseWorld() ([RUNS]) @@ -567,19 +565,31 @@ Ran 1 test for test/ExcludeSelectors.t.sol:ExcludeSelectors Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] -Ran 2 tests for test/TargetArtifacts.t.sol:TargetArtifacts -[FAIL: false world] - [SEQUENCE] - invariantShouldFail() ([RUNS]) +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) -[STATS] +"#]]); -[PASS] invariantShouldPass() ([RUNS]) + // Test ExcludeSenders + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "ExcludeSenders"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/ExcludeSenders.t.sol:ExcludeSenders +[PASS] invariantTrueWorld() ([RUNS]) [STATS] -Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + // Test TargetContracts + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "TargetContracts"])) + .success() + .stdout_eq(str![[r#" +... Ran 1 test for test/TargetContracts.t.sol:TargetContracts [PASS] invariantTrueWorld() ([RUNS]) @@ -587,15 +597,43 @@ Ran 1 test for test/TargetContracts.t.sol:TargetContracts Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/TargetArtifactSelectors2.t.sol:TargetArtifactSelectors2 -[FAIL: it's false] +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test TargetInterfaces (should fail) + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "TargetWorldInterfaces"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TargetInterfaces.t.sol:TargetWorldInterfaces +[FAIL: false world] [SEQUENCE] - invariantShouldFail() ([RUNS]) + invariantTrueWorld() ([RUNS]) [STATS] Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/TargetInterfaces.t.sol:TargetWorldInterfaces +[FAIL: false world] + [SEQUENCE] + invariantTrueWorld() ([RUNS]) + +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); + + // Test TargetSelectors + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "TargetSelectors"])) + .success() + .stdout_eq(str![[r#" +... Ran 1 test for test/TargetSelectors.t.sol:TargetSelectors [PASS] invariantTrueWorld() ([RUNS]) @@ -603,6 +641,14 @@ Ran 1 test for test/TargetSelectors.t.sol:TargetSelectors Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test TargetSenders (should fail) + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "TargetSenders"])).failure().stdout_eq( + str![[r#" +... Ran 1 test for test/TargetSenders.t.sol:TargetSenders [FAIL: false world] [SEQUENCE] @@ -612,16 +658,52 @@ Ran 1 test for test/TargetSenders.t.sol:TargetSenders Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/TargetInterfaces.t.sol:TargetWorldInterfaces +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) + +Failing tests: +Encountered 1 failing test in test/TargetSenders.t.sol:TargetSenders [FAIL: false world] [SEQUENCE] invariantTrueWorld() ([RUNS]) +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]], + ); + + // Test ExcludeArtifacts + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "ExcludeArtifacts"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/ExcludeArtifacts.t.sol:ExcludeArtifacts +[PASS] invariantShouldPass() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test TargetArtifactSelectors2 (should fail) + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "TargetArtifactSelectors2"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TargetArtifactSelectors2.t.sol:TargetArtifactSelectors2 +[FAIL: it's false] + [SEQUENCE] + invariantShouldFail() ([RUNS]) + [STATS] Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] -Ran 11 test suites [ELAPSED]: 8 tests passed, 4 failed, 0 skipped (12 total tests) +Ran 1 test suite [ELAPSED]: 0 tests passed, 1 failed, 0 skipped (1 total tests) Failing tests: Encountered 1 failing test in test/TargetArtifactSelectors2.t.sol:TargetArtifactSelectors2 @@ -629,24 +711,57 @@ Encountered 1 failing test in test/TargetArtifactSelectors2.t.sol:TargetArtifact [SEQUENCE] invariantShouldFail() ([RUNS]) -Encountered 1 failing test in test/TargetArtifacts.t.sol:TargetArtifacts +Encountered a total of 1 failing tests, 0 tests succeeded + +Tip: Run `forge test --rerun` to retry only the 1 failed test + +"#]]); + + // Test TargetArtifactSelectors + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "^TargetArtifactSelectors$"])) + .success() + .stdout_eq(str![[r#" +... +Ran 1 test for test/TargetArtifactSelectors.t.sol:TargetArtifactSelectors +[PASS] invariantShouldPass() ([RUNS]) + +[STATS] + +Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 0 failed, 0 skipped (1 total tests) + +"#]]); + + // Test TargetArtifacts + assert_invariant(cmd.forge_fuse().args(["test", "--mc", "^TargetArtifacts$"])) + .failure() + .stdout_eq(str![[r#" +... +Ran 2 tests for test/TargetArtifacts.t.sol:TargetArtifacts [FAIL: false world] [SEQUENCE] invariantShouldFail() ([RUNS]) -Encountered 1 failing test in test/TargetInterfaces.t.sol:TargetWorldInterfaces -[FAIL: false world] - [SEQUENCE] - invariantTrueWorld() ([RUNS]) +[STATS] -Encountered 1 failing test in test/TargetSenders.t.sol:TargetSenders +[PASS] invariantShouldPass() ([RUNS]) + +[STATS] + +Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] + +Ran 1 test suite [ELAPSED]: 1 tests passed, 1 failed, 0 skipped (2 total tests) + +Failing tests: +Encountered 1 failing test in test/TargetArtifacts.t.sol:TargetArtifacts [FAIL: false world] [SEQUENCE] - invariantTrueWorld() ([RUNS]) + invariantShouldFail() ([RUNS]) -Encountered a total of 4 failing tests, 8 tests succeeded +Encountered a total of 1 failing tests, 1 tests succeeded -Tip: Run `forge test --rerun` to retry only the 4 failed tests +Tip: Run `forge test --rerun` to retry only the 1 failed test "#]]); }); From 4094e8136ade6369a74b76da63368c134fe675fb Mon Sep 17 00:00:00 2001 From: 0xrusowsky <0xrusowsky@proton.me> Date: Wed, 22 Oct 2025 12:09:35 +0200 Subject: [PATCH 36/36] chore: bump forge-std --- crates/forge/tests/cli/ext_integration.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge/tests/cli/ext_integration.rs b/crates/forge/tests/cli/ext_integration.rs index eb74ee03ef8b7..5cca454edede8 100644 --- a/crates/forge/tests/cli/ext_integration.rs +++ b/crates/forge/tests/cli/ext_integration.rs @@ -6,7 +6,7 @@ use foundry_test_utils::util::ExtTester; // #[test] fn forge_std() { - ExtTester::new("foundry-rs", "forge-std", "60acb7aaadcce2d68e52986a0a66fe79f07d138f") + ExtTester::new("foundry-rs", "forge-std", "a6d71da563bbb8d6eef8fbec3a16c61c603d2764") // Skip fork tests. .args(["--nmc", "Fork"]) .verbosity(2)