diff --git a/Cargo.lock b/Cargo.lock index 4c6c7fbad68aa..5ee0267e3d4a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5699,6 +5699,7 @@ dependencies = [ "rand 0.9.4", "revm", "serde", + "serde_json", "solar-compiler", "thiserror 2.0.18", "tracing", diff --git a/crates/config/src/invariant.rs b/crates/config/src/invariant.rs index 71507dd9664c6..481e5bf161f60 100644 --- a/crates/config/src/invariant.rs +++ b/crates/config/src/invariant.rs @@ -147,6 +147,16 @@ pub struct InvariantConfig { /// /// Example: `check_interval = 10` means assert after calls 10, 20, 30, ... and the last call. pub check_interval: u32, + /// Enable gas-envelope fuzzing and per-selector gas reporting. + /// Default `false` (no behavior change, no new output). + /// + /// Turns on three things at once: + /// 1. Per-call `tx.gas_limit` drawn uniformly from `[2^24, 2^25)` to exercise gas-conditional + /// EVM dispatch (refund accounting, EIP-150 1/64 retention, OOG dispatch at the EIP-7825 + /// cap). + /// 2. Per-`(target, selector)` `max_gas` tracking (adds the "Max Gas" column). + /// 3. Per-selector reproducer block: the call sequence that produced each peak. + pub gas_fuzz: bool, } impl Default for InvariantConfig { @@ -169,6 +179,7 @@ impl Default for InvariantConfig { max_time_delay: None, max_block_delay: None, check_interval: 1, + gas_fuzz: false, } } } diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 19cfcd13c861d..bf034fa35b087 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -282,6 +282,7 @@ impl WorkerCorpusSeed { fuzzed_function: Option<&Function>, fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>, dynamic: Option>, + gas_fuzz: bool, ) -> Result { let mut seed = Self::empty(config).with_optimization_state(config); let Some(corpus_dir) = &config.corpus_dir else { @@ -321,7 +322,7 @@ impl WorkerCorpusSeed { metrics: Some(&mut seed.metrics), }; let ReplayOutcome { keep_entry, cmp_seq, failed_replays, .. } = - replay_corpus_sequence(&tx_seq, executor, target, coverage)?; + replay_corpus_sequence(&tx_seq, executor, target, coverage, gas_fuzz)?; seed.failed_replays += failed_replays; if !keep_entry { continue; @@ -351,6 +352,7 @@ impl WorkerCorpusSeed { entries: impl IntoIterator, executor: &Executor, target: ReplayTarget<'_>, + gas_fuzz: bool, optimization_best: Option<(I256, &[BasicTxDetails])>, ) -> Result<()> { let mut history_map = self.history_map.clone(); @@ -367,7 +369,7 @@ impl WorkerCorpusSeed { metrics: None, }; let ReplayOutcome { keep_entry, new_coverage, .. } = - replay_corpus_sequence(&entry.tx_seq, executor, target, coverage)?; + replay_corpus_sequence(&entry.tx_seq, executor, target, coverage, gas_fuzz)?; if !keep_entry || !new_coverage { continue; } @@ -496,6 +498,18 @@ pub struct WorkerCorpus { optimization_best_value: Option, /// Optimization mode: the call sequence that produced the best value. optimization_best_sequence: Vec, + /// `invariant.gas_fuzz` for this campaign; gates persisted `gas_limit` overrides on load. + gas_fuzz: bool, +} + +/// Context used by the master worker to replay persisted corpus entries. +#[derive(Clone, Copy, Default)] +pub struct WorkerCorpusReplayConfig<'a, FEN: FoundryEvmNetwork> { + pub executor: Option<&'a Executor>, + pub fuzzed_function: Option<&'a Function>, + pub fuzzed_contracts: Option<&'a FuzzRunIdentifiedContracts>, + pub dynamic: Option>, + pub gas_fuzz: bool, } /// Refs used during corpus replay to register contracts deployed mid-sequence as fuzz targets, @@ -573,9 +587,18 @@ fn replay_corpus_sequence( executor: &Executor, target: ReplayTarget<'_>, coverage: ReplayCoverage<'_>, + gas_fuzz: bool, ) -> Result { let mut executor = executor.clone(); - replay_corpus_sequence_with_executor(tx_seq, &mut executor, target, coverage, false, true) + replay_corpus_sequence_with_executor( + tx_seq, + &mut executor, + target, + coverage, + false, + true, + gas_fuzz, + ) } fn replay_corpus_sequence_with_executor( @@ -585,6 +608,7 @@ fn replay_corpus_sequence_with_executor( mut coverage: ReplayCoverage<'_>, trace_sync: bool, reject_unmatched_function: bool, + gas_fuzz: bool, ) -> Result { let mut cmp_seq = Vec::with_capacity(tx_seq.len()); let mut failed_replays = 0; @@ -592,6 +616,14 @@ fn replay_corpus_sequence_with_executor( let mut created: Vec
= Vec::new(); for tx in tx_seq { + let mut replay_tx; + let tx = if gas_fuzz { + tx + } else { + replay_tx = tx.clone(); + replay_tx.call_details.gas_limit = None; + &replay_tx + }; if WorkerCorpus::can_replay_tx(tx, target.fuzzed_function, target.fuzzed_contracts) { let mut call_result = execute_tx(executor, tx)?; cmp_seq.push(call_result.evm_cmp_values.take().unwrap_or_default()); @@ -657,12 +689,15 @@ impl WorkerCorpus { id: usize, config: FuzzCorpusConfig, tx_generator: BoxedStrategy, - // Only required by master worker (id = 0) to replay existing corpus. - executor: Option<&Executor>, - fuzzed_function: Option<&Function>, - fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>, - dynamic: Option>, + replay: WorkerCorpusReplayConfig<'_, FEN>, ) -> Result { + let WorkerCorpusReplayConfig { + executor, + fuzzed_function, + fuzzed_contracts, + dynamic, + gas_fuzz, + } = replay; let seed = if id == 0 { WorkerCorpusSeed::load_from_disk( &config, @@ -670,11 +705,12 @@ impl WorkerCorpus { fuzzed_function, fuzzed_contracts, dynamic, + gas_fuzz, )? } else { WorkerCorpusSeed::empty(&config).with_optimization_state(&config) }; - Ok(Self::from_seed(id, config, tx_generator, seed)) + Ok(Self::from_seed(id, config, tx_generator, seed, gas_fuzz)) } pub(crate) fn from_seed( @@ -682,6 +718,7 @@ impl WorkerCorpus { config: FuzzCorpusConfig, tx_generator: BoxedStrategy, seed: WorkerCorpusSeed, + gas_fuzz: bool, ) -> Self { let mutation_generator = prop_oneof![ Just(MutationType::Splice), @@ -724,6 +761,7 @@ impl WorkerCorpus { last_sync_metrics: Default::default(), optimization_best_value: seed.optimization_best_value, optimization_best_sequence: seed.optimization_best_sequence, + gas_fuzz, } } @@ -1389,6 +1427,7 @@ impl WorkerCorpus { coverage, true, false, + self.gas_fuzz, )?; let sync_path = &entry.path; @@ -1700,6 +1739,7 @@ mod tests { target: Address::ZERO, calldata: Bytes::new(), value: None, + gas_limit: None, }, } } @@ -1721,7 +1761,13 @@ mod tests { } fn worker_corpus(id: usize, corpus_root: PathBuf, seed: WorkerCorpusSeed) -> WorkerCorpus { - WorkerCorpus::from_seed(id, corpus_config(corpus_root), Just(basic_tx()).boxed(), seed) + WorkerCorpus::from_seed( + id, + corpus_config(corpus_root), + Just(basic_tx()).boxed(), + seed, + false, + ) } fn empty_worker_corpus(id: usize, corpus_root: PathBuf) -> WorkerCorpus { @@ -1755,6 +1801,7 @@ mod tests { target: Address::ZERO, calldata, value: None, + gas_limit: None, }, }; let cmp = CmpOperands { @@ -1875,10 +1922,7 @@ mod tests { 1, corpus_config(corpus_root), Just(basic_tx()).boxed(), - None, - None, - None, - None, + WorkerCorpusReplayConfig::default(), ) .unwrap(); @@ -1921,8 +1965,13 @@ mod tests { optimization_best_sequence: tx_seq, }; - let manager = - WorkerCorpus::from_seed(1, corpus_config(corpus_root), Just(basic_tx()).boxed(), seed); + let manager = WorkerCorpus::from_seed( + 1, + corpus_config(corpus_root), + Just(basic_tx()).boxed(), + seed, + false, + ); assert_eq!(manager.in_memory_corpus.len(), 1); assert_eq!(manager.history_map, vec![1, 2, 3]); diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 6887830c4ebfa..75344f89480e3 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -1,6 +1,6 @@ use crate::executors::{ DURATION_BETWEEN_METRICS_REPORT, EarlyExit, Executor, FuzzTestTimer, RawCallResult, - corpus::{GlobalCorpusMetrics, WorkerCorpus}, + corpus::{GlobalCorpusMetrics, WorkerCorpus, WorkerCorpusReplayConfig}, }; use alloy_dyn_abi::JsonAbiExt; use alloy_json_abi::Function; @@ -284,6 +284,7 @@ impl FuzzedExecutor { target: address, calldata: calldata.clone(), value: None, + gas_limit: None, }, }], &[cmp_values], @@ -459,18 +460,24 @@ impl FuzzedExecutor { warp: None, roll: None, sender: Default::default(), - call_details: CallDetails { target: Default::default(), calldata, value: None }, + call_details: CallDetails { + target: Default::default(), + calldata, + value: None, + gas_limit: None, + }, }); let mut corpus = WorkerCorpus::new( worker_id, self.config.corpus.clone(), strategy.boxed(), - // Master worker replays the persisted corpus using the executor - (worker_id == 0).then_some(&self.executor_f), - Some(func), - None, // fuzzed_contracts for invariant tests - None, // dynamic target ctx (invariant-only) + WorkerCorpusReplayConfig { + // Master worker replays the persisted corpus using the executor. + executor: (worker_id == 0).then_some(&self.executor_f), + fuzzed_function: Some(func), + ..Default::default() + }, )?; let mut executor = self.executor_f.clone(); diff --git a/crates/evm/evm/src/executors/invariant/campaign.rs b/crates/evm/evm/src/executors/invariant/campaign.rs index 840c04848e316..430ccfa68516b 100644 --- a/crates/evm/evm/src/executors/invariant/campaign.rs +++ b/crates/evm/evm/src/executors/invariant/campaign.rs @@ -469,6 +469,7 @@ mod tests { target: Address::repeat_byte(sender.wrapping_add(1)), calldata: Bytes::from(vec![0, 0, 0, sender]), value: None, + gas_limit: None, }, } } @@ -679,7 +680,7 @@ mod tests { 3, 0x30, "transfer(address)", - InvariantMetrics { calls: 3, reverts: 1, discards: 0 }, + InvariantMetrics { calls: 3, reverts: 1, discards: 0, ..Default::default() }, 3, 0, ), @@ -690,7 +691,7 @@ mod tests { 1, 0x10, "transfer(address)", - InvariantMetrics { calls: 1, reverts: 0, discards: 2 }, + InvariantMetrics { calls: 1, reverts: 0, discards: 2, ..Default::default() }, 1, 4, ), @@ -701,7 +702,7 @@ mod tests { 2, 0x20, "approve(address)", - InvariantMetrics { calls: 2, reverts: 1, discards: 1 }, + InvariantMetrics { calls: 2, reverts: 1, discards: 1, ..Default::default() }, 2, 0, ), @@ -716,9 +717,15 @@ mod tests { assert_eq!(result.last_run_inputs[0].sender, Address::repeat_byte(0x30)); let transfer_metrics = result.metrics.get("transfer(address)").unwrap(); - assert_eq!(transfer_metrics, &InvariantMetrics { calls: 4, reverts: 1, discards: 2 }); + assert_eq!( + transfer_metrics, + &InvariantMetrics { calls: 4, reverts: 1, discards: 2, ..Default::default() } + ); let approve_metrics = result.metrics.get("approve(address)").unwrap(); - assert_eq!(approve_metrics, &InvariantMetrics { calls: 2, reverts: 1, discards: 1 }); + assert_eq!( + approve_metrics, + &InvariantMetrics { calls: 2, reverts: 1, discards: 1, ..Default::default() } + ); let coverage = result.line_coverage.unwrap(); assert_eq!(coverage.get(&B256::ZERO).unwrap().get(7).unwrap().get(), 6); diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 7ba300e3d0dbb..7292b9c094425 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -150,6 +150,30 @@ pub struct InvariantMetrics { pub reverts: usize, // Count of fuzzed selector discards (through assume cheatcodes). pub discards: usize, + // Max `gas_used` for a successful call. Populated only when + // `invariant.gas_fuzz = true`; `0` otherwise. + #[serde(default, skip_serializing_if = "is_zero_u64")] + pub max_gas: u64, + // Run prefix up to and including the call that produced `max_gas`. + // Acts as a reproducer; updated whenever a strict new max is observed. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub max_gas_sequence: Vec, +} + +const fn is_zero_u64(v: &u64) -> bool { + *v == 0 +} + +#[derive(Default)] +struct InvariantMetricState { + metrics: InvariantMetrics, + max_gas_run: Option, +} + +struct MaxGasRun { + run_id: u32, + prefix_len: usize, + inputs: Option>, } /// Campaign-level throughput metrics for invariant progress reporting. @@ -369,7 +393,7 @@ struct InvariantTestData { // Line coverage information collected from all fuzzed calls. line_coverage: Option, // Metrics for each fuzzed selector. - metrics: Map, + metrics: Map, // Proptest runner to query for random values. // The strategy only comes with the first `input`. We fill the rest of the `inputs` @@ -439,25 +463,45 @@ impl InvariantTest { /// Update metrics for a fuzzed selector, extracted from tx details. /// Always increments number of calls; discarded runs (through assume cheatcodes) are tracked /// separated from reverts. - fn record_metrics(&mut self, tx_details: &BasicTxDetails, reverted: bool, discarded: bool) { + fn record_metrics( + &mut self, + tx_details: &BasicTxDetails, + reverted: bool, + discarded: bool, + gas_used: u64, + max_gas_run: Option<(u32, usize)>, + ) { if let Some(metric_key) = self.targeted_contracts.targets().fuzzed_metric_key(tx_details) { let test_metrics = &mut self.test_data.metrics; - let invariant_metrics = test_metrics.entry(metric_key).or_default(); + let metric_state = test_metrics.entry(metric_key).or_default(); + let invariant_metrics = &mut metric_state.metrics; invariant_metrics.calls += 1; if discarded { invariant_metrics.discards += 1; } else if reverted { invariant_metrics.reverts += 1; + } else if gas_used > invariant_metrics.max_gas { + invariant_metrics.max_gas = gas_used; + if let Some((run_id, prefix_len)) = max_gas_run { + metric_state.max_gas_run = Some(MaxGasRun { run_id, prefix_len, inputs: None }); + } } } } /// End invariant test run by collecting results, cleaning collected artifacts and reverting /// created fuzz state. - fn end_run(&mut self, run: InvariantTestRun, gas_samples: usize) { + fn end_run( + &mut self, + run_id: u32, + run: InvariantTestRun, + gas_samples: usize, + ) { // We clear all the targeted contracts created during this run. self.targeted_contracts.clear_created_contracts(run.created_contracts); + self.attach_max_gas_inputs(run_id, &run.inputs); + if self.test_data.gas_report_traces.len() < gas_samples { self.test_data .gas_report_traces @@ -470,6 +514,18 @@ impl InvariantTest { self.fuzz_state.revert(); } + fn attach_max_gas_inputs(&mut self, run_id: u32, run_inputs: &[BasicTxDetails]) { + let mut max_gas_inputs = None; + for metric_state in self.test_data.metrics.values_mut() { + let Some(max_gas_run) = metric_state.max_gas_run.as_mut() else { continue }; + if max_gas_run.run_id == run_id && max_gas_run.inputs.is_none() { + let inputs = + max_gas_inputs.get_or_insert_with(|| Arc::<[BasicTxDetails]>::from(run_inputs)); + max_gas_run.inputs = Some(Arc::clone(inputs)); + } + } + } + /// Updates the optimization state if the new value is better (higher) than the current best. fn update_optimization_value(&mut self, value: I256, sequence: &[BasicTxDetails]) { if self.test_data.optimization_best_value.is_none_or(|best| value > best) { @@ -479,6 +535,20 @@ impl InvariantTest { } } +fn finalize_metrics(metrics: Map) -> Map { + metrics + .into_iter() + .map(|(key, mut state)| { + if let Some(max_gas_run) = state.max_gas_run + && let Some(inputs) = max_gas_run.inputs + { + state.metrics.max_gas_sequence = inputs[..max_gas_run.prefix_len].to_vec(); + } + (key, state.metrics) + }) + .collect() +} + /// Contains data for an invariant test run. struct InvariantTestRun { // Invariant run call sequence. @@ -665,6 +735,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { None, Some(&replay_targets), Some(self.dynamic_target_ctx()), + self.config.gas_fuzz, )?; let corpus_persistence = if actual_worker_count > 1 { InvariantCorpusPersistence::Deferred @@ -765,6 +836,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { fuzzed_contracts: Some(&replay_targets), dynamic: Some(&dynamic_target_ctx), }, + self.config.gas_fuzz, result .optimization_best_value .map(|value| (value, result.optimization_best_sequence.as_slice())), @@ -880,11 +952,17 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // assumes / pops below) get zero-length entries that the corpus side filters out. let call_cmp_values = call_result.evm_cmp_values.take().unwrap_or_default(); let discarded = call_result.result.as_ref() == MAGIC_ASSUME; - if config.show_metrics { + if config.show_metrics || config.gas_fuzz { + let gas_used_for_metric = + if config.gas_fuzz { call_result.gas_used } else { 0 }; + let max_gas_run_for_metric = + config.gas_fuzz.then_some((runs, current_run.inputs.len())); invariant_test.record_metrics( current_run.inputs.last().expect("checked above"), call_result.reverted, discarded, + gas_used_for_metric, + max_gas_run_for_metric, ); } @@ -912,6 +990,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { invariant_contract.anchor(), InvariantFuzzError::MaxAssumeRejects(config.max_assume_rejects), ); + invariant_test.attach_max_gas_inputs(runs, ¤t_run.inputs); campaign_state.request_terminal_stop(); break 'stop; } @@ -1141,7 +1220,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // End current invariant test run. current_run.drop_corpus_payloads(); - invariant_test.end_run(current_run, gas_report_samples); + invariant_test.end_run(runs, current_run, gas_report_samples); runs += 1; let total_runs = campaign_state.increment_runs(); debug_assert!( @@ -1228,6 +1307,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // target state behind; once shrinking is complete, only `test_data` is needed. let InvariantTest { fuzz_state: _, targeted_contracts: _, test_data: result } = invariant_test; + let metrics = finalize_metrics(result.metrics); let reverts = result.failures.reverts; let (errors, handler_errors) = result.failures.partition(); let worker_result = InvariantFuzzTestResult::new( @@ -1239,7 +1319,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { result.last_run_inputs, result.gas_report_traces, result.line_coverage, - result.metrics, + metrics, if plan.worker_id == 0 { corpus_manager.failed_replays } else { 0 }, 1, result.optimization_best_value, @@ -1432,6 +1512,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { config.corpus.clone(), strategy.boxed(), corpus_seed, + config.gas_fuzz, ); let mut invariant_test = diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index 2613437827f9a..05becc8c2389b 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -713,6 +713,7 @@ mod tests { target: Address::ZERO, calldata: Bytes::new(), value: None, + gas_limit: None, }, } } diff --git a/crates/evm/fuzz/Cargo.toml b/crates/evm/fuzz/Cargo.toml index 5629b17d936da..0edf264e11ec6 100644 --- a/crates/evm/fuzz/Cargo.toml +++ b/crates/evm/fuzz/Cargo.toml @@ -51,6 +51,9 @@ serde.workspace = true thiserror.workspace = true tracing.workspace = true +[dev-dependencies] +serde_json.workspace = true + [features] default = ["optimism"] optimism = [ diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index b35f1f970df11..b85ffac863111 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -55,7 +55,7 @@ impl FuzzRunMetadata { } /// Details of a transaction generated by fuzz strategy for fuzzing a target. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct BasicTxDetails { /// Time (in seconds) to increase block timestamp before executing the tx. #[serde(default, skip_serializing_if = "Option::is_none")] @@ -71,7 +71,7 @@ pub struct BasicTxDetails { } /// Call details of a transaction generated to fuzz. -#[derive(Clone, Debug, Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct CallDetails { /// Address of target contract. pub target: Address, @@ -81,6 +81,11 @@ pub struct CallDetails { /// Uses `#[serde(default)]` for backwards compatibility with existing corpus files. #[serde(default, skip_serializing_if = "Option::is_none")] pub value: Option, + /// Per-call `tx.gas_limit` override stamped by the `invariant.gas_fuzz` + /// sampler. `None` outside `gas_fuzz`. Persisted in the corpus so failing + /// sequences replay at the exact gas envelope that triggered them. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gas_limit: Option, } impl BasicTxDetails { @@ -114,6 +119,9 @@ pub struct BaseCounterExample { /// Ether value sent with the call. #[serde(default, skip_serializing_if = "Option::is_none")] pub value: Option, + /// Optional transaction gas limit override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gas_limit: Option, /// Contract name if it exists. pub contract_name: Option, /// Function name if it exists. @@ -147,6 +155,7 @@ impl BaseCounterExample { let target = tx.call_details.target; let bytes = &tx.call_details.calldata; let value = tx.call_details.value; + let gas_limit = tx.call_details.gas_limit; let warp = tx.warp; let roll = tx.roll; if let Some((name, abi)) = &contracts.get(&target) @@ -161,6 +170,7 @@ impl BaseCounterExample { addr: Some(target), calldata: bytes.clone(), value, + gas_limit, contract_name: Some(name.clone()), func_name: Some(func.name.clone()), signature: Some(func.signature()), @@ -182,6 +192,7 @@ impl BaseCounterExample { addr: Some(target), calldata: bytes.clone(), value, + gas_limit, contract_name: None, func_name: None, signature: None, @@ -206,6 +217,7 @@ impl BaseCounterExample { addr: None, calldata: bytes, value: None, + gas_limit: None, contract_name: None, func_name: None, signature: None, @@ -497,3 +509,35 @@ pub fn fixture_name(function_name: String) -> String { fn normalize_fixture(param_name: &str) -> String { param_name.trim_matches('_').to_ascii_lowercase() } + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + + #[test] + fn gas_limit_counterexample_serializes_as_json_number() { + let tx = BasicTxDetails { + warp: None, + roll: None, + sender: Address::ZERO, + call_details: CallDetails { + target: address!("00000000000000000000000000000000000000aa"), + calldata: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]), + value: None, + gas_limit: Some(123_456), + }, + }; + + let counterexample = BaseCounterExample::from_invariant_call( + &tx, + &ContractsByAddress::default(), + None, + false, + ); + let value = serde_json::to_value(&counterexample).unwrap(); + + assert_eq!(value["gas_limit"], serde_json::json!(123_456_u64)); + assert!(serde_json::from_value::(value).is_ok()); + } +} diff --git a/crates/evm/fuzz/src/strategies/gas_sampler.rs b/crates/evm/fuzz/src/strategies/gas_sampler.rs new file mode 100644 index 0000000000000..22a3b3e588575 --- /dev/null +++ b/crates/evm/fuzz/src/strategies/gas_sampler.rs @@ -0,0 +1,38 @@ +//! Per-call `tx.gas_limit` sampler for invariant fuzzing. +//! +//! Defaults the per-call gas limit to the EIP-7825 transaction cap (`2^24` = +//! 16_777_216) and samples uniformly in `[2^24, 2^25)`. Calibration-free, no +//! per-(target, selector) state, no observations map. +//! +//! Lifting the executor's natural limit out of the call path varies the gas +//! envelope across the EIP-7825 cap on every call, exercising gas-conditional +//! EVM dispatch (refund accounting, EIP-150 1/64 retention, OOG dispatch at +//! the cap) that a fixed natural limit cannot. + +use rand::Rng; + +/// EIP-7825 transaction gas-limit cap. +pub const TX_GAS_CAP: u64 = 1 << 24; + +/// Sample a per-call `tx.gas_limit` uniformly in `[TX_GAS_CAP, TX_GAS_CAP * 2)`. +pub fn sample_gas_limit(rng: &mut R) -> u64 { + rng.random_range(TX_GAS_CAP..(TX_GAS_CAP * 2)) +} + +#[cfg(test)] +mod tests { + use super::*; + use rand::{SeedableRng, rngs::StdRng}; + + #[test] + fn sampled_gas_limit_stays_in_range() { + let mut rng = StdRng::seed_from_u64(0xAA); + for _ in 0..10_000 { + let g = sample_gas_limit(&mut rng); + assert!( + (TX_GAS_CAP..TX_GAS_CAP * 2).contains(&g), + "sampled gas_limit {g} outside [2^24, 2^25)", + ); + } + } +} diff --git a/crates/evm/fuzz/src/strategies/invariants.rs b/crates/evm/fuzz/src/strategies/invariants.rs index d4962436b272d..ce365f87c7fe7 100644 --- a/crates/evm/fuzz/src/strategies/invariants.rs +++ b/crates/evm/fuzz/src/strategies/invariants.rs @@ -176,6 +176,6 @@ pub fn fuzz_contract_with_calldata( (calldata_strategy, value_strategy).prop_map(move |(calldata, value)| { trace!(input=?calldata, ?value); - CallDetails { target, calldata, value } + CallDetails { target, calldata, value, gas_limit: None } }) } diff --git a/crates/evm/fuzz/src/strategies/mod.rs b/crates/evm/fuzz/src/strategies/mod.rs index a5fe6188709bd..8ef9dad8e6775 100644 --- a/crates/evm/fuzz/src/strategies/mod.rs +++ b/crates/evm/fuzz/src/strategies/mod.rs @@ -24,3 +24,6 @@ pub use mutators::BoundMutator; mod literals; pub use literals::{LiteralMaps, LiteralsCollector, LiteralsDictionary}; + +mod gas_sampler; +pub use gas_sampler::sample_gas_limit; diff --git a/crates/forge/src/cmd/test/mod.rs b/crates/forge/src/cmd/test/mod.rs index c067feeeffa2a..5012ab9074f06 100644 --- a/crates/forge/src/cmd/test/mod.rs +++ b/crates/forge/src/cmd/test/mod.rs @@ -1340,6 +1340,7 @@ impl TestArgs { let known_contracts = runner.known_contracts.clone(); let libraries = runner.libraries.clone(); + let block_gas_limit = runner.evm_opts.gas_limit(); // Capture multi-pass state before moving `runner` into the spawn task. // In multi-pass mode the per-pass summary is suppressed; the merged summary is @@ -1446,10 +1447,17 @@ impl TestArgs { sh_println!("{}", result.short_result_with_suite(name, &contract_name))?; // Display invariant metrics if invariant kind. - if let TestKind::Invariant { metrics, .. } = &result.kind + if let TestKind::Invariant { metrics, gas_fuzz, .. } = &result.kind && !metrics.is_empty() { - let _ = sh_println!("\n{}\n", format_invariant_metrics_table(metrics)); + let _ = sh_println!( + "\n{}\n", + format_invariant_metrics_table( + metrics, + Some(block_gas_limit), + *gas_fuzz + ) + ); } // We only display logs at level 2 and above diff --git a/crates/forge/src/cmd/test/summary.rs b/crates/forge/src/cmd/test/summary.rs index a0123e896d0bf..9fee4aa4e0fc5 100644 --- a/crates/forge/src/cmd/test/summary.rs +++ b/crates/forge/src/cmd/test/summary.rs @@ -139,6 +139,8 @@ impl TestSummaryReport { /// ╰-----------------------+----------------+-------+---------+----------╯ pub(crate) fn format_invariant_metrics_table( test_metrics: &HashMap, + block_gas_limit: Option, + show_max_gas: bool, ) -> Table { let mut table = Table::new(); if shell::is_markdown() { @@ -147,13 +149,22 @@ pub(crate) fn format_invariant_metrics_table( table.apply_modifier(UTF8_ROUND_CORNERS); } - table.set_header(vec![ + let show_max_gas = show_max_gas || test_metrics.values().any(|m| m.max_gas > 0); + + let mut header = vec![ Cell::new("Contract"), Cell::new("Selector"), Cell::new("Calls").fg(Color::Green), Cell::new("Reverts").fg(Color::Red), Cell::new("Discards").fg(Color::Yellow), - ]); + ]; + if show_max_gas { + header.push(Cell::new("Max Gas").fg(Color::Cyan)); + } + table.set_header(header); + + let render_max_gas = + |max_gas: u64| -> Cell { Cell::new(max_gas).fg(max_gas_color(max_gas, block_gas_limit)) }; for name in test_metrics.keys().sorted() { if let Some((contract, selector)) = @@ -185,6 +196,10 @@ pub(crate) fn format_invariant_metrics_table( row.add_cell(calls_cell); row.add_cell(reverts_cell); row.add_cell(discards_cell); + + if show_max_gas { + row.add_cell(render_max_gas(metrics.max_gas)); + } } table.add_row(row); @@ -193,6 +208,26 @@ pub(crate) fn format_invariant_metrics_table( table } +/// Warn (yellow) when a single selector burns >= this % of the block gas limit. +const GAS_WARN_PCT: u64 = 25; +/// Danger (red) when it crosses this % — DoS risk territory. +const GAS_DANGER_PCT: u64 = 50; + +/// Color for a `max_gas` cell by share of `block_gas_limit`. +fn max_gas_color(max_gas: u64, block_gas_limit: Option) -> Color { + if max_gas == 0 { + return Color::White; + } + let Some(cap) = block_gas_limit.filter(|c| *c > 0) else { + return Color::Cyan; + }; + match max_gas.saturating_mul(100) / cap { + p if p >= GAS_DANGER_PCT => Color::Red, + p if p >= GAS_WARN_PCT => Color::Yellow, + _ => Color::Cyan, + } +} + #[cfg(test)] mod tests { use crate::cmd::test::summary::format_invariant_metrics_table; @@ -204,13 +239,13 @@ mod tests { let mut test_metrics = HashMap::new(); test_metrics.insert( "SystemConfig.setGasLimit".to_string(), - InvariantMetrics { calls: 10, reverts: 1, discards: 1 }, + InvariantMetrics { calls: 10, reverts: 1, discards: 1, ..Default::default() }, ); test_metrics.insert( "src/universal/Proxy.sol:Proxy.changeAdmin".to_string(), - InvariantMetrics { calls: 20, reverts: 2, discards: 2 }, + InvariantMetrics { calls: 20, reverts: 2, discards: 2, ..Default::default() }, ); - let table = format_invariant_metrics_table(&test_metrics); + let table = format_invariant_metrics_table(&test_metrics, None, false); assert_eq!(table.row_count(), 2); let mut first_row_content = table.row(0).unwrap().cell_iter(); @@ -227,4 +262,31 @@ mod tests { assert_eq!(second_row_content.next().unwrap().content(), "2"); assert_eq!(second_row_content.next().unwrap().content(), "2"); } + + #[test] + fn max_gas_cell_color_by_share_of_block_gas() { + use crate::cmd::test::summary::max_gas_color; + use comfy_table::Color; + + let cap = Some(10_000_000_u64); + assert_eq!(max_gas_color(6_000_000, cap), Color::Red); // 60% → danger + assert_eq!(max_gas_color(3_000_000, cap), Color::Yellow); // 30% → warn + assert_eq!(max_gas_color(500_000, cap), Color::Cyan); // 5% → safe + assert_eq!(max_gas_color(0, cap), Color::White); + // Without a cap, gas is always cyan (no warning available). + assert_eq!(max_gas_color(6_000_000, None), Color::Cyan); + } + + #[test] + fn max_gas_column_can_be_forced_when_all_maxes_are_zero() { + let mut test_metrics = HashMap::new(); + test_metrics.insert( + "SystemConfig.setGasLimit".to_string(), + InvariantMetrics { calls: 10, reverts: 10, discards: 0, ..Default::default() }, + ); + + let table = format_invariant_metrics_table(&test_metrics, None, true); + let mut row = table.row(0).unwrap().cell_iter(); + assert_eq!(row.nth(5).unwrap().content(), "0"); + } } diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index c75c055c2a5a4..f0a6bd3a90928 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -303,6 +303,7 @@ mod tests { reverts: 0, workers: *workers, metrics: Map::new(), + gas_fuzz: false, failed_corpus_replays: 0, optimization_best_value: None, }, @@ -1122,6 +1123,7 @@ impl TestResult { reverts: 1, workers: default_invariant_workers(), metrics: HashMap::default(), + gas_fuzz: false, failed_corpus_replays: 0, optimization_best_value: None, }; @@ -1147,6 +1149,7 @@ impl TestResult { reverts: 1, workers: default_invariant_workers(), metrics: HashMap::default(), + gas_fuzz: false, failed_corpus_replays: 0, optimization_best_value: None, }; @@ -1169,6 +1172,7 @@ impl TestResult { reverts: 0, workers: default_invariant_workers(), metrics: HashMap::default(), + gas_fuzz: false, failed_corpus_replays: 0, optimization_best_value: None, }; @@ -1192,6 +1196,7 @@ impl TestResult { calls: usize, reverts: usize, metrics: Map, + gas_fuzz: bool, failed_corpus_replays: usize, workers: usize, optimization_best_value: Option, @@ -1202,6 +1207,7 @@ impl TestResult { reverts, workers: workers.max(1), metrics, + gas_fuzz, failed_corpus_replays, optimization_best_value, }; @@ -1520,6 +1526,8 @@ pub enum TestKind { #[serde(default = "default_invariant_workers")] workers: usize, metrics: Map, + #[serde(default)] + gas_fuzz: bool, failed_corpus_replays: usize, /// For optimization mode (int256 return): the best value achieved. None = check mode. optimization_best_value: Option, @@ -1592,6 +1600,7 @@ impl TestKind { reverts, workers: _, metrics: _, + gas_fuzz: _, failed_corpus_replays, optimization_best_value, } => TestKindReport::Invariant { diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index 30605e905727a..3cb96e662b5d0 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -1862,6 +1862,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { invariant_result.calls, invariant_result.reverts, invariant_result.metrics, + invariant_config.gas_fuzz, invariant_result.failed_corpus_replays, invariant_result.workers, invariant_result.optimization_best_value, @@ -2245,6 +2246,7 @@ fn base_counterexamples_to_txes( target: seq.addr.unwrap_or_default(), calldata: seq.calldata.clone(), value: seq.value, + gas_limit: seq.gas_limit, }, } }) diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index de07253780817..ba6c3e0c5c120 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -247,6 +247,7 @@ failure_persist_dir = "cache/invariant" show_metrics = true show_solidity = false check_interval = 1 +gas_fuzz = false [coverage] report = ["summary"] @@ -1473,7 +1474,8 @@ forgetest_init!(test_default_config, |prj, cmd| { "show_solidity": false, "max_time_delay": null, "max_block_delay": null, - "check_interval": 1 + "check_interval": 1, + "gas_fuzz": false }, "symbolic": { "enabled": false, diff --git a/crates/forge/tests/cli/test_cmd/invariant/gas.rs b/crates/forge/tests/cli/test_cmd/invariant/gas.rs new file mode 100644 index 0000000000000..c9f7029e0377e --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/invariant/gas.rs @@ -0,0 +1,183 @@ +use super::*; + +// `gas_fuzz = true` adds a "Max Gas" column to the metrics table. +forgetest_init!(should_show_max_gas_column_when_gas_fuzz_enabled, |prj, cmd| { + prj.add_test( + "GasFuzzColumnTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract GasFuzzHandler is Test { + uint256 public n; + + // Storage write keeps gas_used > 0 so `max_gas` becomes populated. + function bump(uint256 a) public { + n = a; + } +} + +contract GasFuzzColumnTest is Test { + function setUp() public { + new GasFuzzHandler(); + } + + /// forge-config: default.invariant.runs = 3 + /// forge-config: default.invariant.depth = 5 + /// forge-config: default.invariant.show-metrics = true + /// forge-config: default.invariant.gas-fuzz = true + function invariant_gas_fuzz_column() public {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_gas_fuzz_column"]).assert_success().stdout_eq(str![[r#" +... +[PASS] invariant_gas_fuzz_column() (runs: 3, calls: [..], reverts: [..]) + +[..] +| Contract | Selector | Calls | Reverts | Discards | Max Gas | +[..] +| GasFuzzHandler | bump |[..] +[..] +... +"#]]); +}); + +// Without `gas_fuzz`, the metrics table stays at the legacy 5 columns. +// Pinning the header verbatim is the absence-check. +forgetest_init!(should_not_show_max_gas_column_by_default, |prj, cmd| { + prj.add_test( + "GasFuzzAbsentTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract NoGasFuzzHandler is Test { + uint256 public n; + function bump(uint256 a) public { n = a; } +} + +contract GasFuzzAbsentTest is Test { + function setUp() public { + new NoGasFuzzHandler(); + } + + /// forge-config: default.invariant.runs = 3 + /// forge-config: default.invariant.depth = 5 + /// forge-config: default.invariant.show-metrics = true + function invariant_no_gas_fuzz() public {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_no_gas_fuzz"]).assert_success().stdout_eq(str![[r#" +... +[PASS] invariant_no_gas_fuzz() (runs: 3, calls: [..], reverts: [..]) + +[..] +| Contract | Selector | Calls | Reverts | Discards | +[..] +| NoGasFuzzHandler | bump |[..] +[..] +... +"#]]); +}); + +// The GovernMental 2016 gas-DoS repro should surface the hot selector in the +// gas-fuzz metrics table. +forgetest_init!(should_surface_gas_dos_on_governmental_repro, |prj, cmd| { + prj.add_source( + "GovernMental.sol", + r#" +contract GovernMental { + struct Creditor { address addr; uint256 amount; } + Creditor[] public creditors; + uint256 public totalOwed; + + function lend(uint256 amount) external { + require(amount > 0, "zero"); + creditors.push(Creditor({ addr: msg.sender, amount: amount })); + totalOwed += amount; + } + + function payout() external { + uint256 n = creditors.length; + uint256 paid; + for (uint256 i = 0; i < n; i++) { + paid += creditors[i].amount; + creditors[i].amount = 0; + } + delete creditors; + totalOwed = 0; + } +} + "#, + ); + + prj.add_test( + "GovernMentalReproTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; +import {GovernMental} from "../src/GovernMental.sol"; + +contract BuggyHandler is Test { + GovernMental public target; + constructor(GovernMental _t) { target = _t; } + function lend(uint256 a) external { target.lend(bound(a, 1, 1e18)); } + function bulkPayout() external { target.payout(); } +} + +contract GovernMentalReproTest is Test { + GovernMental internal underlying; + BuggyHandler internal handler; + + function setUp() public { + underlying = new GovernMental(); + handler = new BuggyHandler(underlying); + for (uint256 i = 0; i < 200; i++) { + vm.prank(address(uint160(0x1000 + i))); + underlying.lend(1 ether); + } + targetContract(address(handler)); + } + + /// forge-config: default.invariant.runs = 3 + /// forge-config: default.invariant.depth = 2000 + /// forge-config: default.invariant.show-metrics = true + /// forge-config: default.invariant.gas-fuzz = true + function invariant_alwaysTrue() public pure { assert(true); } +} + "#, + ); + + let stdout = cmd + .args(["test", "--mt", "invariant_alwaysTrue"]) + .assert_success() + .get_output() + .stdout_lossy(); + + let row = stdout + .lines() + .find(|l| l.contains("bulkPayout") && l.contains('|')) + .unwrap_or_else(|| panic!("no bulkPayout metrics row found:\n{stdout}")); + + let cells = row.split('|').map(str::trim).filter(|s| !s.is_empty()).collect::>(); + let calls: u64 = cells + .get(2) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| panic!("could not parse Calls from row: {row:?}")); + let _max_gas: u64 = cells + .last() + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| panic!("could not parse Max Gas from row: {row:?}")); + assert!(calls > 0, "expected bulkPayout to be called, got row: {row:?}"); + assert!( + stdout + .lines() + .any(|l| l.contains("Contract") && l.contains("Selector") && l.contains("Max Gas")), + "expected Max Gas header in metrics table:\n{stdout}", + ); + assert!( + row.split('|').map(str::trim).filter(|s| !s.is_empty()).count() >= 6, + "expected bulkPayout row to include Max Gas cell, got row: {row:?}", + ); +}); diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index a40e31c7cd58d..7676c60bed0d2 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -1,7 +1,10 @@ use alloy_primitives::U256; -use foundry_test_utils::{TestCommand, forgetest_init, snapbox::cmd::OutputAssert, str}; +use foundry_test_utils::{ + TestCommand, forgetest_init, snapbox::cmd::OutputAssert, str, util::OutputExt, +}; mod common; +mod gas; mod handler; mod storage; mod target;