diff --git a/crates/evm/fuzz/src/strategies/invariants.rs b/crates/evm/fuzz/src/strategies/invariants.rs index c7d04dd1ab02..4dc47eeada95 100644 --- a/crates/evm/fuzz/src/strategies/invariants.rs +++ b/crates/evm/fuzz/src/strategies/invariants.rs @@ -1,7 +1,7 @@ use super::{fuzz_calldata, fuzz_param_from_state}; use crate::{ invariant::{BasicTxDetails, CallDetails, FuzzRunIdentifiedContracts, SenderFilters}, - strategies::{fuzz_calldata_from_state, fuzz_param, EvmFuzzState}, + strategies::{fuzz_calldata_from_state, fuzz_param, param::FuzzConfig, EvmFuzzState}, FuzzFixtures, }; use alloy_json_abi::Function; @@ -94,7 +94,7 @@ fn select_random_sender( } else { assert!(dictionary_weight <= 100, "dictionary_weight must be <= 100"); proptest::prop_oneof![ - 100 - dictionary_weight => fuzz_param(&alloy_dyn_abi::DynSolType::Address), + 100 - dictionary_weight => fuzz_param(&alloy_dyn_abi::DynSolType::Address, &FuzzConfig::new()), dictionary_weight => fuzz_param_from_state(&alloy_dyn_abi::DynSolType::Address, fuzz_state), ] .prop_map(move |addr| addr.as_address().unwrap()) diff --git a/crates/evm/fuzz/src/strategies/param.rs b/crates/evm/fuzz/src/strategies/param.rs index 7e5218fd872b..05ef3dc67dfe 100644 --- a/crates/evm/fuzz/src/strategies/param.rs +++ b/crates/evm/fuzz/src/strategies/param.rs @@ -2,15 +2,35 @@ use super::state::EvmFuzzState; use alloy_dyn_abi::{DynSolType, DynSolValue}; use alloy_primitives::{Address, B256, I256, U256}; use proptest::prelude::*; +use std::collections::HashMap; /// The max length of arrays we fuzz for is 256. const MAX_ARRAY_LEN: usize = 256; +/// Struct to hold range configuration +#[derive(Default, Clone)] +pub struct FuzzConfig { + ranges: HashMap, +} + +impl FuzzConfig { + /// Initiates a new range configuration + pub fn new() -> Self { + Self { ranges: HashMap::new() } + } + + /// Adds a range + pub fn with_range(mut self, param_name: &str, min: U256, max: U256) -> Self { + self.ranges.insert(param_name.to_string(), (min, max)); + self + } +} + /// Given a parameter type, returns a strategy for generating values for that type. /// /// See [`fuzz_param_with_fixtures`] for more information. -pub fn fuzz_param(param: &DynSolType) -> BoxedStrategy { - fuzz_param_inner(param, None) +pub fn fuzz_param(param: &DynSolType, config: &FuzzConfig) -> BoxedStrategy { + fuzz_param_inner(param, config, None) } /// Given a parameter type and configured fixtures for param name, returns a strategy for generating @@ -33,13 +53,16 @@ pub fn fuzz_param_with_fixtures( fixtures: Option<&[DynSolValue]>, name: &str, ) -> BoxedStrategy { - fuzz_param_inner(param, fixtures.map(|f| (f, name))) + fuzz_param_inner(param, &FuzzConfig::new(), fixtures.map(|f| (f, name))) } fn fuzz_param_inner( param: &DynSolType, + config: &FuzzConfig, mut fuzz_fixtures: Option<(&[DynSolValue], &str)>, ) -> BoxedStrategy { + let param_name = fuzz_fixtures.as_ref().map(|(_, name)| *name); + if let Some((fixtures, name)) = fuzz_fixtures { if !fixtures.iter().all(|f| f.matches(param)) { error!("fixtures for {name:?} do not match type {param}"); @@ -70,9 +93,19 @@ fn fuzz_param_inner( DynSolType::Int(n @ 8..=256) => super::IntStrategy::new(n, fuzz_fixtures) .prop_map(move |x| DynSolValue::Int(x, n)) .boxed(), - DynSolType::Uint(n @ 8..=256) => super::UintStrategy::new(n, fuzz_fixtures) - .prop_map(move |x| DynSolValue::Uint(x, n)) - .boxed(), + DynSolType::Uint(n @ 8..=256) => { + let bounds = param_name.and_then(|name| config.ranges.get(name)); + match bounds { + Some((min, max)) => { + super::UintStrategy::new(n, fuzz_fixtures, Some(*min), Some(*max)) + .prop_map(move |x| DynSolValue::Uint(x, n)) + .boxed() + } + None => super::UintStrategy::new(n, fuzz_fixtures, None, None) + .prop_map(move |x| DynSolValue::Uint(x, n)) + .boxed(), + } + } DynSolType::Function | DynSolType::Bool => DynSolValue::type_strategy(param).boxed(), DynSolType::Bytes => value(), DynSolType::FixedBytes(_size @ 1..=32) => value(), @@ -85,20 +118,21 @@ fn fuzz_param_inner( .boxed(), DynSolType::Tuple(ref params) => params .iter() - .map(|param| fuzz_param_inner(param, None)) + .map(|param| fuzz_param_inner(param, &FuzzConfig::new(), None)) .collect::>() .prop_map(DynSolValue::Tuple) .boxed(), DynSolType::FixedArray(ref param, size) => { - proptest::collection::vec(fuzz_param_inner(param, None), size) + proptest::collection::vec(fuzz_param_inner(param, &FuzzConfig::new(), None), size) .prop_map(DynSolValue::FixedArray) .boxed() } - DynSolType::Array(ref param) => { - proptest::collection::vec(fuzz_param_inner(param, None), 0..MAX_ARRAY_LEN) - .prop_map(DynSolValue::Array) - .boxed() - } + DynSolType::Array(ref param) => proptest::collection::vec( + fuzz_param_inner(param, &FuzzConfig::new(), None), + 0..MAX_ARRAY_LEN, + ) + .prop_map(DynSolValue::Array) + .boxed(), _ => panic!("unsupported fuzz param type: {param}"), } } @@ -208,10 +242,15 @@ mod tests { strategies::{fuzz_calldata, fuzz_calldata_from_state, EvmFuzzState}, FuzzFixtures, }; + use alloy_dyn_abi::{DynSolType, DynSolValue}; + use alloy_primitives::U256; use foundry_common::abi::get_func; use foundry_config::FuzzDictionaryConfig; + use proptest::{prelude::Strategy, test_runner::TestRunner}; use revm::db::{CacheDB, EmptyDB}; + use super::{fuzz_param_inner, FuzzConfig}; + #[test] fn can_fuzz_array() { let f = "testArray(uint64[2] calldata values)"; @@ -226,4 +265,143 @@ mod tests { let mut runner = proptest::test_runner::TestRunner::new(cfg); let _ = runner.run(&strategy, |_| Ok(())); } + + #[test] + fn test_uint_param_with_range() { + let mut config = FuzzConfig::new(); + let min = U256::from(100u64); + let max = U256::from(1000u64); + config = config.with_range("amount", min, max); + + let param = DynSolType::Uint(256); + let strategy = fuzz_param_inner(¶m, &config, Some((&[], "amount"))); + + let mut runner = TestRunner::default(); + for _ in 0..1000 { + let value = strategy.new_tree(&mut runner).unwrap().current(); + if let DynSolValue::Uint(value, _) = value { + assert!( + value >= min && value <= max, + "Generated value {value} outside configured range [{min}, {max}]" + ); + } else { + panic!("Expected Uint value"); + } + } + } + + #[test] + fn test_uint_param_without_range() { + let config = FuzzConfig::new(); + let param = DynSolType::Uint(8); + let strategy = fuzz_param_inner(¶m, &config, None); + + let mut runner = TestRunner::default(); + for _ in 0..1000 { + let value = strategy.new_tree(&mut runner).unwrap().current(); + if let DynSolValue::Uint(value, bits) = value { + assert!(value <= U256::from(u8::MAX), "Generated value {value} exceeds uint8 max"); + assert_eq!(bits, 8, "Incorrect bit size"); + } else { + panic!("Expected Uint value"); + } + } + } + + #[test] + fn test_uint_param_with_fixtures() { + let config = FuzzConfig::new(); + let fixtures = vec![ + DynSolValue::Uint(U256::from(500u64), 256), + DynSolValue::Uint(U256::from(600u64), 256), + ]; + + let param = DynSolType::Uint(256); + let strategy = fuzz_param_inner(¶m, &config, Some((&fixtures, "test"))); + + let mut runner = TestRunner::default(); + let mut found_fixture = false; + + for _ in 0..1000 { + let value = strategy.new_tree(&mut runner).unwrap().current(); + if let DynSolValue::Uint(value, _) = value { + if value == U256::from(500u64) || value == U256::from(600u64) { + found_fixture = true; + break; + } + } + } + assert!(found_fixture, "Never generated fixture value"); + } + + #[test] + fn test_uint_param_with_range_and_fixtures() { + let mut config = FuzzConfig::new(); + let min = U256::from(100u64); + let max = U256::from(1000u64); + config = config.with_range("test", min, max); + + let fixtures = vec![ + DynSolValue::Uint(U256::from(50u64), 256), + DynSolValue::Uint(U256::from(500u64), 256), + DynSolValue::Uint(U256::from(1500u64), 256), + ]; + + let param = DynSolType::Uint(256); + let strategy = fuzz_param_inner(¶m, &config, Some((&fixtures, "test"))); + + let mut runner = TestRunner::default(); + for _ in 0..1000 { + let value = strategy.new_tree(&mut runner).unwrap().current(); + if let DynSolValue::Uint(value, _) = value { + assert!( + value >= min && value <= max, + "Generated value {value} outside configured range [{min}, {max}]" + ); + } + } + } + + #[test] + fn test_param_range_matching() { + let mut config = FuzzConfig::new(); + config = config.with_range("amount", U256::from(100u64), U256::from(1000u64)).with_range( + "other", + U256::from(2000u64), + U256::from(3000u64), + ); + + let param = DynSolType::Uint(256); + let mut runner = TestRunner::default(); + + let strategy1 = fuzz_param_inner(¶m, &config, Some((&[], "amount"))); + for _ in 0..100 { + let value = strategy1.new_tree(&mut runner).unwrap().current(); + match value { + DynSolValue::Uint(value, bits) => { + assert_eq!(bits, 256, "Incorrect bit size"); + assert!( + value >= U256::from(100u64) && value <= U256::from(1000u64), + "Generated value {value} outside 'amount' range [100, 1000]" + ); + } + _ => panic!("Expected Uint value"), + } + } + + let strategy2 = fuzz_param_inner(¶m, &config, Some((&[], "nonexistent"))); + for _ in 0..100 { + let value = strategy2.new_tree(&mut runner).unwrap().current(); + match value { + DynSolValue::Uint(value, bits) => { + assert_eq!(bits, 256, "Incorrect bit size"); + assert!( + value <= (U256::from(1) << 256) - U256::from(1), + "Generated value {value} exceeds maximum uint256 value" + ); + } + _ => panic!("Expected Uint value"), + } + } + } } diff --git a/crates/evm/fuzz/src/strategies/uint.rs b/crates/evm/fuzz/src/strategies/uint.rs index af133efa0082..00dce1792838 100644 --- a/crates/evm/fuzz/src/strategies/uint.rs +++ b/crates/evm/fuzz/src/strategies/uint.rs @@ -16,6 +16,10 @@ pub struct UintValueTree { hi: U256, /// If true cannot be simplified or complexified fixed: bool, + ///Optional Min Value + min_bound: Option, + ///Optional Max Value + max_bound: Option, } impl UintValueTree { @@ -23,8 +27,8 @@ impl UintValueTree { /// # Arguments /// * `start` - Starting value for the tree /// * `fixed` - If `true` the tree would only contain one element and won't be simplified. - fn new(start: U256, fixed: bool) -> Self { - Self { lo: U256::ZERO, curr: start, hi: start, fixed } + fn new(start: U256, fixed: bool, min_bound: Option, max_bound: Option) -> Self { + Self { lo: U256::ZERO, curr: start, hi: start, fixed, min_bound, max_bound } } fn reposition(&mut self) -> bool { @@ -44,7 +48,12 @@ impl ValueTree for UintValueTree { type Value = U256; fn current(&self) -> Self::Value { - self.curr + match (self.min_bound, self.max_bound) { + (Some(min), Some(max)) => self.curr.clamp(min, max), + (Some(min), None) => self.curr.max(min), + (None, Some(max)) => self.curr.min(max), + (None, None) => self.curr, + } } fn simplify(&mut self) -> bool { @@ -91,6 +100,8 @@ pub struct UintStrategy { fixtures_weight: usize, /// The weight for purely random values random_weight: usize, + /// Optional bounds for generated values + bounds: Option<(U256, U256)>, } impl UintStrategy { @@ -98,23 +109,65 @@ impl UintStrategy { /// #Arguments /// * `bits` - Size of uint in bits /// * `fixtures` - A set of fixed values to be generated (according to fixtures weight) - pub fn new(bits: usize, fixtures: Option<&[DynSolValue]>) -> Self { + pub fn new( + bits: usize, + fixtures: Option<&[DynSolValue]>, + min_bound: Option, + max_bound: Option, + ) -> Self { + let type_max = if bits < 256 { (U256::from(1) << bits) - U256::from(1) } else { U256::MAX }; + + let bounds = match (min_bound, max_bound) { + (Some(min), Some(max)) if min <= max => Some((min, max)), + (Some(min), None) => Some((min, type_max)), + (None, Some(max)) => Some((U256::ZERO, max)), + _ => None, + }; + Self { bits, fixtures: Vec::from(fixtures.unwrap_or_default()), edge_weight: 10usize, fixtures_weight: 40usize, random_weight: 50usize, + bounds, } } + pub fn use_log_sampling(&self) -> bool { + self.bits > 8 + } + fn generate_edge_tree(&self, runner: &mut TestRunner) -> NewTree { let rng = runner.rng(); - // Choose if we want values around 0 or max let is_min = rng.gen_bool(0.5); let offset = U256::from(rng.gen_range(0..4)); - let start = if is_min { offset } else { self.type_max().saturating_sub(offset) }; - Ok(UintValueTree::new(start, false)) + + let start = if let Some((min, max)) = self.bounds { + // If bounds are set,we use them + if is_min { + min.saturating_add(offset) + } else { + max.saturating_sub(offset) + } + } else { + let type_max = self.type_max(); + if is_min { + offset + } else if offset == U256::ZERO { + type_max + } else { + type_max.saturating_sub(offset) + } + }; + + let (_min, _max) = self.bounds.unwrap_or((U256::ZERO, self.type_max())); + Ok(UintValueTree::new( + start, + false, + self.bounds.map(|(min, _)| min), + self.bounds.map(|(_, max)| max), + )) } fn generate_fixtures_tree(&self, runner: &mut TestRunner) -> NewTree { @@ -125,9 +178,20 @@ impl UintStrategy { // Generate value tree from fixture. let fixture = &self.fixtures[runner.rng().gen_range(0..self.fixtures.len())]; + if let Some(uint_fixture) = fixture.as_uint() { if uint_fixture.1 == self.bits { - return Ok(UintValueTree::new(uint_fixture.0, false)); + let fixture_value = match self.bounds { + Some((min, max)) => uint_fixture.0.clamp(min, max), + None => uint_fixture.0, + }; + + return Ok(UintValueTree::new( + fixture_value, + false, + self.bounds.map(|(min, _)| min), + self.bounds.map(|(_, max)| max), + )); } } @@ -136,39 +200,90 @@ impl UintStrategy { self.generate_random_tree(runner) } - fn generate_random_tree(&self, runner: &mut TestRunner) -> NewTree { + fn generate_random_values_uniformly(&self, runner: &mut TestRunner) -> U256 { let rng = runner.rng(); - // generate random number of bits uniformly - let bits = rng.gen_range(0..=self.bits); + //Generate the bits to use + let bits = self.bits; - // init 2 128-bit randoms - let mut higher: u128 = rng.gen_range(0..=u128::MAX); - let mut lower: u128 = rng.gen_range(0..=u128::MAX); + // Generate lower and higher parts + let lower: u128 = rng.gen(); + let higher: u128 = rng.gen(); - // cut 2 randoms according to bits size - match bits { - x if x < 128 => { - lower &= (1u128 << x) - 1; - higher = 0; - } - x if (128..256).contains(&x) => higher &= (1u128 << (x - 128)) - 1, - _ => {} + // Apply masking + let (masked_lower, masked_higher) = if bits < 128 { + (lower & ((1u128 << bits) - 1), 0) + } else if bits < 256 { + (lower, higher & ((1u128 << (bits - 128)) - 1)) + } else { + (lower, higher) }; - // init U256 from 2 randoms + //Convert to U256 let mut inner: [u64; 4] = [0; 4]; - let mask64 = (1 << 65) - 1; - inner[0] = (lower & mask64) as u64; - inner[1] = (lower >> 64) as u64; - inner[2] = (higher & mask64) as u64; - inner[3] = (higher >> 64) as u64; - let start: U256 = U256::from_limbs(inner); + inner[0] = (masked_lower & ((1u128 << 64) - 1)) as u64; + inner[1] = (masked_lower >> 64) as u64; + inner[2] = (masked_higher & ((1u128 << 64) - 1)) as u64; + inner[3] = (masked_higher >> 64) as u64; - Ok(UintValueTree::new(start, false)) + U256::from_limbs(inner) } - fn type_max(&self) -> U256 { + fn generate_random_tree(&self, runner: &mut TestRunner) -> NewTree { + let start = match self.bounds { + Some((min, max)) => { + if max <= min { + min + } else if Self::use_log_sampling(self) { + self.generate_log_uniform(runner) + } else { + let range = max - min + U256::from(1); + if range == U256::ZERO { + min + } else { + let random = self.generate_random_values_uniformly(runner) % range; + min + random + } + } + } + None => { + if Self::use_log_sampling(self) { + self.generate_log_uniform(runner) + } else { + // When no bounds are specified, generate within type bounds + let type_max = self.type_max(); + self.generate_random_values_uniformly(runner) % (type_max + U256::from(1)) + } + } + }; + + let (min, max) = self.bounds.unwrap_or((U256::ZERO, self.type_max())); + + Ok(UintValueTree::new(start.clamp(min, max), false, Some(min), Some(max))) + } + + fn generate_log_uniform(&self, runner: &mut TestRunner) -> U256 { + let rng = runner.rng(); + let exp = rng.gen::() % 256; + let mantissa = rng.gen::(); + + let base = U256::from(1) << exp; + let mut value = base | (U256::from(mantissa) & (base - U256::from(1))); + + let (min, max) = self.bounds.unwrap_or((U256::ZERO, self.type_max())); + + value = value.clamp(min, max); + + if value == min && max > min { + let range = max - min; + let offset = U256::from(rng.gen::()) % range; + value = min + offset; + } + + value + } + + pub fn type_max(&self) -> U256 { if self.bits < 256 { (U256::from(1) << self.bits) - U256::from(1) } else { @@ -195,15 +310,138 @@ impl Strategy for UintStrategy { #[cfg(test)] mod tests { use crate::strategies::uint::UintValueTree; + use alloy_dyn_abi::DynSolValue; use alloy_primitives::U256; - use proptest::strategy::ValueTree; + use proptest::{prelude::Strategy, strategy::ValueTree, test_runner::TestRunner}; + + use super::UintStrategy; #[test] fn test_uint_tree_complicate_max() { - let mut uint_tree = UintValueTree::new(U256::MAX, false); + let mut uint_tree = UintValueTree::new(U256::MAX, false, Some(U256::MAX), Some(U256::MIN)); assert_eq!(uint_tree.hi, U256::MAX); assert_eq!(uint_tree.curr, U256::MAX); uint_tree.complicate(); assert_eq!(uint_tree.lo, U256::MIN); } + + #[test] + fn test_uint_strategy_respects_bounds() { + let min = U256::from(1000u64); + let max = U256::from(2000u64); + let strategy = UintStrategy::new(16, None, Some(min), Some(max)); + let mut runner = TestRunner::default(); + + for _ in 0..1000 { + let value = strategy.new_tree(&mut runner).unwrap().current(); + assert!(value >= min && value <= max, "Generated value {value} is out of bounds"); + } + } + + #[test] + fn test_uint_value_tree_bounds() { + let min = U256::from(100u64); + let max = U256::from(200u64); + let start = U256::from(150u64); + + let mut tree = UintValueTree::new(start, false, Some(min), Some(max)); + + assert_eq!(tree.current(), start); + + while tree.simplify() { + let curr = tree.current(); + assert!(curr >= min && curr <= max, "Simplify produced out of bounds value: {curr}"); + } + + tree = UintValueTree::new(start, false, Some(min), Some(max)); + + while tree.complicate() { + let curr = tree.current(); + assert!(curr >= min && curr <= max, "Complicate produced out of bounds value: {curr}"); + } + } + + #[test] + fn test_edge_case_generation() { + let min = U256::from(100u64); + let max = U256::from(1000u64); + let strategy = UintStrategy::new(64, None, Some(min), Some(max)); + let mut runner = TestRunner::default(); + + let mut found_min_area = false; + let mut found_max_area = false; + + for _ in 0..1000 { + let tree = strategy.generate_edge_tree(&mut runner).unwrap(); + let value = tree.current(); + + assert!( + value >= min && value <= max, + "Edge case {value} outside bounds [{min}, {max}]" + ); + + if value <= min + U256::from(3) { + found_min_area = true; + } + if value >= max - U256::from(3) { + found_max_area = true; + } + } + + assert!(found_min_area, "Never generated values near minimum"); + assert!(found_max_area, "Never generated values near maximum"); + } + + #[test] + fn test_fixture_generation() { + let min = U256::from(100u64); + let max = U256::from(1000u64); + let valid_fixture = U256::from(500u64); + let fixtures = vec![DynSolValue::Uint(valid_fixture, 64)]; + + let strategy = UintStrategy::new(64, Some(&fixtures), Some(min), Some(max)); + let mut runner = TestRunner::default(); + + for _ in 0..100 { + let tree = strategy.generate_fixtures_tree(&mut runner).unwrap(); + let value = tree.current(); + assert!( + value >= min && value <= max, + "Fixture value {value} outside bounds [{min}, {max}]" + ); + } + } + + #[test] + fn test_log_uniform_sampling() { + let strategy = UintStrategy::new(256, None, None, None); + let mut runner = TestRunner::default(); + let mut log2_buckets = vec![0; 256]; + let iterations = 100000; + + for _ in 0..iterations { + let tree = strategy.generate_random_tree(&mut runner).unwrap(); + let value = tree.current(); + + // Find the highest set bit (log2 bucket) + let mut highest_bit = 0; + for i in 0..256 { + if value >= (U256::from(1) << i) { + highest_bit = i; + } + } + log2_buckets[highest_bit] += 1; + } + + let mut populated_buckets = 0; + for &count in &log2_buckets { + if count > 0 { + populated_buckets += 1; + } + } + assert!( + populated_buckets > 200, + "Log-uniform sampling didn't cover enough orders of magnitude" + ); + } }