diff --git a/Cargo.lock b/Cargo.lock index 0320ec9c32c15..b4b5caa1b3d1d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4770,7 +4770,6 @@ dependencies = [ "revm-inspectors", "serde", "serde_json", - "solar-compiler", "thiserror 2.0.17", "tracing", "uuid 1.18.1", @@ -4864,6 +4863,7 @@ dependencies = [ "rand 0.9.2", "revm", "serde", + "solar-compiler", "thiserror 2.0.17", "tracing", ] 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/config/src/fuzz.rs b/crates/config/src/fuzz.rs index ccb8cf45b632b..9f2ba141897a8 100644 --- a/crates/config/src/fuzz.rs +++ b/crates/config/src/fuzz.rs @@ -80,18 +80,24 @@ 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 { 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 200MB - max_fuzz_dictionary_values: (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/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/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index cc90e0670fae6..9b65928d9a4fb 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(); + if let Some(fork_db) = self.executor.backend().active_fork_db() { - EvmFuzzState::new(fork_db, self.config.dictionary, deployed_libs) + EvmFuzzState::new( + fork_db, + self.config.dictionary, + deployed_libs, + inspector.analysis.as_ref(), + inspector.paths_config(), + ) } else { EvmFuzzState::new( self.executor.backend().mem_db(), self.config.dictionary, deployed_libs, + inspector.analysis.as_ref(), + inspector.paths_config(), ) } } diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 9361dca926549..ee5997eb16f0c 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -566,10 +566,13 @@ impl<'a> InvariantExecutor<'a> { self.select_contracts_and_senders(invariant_contract.address)?; // Stores fuzz state for use with [fuzz_calldata_from_state]. + let inspector = self.executor.inspector(); let fuzz_state = EvmFuzzState::new( self.executor.backend().mem_db(), self.config.dictionary, deployed_libs, + inspector.analysis.as_ref(), + inspector.paths_config(), ); // Creates the invariant strategy. diff --git a/crates/evm/evm/src/inspectors/stack.rs b/crates/evm/evm/src/inspectors/stack.rs index 6991884e22220..868d7404d6f85 100644 --- a/crates/evm/evm/src/inspectors/stack.rs +++ b/crates/evm/evm/src/inspectors/stack.rs @@ -8,6 +8,8 @@ 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, backend::{DatabaseExt, JournaledState}, @@ -38,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 @@ -85,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 } @@ -209,6 +211,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 @@ -308,11 +311,20 @@ pub struct InspectorStack { pub inner: InspectorStackInner, } +impl InspectorStack { + pub fn paths_config(&self) -> Option<&ProjectPathsConfig> { + self.cheatcodes.as_ref().map(|c| &c.config.paths) + } +} + /// All used inpectors besides [Cheatcodes]. /// /// See [`InspectorStack`]. #[derive(Default, Clone, Debug)] pub struct InspectorStackInner { + /// Solar compiler instance, to grant syntactic and semantic analysis capabilities. + pub analysis: Option, + // Inspectors. // These are boxed to reduce the size of the struct and slightly improve performance of the // `if let Some` checks. @@ -388,6 +400,12 @@ impl InspectorStack { }); } + /// Set the solar compiler instance. + #[inline] + pub fn set_analysis(&mut self, analysis: Analysis) { + 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/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/literals.rs b/crates/evm/fuzz/src/strategies/literals.rs new file mode 100644 index 0000000000000..16f6e3c79097f --- /dev/null +++ b/crates/evm/fuzz/src/strategies/literals.rs @@ -0,0 +1,352 @@ +use alloy_dyn_abi::DynSolType; +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, +}; +use std::{ops::ControlFlow, sync::OnceLock}; + +#[derive(Debug, Default)] +pub struct LiteralsDictionary { + /// Data required for initialization, captured from `EvmFuzzState::new`. + analysis: Option, + paths_config: Option, + max_values: usize, + + /// Lazy initialized literal maps. + maps: OnceLock, +} + +impl LiteralsDictionary { + pub fn new( + analysis: Option, + paths_config: Option, + max_values: usize, + ) -> Self { + 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(analysis) = &self.analysis { + let literals = LiteralsCollector::process( + analysis, + 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 { + 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( + analysis: &Analysis, + paths_config: Option<&ProjectPathsConfig>, + max_values: usize, + ) -> LiteralMaps { + analysis.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 = + solar::sema::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(&std::sync::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 e96ebc5443c8b..06cec62230d7b 100644 --- a/crates/evm/fuzz/src/strategies/mod.rs +++ b/crates/evm/fuzz/src/strategies/mod.rs @@ -18,3 +18,6 @@ pub use invariants::{fuzz_contract_with_calldata, invariant_strat, override_call 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 ea330b1fcb102..7c8aaa59b6d3e 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -170,15 +170,59 @@ 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 = 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: 30% probability + let ast_strings = dict.ast_strings(); + 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(); + } + + // Fallback to random string generation + DynSolValue::type_strategy(&DynSolType::String) + .prop_map(|value| { + DynSolValue::String( + value.as_str().unwrap().trim().trim_end_matches('\0').to_string(), + ) + }) + .boxed() + }) + .boxed() + } DynSolType::Bytes => { - value().prop_map(move |value| DynSolValue::Bytes(value.0.into())).boxed() + let state_clone = state.clone(); + ( + value(), + 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 + let ast_strings = dict.ast_strings(); + 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()); + } + + // Try hex literals: 20% chance + let ast_bytes = dict.ast_bytes(); + 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 + DynSolValue::Bytes(word.0.into()) + }) + .boxed() } DynSolType::Int(n @ 8..=256) => match n / 8 { 32 => value() @@ -186,11 +230,19 @@ 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 int (two's complement) --> check sign bit (bit N-1). + let sign_bit = U256::from(1) << (n - 1); + let num = if uint_n >= sign_bit { + // Negative number in two's complement + let modulus = U256::from(1) << n; + I256::from_raw(uint_n.wrapping_sub(modulus)) + } else { + // Positive number + I256::from_raw(uint_n) + }; + DynSolValue::Int(num, n) }) .boxed(), @@ -379,16 +431,18 @@ mod tests { FuzzFixtures, strategies::{EvmFuzzState, fuzz_calldata, fuzz_calldata_from_state}, }; + use alloy_primitives::B256; use foundry_common::abi::get_func; use foundry_config::FuzzDictionaryConfig; use revm::database::{CacheDB, EmptyDB}; + use std::collections::HashSet; #[test] fn can_fuzz_array() { let f = "testArray(uint64[2] calldata values)"; let func = get_func(f).unwrap(); let db = CacheDB::new(EmptyDB::default()); - let state = EvmFuzzState::new(&db, FuzzDictionaryConfig::default(), &[]); + let state = EvmFuzzState::new(&db, FuzzDictionaryConfig::default(), &[], None, None); let strategy = proptest::prop_oneof![ 60 => fuzz_calldata(func.clone(), &FuzzFixtures::default()), 40 => fuzz_calldata_from_state(func, &state), @@ -397,4 +451,63 @@ mod tests { let mut runner = proptest::test_runner::TestRunner::new(cfg); let _ = runner.run(&strategy, |_| Ok(())); } + + #[test] + fn can_fuzz_string_and_bytes_with_ast_literals_and_hashes() { + use super::fuzz_param_from_state; + use crate::strategies::LiteralMaps; + use alloy_dyn_abi::DynSolType; + use alloy_primitives::keccak256; + use proptest::strategy::Strategy; + + // Seed dict with string values and their hashes --> mimic `CheatcodeAnalysis` behavior. + let mut literals = LiteralMaps::default(); + literals.strings.insert("hello".to_string()); + literals.strings.insert("world".to_string()); + literals.words.entry(DynSolType::FixedBytes(32)).or_default().insert(keccak256("hello")); + literals.words.entry(DynSolType::FixedBytes(32)).or_default().insert(keccak256("world")); + + let db = CacheDB::new(EmptyDB::default()); + let state = EvmFuzzState::new(&db, FuzzDictionaryConfig::default(), &[], None, None); + state.seed_literals(literals); + + let cfg = proptest::test_runner::Config { failure_persistence: None, ..Default::default() }; + let mut runner = proptest::test_runner::TestRunner::new(cfg); + + // Verify strategies generates the seeded AST literals + let mut generated_bytes = HashSet::new(); + let mut generated_hashes = HashSet::new(); + let mut generated_strings = HashSet::new(); + let bytes_strategy = fuzz_param_from_state(&DynSolType::Bytes, &state); + let string_strategy = fuzz_param_from_state(&DynSolType::String, &state); + let bytes32_strategy = fuzz_param_from_state(&DynSolType::FixedBytes(32), &state); + + for _ in 0..256 { + let tree = bytes_strategy.new_tree(&mut runner).unwrap(); + if let Some(bytes) = tree.current().as_bytes() + && let Ok(s) = std::str::from_utf8(bytes) + { + generated_bytes.insert(s.to_string()); + } + + let tree = string_strategy.new_tree(&mut runner).unwrap(); + if let Some(s) = tree.current().as_str() { + generated_strings.insert(s.to_string()); + } + + let tree = bytes32_strategy.new_tree(&mut runner).unwrap(); + if let Some((bytes, size)) = tree.current().as_fixed_bytes() + && size == 32 + { + generated_hashes.insert(B256::from_slice(bytes)); + } + } + + assert!(generated_bytes.contains("hello")); + assert!(generated_bytes.contains("world")); + assert!(generated_strings.contains("hello")); + assert!(generated_strings.contains("world")); + assert!(generated_hashes.contains(&keccak256("hello"))); + assert!(generated_hashes.contains(&keccak256("world"))); + } } diff --git a/crates/evm/fuzz/src/strategies/state.rs b/crates/evm/fuzz/src/strategies/state.rs index 92121e46d337e..e9360e406ca39 100644 --- a/crates/evm/fuzz/src/strategies/state.rs +++ b/crates/evm/fuzz/src/strategies/state.rs @@ -1,14 +1,17 @@ -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::{ 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, + compile::Analysis, 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}; @@ -44,6 +47,8 @@ impl EvmFuzzState { db: &CacheDB, config: FuzzDictionaryConfig, deployed_libs: &[Address], + analysis: Option<&Analysis>, + 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::>(); @@ -52,6 +57,12 @@ impl EvmFuzzState { // Create fuzz dictionary and insert values from db state. let mut dictionary = FuzzDictionary::new(config); dictionary.insert_db_values(accs); + dictionary.literal_values = LiteralsDictionary::new( + analysis.cloned(), + paths_config.cloned(), + config.max_fuzz_dictionary_literals, + ); + Self { inner: Arc::new(RwLock::new(dictionary)), deployed_libs: deployed_libs.to_vec(), @@ -114,6 +125,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: super::LiteralMaps) { + self.inner.write().seed_literals(map); + } } // We're using `IndexSet` to have a stable element order when restoring persisted state, as well as @@ -132,8 +149,16 @@ 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. + /// Typed runtime sample values persisted across invariant runs. + /// Initially seeded with literal values collected from the source code. sample_values: HashMap, + /// 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, @@ -150,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 } @@ -160,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)>) { @@ -330,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()); @@ -379,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) { @@ -407,11 +447,32 @@ 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 literal strings, triggering initialization if needed. + #[inline] + pub fn ast_strings(&self) -> &IndexSet { + &self.literal_values.get().strings + } + + /// Returns the collected literal bytes (hex strings), triggering initialization if needed. + #[inline] + pub fn ast_bytes(&self) -> &IndexSet { + &self.literal_values.get().bytes + } + #[inline] pub fn addresses(&self) -> &AddressIndexSet { &self.addresses @@ -433,4 +494,10 @@ impl FuzzDictionary { "FuzzDictionary stats", ); } + + #[cfg(test)] + /// Test-only helper to seed the dictionary with literal values. + pub(crate) fn seed_literals(&mut self, map: super::LiteralMaps) { + self.literal_values.set(map); + } } diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index 49b906dd09782..53733d18d301a 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -165,7 +165,8 @@ dictionary_weight = 40 include_storage = true include_push_bytes = true max_fuzz_dictionary_addresses = 15728640 -max_fuzz_dictionary_values = 6553600 +max_fuzz_dictionary_values = 9830400 +max_fuzz_dictionary_literals = 6553600 gas_report_samples = 256 corpus_gzip = true corpus_min_mutations = 5 @@ -183,7 +184,8 @@ dictionary_weight = 80 include_storage = true include_push_bytes = true max_fuzz_dictionary_addresses = 15728640 -max_fuzz_dictionary_values = 6553600 +max_fuzz_dictionary_values = 9830400 +max_fuzz_dictionary_literals = 6553600 shrink_run_limit = 5000 max_assume_rejects = 65536 gas_report_samples = 256 @@ -1222,7 +1224,8 @@ 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": 9830400, + "max_fuzz_dictionary_literals": 6553600, "gas_report_samples": 256, "corpus_dir": null, "corpus_gzip": true, @@ -1242,7 +1245,8 @@ 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": 9830400, + "max_fuzz_dictionary_literals": 6553600, "shrink_run_limit": 5000, "max_assume_rejects": 65536, "gas_report_samples": 256, 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) 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 { diff --git a/crates/forge/tests/cli/test_cmd/fuzz.rs b/crates/forge/tests/cli/test_cmd/fuzz.rs index aaa8fe687d369..a91a48399a9b7 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]) ... "#]]); @@ -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 734ae93501041..4e3f62752e4bd 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/common.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/common.rs @@ -442,6 +442,9 @@ 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; + config.fuzz.dictionary.max_fuzz_dictionary_literals = 0; }); prj.add_test( @@ -550,6 +553,93 @@ 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; + }); + + 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| { @@ -966,19 +1056,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] @@ -1083,8 +1173,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]) @@ -1092,8 +1182,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/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 31e684d4f307d..8929c9d5dc398 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/target.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/target.rs @@ -537,15 +537,11 @@ 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/ExcludeContracts.t.sol:ExcludeContracts [PASS] invariantTrueWorld() ([RUNS]) @@ -553,6 +549,15 @@ Ran 1 test for test/ExcludeContracts.t.sol:ExcludeContracts 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 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]) @@ -560,6 +565,15 @@ Ran 1 test for test/ExcludeSelectors.t.sol:ExcludeSelectors 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 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]) @@ -567,43 +581,75 @@ Ran 1 test for test/ExcludeSenders.t.sol:ExcludeSenders Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/TargetArtifactSelectors.t.sol:TargetArtifactSelectors -[PASS] invariantShouldPass() ([RUNS]) +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]) [STATS] 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 2 tests for test/TargetArtifacts.t.sol:TargetArtifacts +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] - invariantShouldFail() ([RUNS]) - -[STATS] + invariantTrueWorld() ([RUNS]) -[PASS] invariantShouldPass() ([RUNS]) +Encountered a total of 1 failing tests, 0 tests succeeded -[STATS] +Tip: Run `forge test --rerun` to retry only the 1 failed test -Suite result: FAILED. 1 passed; 1 failed; 0 skipped; [ELAPSED] +"#]]); -Ran 1 test for test/TargetContracts.t.sol:TargetContracts + // 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]) [STATS] Suite result: ok. 1 passed; 0 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/TargetInterfaces.t.sol:TargetWorldInterfaces +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] invariantTrueWorld() ([RUNS]) @@ -612,23 +658,52 @@ Ran 1 test for test/TargetInterfaces.t.sol:TargetWorldInterfaces Suite result: FAILED. 0 passed; 1 failed; 0 skipped; [ELAPSED] -Ran 1 test for test/TargetSelectors.t.sol:TargetSelectors -[PASS] invariantTrueWorld() ([RUNS]) +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 for test/TargetSenders.t.sol:TargetSenders -[FAIL: false world] +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] - invariantTrueWorld() ([RUNS]) + 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 @@ -636,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 "#]]); }); @@ -734,6 +842,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 +858,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/cli/test_cmd/mod.rs b/crates/forge/tests/cli/test_cmd/mod.rs index 478d7c5f9530b..498b33fb48b71 100644 --- a/crates/forge/tests/cli/test_cmd/mod.rs +++ b/crates/forge/tests/cli/test_cmd/mod.rs @@ -2450,6 +2450,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( diff --git a/crates/test-utils/src/util.rs b/crates/test-utils/src/util.rs index 1f58821a574a6..a0486dfbfd18c 100644 --- a/crates/test-utils/src/util.rs +++ b/crates/test-utils/src/util.rs @@ -618,6 +618,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)); diff --git a/testdata/default/repros/Issue2851.t.sol b/testdata/default/repros/Issue2851.t.sol new file mode 100644 index 0000000000000..bafa7c0ed0d07 --- /dev/null +++ b/testdata/default/repros/Issue2851.t.sol @@ -0,0 +1,30 @@ +// SPDX-License-Identifier: MIT OR Apache-2.0 +pragma solidity ^0.8.1; + +import "utils/Test.sol"; + +contract Backdoor { + uint256 public number = 1; + + function backdoor(uint256 newNumber) public payable { + uint256 x = newNumber - 1; + if (x == 6912213124124531) { + number = 0; + } + } +} + +// https://github.com/foundry-rs/foundry/issues/2851 +contract Issue2851Test is Test { + Backdoor back; + + function setUp() public { + 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); + } +}