From 3dfb5485618a354082da1d9fa956cea83a9339ee Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Mon, 25 May 2026 14:47:49 +0300 Subject: [PATCH 01/20] feat(invariant): gas-envelope fuzzing via `invariant.gas_fuzz` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a single `invariant.gas_fuzz = false` config flag that, when enabled, turns on per-call gas envelope randomization, per-selector max-gas tracking, and a colored "Max Gas" column in the metrics table. Config - New `gas_fuzz: bool` field on `InvariantConfig` (default false; no behavior change for existing tests). Sampler (`crates/evm/fuzz/src/strategies/gas_sampler.rs`) - `GasObservations`: campaign-scoped `(target, selector) -> max gas_used` tracker, `Arc>` so it's cheap to share. - `sample_gas_limit`: razor-power-law (k=4) biased toward the OOG edge (15% natural / 80% [50%-100%) of max / 5% [10%-30%) of max). - `sample_gas_price`: calibration-free 40% zero / 40% [1, 1e10) wei / 20% [1e10, 1e12) wei. - Floors at 50_000 gas; skips selectors whose observed max is under 100k. Tx plumbing (`fuzz/src/lib.rs`, `invariant/mod.rs`, etc.) - `CallDetails.gas_limit: Option` added so a sampled per-call limit rides with the tx all the way to `execute_tx` and into the corpus. - `PartialEq, Eq` on `BasicTxDetails` / `CallDetails` so corpus replay can deduplicate. Runner (`invariant/mod.rs`) - Per call when `gas_fuzz` is on: sample `tx.gas_limit` and stamp it on `call_details`; sample `tx.gasprice` and stuff it into `cheatcodes.gas_price` (one-shot slot consumed in `initialize_interp`). - `execute_tx`: save/restore the executor's natural gas limit around `call_raw` so invariant assertion calls aren't starved. - Only natural-gas runs (no override) feed the observation tracker, so truncated-OOG `gas_used` doesn't poison the running max. Metrics & summary (`InvariantMetrics`, `cmd/test/summary.rs`) - New `max_gas: u64` and `max_gas_sequence: Vec` fields, both `skip_serializing_if` so they're absent in JSON when gas_fuzz=false. - Sequence snapshot is the run prefix up to the strict-new-max call — acts as a reproducer for power-users who pipe `--json` to dashboards. - Table grows a "Max Gas" column only when at least one selector has `max_gas > 0`; the legacy 5-column layout is unchanged otherwise. Tests (`crates/forge/tests/cli/test_cmd/invariant/gas.rs`) - Dedicated `gas` submodule of the invariant CLI test suite. - Column presence: snapshot tests for the Max Gas column being shown when `gas_fuzz = true` and absent (legacy 5-column layout) when off. - GovernMental 2016 gas-DoS repro: asserts `bulkPayout` Max Gas > 500k (parsed imperatively; snapbox can't express a numeric lower bound). - Proof-of-value for the active OOG-driving strategy: a swallowed inner-OOG state-corruption bug where the inner SSTORE loop OOGs and the outer keeps its retained 1/64 gas and silently advances `debited` without the matching `credited` write. Paired tests show the invariant fires under `gas_fuzz = true` and passes (16x200 calls) under `gas_fuzz = false` on the same harness. Comparison with Echidna / Medusa - New mode: **driving execution into OOG windows as a fuzz strategy**. Echidna's `estimateGas` and Medusa's gas reporting are passive observers — they report peak gas. We additionally use gas as an active fuzz axis: the razor-power-law sampler deliberately starves calls just below their observed natural cost, exposing the OOG-induced state-corruption / swallowed-error bug class that neither tool reaches today (the buggy sub-step OOGs while the outer call proceeds, so any user invariant about post-conditions catches the divergence). Foundry's existing `int256`-return optimization mode (`is_optimization_invariant`) covers return-value maximization the same way Medusa does, but neither it nor Medusa actively probes the OOG envelope. - Single `gas_fuzz` flag turns on what Echidna splits across `estimateGas` and `maxGasprice` plus the gas-DoS reporting workflow. --- Cargo.lock | 1 + crates/config/src/invariant.rs | 10 + crates/evm/evm/Cargo.toml | 1 + crates/evm/evm/src/executors/corpus.rs | 2 + crates/evm/evm/src/executors/fuzz/mod.rs | 8 +- crates/evm/evm/src/executors/invariant/mod.rs | 112 ++++++- .../evm/evm/src/executors/invariant/shrink.rs | 1 + crates/evm/fuzz/src/lib.rs | 11 +- crates/evm/fuzz/src/strategies/gas_sampler.rs | 200 +++++++++++ crates/evm/fuzz/src/strategies/invariants.rs | 2 +- crates/evm/fuzz/src/strategies/mod.rs | 3 + crates/forge/src/cmd/test/mod.rs | 6 +- crates/forge/src/cmd/test/summary.rs | 59 +++- crates/forge/src/runner.rs | 1 + crates/forge/tests/cli/config.rs | 4 +- .../forge/tests/cli/test_cmd/invariant/gas.rs | 311 ++++++++++++++++++ .../forge/tests/cli/test_cmd/invariant/mod.rs | 5 +- 17 files changed, 716 insertions(+), 21 deletions(-) create mode 100644 crates/evm/fuzz/src/strategies/gas_sampler.rs create mode 100644 crates/forge/tests/cli/test_cmd/invariant/gas.rs diff --git a/Cargo.lock b/Cargo.lock index 80968c05636c5..91c86f912e759 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5284,6 +5284,7 @@ dependencies = [ "indicatif", "parking_lot", "proptest", + "rand 0.9.4", "rayon", "revm", "revm-inspectors", diff --git a/crates/config/src/invariant.rs b/crates/config/src/invariant.rs index 15f8d4608ac5f..70ac52a449518 100644 --- a/crates/config/src/invariant.rs +++ b/crates/config/src/invariant.rs @@ -49,6 +49,15 @@ 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` / `tx.gasprice` randomization, biased toward the OOG / + /// branch-on-gasprice bug regimes. + /// 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 { @@ -70,6 +79,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/Cargo.toml b/crates/evm/evm/Cargo.toml index 5dbf07c7a356c..f13daeca82779 100644 --- a/crates/evm/evm/Cargo.toml +++ b/crates/evm/evm/Cargo.toml @@ -53,6 +53,7 @@ tempo-primitives.workspace = true eyre.workspace = true parking_lot.workspace = true proptest.workspace = true +rand.workspace = true thiserror.workspace = true tracing.workspace = true indicatif.workspace = true diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 1f35301c2c533..55ab73e3cdeb0 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -1403,6 +1403,7 @@ mod tests { target: Address::ZERO, calldata: Bytes::new(), value: None, + gas_limit: None, }, } } @@ -1428,6 +1429,7 @@ mod tests { target: Address::ZERO, calldata, value: None, + gas_limit: None, }, }; let cmp = CmpOperands { diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index f597429b5ed66..86bde9de1d0a2 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -278,6 +278,7 @@ impl FuzzedExecutor { target: address, calldata: calldata.clone(), value: None, + gas_limit: None, }, }], &[cmp_values], @@ -450,7 +451,12 @@ 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( diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 3e2485a80731f..ba850cf918e1e 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -29,7 +29,10 @@ use foundry_evm_fuzz::{ ArtifactFilters, FuzzRunIdentifiedContracts, InvariantContract, InvariantSettings, RandomCallGenerator, SenderFilters, TargetedContract, TargetedContracts, }, - strategies::{EvmFuzzState, InvariantFuzzState, invariant_strat, override_call_strat}, + strategies::{ + EvmFuzzState, GasObservations, InvariantFuzzState, invariant_strat, override_call_strat, + sample_gas_limit, sample_gas_price, + }, }; use foundry_evm_traces::{CallTraceArena, SparsedTraceArena}; use indicatif::ProgressBar; @@ -127,6 +130,18 @@ 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 } /// Campaign-level throughput metrics for invariant progress reporting. @@ -326,10 +341,16 @@ impl InvariantTest { HitMaps::merge_opt(&mut self.test_data.line_coverage, new_coverage); } - /// 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) { + /// Update metrics for a fuzzed selector. On a successful call with + /// `gas_used > max_gas`, also snapshots `run_inputs` as the reproducer. + fn record_metrics( + &mut self, + tx_details: &BasicTxDetails, + reverted: bool, + discarded: bool, + gas_used: u64, + run_inputs: Option<&[BasicTxDetails]>, + ) { 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(); @@ -338,6 +359,11 @@ impl InvariantTest { 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(inputs) = run_inputs { + invariant_metrics.max_gas_sequence = inputs.to_vec(); + } } } } @@ -493,6 +519,10 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { let campaign_start = Instant::now(); let mut throughput = InvariantThroughputMetrics::default(); let mut failure_metrics = InvariantFailureMetrics::default(); + + // Campaign-scoped state for `invariant.gas_fuzz`. Unused when disabled. + let gas_observations = GasObservations::new(); + let block_gas_cap = self.executor.gas_limit(); let continue_campaign = |runs: u32| { if early_exit.should_stop() { return false; @@ -554,6 +584,30 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { (last.call_details.target, Selector::from(sel_bytes)) }; + // Per-call gas-envelope sampling. `None` from `sample_gas_limit` + // leaves `gas_limit` unset → executor's default budget applies. + // `gas_price` is applied via the cheatcodes one-shot slot. + if self.config.gas_fuzz { + let mut rng = rand::rng(); + let sampled = sample_gas_limit( + &gas_observations, + handler_target, + handler_selector, + block_gas_cap, + &mut rng, + ); + if let Some(g) = sampled + && let Some(last) = current_run.inputs.last_mut() + { + last.call_details.gas_limit = Some(g); + } + + let sampled_price = sample_gas_price(&mut rng); + if let Some(cheats) = self.executor.inspector_mut().cheatcodes.as_mut() { + cheats.gas_price = Some(sampled_price); + } + } + // Execute call from the randomly generated sequence without committing state. // State is committed only if call is not a magic assume. let mut call_result = execute_tx( @@ -568,11 +622,19 @@ 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 self.config.show_metrics { + if self.config.show_metrics || self.config.gas_fuzz { + // gas_used / run_inputs are only meaningful under `gas_fuzz`; + // `show_metrics` alone keeps the legacy table. + let gas_used_for_metric = + if self.config.gas_fuzz { call_result.gas_used } else { 0 }; + let run_inputs_for_metric = + self.config.gas_fuzz.then_some(current_run.inputs.as_slice()); invariant_test.record_metrics( current_run.inputs.last().expect("checked above"), call_result.reverted, discarded, + gas_used_for_metric, + run_inputs_for_metric, ); } @@ -649,6 +711,20 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { .push(FuzzCase { gas: call_result.gas_used, stipend: call_result.stipend }); throughput.record_call(call_result.gas_used); + // Only natural-gas runs feed the observation tracker; calls under + // a tight sampled budget may have OOG'd and would skew the max down. + if self.config.gas_fuzz + && call_result.gas_used > 0 + && let Some(last) = current_run.inputs.last() + && last.call_details.gas_limit.is_none() + { + gas_observations.record( + handler_target, + handler_selector, + call_result.gas_used, + ); + } + // Determine if test can continue or should exit. // Check invariants based on check_interval to improve deep run performance. // - check_interval=0: only assert on the last call @@ -1506,9 +1582,27 @@ pub(crate) fn execute_tx( let requested_value = tx.call_details.value.unwrap_or(U256::ZERO); let sender_balance = executor.get_balance(tx.sender)?; let value = requested_value.min(sender_balance); - executor - .call_raw(tx.sender, tx.call_details.target, tx.call_details.calldata.clone(), value) - .map_err(|e| eyre!(format!("Could not make raw evm call: {e}"))) + + // Per-call gas-limit override under `gas_fuzz`. Save/restore so the executor's + // natural limit applies to invariant assertion calls. + let saved_gas_limit = tx.call_details.gas_limit.map(|g| { + let saved = executor.gas_limit(); + executor.set_gas_limit(g); + saved + }); + + let result = executor.call_raw( + tx.sender, + tx.call_details.target, + tx.call_details.calldata.clone(), + value, + ); + + if let Some(saved) = saved_gas_limit { + executor.set_gas_limit(saved); + } + + result.map_err(|e| eyre!(format!("Could not make raw evm call: {e}"))) } #[cfg(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/src/lib.rs b/crates/evm/fuzz/src/lib.rs index fe6e36f746185..fbb73f55030a5 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,13 @@ pub struct CallDetails { /// Uses `#[serde(default)]` for backwards compatibility with existing corpus files. #[serde(default, skip_serializing_if = "Option::is_none")] pub value: Option, + /// Optional override for `tx.gas_limit`. Set by the gas-envelope sampler when + /// `invariant.gas_fuzz = true`. `None` means use the executor's default (typically + /// the block gas limit). Persisted in the corpus so failing sequences can be replayed + /// at the exact gas budget that triggered the failure. + /// Uses `#[serde(default)]` for backwards compatibility with existing corpus files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gas_limit: Option, } impl BasicTxDetails { 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..823fcbea51286 --- /dev/null +++ b/crates/evm/fuzz/src/strategies/gas_sampler.rs @@ -0,0 +1,200 @@ +//! Adaptive gas-envelope sampler for invariant fuzzing. +//! +//! Tracks observed `gas_used` per `(target, selector)` and samples per-call +//! `tx.gas_limit` biased to the "edge of feasibility" where OOG-induced bugs +//! become reachable. Calibration-free: observations accumulate online. +//! +//! Distribution per call (when an observation exists): +//! +//! | Bucket | Range | Purpose | +//! |--------|----------------------------------|--------------------------| +//! | 15% | `None` (natural) | Keep calibration current | +//! | 80% | razor: `[~0.5·max, max)`, k=4 | Hairline OOG band | +//! | 5% | `[max/10, max·3/10)` | DoS / griefing | + +use alloy_primitives::{Address, Selector}; +use parking_lot::RwLock; +use rand::Rng; +use std::{collections::HashMap, sync::Arc}; + +/// Floor for sampled gas — well above EVM intrinsic to avoid tx-validation noise. +const MIN_SAMPLED_GAS: u64 = 50_000; + +/// Skip sampling for cheap selectors (below the OOG-corruption regime). +const MIN_OBSERVED_FOR_SAMPLING: u64 = 100_000; + +/// Running max `gas_used` per `(target, selector)`. Cheaply cloneable. +#[derive(Clone, Debug, Default)] +pub struct GasObservations { + inner: Arc>>, +} + +impl GasObservations { + pub fn new() -> Self { + Self::default() + } + + /// Record `gas_used`; keeps the running max. + pub fn record(&self, target: Address, selector: Selector, gas_used: u64) { + if gas_used == 0 { + return; + } + let mut w = self.inner.write(); + let entry = w.entry((target, selector)).or_insert(0); + if gas_used > *entry { + *entry = gas_used; + } + } + + pub fn max_for(&self, target: Address, selector: Selector) -> Option { + self.inner.read().get(&(target, selector)).copied() + } +} + +/// Sample a per-call `gas_limit`. Returns `None` to use the executor's +/// natural limit (no observation yet, or the natural-gas bucket was rolled). +/// Sampled values are clamped to `block_cap`. +pub fn sample_gas_limit( + observations: &GasObservations, + target: Address, + selector: Selector, + block_cap: u64, + rng: &mut R, +) -> Option { + let max = observations.max_for(target, selector)?; + if max < MIN_OBSERVED_FOR_SAMPLING { + return None; + } + + let bucket: u8 = rng.random_range(0..100); + let raw = match bucket { + 0..15 => return None, + 15..95 => { + // Razor: power-law biased toward `max` (~32% within 1%, ~51% within 7%). + let u: f64 = rng.random_range(0.0..1.0); + let bite = ((max as f64) * u.powi(4)) as u64; + let lo = max / 2; + max.saturating_sub(bite).max(lo) + } + _ => { + // 10–30% of max — DoS / griefing band. + let lo = (max / 10).max(MIN_SAMPLED_GAS); + let hi = (max * 3 / 10).max(lo + 1); + rng.random_range(lo..hi) + } + }; + + Some(raw.max(MIN_SAMPLED_GAS).min(block_cap)) +} + +/// Sample a per-call `tx.gasprice`. Targets bugs that branch on gasprice +/// (refund accounting, gas-aware token logic). Calibration-free. +/// +/// | Bucket | Range | Purpose | +/// |--------|--------------------|----------------------------------| +/// | 40% | `0` | Baseline / refund accounting | +/// | 40% | `[1, 1e10)` wei | Low — typical L2 fee (~0–10 gwei)| +/// | 20% | `[1e10, 1e12)` wei | High — congested L1 band | +pub fn sample_gas_price(rng: &mut R) -> u128 { + let bucket: u8 = rng.random_range(0..100); + match bucket { + 0..40 => 0, + 40..80 => rng.random_range(1u128..10_000_000_000u128), + _ => rng.random_range(10_000_000_000u128..1_000_000_000_000u128), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloy_primitives::address; + use rand::{SeedableRng, rngs::StdRng}; + + fn target() -> Address { + address!("00000000000000000000000000000000000000aa") + } + + fn sel() -> Selector { + Selector::from([0xde, 0xad, 0xbe, 0xef]) + } + + #[test] + fn returns_none_without_observations() { + let obs = GasObservations::new(); + let mut rng = StdRng::seed_from_u64(42); + assert!(sample_gas_limit(&obs, target(), sel(), 30_000_000, &mut rng).is_none()); + } + + #[test] + fn records_running_max() { + let obs = GasObservations::new(); + obs.record(target(), sel(), 1_000); + obs.record(target(), sel(), 5_000); + obs.record(target(), sel(), 2_000); + assert_eq!(obs.max_for(target(), sel()), Some(5_000)); + } + + #[test] + fn samples_stay_within_block_cap() { + let obs = GasObservations::new(); + obs.record(target(), sel(), 4_730_082); // baseline from gist + let mut rng = StdRng::seed_from_u64(1); + let block_cap = 30_000_000_u64; + for _ in 0..10_000 { + if let Some(g) = sample_gas_limit(&obs, target(), sel(), block_cap, &mut rng) { + assert!(g >= MIN_SAMPLED_GAS, "gas {g} below intrinsic floor"); + assert!(g <= block_cap, "gas {g} exceeds block cap"); + } + } + } + + #[test] + fn gas_price_distribution_matches_buckets() { + let mut rng = StdRng::seed_from_u64(0xC0FFEE); + let mut zero = 0; + let mut low = 0; + let mut high = 0; + for _ in 0..10_000 { + let p = sample_gas_price(&mut rng); + if p == 0 { + zero += 1; + } else if p < 10_000_000_000 { + low += 1; + } else if p < 1_000_000_000_000 { + high += 1; + } else { + panic!("sampled gas_price {p} exceeded upper bucket cap"); + } + } + // Expected ~40/40/20 — allow a generous 5-pp tolerance. + assert!((35..=45).contains(&(zero / 100)), "zero bucket: {zero}/10000"); + assert!((35..=45).contains(&(low / 100)), "low bucket: {low}/10000"); + assert!((15..=25).contains(&(high / 100)), "high bucket: {high}/10000"); + } + + #[test] + fn distribution_hits_edge_band() { + // The 65% threshold from the gist (3_074_553 / 4_730_082 ≈ 0.65) must be + // reachable by the sampler — this is the band where the Tempo bug manifests. + let obs = GasObservations::new(); + let max = 4_730_082_u64; + obs.record(target(), sel(), max); + let mut rng = StdRng::seed_from_u64(7); + let mut hits_edge = 0; + let mut total = 0; + for _ in 0..10_000 { + if let Some(g) = sample_gas_limit(&obs, target(), sel(), 30_000_000, &mut rng) { + total += 1; + // 60-70% of max — the corruption window in the gist + if g >= max * 60 / 100 && g <= max * 70 / 100 { + hits_edge += 1; + } + } + } + assert!(total > 0); + // Edge band is ~10% of max. With 40% of samples in the [50%, 110%] range + // (uniform), the [60%, 70%] sub-band should get ~6-7% of samples. + let pct = hits_edge * 100 / total; + assert!(pct >= 3, "edge band hit rate too low: {pct}% ({hits_edge}/{total})"); + } +} 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..b1d333647eb50 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::{GasObservations, sample_gas_limit, sample_gas_price}; diff --git a/crates/forge/src/cmd/test/mod.rs b/crates/forge/src/cmd/test/mod.rs index c6bdfee4ef7c4..c2f24b0ee317f 100644 --- a/crates/forge/src/cmd/test/mod.rs +++ b/crates/forge/src/cmd/test/mod.rs @@ -697,6 +697,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 @@ -801,7 +802,10 @@ impl TestArgs { if let TestKind::Invariant { metrics, .. } = &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)) + ); } // 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..76b9651fbb1ee 100644 --- a/crates/forge/src/cmd/test/summary.rs +++ b/crates/forge/src/cmd/test/summary.rs @@ -139,6 +139,7 @@ impl TestSummaryReport { /// ╰-----------------------+----------------+-------+---------+----------╯ pub(crate) fn format_invariant_metrics_table( test_metrics: &HashMap, + block_gas_limit: Option, ) -> Table { let mut table = Table::new(); if shell::is_markdown() { @@ -147,13 +148,23 @@ pub(crate) fn format_invariant_metrics_table( table.apply_modifier(UTF8_ROUND_CORNERS); } - table.set_header(vec![ + // Show "Max Gas" only when populated (i.e. `invariant.gas_fuzz = true`). + let 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); assert_eq!(table.row_count(), 2); let mut first_row_content = table.row(0).unwrap().cell_iter(); @@ -227,4 +262,18 @@ 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); + } } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index f82789db12bc3..1e3f589c1954e 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -1757,6 +1757,7 @@ fn base_counterexamples_to_txes( target: seq.addr.unwrap_or_default(), calldata: seq.calldata.clone(), value: seq.value, + gas_limit: None, }, } }) diff --git a/crates/forge/tests/cli/config.rs b/crates/forge/tests/cli/config.rs index 3eebca475a781..7903f19c728d2 100644 --- a/crates/forge/tests/cli/config.rs +++ b/crates/forge/tests/cli/config.rs @@ -224,6 +224,7 @@ failure_persist_dir = "cache/invariant" show_metrics = true show_solidity = false check_interval = 1 +gas_fuzz = false [labels] @@ -1345,7 +1346,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 }, "ffi": false, "live_logs": 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..f8f82c6501bbd --- /dev/null +++ b/crates/forge/tests/cli/test_cmd/invariant/gas.rs @@ -0,0 +1,311 @@ +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 bug should surface as `bulkPayout` Max +// Gas > 500k (clears the bounded-fixed ~474k cap with margin). +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(); + + // Last `|`-delimited cell of the `bulkPayout` row is its Max Gas value. + let row = stdout + .lines() + .find(|l| l.contains("bulkPayout") && l.contains('|')) + .unwrap_or_else(|| panic!("no bulkPayout metrics row found:\n{stdout}")); + let max_gas: u64 = row + .split('|') + .map(str::trim) + .rfind(|s| !s.is_empty()) + .and_then(|s| s.parse().ok()) + .unwrap_or_else(|| panic!("could not parse Max Gas from row: {row:?}")); + assert!( + max_gas > 500_000, + "expected bulkPayout Max Gas > 500_000 (gas DoS), got {max_gas} from row: {row:?}", + ); +}); + +// Razor gas surfaces a swallowed inner-OOG state-corruption bug: the inner +// SSTORE loop OOGs, the outer keeps its retained 1/64 and silently advances +// `debited` without the matching `credited` write. +const SWALLOWED_OOG_SOURCE: &str = r#" +contract Inner { + uint256 public credited; + uint256[] public log; + + function credit(uint256 amount) external { + credited += amount; + for (uint256 i = 0; i < 80; i++) { + log.push(i); + } + } +} + +contract Outer { + Inner public inner; + uint256 public debited; + + constructor() { + inner = new Inner(); + } + + function transfer(uint256 amount) external { + debited += amount; + (bool ok, ) = address(inner).call( + abi.encodeWithSelector(Inner.credit.selector, amount) + ); + ok; // swallowed failure (the bug) + } +} +"#; + +const SWALLOWED_OOG_HANDLER: &str = r#" +import {Test} from "forge-std/Test.sol"; +import {Outer, Inner} from "../src/SwallowedOOG.sol"; + +contract SwallowedOOGHandler is Test { + Outer public outer; + constructor(Outer _o) { outer = _o; } + function doTransfer(uint256 amount) external { + outer.transfer(bound(amount, 1, 1e9)); + } +} +"#; + +forgetest_init!(should_surface_swallowed_oog_corruption_under_gas_fuzz, |prj, cmd| { + prj.add_source("SwallowedOOG.sol", SWALLOWED_OOG_SOURCE); + prj.add_test( + "SwallowedOOGUnderGasFuzz.t.sol", + &format!( + r#" +{SWALLOWED_OOG_HANDLER} + +contract SwallowedOOGUnderGasFuzzTest is Test {{ + Outer public outer; + SwallowedOOGHandler public handler; + + function setUp() public {{ + outer = new Outer(); + handler = new SwallowedOOGHandler(outer); + targetContract(address(handler)); + }} + + /// forge-config: default.invariant.runs = 16 + /// forge-config: default.invariant.depth = 200 + /// forge-config: default.invariant.fail-on-revert = false + /// forge-config: default.invariant.gas-fuzz = true + function invariant_oog_debitedEqualsCredited() public view {{ + assertEq( + outer.debited(), + outer.inner().credited(), + "swallowed OOG corruption" + ); + }} +}} + "# + ), + ); + + cmd.args(["test", "--mt", "invariant_oog_debitedEqualsCredited"]).assert_failure().stdout_eq( + str![[r#" +... +[FAIL: swallowed OOG corruption: [..]] +... + invariant_oog_debitedEqualsCredited() (runs: [..], calls: [..], reverts: [..]) +... +"#]], + ); +}); + +// Control: without gas-fuzz the natural ~30M gas always covers the inner; +// the same harness, depth, and seed never reach the razor and the invariant +// holds. +forgetest_init!(should_not_surface_swallowed_oog_corruption_without_gas_fuzz, |prj, cmd| { + prj.add_source("SwallowedOOG.sol", SWALLOWED_OOG_SOURCE); + prj.add_test( + "SwallowedOOGWithoutGasFuzz.t.sol", + &format!( + r#" +{SWALLOWED_OOG_HANDLER} + +contract SwallowedOOGWithoutGasFuzzTest is Test {{ + Outer public outer; + SwallowedOOGHandler public handler; + + function setUp() public {{ + outer = new Outer(); + handler = new SwallowedOOGHandler(outer); + targetContract(address(handler)); + }} + + /// forge-config: default.invariant.runs = 16 + /// forge-config: default.invariant.depth = 200 + /// forge-config: default.invariant.fail-on-revert = false + /// forge-config: default.invariant.gas-fuzz = false + function invariant_oog_natural_gas_passes() public view {{ + assertEq( + outer.debited(), + outer.inner().credited(), + "swallowed OOG corruption" + ); + }} +}} + "# + ), + ); + + cmd.args(["test", "--mt", "invariant_oog_natural_gas_passes"]).assert_success().stdout_eq( + str![[r#" +... +[PASS] invariant_oog_natural_gas_passes() (runs: 16, calls: [..], reverts: [..]) +... +"#]], + ); +}); diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index 8df9a9da5bf75..2753de37e608e 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; From 4b55099f17812cb033fc91196cf64807d4503544 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Mon, 25 May 2026 15:00:44 +0300 Subject: [PATCH 02/20] fix(invariant): route sampled gas-price through the run-local executor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-call `gas_price` sampled under `invariant.gas_fuzz = true` was written to `self.executor`'s cheatcodes inspector, but each run executes against `current_run.executor` — a deep clone made before the inner loop. Because the `Executor::clone` impl in `executors/mod.rs` clones the inspector stack (and therefore the `Cheatcodes` inspector with it), the sampled price never reached the transaction under test: `tx.gasprice` inside the handler stayed at the executor's default for the whole campaign and the gas-price axis was silently inert. Fix - Stamp the sampled price onto `current_run.inputs.last_mut().call_details .gas_price`, mirroring how `gas_limit` is already plumbed. - `execute_tx` writes `tx.call_details.gas_price` to the executor it is actually running against, just before `call_raw`. The cheatcodes inspector consumes the slot one-shot via `.take()` in `initialize_interp`, so the override applies to exactly the call being fuzzed and does not leak into follow-up invariant assertion calls. Replay - `CallDetails` gains `gas_price: Option` next to `gas_limit`, both `#[serde(default, skip_serializing_if = "Option::is_none")]` so existing corpus files stay compatible and only gas-fuzzed sequences carry the field. Failing sequences now replay with the exact gas envelope (limit + price) that triggered them. Test (`gas::should_apply_sampled_gas_price_to_call`) - Handler stamps each observed `tx.gasprice` into a `mapping(uint256 => bool)` and bumps `uniqueCount`. The invariant asserts `uniqueCount <= 1`, so it must fire once the sampler delivers a second distinct price — a direct check that the override reaches the EVM, not a write-only destination. - Verified by temporarily disabling the apply-side of the fix: the test flipped from `assert_failure` back to passing (invariant never fired, proving no diversity reached the handler), and back to failing once the apply was restored. --- crates/evm/evm/src/executors/corpus.rs | 2 + crates/evm/evm/src/executors/fuzz/mod.rs | 2 + crates/evm/evm/src/executors/invariant/mod.rs | 30 ++++++----- .../evm/evm/src/executors/invariant/shrink.rs | 1 + crates/evm/fuzz/src/lib.rs | 6 +++ crates/evm/fuzz/src/strategies/invariants.rs | 2 +- crates/forge/src/runner.rs | 1 + .../forge/tests/cli/test_cmd/invariant/gas.rs | 54 +++++++++++++++++++ 8 files changed, 85 insertions(+), 13 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 55ab73e3cdeb0..b9e4217034b40 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -1404,6 +1404,7 @@ mod tests { calldata: Bytes::new(), value: None, gas_limit: None, + gas_price: None, }, } } @@ -1430,6 +1431,7 @@ mod tests { calldata, value: None, gas_limit: None, + gas_price: None, }, }; let cmp = CmpOperands { diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 86bde9de1d0a2..d7572884beb55 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -279,6 +279,7 @@ impl FuzzedExecutor { calldata: calldata.clone(), value: None, gas_limit: None, + gas_price: None, }, }], &[cmp_values], @@ -456,6 +457,7 @@ impl FuzzedExecutor { calldata, value: None, gas_limit: None, + gas_price: None, }, }); diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index ba850cf918e1e..1a744fe02f287 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -584,27 +584,23 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { (last.call_details.target, Selector::from(sel_bytes)) }; - // Per-call gas-envelope sampling. `None` from `sample_gas_limit` - // leaves `gas_limit` unset → executor's default budget applies. - // `gas_price` is applied via the cheatcodes one-shot slot. + // Per-call gas-envelope sampling. Both `gas_limit` and `gas_price` + // are stamped onto `call_details` so `execute_tx` applies them on + // the run-local executor (not `self.executor`) and so failing + // sequences replay with the exact gas envelope that triggered them. if self.config.gas_fuzz { let mut rng = rand::rng(); - let sampled = sample_gas_limit( + let sampled_limit = sample_gas_limit( &gas_observations, handler_target, handler_selector, block_gas_cap, &mut rng, ); - if let Some(g) = sampled - && let Some(last) = current_run.inputs.last_mut() - { - last.call_details.gas_limit = Some(g); - } - let sampled_price = sample_gas_price(&mut rng); - if let Some(cheats) = self.executor.inspector_mut().cheatcodes.as_mut() { - cheats.gas_price = Some(sampled_price); + if let Some(last) = current_run.inputs.last_mut() { + last.call_details.gas_limit = sampled_limit; + last.call_details.gas_price = Some(sampled_price); } } @@ -1591,6 +1587,16 @@ pub(crate) fn execute_tx( saved }); + // Per-call `tx.gasprice` override under `gas_fuzz`. The cheatcodes inspector + // consumes this one-shot during `initialize_interp` (via `.take()`), so the + // executor's natural price applies to any follow-up invariant assertion call + // even if the sampled price is non-zero. + if let Some(p) = tx.call_details.gas_price + && let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() + { + cheats.gas_price = Some(p); + } + let result = executor.call_raw( tx.sender, tx.call_details.target, diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index 05becc8c2389b..3fa2387c09bda 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -714,6 +714,7 @@ mod tests { calldata: Bytes::new(), value: None, gas_limit: None, + gas_price: None, }, } } diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index fbb73f55030a5..da394f6a1793b 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -88,6 +88,12 @@ pub struct CallDetails { /// Uses `#[serde(default)]` for backwards compatibility with existing corpus files. #[serde(default, skip_serializing_if = "Option::is_none")] pub gas_limit: Option, + /// Optional override for `tx.gasprice`. Same lifecycle as `gas_limit`: set by the + /// gas-envelope sampler when `invariant.gas_fuzz = true`, `None` means default, + /// persisted for deterministic replay of gas-price-dependent failures. + /// Uses `#[serde(default)]` for backwards compatibility with existing corpus files. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gas_price: Option, } impl BasicTxDetails { diff --git a/crates/evm/fuzz/src/strategies/invariants.rs b/crates/evm/fuzz/src/strategies/invariants.rs index ce365f87c7fe7..57f5a7f79112b 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, gas_limit: None } + CallDetails { target, calldata, value, gas_limit: None, gas_price: None } }) } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index 1e3f589c1954e..9a4bc94f028b7 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -1758,6 +1758,7 @@ fn base_counterexamples_to_txes( calldata: seq.calldata.clone(), value: seq.value, gas_limit: None, + gas_price: None, }, } }) diff --git a/crates/forge/tests/cli/test_cmd/invariant/gas.rs b/crates/forge/tests/cli/test_cmd/invariant/gas.rs index f8f82c6501bbd..2fbcf7465aa69 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/gas.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/gas.rs @@ -309,3 +309,57 @@ contract SwallowedOOGWithoutGasFuzzTest is Test {{ "#]], ); }); + +// Per-call `tx.gasprice` actually reaches the handler under `gas_fuzz = true`. +// The handler records distinct `tx.gasprice` values into storage; the invariant +// asserts no diversity, so it must fire once the sampler delivers a second +// distinct value. If the sampled price were written to the wrong executor (the +// pre-fix bug) `uniqueCount` would stay at 1 and the invariant would never fire. +forgetest_init!(should_apply_sampled_gas_price_to_call, |prj, cmd| { + prj.add_test( + "GasPriceFuzzTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract GasPriceObserver { + mapping(uint256 => bool) public seen; + uint256 public uniqueCount; + + function probe() external { + uint256 p = tx.gasprice; + if (!seen[p]) { + seen[p] = true; + uniqueCount++; + } + } +} + +contract GasPriceFuzzTest is Test { + GasPriceObserver public obs; + + function setUp() public { + obs = new GasPriceObserver(); + targetContract(address(obs)); + } + + /// forge-config: default.invariant.runs = 2 + /// forge-config: default.invariant.depth = 20 + /// forge-config: default.invariant.fail-on-revert = false + /// forge-config: default.invariant.gas-fuzz = true + function invariant_gasPriceStaysConstant() public view { + assertLe(obs.uniqueCount(), 1, "gas-price diversity observed"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_gasPriceStaysConstant"]).assert_failure().stdout_eq(str![ + [r#" +... +[FAIL: gas-price diversity observed[..]] +... + invariant_gasPriceStaysConstant() (runs: [..], calls: [..], reverts: [..]) +... +"#] + ]); +}); From 3b13d8dc6e0744a586711e944cc17b3d73c229f7 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Mon, 25 May 2026 15:25:56 +0300 Subject: [PATCH 03/20] fix(invariant): seed gas-envelope sampler from the campaign RNG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The per-call gas-limit / gas-price sampler used `rand::rng()`, a fresh thread-local entropy source. That made `--fuzz-seed` no longer deterministically reproduce a gas-fuzzed campaign — failure replay still worked via the values persisted on `CallDetails`, but rerunning the same seed could explore a different sequence of gas envelopes and surface (or hide) a different bug. CI triage on a failing seed silently lost its anchor. Route both samplers through `invariant_test.test_data.branch_runner .rng()` instead — the same seeded `TestRng` the rest of the campaign already uses for input generation. Drops the now-unused `rand` workspace dependency from `foundry-evm`. --- Cargo.lock | 1 - crates/evm/evm/Cargo.toml | 1 - crates/evm/evm/src/executors/invariant/mod.rs | 9 ++++++--- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 91c86f912e759..80968c05636c5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5284,7 +5284,6 @@ dependencies = [ "indicatif", "parking_lot", "proptest", - "rand 0.9.4", "rayon", "revm", "revm-inspectors", diff --git a/crates/evm/evm/Cargo.toml b/crates/evm/evm/Cargo.toml index f13daeca82779..5dbf07c7a356c 100644 --- a/crates/evm/evm/Cargo.toml +++ b/crates/evm/evm/Cargo.toml @@ -53,7 +53,6 @@ tempo-primitives.workspace = true eyre.workspace = true parking_lot.workspace = true proptest.workspace = true -rand.workspace = true thiserror.workspace = true tracing.workspace = true indicatif.workspace = true diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 1a744fe02f287..3d50d9d57d4cc 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -588,16 +588,19 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // are stamped onto `call_details` so `execute_tx` applies them on // the run-local executor (not `self.executor`) and so failing // sequences replay with the exact gas envelope that triggered them. + // The campaign-seeded `branch_runner` RNG is used (not `rand::rng()`) + // so `--fuzz-seed` continues to deterministically reproduce gas-fuzzed + // campaigns. if self.config.gas_fuzz { - let mut rng = rand::rng(); + let rng = invariant_test.test_data.branch_runner.rng(); let sampled_limit = sample_gas_limit( &gas_observations, handler_target, handler_selector, block_gas_cap, - &mut rng, + rng, ); - let sampled_price = sample_gas_price(&mut rng); + let sampled_price = sample_gas_price(rng); if let Some(last) = current_run.inputs.last_mut() { last.call_details.gas_limit = sampled_limit; last.call_details.gas_price = Some(sampled_price); From 56d0bcc10a685b5692757d225f31dbd2824d9413 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Mon, 25 May 2026 14:06:52 +0000 Subject: [PATCH 04/20] fix(invariant): isolate gas-fuzz gas price --- crates/evm/evm/src/executors/invariant/mod.rs | 13 +++++ .../forge/tests/cli/test_cmd/invariant/gas.rs | 51 +++++++++++++++++++ 2 files changed, 64 insertions(+) diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 3d50d9d57d4cc..3d102025e0be1 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -666,6 +666,13 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { } else { // Commit executed call result. current_run.executor.commit(&mut call_result); + if current_run + .inputs + .last() + .is_some_and(|tx| tx.call_details.gas_price.is_some()) + { + clear_pending_gas_price(&mut current_run.executor); + } // Collect data for fuzzing from the state changeset. // This step updates the state dictionary and therefore invalidates the @@ -1614,6 +1621,12 @@ pub(crate) fn execute_tx( result.map_err(|e| eyre!(format!("Could not make raw evm call: {e}"))) } +fn clear_pending_gas_price(executor: &mut Executor) { + if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() { + cheats.gas_price = None; + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/forge/tests/cli/test_cmd/invariant/gas.rs b/crates/forge/tests/cli/test_cmd/invariant/gas.rs index 2fbcf7465aa69..d3cff7f541f8f 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/gas.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/gas.rs @@ -363,3 +363,54 @@ contract GasPriceFuzzTest is Test { "#] ]); }); + +forgetest_init!(should_not_leak_sampled_gas_price_to_invariant_call, |prj, cmd| { + prj.add_test( + "GasPriceIsolationTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract GasPriceIsolationObserver { + mapping(uint256 => bool) public seen; + uint256 public uniqueCount; + + function probe() external { + uint256 p = tx.gasprice; + if (!seen[p]) { + seen[p] = true; + uniqueCount++; + } + } +} + +contract GasPriceIsolationTest is Test { + GasPriceIsolationObserver public obs; + + function setUp() public { + obs = new GasPriceIsolationObserver(); + targetContract(address(obs)); + } + + /// forge-config: default.invariant.runs = 2 + /// forge-config: default.invariant.depth = 20 + /// forge-config: default.invariant.fail-on-revert = false + /// forge-config: default.invariant.gas-fuzz = true + function invariant_sampledGasPriceIsHandlerOnly() public view { + assertEq(tx.gasprice, 0, "sampled gas price leaked"); + } + + function afterInvariant() public view { + assertGt(obs.uniqueCount(), 1, "handler saw sampled prices"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_sampledGasPriceIsHandlerOnly"]) + .assert_success() + .stdout_eq(str![[r#" +... +[PASS] invariant_sampledGasPriceIsHandlerOnly() (runs: 2, calls: [..], reverts: [..]) +... +"#]]); +}); From 99d33b04aca55be89bd26e7d0d64eaff7ff46f25 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Mon, 25 May 2026 14:33:15 +0000 Subject: [PATCH 05/20] fix(invariant): persist gas fuzz replay envelope --- crates/evm/fuzz/src/lib.rs | 14 ++++ crates/forge/src/runner.rs | 4 +- .../forge/tests/cli/test_cmd/invariant/gas.rs | 69 +++++++++++++++++++ 3 files changed, 85 insertions(+), 2 deletions(-) diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index da394f6a1793b..94e3a91838ba4 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -127,6 +127,12 @@ 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, + /// Optional transaction gas price override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gas_price: Option, /// Contract name if it exists. pub contract_name: Option, /// Function name if it exists. @@ -160,6 +166,8 @@ 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 gas_price = tx.call_details.gas_price; let warp = tx.warp; let roll = tx.roll; if let Some((name, abi)) = &contracts.get(&target) @@ -174,6 +182,8 @@ impl BaseCounterExample { addr: Some(target), calldata: bytes.clone(), value, + gas_limit, + gas_price, contract_name: Some(name.clone()), func_name: Some(func.name.clone()), signature: Some(func.signature()), @@ -195,6 +205,8 @@ impl BaseCounterExample { addr: Some(target), calldata: bytes.clone(), value, + gas_limit, + gas_price, contract_name: None, func_name: None, signature: None, @@ -219,6 +231,8 @@ impl BaseCounterExample { addr: None, calldata: bytes, value: None, + gas_limit: None, + gas_price: None, contract_name: None, func_name: None, signature: None, diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index 9a4bc94f028b7..f985bf02151ea 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -1757,8 +1757,8 @@ fn base_counterexamples_to_txes( target: seq.addr.unwrap_or_default(), calldata: seq.calldata.clone(), value: seq.value, - gas_limit: None, - gas_price: None, + gas_limit: seq.gas_limit, + gas_price: seq.gas_price, }, } }) diff --git a/crates/forge/tests/cli/test_cmd/invariant/gas.rs b/crates/forge/tests/cli/test_cmd/invariant/gas.rs index d3cff7f541f8f..85e785309cf7c 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/gas.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/gas.rs @@ -414,3 +414,72 @@ contract GasPriceIsolationTest is Test { ... "#]]); }); + +forgetest_init!(should_replay_persisted_gas_price_failure, |prj, cmd| { + prj.update_config(|config| { + config.invariant.runs = 2; + config.invariant.depth = 20; + config.invariant.fail_on_revert = false; + config.invariant.gas_fuzz = true; + }); + prj.add_test( + "GasPricePersistedReplayTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract PersistedGasPriceObserver { + mapping(uint256 => bool) public seen; + uint256 public uniqueCount; + + function probe() external { + uint256 p = tx.gasprice; + if (!seen[p]) { + seen[p] = true; + uniqueCount++; + } + } +} + +contract GasPricePersistedReplayTest is Test { + PersistedGasPriceObserver public obs; + + function setUp() public { + obs = new PersistedGasPriceObserver(); + targetContract(address(obs)); + } + + function invariant_gasPriceStaysConstant() public view { + assertLe(obs.uniqueCount(), 1, "gas-price diversity observed"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_gasPriceStaysConstant"]).assert_failure(); + + let persisted = prj + .root() + .join("cache") + .join("invariant") + .join("failures") + .join("GasPricePersistedReplayTest") + .join("invariants") + .join("invariant_gasPriceStaysConstant"); + let json: serde_json::Value = + serde_json::from_reader(std::fs::File::open(&persisted).unwrap()).unwrap(); + let calls = json["call_sequence"].as_array().unwrap(); + assert!(calls.iter().any(|call| call.get("gas_price").is_some())); + + prj.update_config(|config| { + config.invariant.runs = 0; + }); + cmd.forge_fuse() + .args(["test", "--mt", "invariant_gasPriceStaysConstant"]) + .assert_failure() + .stderr_eq(str![[" +... +Warning: Replayed invariant failure from persisted file. \u{20} +Run `forge clean` or remove file to ignore failure and to continue invariant test campaign. +... +"]]); +}); From 906d91fb620ac332f58eaadbbfd063463674c001 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Mon, 25 May 2026 14:53:00 +0000 Subject: [PATCH 06/20] perf(invariant): defer max gas sequence materialization --- crates/evm/evm/src/executors/invariant/mod.rs | 70 ++++++++++++++++--- 1 file changed, 59 insertions(+), 11 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 3d102025e0be1..6e5009b9ecfbf 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -144,6 +144,18 @@ 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. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] struct InvariantThroughputMetrics { @@ -275,7 +287,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` @@ -349,11 +361,12 @@ impl InvariantTest { reverted: bool, discarded: bool, gas_used: u64, - run_inputs: Option<&[BasicTxDetails]>, + 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; @@ -361,8 +374,8 @@ impl InvariantTest { invariant_metrics.reverts += 1; } else if gas_used > invariant_metrics.max_gas { invariant_metrics.max_gas = gas_used; - if let Some(inputs) = run_inputs { - invariant_metrics.max_gas_sequence = inputs.to_vec(); + if let Some((run_id, prefix_len)) = max_gas_run { + metric_state.max_gas_run = Some(MaxGasRun { run_id, prefix_len, inputs: None }); } } } @@ -370,10 +383,17 @@ impl InvariantTest { /// 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 @@ -385,6 +405,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) { @@ -394,6 +426,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. @@ -565,6 +611,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // successful even though it timed out. We *want* // this behavior for now, so that's ok, but // future developers should be aware of this. + invariant_test.attach_max_gas_inputs(runs, ¤t_run.inputs); break 'stop; } @@ -626,14 +673,14 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { // `show_metrics` alone keeps the legacy table. let gas_used_for_metric = if self.config.gas_fuzz { call_result.gas_used } else { 0 }; - let run_inputs_for_metric = - self.config.gas_fuzz.then_some(current_run.inputs.as_slice()); + let max_gas_run_for_metric = + self.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, - run_inputs_for_metric, + max_gas_run_for_metric, ); } @@ -896,7 +943,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { } // End current invariant test run. - invariant_test.end_run(current_run, self.config.gas_report_samples as usize); + invariant_test.end_run(runs, current_run, self.config.gas_report_samples as usize); if let Some(progress) = progress { // If running with progress then increment completed runs. progress.inc(1); @@ -998,6 +1045,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { } } + let metrics = finalize_metrics(result.metrics); let reverts = result.failures.reverts; let (errors, handler_errors) = result.failures.partition(); Ok(InvariantFuzzTestResult { @@ -1008,7 +1056,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { last_run_inputs: result.last_run_inputs, gas_report_traces: result.gas_report_traces, line_coverage: result.line_coverage, - metrics: result.metrics, + metrics, failed_corpus_replays: corpus_manager.failed_replays, optimization_best_value: result.optimization_best_value, optimization_best_sequence: result.optimization_best_sequence, From 1eab6c2ba3bfccf62f08dd9bb34010ab8cfd7de3 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Mon, 25 May 2026 16:02:41 +0000 Subject: [PATCH 07/20] fix(invariant): serialize gas price as u64 --- Cargo.lock | 1 + crates/evm/evm/src/executors/invariant/mod.rs | 2 +- crates/evm/fuzz/Cargo.toml | 3 ++ crates/evm/fuzz/src/lib.rs | 37 ++++++++++++++++++- crates/evm/fuzz/src/strategies/gas_sampler.rs | 6 +-- 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 80968c05636c5..4e66ee6c7493f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5397,6 +5397,7 @@ dependencies = [ "rand 0.9.4", "revm", "serde", + "serde_json", "solar-compiler", "thiserror 2.0.18", "tracing", diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 6e5009b9ecfbf..a87b49b01c413 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -1652,7 +1652,7 @@ pub(crate) fn execute_tx( if let Some(p) = tx.call_details.gas_price && let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() { - cheats.gas_price = Some(p); + cheats.gas_price = Some(p.into()); } let result = executor.call_raw( 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 94e3a91838ba4..f3f6edb6c0b14 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -93,7 +93,7 @@ pub struct CallDetails { /// persisted for deterministic replay of gas-price-dependent failures. /// Uses `#[serde(default)]` for backwards compatibility with existing corpus files. #[serde(default, skip_serializing_if = "Option::is_none")] - pub gas_price: Option, + pub gas_price: Option, } impl BasicTxDetails { @@ -132,7 +132,7 @@ pub struct BaseCounterExample { pub gas_limit: Option, /// Optional transaction gas price override. #[serde(default, skip_serializing_if = "Option::is_none")] - pub gas_price: Option, + pub gas_price: Option, /// Contract name if it exists. pub contract_name: Option, /// Function name if it exists. @@ -521,3 +521,36 @@ 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_price_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), + gas_price: Some(1_000_000_000_000), + }, + }; + + let counterexample = BaseCounterExample::from_invariant_call( + &tx, + &ContractsByAddress::default(), + None, + false, + ); + let value = serde_json::to_value(&counterexample).unwrap(); + + assert_eq!(value["gas_price"], serde_json::json!(1_000_000_000_000_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 index 823fcbea51286..dcef8f5027f48 100644 --- a/crates/evm/fuzz/src/strategies/gas_sampler.rs +++ b/crates/evm/fuzz/src/strategies/gas_sampler.rs @@ -95,12 +95,12 @@ pub fn sample_gas_limit( /// | 40% | `0` | Baseline / refund accounting | /// | 40% | `[1, 1e10)` wei | Low — typical L2 fee (~0–10 gwei)| /// | 20% | `[1e10, 1e12)` wei | High — congested L1 band | -pub fn sample_gas_price(rng: &mut R) -> u128 { +pub fn sample_gas_price(rng: &mut R) -> u64 { let bucket: u8 = rng.random_range(0..100); match bucket { 0..40 => 0, - 40..80 => rng.random_range(1u128..10_000_000_000u128), - _ => rng.random_range(10_000_000_000u128..1_000_000_000_000u128), + 40..80 => rng.random_range(1u64..10_000_000_000u64), + _ => rng.random_range(10_000_000_000u64..1_000_000_000_000u64), } } From 6de87077cc3af9943ebe370a198624056847f781 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Mon, 25 May 2026 16:15:08 +0000 Subject: [PATCH 08/20] fix(invariant): show max gas column when gas fuzz is enabled --- crates/forge/src/cmd/test/mod.rs | 8 ++++++-- crates/forge/src/cmd/test/summary.rs | 19 ++++++++++++++++--- crates/forge/src/result.rs | 7 +++++++ crates/forge/src/runner.rs | 1 + 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/crates/forge/src/cmd/test/mod.rs b/crates/forge/src/cmd/test/mod.rs index c2f24b0ee317f..60ef053cd4f67 100644 --- a/crates/forge/src/cmd/test/mod.rs +++ b/crates/forge/src/cmd/test/mod.rs @@ -799,12 +799,16 @@ impl TestArgs { sh_println!("{}", result.short_result(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, Some(block_gas_limit)) + format_invariant_metrics_table( + metrics, + Some(block_gas_limit), + *gas_fuzz + ) ); } diff --git a/crates/forge/src/cmd/test/summary.rs b/crates/forge/src/cmd/test/summary.rs index 76b9651fbb1ee..9fee4aa4e0fc5 100644 --- a/crates/forge/src/cmd/test/summary.rs +++ b/crates/forge/src/cmd/test/summary.rs @@ -140,6 +140,7 @@ 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() { @@ -148,8 +149,7 @@ pub(crate) fn format_invariant_metrics_table( table.apply_modifier(UTF8_ROUND_CORNERS); } - // Show "Max Gas" only when populated (i.e. `invariant.gas_fuzz = true`). - let show_max_gas = test_metrics.values().any(|m| m.max_gas > 0); + let show_max_gas = show_max_gas || test_metrics.values().any(|m| m.max_gas > 0); let mut header = vec![ Cell::new("Contract"), @@ -245,7 +245,7 @@ mod tests { "src/universal/Proxy.sol:Proxy.changeAdmin".to_string(), InvariantMetrics { calls: 20, reverts: 2, discards: 2, ..Default::default() }, ); - let table = format_invariant_metrics_table(&test_metrics, None); + 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(); @@ -276,4 +276,17 @@ mod tests { // 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 8e4732f32e08e..0496477f6b090 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -964,6 +964,7 @@ impl TestResult { calls: 1, reverts: 1, metrics: HashMap::default(), + gas_fuzz: false, failed_corpus_replays: 0, optimization_best_value: None, }; @@ -987,6 +988,7 @@ impl TestResult { calls: 1, reverts: 1, metrics: HashMap::default(), + gas_fuzz: false, failed_corpus_replays: 0, optimization_best_value: None, }; @@ -1008,6 +1010,7 @@ impl TestResult { calls: 0, reverts: 0, metrics: HashMap::default(), + gas_fuzz: false, failed_corpus_replays: 0, optimization_best_value: None, }; @@ -1030,6 +1033,7 @@ impl TestResult { cases: Vec, reverts: usize, metrics: Map, + gas_fuzz: bool, failed_corpus_replays: usize, optimization_best_value: Option, ) { @@ -1038,6 +1042,7 @@ impl TestResult { calls: cases.iter().map(|sequence| sequence.cases().len()).sum(), reverts, metrics, + gas_fuzz, failed_corpus_replays, optimization_best_value, }; @@ -1256,6 +1261,7 @@ pub enum TestKind { calls: usize, reverts: usize, metrics: Map, + gas_fuzz: bool, failed_corpus_replays: usize, /// For optimization mode (int256 return): the best value achieved. None = check mode. optimization_best_value: Option, @@ -1298,6 +1304,7 @@ impl TestKind { calls, reverts, 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 f985bf02151ea..c4bceafc44456 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -1459,6 +1459,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { invariant_result.cases, invariant_result.reverts, invariant_result.metrics, + invariant_config.gas_fuzz, invariant_result.failed_corpus_replays, invariant_result.optimization_best_value, ); From ebbc32f84722f148632e8849429941297386d225 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Mon, 25 May 2026 16:27:16 +0000 Subject: [PATCH 09/20] test(invariant): fix persisted gas price replay snapshot --- crates/forge/tests/cli/test_cmd/invariant/gas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge/tests/cli/test_cmd/invariant/gas.rs b/crates/forge/tests/cli/test_cmd/invariant/gas.rs index 85e785309cf7c..7977f39edb292 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/gas.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/gas.rs @@ -478,7 +478,7 @@ contract GasPricePersistedReplayTest is Test { .assert_failure() .stderr_eq(str![[" ... -Warning: Replayed invariant failure from persisted file. \u{20} +Warning: Replayed invariant failure from persisted file. Run `forge clean` or remove file to ignore failure and to continue invariant test campaign. ... "]]); From 0cfd30c57ec6d64a0679faaba0cb518cacf61cde Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Mon, 25 May 2026 16:45:41 +0000 Subject: [PATCH 10/20] test(invariant): match replay warning whitespace --- crates/forge/tests/cli/test_cmd/invariant/gas.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/forge/tests/cli/test_cmd/invariant/gas.rs b/crates/forge/tests/cli/test_cmd/invariant/gas.rs index 7977f39edb292..e5e00672a7a2a 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/gas.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/gas.rs @@ -478,7 +478,7 @@ contract GasPricePersistedReplayTest is Test { .assert_failure() .stderr_eq(str![[" ... -Warning: Replayed invariant failure from persisted file. +Warning: Replayed invariant failure from persisted file.\u{20} Run `forge clean` or remove file to ignore failure and to continue invariant test campaign. ... "]]); From 4661bf390451034bfd2069c0af1b1f53d30a571c Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Wed, 27 May 2026 11:00:40 +0300 Subject: [PATCH 11/20] perf(invariant): box gas-fuzz overrides to remove OFF-mode regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The two Option fields added directly on CallDetails widened every BasicTxDetails by 32 bytes, paid by every invariant tx regardless of whether gas_fuzz was enabled. On aave-v4-scfuzzbench this cost ~7% of throughput with gas_fuzz=false vs master. Move the overrides behind Option> so the inline cost is one niche-optimized pointer (8 bytes) and is allocation-free when gas_fuzz is off. Accessors gas_limit()/gas_price()/set_gas_envelope() keep call sites unchanged; serde(flatten) preserves the existing top- level JSON keys so corpus files written by earlier revisions of this PR still round-trip. 1h/10-seed aave-v4-scfuzzbench: 8046 → 8188 tx/s (vs master 8594), 9 unique bugs found (vs 7 OFF / 8 master), full envelope replay preserved. Co-authored-by: grandizzy <38490174+grandizzy@users.noreply.github.com> --- crates/evm/evm/src/executors/corpus.rs | 6 +- crates/evm/evm/src/executors/fuzz/mod.rs | 6 +- crates/evm/evm/src/executors/invariant/mod.rs | 11 ++- .../evm/evm/src/executors/invariant/shrink.rs | 3 +- crates/evm/fuzz/src/lib.rs | 77 +++++++++++++++---- crates/evm/fuzz/src/strategies/invariants.rs | 2 +- crates/forge/src/runner.rs | 15 ++-- 7 files changed, 80 insertions(+), 40 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 063d56443030f..64a02dccb68c2 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -1418,8 +1418,7 @@ mod tests { target: Address::ZERO, calldata: Bytes::new(), value: None, - gas_limit: None, - gas_price: None, + gas: None, }, } } @@ -1445,8 +1444,7 @@ mod tests { target: Address::ZERO, calldata, value: None, - gas_limit: None, - gas_price: None, + gas: None, }, }; let cmp = CmpOperands { diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 6a748e84134a4..136c6b2d95eb3 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -278,8 +278,7 @@ impl FuzzedExecutor { target: address, calldata: calldata.clone(), value: None, - gas_limit: None, - gas_price: None, + gas: None, }, }], &[cmp_values], @@ -456,8 +455,7 @@ impl FuzzedExecutor { target: Default::default(), calldata, value: None, - gas_limit: None, - gas_price: None, + gas: None, }, }); diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 72d5a48cf4db1..8587bcaf663d4 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -665,8 +665,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { ); let sampled_price = sample_gas_price(rng); if let Some(last) = current_run.inputs.last_mut() { - last.call_details.gas_limit = sampled_limit; - last.call_details.gas_price = Some(sampled_price); + last.call_details.set_gas_envelope(sampled_limit, Some(sampled_price)); } } @@ -732,7 +731,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { if current_run .inputs .last() - .is_some_and(|tx| tx.call_details.gas_price.is_some()) + .is_some_and(|tx| tx.call_details.gas_price().is_some()) { clear_pending_gas_price(&mut current_run.executor); } @@ -785,7 +784,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { if self.config.gas_fuzz && call_result.gas_used > 0 && let Some(last) = current_run.inputs.last() - && last.call_details.gas_limit.is_none() + && last.call_details.gas_limit().is_none() { gas_observations.record( handler_target, @@ -1665,7 +1664,7 @@ pub(crate) fn execute_tx( // Per-call gas-limit override under `gas_fuzz`. Save/restore so the executor's // natural limit applies to invariant assertion calls. - let saved_gas_limit = tx.call_details.gas_limit.map(|g| { + let saved_gas_limit = tx.call_details.gas_limit().map(|g| { let saved = executor.gas_limit(); executor.set_gas_limit(g); saved @@ -1675,7 +1674,7 @@ pub(crate) fn execute_tx( // consumes this one-shot during `initialize_interp` (via `.take()`), so the // executor's natural price applies to any follow-up invariant assertion call // even if the sampled price is non-zero. - if let Some(p) = tx.call_details.gas_price + if let Some(p) = tx.call_details.gas_price() && let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() { cheats.gas_price = Some(p.into()); diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index 3fa2387c09bda..93a2fc3419518 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -713,8 +713,7 @@ mod tests { target: Address::ZERO, calldata: Bytes::new(), value: None, - gas_limit: None, - gas_price: None, + gas: None, }, } } diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index f3f6edb6c0b14..83c88d3b16eb8 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -70,6 +70,22 @@ pub struct BasicTxDetails { pub call_details: CallDetails, } +/// Optional per-call gas envelope overrides. Stamped by the `invariant.gas_fuzz` +/// sampler. Lives behind a `Box` on `CallDetails` so the OFF-mode hot path pays a +/// single niche-optimized pointer (`None` = no overrides, no allocation, no +/// pointer chase). Persisted in the corpus so failing sequences replay at the +/// exact gas envelope that triggered the failure. +#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] +pub struct GasOverrides { + /// Override for `tx.gas_limit`. `None` means use the executor's default + /// (typically the block gas limit). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gas_limit: Option, + /// Override for `tx.gasprice`. `None` means use the executor's default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gas_price: Option, +} + /// Call details of a transaction generated to fuzz. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct CallDetails { @@ -81,19 +97,44 @@ pub struct CallDetails { /// Uses `#[serde(default)]` for backwards compatibility with existing corpus files. #[serde(default, skip_serializing_if = "Option::is_none")] pub value: Option, - /// Optional override for `tx.gas_limit`. Set by the gas-envelope sampler when - /// `invariant.gas_fuzz = true`. `None` means use the executor's default (typically - /// the block gas limit). Persisted in the corpus so failing sequences can be replayed - /// at the exact gas budget that triggered the failure. - /// Uses `#[serde(default)]` for backwards compatibility with existing corpus files. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gas_limit: Option, - /// Optional override for `tx.gasprice`. Same lifecycle as `gas_limit`: set by the - /// gas-envelope sampler when `invariant.gas_fuzz = true`, `None` means default, - /// persisted for deterministic replay of gas-price-dependent failures. - /// Uses `#[serde(default)]` for backwards compatibility with existing corpus files. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gas_price: Option, + /// Boxed per-call gas envelope. `None` when `invariant.gas_fuzz = false` (default) + /// so the hot path adds only 8 bytes vs master. Flattened so JSON keys remain + /// `gas_limit` / `gas_price` at the top level (backwards-compatible with corpus + /// files written by earlier revisions of #14902). + #[serde(default, flatten, skip_serializing_if = "Option::is_none")] + pub gas: Option>, +} + +impl CallDetails { + /// Returns the per-call `tx.gas_limit` override, if any. + #[inline] + pub fn gas_limit(&self) -> Option { + self.gas.as_deref().and_then(|g| g.gas_limit) + } + + /// Returns the per-call `tx.gasprice` override, if any. + #[inline] + pub fn gas_price(&self) -> Option { + self.gas.as_deref().and_then(|g| g.gas_price) + } + + /// Stamp a sampled gas envelope onto this call. Allocates one `GasOverrides` + /// when first set; reuses on subsequent updates. + pub fn set_gas_envelope(&mut self, gas_limit: Option, gas_price: Option) { + if gas_limit.is_none() && gas_price.is_none() { + self.gas = None; + return; + } + match &mut self.gas { + Some(g) => { + g.gas_limit = gas_limit; + g.gas_price = gas_price; + } + None => { + self.gas = Some(Box::new(GasOverrides { gas_limit, gas_price })); + } + } + } } impl BasicTxDetails { @@ -166,8 +207,8 @@ 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 gas_price = tx.call_details.gas_price; + let gas_limit = tx.call_details.gas_limit(); + let gas_price = tx.call_details.gas_price(); let warp = tx.warp; let roll = tx.roll; if let Some((name, abi)) = &contracts.get(&target) @@ -537,8 +578,10 @@ mod tests { target: address!("00000000000000000000000000000000000000aa"), calldata: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]), value: None, - gas_limit: Some(123_456), - gas_price: Some(1_000_000_000_000), + gas: Some(Box::new(GasOverrides { + gas_limit: Some(123_456), + gas_price: Some(1_000_000_000_000), + })), }, }; diff --git a/crates/evm/fuzz/src/strategies/invariants.rs b/crates/evm/fuzz/src/strategies/invariants.rs index 57f5a7f79112b..bfe5805b6ffe3 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, gas_limit: None, gas_price: None } + CallDetails { target, calldata, value, gas: None } }) } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index dac7b6ba9d5f5..a119dbc9392b4 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -1876,12 +1876,15 @@ fn base_counterexamples_to_txes( warp: seq.warp, roll: seq.roll, sender: seq.sender.unwrap_or_default(), - call_details: CallDetails { - target: seq.addr.unwrap_or_default(), - calldata: seq.calldata.clone(), - value: seq.value, - gas_limit: seq.gas_limit, - gas_price: seq.gas_price, + call_details: { + let mut cd = CallDetails { + target: seq.addr.unwrap_or_default(), + calldata: seq.calldata.clone(), + value: seq.value, + gas: None, + }; + cd.set_gas_envelope(seq.gas_limit, seq.gas_price); + cd }, } }) From aac8d91cfa716deee39048bd052b94ef49df1429 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Thu, 28 May 2026 08:31:48 +0300 Subject: [PATCH 12/20] fix(invariant): scrub sampled gas_price in execute_tx so commit cannot re-arm it execute_tx sets cheats.gas_price = Some(sampled) for the per-call tx.gasprice override. The cheatcodes inspector consumes it one-shot during initialize_interp, but the surrounding Executor::commit(&mut result) calls set_gas_price(result.tx_env.gas_price()) and re-arms the override from the sampled value still sitting on result.tx_env. The main loop worked around this with a local clear_pending_gas_price helper after commit, but replay, shrink, corpus replay and showmap all go through execute_tx + commit without that cleanup and leak the sampled price into the next call / invariant assertion. Snapshot the executor's natural gas price up front, scrub it back onto result.tx_env before returning from execute_tx, and defensively clear the inspector field. Remove the now-redundant main-loop helper. Add a CLI regression test that exercises the replay path: a forced failure triggers shrink + replay_run (execute_tx -> commit -> call_invariant_function), and the surfaced revert must be the forced one, not the leak. --- crates/evm/evm/src/executors/invariant/mod.rs | 49 +++++++++-------- .../forge/tests/cli/test_cmd/invariant/gas.rs | 52 +++++++++++++++++++ 2 files changed, 79 insertions(+), 22 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 8587bcaf663d4..97e15908f2422 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -17,7 +17,7 @@ use foundry_common::{ }; use foundry_config::InvariantConfig; use foundry_evm_core::{ - FoundryBlock, + FoundryBlock, FoundryTransaction, constants::{ CALLER, CHEATCODE_ADDRESS, DEFAULT_CREATE2_DEPLOYER, HARDHAT_CONSOLE_ADDRESS, MAGIC_ASSUME, }, @@ -40,7 +40,10 @@ use indicatif::ProgressBar; use parking_lot::RwLock; use proptest::{strategy::Strategy, test_runner::TestRunner}; use result::{assert_after_invariant, can_continue, did_fail_on_assert, invariant_preflight_check}; -use revm::{context::Block, state::Account}; +use revm::{ + context::{Block, Transaction}, + state::Account, +}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::{ @@ -726,15 +729,9 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { break 'stop; } } else { - // Commit executed call result. + // `execute_tx` scrubs any sampled gas-price override out + // of `call_result.tx_env`, so `commit()` cannot re-arm it. current_run.executor.commit(&mut call_result); - if current_run - .inputs - .last() - .is_some_and(|tx| tx.call_details.gas_price().is_some()) - { - clear_pending_gas_price(&mut current_run.executor); - } // Collect data for fuzzing from the state changeset. // This step updates the state dictionary and therefore invalidates the @@ -1670,17 +1667,19 @@ pub(crate) fn execute_tx( saved }); - // Per-call `tx.gasprice` override under `gas_fuzz`. The cheatcodes inspector - // consumes this one-shot during `initialize_interp` (via `.take()`), so the - // executor's natural price applies to any follow-up invariant assertion call - // even if the sampled price is non-zero. - if let Some(p) = tx.call_details.gas_price() + // Per-call `tx.gasprice` override under `gas_fuzz`. Snapshot the natural + // price so we can scrub it back below — otherwise downstream `commit()` + // (replay/shrink/corpus/showmap) would re-arm `cheats.gas_price` from + // `result.tx_env.gas_price()` and leak the sampled value to the next call. + let gas_price_override = tx.call_details.gas_price(); + let natural_gas_price = executor.tx_env().gas_price(); + if let Some(p) = gas_price_override && let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() { cheats.gas_price = Some(p.into()); } - let result = executor.call_raw( + let mut result = executor.call_raw( tx.sender, tx.call_details.target, tx.call_details.calldata.clone(), @@ -1691,13 +1690,19 @@ pub(crate) fn execute_tx( executor.set_gas_limit(saved); } - result.map_err(|e| eyre!(format!("Could not make raw evm call: {e}"))) -} - -fn clear_pending_gas_price(executor: &mut Executor) { - if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() { - cheats.gas_price = None; + // Scrub override before returning so any downstream `commit()` cannot + // re-arm `cheats.gas_price`. Also defensively clear the inspector field + // in case `initialize_interp` was never reached. + if gas_price_override.is_some() { + if let Ok(call_result) = &mut result { + call_result.tx_env.set_gas_price(natural_gas_price); + } + if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() { + cheats.gas_price = None; + } } + + result.map_err(|e| eyre!(format!("Could not make raw evm call: {e}"))) } #[cfg(test)] diff --git a/crates/forge/tests/cli/test_cmd/invariant/gas.rs b/crates/forge/tests/cli/test_cmd/invariant/gas.rs index e5e00672a7a2a..4f7c23078001b 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/gas.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/gas.rs @@ -415,6 +415,58 @@ contract GasPriceIsolationTest is Test { "#]]); }); +// Replay path must not observe a leaked sampled `tx.gasprice`. +// `replay_run` loops `execute_tx` + `executor.commit(&mut result)` per tx and +// then runs the invariant assertion. If `commit` re-armed `cheats.gas_price` +// from a sampled override, the invariant view would observe non-zero +// `tx.gasprice` instead of the natural `0`, and the surfaced failure would +// flip to the leak revert instead of the intended forced revert. +forgetest_init!(should_not_leak_sampled_gas_price_through_replay_commit, |prj, cmd| { + prj.add_test( + "GasPriceReplayLeakTest.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract ReplayLeakHandler is Test { + // Storage write so the sampled gas envelope is actually exercised. + uint256 public n; + function poke(uint256 a) public { n = a; } +} + +contract GasPriceReplayLeakTest is Test { + ReplayLeakHandler public handler; + + function setUp() public { + handler = new ReplayLeakHandler(); + targetContract(address(handler)); + } + + /// forge-config: default.invariant.runs = 2 + /// forge-config: default.invariant.depth = 10 + /// forge-config: default.invariant.fail-on-revert = false + /// forge-config: default.invariant.gas-fuzz = true + function invariant_replayDoesNotObserveLeakedGasPrice() public view { + // Natural price is 0; any non-zero here means a sampled override + // leaked through `commit()` into this invariant call. + require(tx.gasprice == 0, "gas-price leaked across commit in replay"); + // Force a failure so shrink + replay actually run. + require(false, "forced failure to trigger shrink+replay"); + } +} + "#, + ); + + // Surfaced failure must be the forced one; a leak would flip it to the + // leak revert above. + cmd.args(["test", "--mt", "invariant_replayDoesNotObserveLeakedGasPrice"]) + .assert_failure() + .stdout_eq(str![[r#" +... +[FAIL: forced failure to trigger shrink+replay] invariant_replayDoesNotObserveLeakedGasPrice() (runs: [..], calls: [..], reverts: [..]) +... +"#]]); +}); + forgetest_init!(should_replay_persisted_gas_price_failure, |prj, cmd| { prj.update_config(|config| { config.invariant.runs = 2; From 1e0858818a6cf05d8bdc6f47de7af383f151da49 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Thu, 28 May 2026 13:20:11 +0300 Subject: [PATCH 13/20] feat(invariant): simplify gas_fuzz to flat tx.gas_limit sampling in [2^24, 2^25) Drop the per-(target, selector) gas_used observation tracker, the gas_price sampling envelope, the calibrated-limit machinery, and the boxed override struct in favor of a single Option gas_limit field on CallDetails that is stamped per call with a flat draw over [2^24, 2^25). This keeps the EIP-7825 cap as the natural floor and exercises gas-conditional EVM dispatch (refund accounting, EIP-150 1/64 retention, OOG dispatch at the cap) without the per-handler state, calibration warm-up, replay gas_price scrubbing, or heap allocation per call that the richer envelope required. Behavior under gas_fuzz=false is unchanged. --- crates/evm/evm/src/executors/corpus.rs | 4 +- crates/evm/evm/src/executors/fuzz/mod.rs | 4 +- crates/evm/evm/src/executors/invariant/mod.rs | 81 +--- .../evm/evm/src/executors/invariant/shrink.rs | 2 +- crates/evm/fuzz/src/lib.rs | 72 +--- crates/evm/fuzz/src/strategies/gas_sampler.rs | 202 +--------- crates/evm/fuzz/src/strategies/invariants.rs | 2 +- crates/evm/fuzz/src/strategies/mod.rs | 2 +- crates/forge/src/runner.rs | 14 +- .../forge/tests/cli/test_cmd/invariant/gas.rs | 364 ------------------ 10 files changed, 55 insertions(+), 692 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 64a02dccb68c2..89613f97e9994 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -1418,7 +1418,7 @@ mod tests { target: Address::ZERO, calldata: Bytes::new(), value: None, - gas: None, + gas_limit: None, }, } } @@ -1444,7 +1444,7 @@ mod tests { target: Address::ZERO, calldata, value: None, - gas: None, + gas_limit: None, }, }; let cmp = CmpOperands { diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 136c6b2d95eb3..7c94140317d43 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -278,7 +278,7 @@ impl FuzzedExecutor { target: address, calldata: calldata.clone(), value: None, - gas: None, + gas_limit: None, }, }], &[cmp_values], @@ -455,7 +455,7 @@ impl FuzzedExecutor { target: Default::default(), calldata, value: None, - gas: None, + gas_limit: None, }, }); diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 97e15908f2422..fb2cc5245fe98 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -17,7 +17,7 @@ use foundry_common::{ }; use foundry_config::InvariantConfig; use foundry_evm_core::{ - FoundryBlock, FoundryTransaction, + FoundryBlock, constants::{ CALLER, CHEATCODE_ADDRESS, DEFAULT_CREATE2_DEPLOYER, HARDHAT_CONSOLE_ADDRESS, MAGIC_ASSUME, }, @@ -31,8 +31,7 @@ use foundry_evm_fuzz::{ RandomCallGenerator, SenderFilters, TargetedContract, TargetedContracts, }, strategies::{ - EvmFuzzState, GasObservations, InvariantFuzzState, invariant_strat, override_call_strat, - sample_gas_limit, sample_gas_price, + EvmFuzzState, InvariantFuzzState, invariant_strat, override_call_strat, sample_gas_limit, }, }; use foundry_evm_traces::{CallTraceArena, SparsedTraceArena}; @@ -40,10 +39,7 @@ use indicatif::ProgressBar; use parking_lot::RwLock; use proptest::{strategy::Strategy, test_runner::TestRunner}; use result::{assert_after_invariant, can_continue, did_fail_on_assert, invariant_preflight_check}; -use revm::{ - context::{Block, Transaction}, - state::Account, -}; +use revm::{context::Block, state::Account}; use serde::{Deserialize, Serialize}; use serde_json::json; use std::{ @@ -584,9 +580,6 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { let mut throughput = InvariantThroughputMetrics::default(); let mut failure_metrics = InvariantFailureMetrics::default(); - // Campaign-scoped state for `invariant.gas_fuzz`. Unused when disabled. - let gas_observations = GasObservations::new(); - let block_gas_cap = self.executor.gas_limit(); let continue_campaign = |runs: u32| { if early_exit.should_stop() { return false; @@ -650,25 +643,17 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { (last.call_details.target, Selector::from(sel_bytes)) }; - // Per-call gas-envelope sampling. Both `gas_limit` and `gas_price` - // are stamped onto `call_details` so `execute_tx` applies them on - // the run-local executor (not `self.executor`) and so failing - // sequences replay with the exact gas envelope that triggered them. - // The campaign-seeded `branch_runner` RNG is used (not `rand::rng()`) - // so `--fuzz-seed` continues to deterministically reproduce gas-fuzzed - // campaigns. + // Per-call `tx.gas_limit` sampling. Stamped onto `call_details` so + // `execute_tx` applies it on the run-local executor (not + // `self.executor`) and so failing sequences replay with the exact + // gas envelope that triggered them. The campaign-seeded + // `branch_runner` RNG is used (not `rand::rng()`) so `--fuzz-seed` + // continues to deterministically reproduce gas-fuzzed campaigns. if self.config.gas_fuzz { let rng = invariant_test.test_data.branch_runner.rng(); - let sampled_limit = sample_gas_limit( - &gas_observations, - handler_target, - handler_selector, - block_gas_cap, - rng, - ); - let sampled_price = sample_gas_price(rng); + let sampled_limit = sample_gas_limit(rng); if let Some(last) = current_run.inputs.last_mut() { - last.call_details.set_gas_envelope(sampled_limit, Some(sampled_price)); + last.call_details.gas_limit = Some(sampled_limit); } } @@ -729,8 +714,6 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { break 'stop; } } else { - // `execute_tx` scrubs any sampled gas-price override out - // of `call_result.tx_env`, so `commit()` cannot re-arm it. current_run.executor.commit(&mut call_result); // Collect data for fuzzing from the state changeset. @@ -776,20 +759,6 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { .push(FuzzCase { gas: call_result.gas_used, stipend: call_result.stipend }); throughput.record_call(call_result.gas_used); - // Only natural-gas runs feed the observation tracker; calls under - // a tight sampled budget may have OOG'd and would skew the max down. - if self.config.gas_fuzz - && call_result.gas_used > 0 - && let Some(last) = current_run.inputs.last() - && last.call_details.gas_limit().is_none() - { - gas_observations.record( - handler_target, - handler_selector, - call_result.gas_used, - ); - } - // Determine if test can continue or should exit. // Check invariants based on check_interval to improve deep run performance. // - check_interval=0: only assert on the last call @@ -1661,25 +1630,13 @@ pub(crate) fn execute_tx( // Per-call gas-limit override under `gas_fuzz`. Save/restore so the executor's // natural limit applies to invariant assertion calls. - let saved_gas_limit = tx.call_details.gas_limit().map(|g| { + let saved_gas_limit = tx.call_details.gas_limit.map(|g| { let saved = executor.gas_limit(); executor.set_gas_limit(g); saved }); - // Per-call `tx.gasprice` override under `gas_fuzz`. Snapshot the natural - // price so we can scrub it back below — otherwise downstream `commit()` - // (replay/shrink/corpus/showmap) would re-arm `cheats.gas_price` from - // `result.tx_env.gas_price()` and leak the sampled value to the next call. - let gas_price_override = tx.call_details.gas_price(); - let natural_gas_price = executor.tx_env().gas_price(); - if let Some(p) = gas_price_override - && let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() - { - cheats.gas_price = Some(p.into()); - } - - let mut result = executor.call_raw( + let result = executor.call_raw( tx.sender, tx.call_details.target, tx.call_details.calldata.clone(), @@ -1690,18 +1647,6 @@ pub(crate) fn execute_tx( executor.set_gas_limit(saved); } - // Scrub override before returning so any downstream `commit()` cannot - // re-arm `cheats.gas_price`. Also defensively clear the inspector field - // in case `initialize_interp` was never reached. - if gas_price_override.is_some() { - if let Ok(call_result) = &mut result { - call_result.tx_env.set_gas_price(natural_gas_price); - } - if let Some(cheats) = executor.inspector_mut().cheatcodes.as_mut() { - cheats.gas_price = None; - } - } - result.map_err(|e| eyre!(format!("Could not make raw evm call: {e}"))) } diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index 93a2fc3419518..05becc8c2389b 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -713,7 +713,7 @@ mod tests { target: Address::ZERO, calldata: Bytes::new(), value: None, - gas: None, + gas_limit: None, }, } } diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index 83c88d3b16eb8..af932ed7c89ed 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -70,22 +70,6 @@ pub struct BasicTxDetails { pub call_details: CallDetails, } -/// Optional per-call gas envelope overrides. Stamped by the `invariant.gas_fuzz` -/// sampler. Lives behind a `Box` on `CallDetails` so the OFF-mode hot path pays a -/// single niche-optimized pointer (`None` = no overrides, no allocation, no -/// pointer chase). Persisted in the corpus so failing sequences replay at the -/// exact gas envelope that triggered the failure. -#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)] -pub struct GasOverrides { - /// Override for `tx.gas_limit`. `None` means use the executor's default - /// (typically the block gas limit). - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gas_limit: Option, - /// Override for `tx.gasprice`. `None` means use the executor's default. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gas_price: Option, -} - /// Call details of a transaction generated to fuzz. #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub struct CallDetails { @@ -97,44 +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, - /// Boxed per-call gas envelope. `None` when `invariant.gas_fuzz = false` (default) - /// so the hot path adds only 8 bytes vs master. Flattened so JSON keys remain - /// `gas_limit` / `gas_price` at the top level (backwards-compatible with corpus - /// files written by earlier revisions of #14902). - #[serde(default, flatten, skip_serializing_if = "Option::is_none")] - pub gas: Option>, -} - -impl CallDetails { - /// Returns the per-call `tx.gas_limit` override, if any. - #[inline] - pub fn gas_limit(&self) -> Option { - self.gas.as_deref().and_then(|g| g.gas_limit) - } - - /// Returns the per-call `tx.gasprice` override, if any. - #[inline] - pub fn gas_price(&self) -> Option { - self.gas.as_deref().and_then(|g| g.gas_price) - } - - /// Stamp a sampled gas envelope onto this call. Allocates one `GasOverrides` - /// when first set; reuses on subsequent updates. - pub fn set_gas_envelope(&mut self, gas_limit: Option, gas_price: Option) { - if gas_limit.is_none() && gas_price.is_none() { - self.gas = None; - return; - } - match &mut self.gas { - Some(g) => { - g.gas_limit = gas_limit; - g.gas_price = gas_price; - } - None => { - self.gas = Some(Box::new(GasOverrides { gas_limit, gas_price })); - } - } - } + /// 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 { @@ -207,8 +158,8 @@ 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 gas_price = tx.call_details.gas_price(); + let gas_limit = tx.call_details.gas_limit; + let gas_price = None; let warp = tx.warp; let roll = tx.roll; if let Some((name, abi)) = &contracts.get(&target) @@ -569,7 +520,7 @@ mod tests { use alloy_primitives::address; #[test] - fn gas_price_counterexample_serializes_as_json_number() { + fn gas_limit_counterexample_serializes_as_json_number() { let tx = BasicTxDetails { warp: None, roll: None, @@ -578,10 +529,7 @@ mod tests { target: address!("00000000000000000000000000000000000000aa"), calldata: Bytes::from_static(&[0xde, 0xad, 0xbe, 0xef]), value: None, - gas: Some(Box::new(GasOverrides { - gas_limit: Some(123_456), - gas_price: Some(1_000_000_000_000), - })), + gas_limit: Some(123_456), }, }; @@ -593,7 +541,7 @@ mod tests { ); let value = serde_json::to_value(&counterexample).unwrap(); - assert_eq!(value["gas_price"], serde_json::json!(1_000_000_000_000_u64)); + 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 index dcef8f5027f48..22a3b3e588575 100644 --- a/crates/evm/fuzz/src/strategies/gas_sampler.rs +++ b/crates/evm/fuzz/src/strategies/gas_sampler.rs @@ -1,200 +1,38 @@ -//! Adaptive gas-envelope sampler for invariant fuzzing. +//! Per-call `tx.gas_limit` sampler for invariant fuzzing. //! -//! Tracks observed `gas_used` per `(target, selector)` and samples per-call -//! `tx.gas_limit` biased to the "edge of feasibility" where OOG-induced bugs -//! become reachable. Calibration-free: observations accumulate online. +//! 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. //! -//! Distribution per call (when an observation exists): -//! -//! | Bucket | Range | Purpose | -//! |--------|----------------------------------|--------------------------| -//! | 15% | `None` (natural) | Keep calibration current | -//! | 80% | razor: `[~0.5·max, max)`, k=4 | Hairline OOG band | -//! | 5% | `[max/10, max·3/10)` | DoS / griefing | +//! 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 alloy_primitives::{Address, Selector}; -use parking_lot::RwLock; use rand::Rng; -use std::{collections::HashMap, sync::Arc}; - -/// Floor for sampled gas — well above EVM intrinsic to avoid tx-validation noise. -const MIN_SAMPLED_GAS: u64 = 50_000; - -/// Skip sampling for cheap selectors (below the OOG-corruption regime). -const MIN_OBSERVED_FOR_SAMPLING: u64 = 100_000; -/// Running max `gas_used` per `(target, selector)`. Cheaply cloneable. -#[derive(Clone, Debug, Default)] -pub struct GasObservations { - inner: Arc>>, -} - -impl GasObservations { - pub fn new() -> Self { - Self::default() - } +/// EIP-7825 transaction gas-limit cap. +pub const TX_GAS_CAP: u64 = 1 << 24; - /// Record `gas_used`; keeps the running max. - pub fn record(&self, target: Address, selector: Selector, gas_used: u64) { - if gas_used == 0 { - return; - } - let mut w = self.inner.write(); - let entry = w.entry((target, selector)).or_insert(0); - if gas_used > *entry { - *entry = gas_used; - } - } - - pub fn max_for(&self, target: Address, selector: Selector) -> Option { - self.inner.read().get(&(target, selector)).copied() - } -} - -/// Sample a per-call `gas_limit`. Returns `None` to use the executor's -/// natural limit (no observation yet, or the natural-gas bucket was rolled). -/// Sampled values are clamped to `block_cap`. -pub fn sample_gas_limit( - observations: &GasObservations, - target: Address, - selector: Selector, - block_cap: u64, - rng: &mut R, -) -> Option { - let max = observations.max_for(target, selector)?; - if max < MIN_OBSERVED_FOR_SAMPLING { - return None; - } - - let bucket: u8 = rng.random_range(0..100); - let raw = match bucket { - 0..15 => return None, - 15..95 => { - // Razor: power-law biased toward `max` (~32% within 1%, ~51% within 7%). - let u: f64 = rng.random_range(0.0..1.0); - let bite = ((max as f64) * u.powi(4)) as u64; - let lo = max / 2; - max.saturating_sub(bite).max(lo) - } - _ => { - // 10–30% of max — DoS / griefing band. - let lo = (max / 10).max(MIN_SAMPLED_GAS); - let hi = (max * 3 / 10).max(lo + 1); - rng.random_range(lo..hi) - } - }; - - Some(raw.max(MIN_SAMPLED_GAS).min(block_cap)) -} - -/// Sample a per-call `tx.gasprice`. Targets bugs that branch on gasprice -/// (refund accounting, gas-aware token logic). Calibration-free. -/// -/// | Bucket | Range | Purpose | -/// |--------|--------------------|----------------------------------| -/// | 40% | `0` | Baseline / refund accounting | -/// | 40% | `[1, 1e10)` wei | Low — typical L2 fee (~0–10 gwei)| -/// | 20% | `[1e10, 1e12)` wei | High — congested L1 band | -pub fn sample_gas_price(rng: &mut R) -> u64 { - let bucket: u8 = rng.random_range(0..100); - match bucket { - 0..40 => 0, - 40..80 => rng.random_range(1u64..10_000_000_000u64), - _ => rng.random_range(10_000_000_000u64..1_000_000_000_000u64), - } +/// 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 alloy_primitives::address; use rand::{SeedableRng, rngs::StdRng}; - fn target() -> Address { - address!("00000000000000000000000000000000000000aa") - } - - fn sel() -> Selector { - Selector::from([0xde, 0xad, 0xbe, 0xef]) - } - - #[test] - fn returns_none_without_observations() { - let obs = GasObservations::new(); - let mut rng = StdRng::seed_from_u64(42); - assert!(sample_gas_limit(&obs, target(), sel(), 30_000_000, &mut rng).is_none()); - } - - #[test] - fn records_running_max() { - let obs = GasObservations::new(); - obs.record(target(), sel(), 1_000); - obs.record(target(), sel(), 5_000); - obs.record(target(), sel(), 2_000); - assert_eq!(obs.max_for(target(), sel()), Some(5_000)); - } - - #[test] - fn samples_stay_within_block_cap() { - let obs = GasObservations::new(); - obs.record(target(), sel(), 4_730_082); // baseline from gist - let mut rng = StdRng::seed_from_u64(1); - let block_cap = 30_000_000_u64; - for _ in 0..10_000 { - if let Some(g) = sample_gas_limit(&obs, target(), sel(), block_cap, &mut rng) { - assert!(g >= MIN_SAMPLED_GAS, "gas {g} below intrinsic floor"); - assert!(g <= block_cap, "gas {g} exceeds block cap"); - } - } - } - - #[test] - fn gas_price_distribution_matches_buckets() { - let mut rng = StdRng::seed_from_u64(0xC0FFEE); - let mut zero = 0; - let mut low = 0; - let mut high = 0; - for _ in 0..10_000 { - let p = sample_gas_price(&mut rng); - if p == 0 { - zero += 1; - } else if p < 10_000_000_000 { - low += 1; - } else if p < 1_000_000_000_000 { - high += 1; - } else { - panic!("sampled gas_price {p} exceeded upper bucket cap"); - } - } - // Expected ~40/40/20 — allow a generous 5-pp tolerance. - assert!((35..=45).contains(&(zero / 100)), "zero bucket: {zero}/10000"); - assert!((35..=45).contains(&(low / 100)), "low bucket: {low}/10000"); - assert!((15..=25).contains(&(high / 100)), "high bucket: {high}/10000"); - } - #[test] - fn distribution_hits_edge_band() { - // The 65% threshold from the gist (3_074_553 / 4_730_082 ≈ 0.65) must be - // reachable by the sampler — this is the band where the Tempo bug manifests. - let obs = GasObservations::new(); - let max = 4_730_082_u64; - obs.record(target(), sel(), max); - let mut rng = StdRng::seed_from_u64(7); - let mut hits_edge = 0; - let mut total = 0; + fn sampled_gas_limit_stays_in_range() { + let mut rng = StdRng::seed_from_u64(0xAA); for _ in 0..10_000 { - if let Some(g) = sample_gas_limit(&obs, target(), sel(), 30_000_000, &mut rng) { - total += 1; - // 60-70% of max — the corruption window in the gist - if g >= max * 60 / 100 && g <= max * 70 / 100 { - hits_edge += 1; - } - } + 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)", + ); } - assert!(total > 0); - // Edge band is ~10% of max. With 40% of samples in the [50%, 110%] range - // (uniform), the [60%, 70%] sub-band should get ~6-7% of samples. - let pct = hits_edge * 100 / total; - assert!(pct >= 3, "edge band hit rate too low: {pct}% ({hits_edge}/{total})"); } } diff --git a/crates/evm/fuzz/src/strategies/invariants.rs b/crates/evm/fuzz/src/strategies/invariants.rs index bfe5805b6ffe3..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, gas: None } + 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 b1d333647eb50..8ef9dad8e6775 100644 --- a/crates/evm/fuzz/src/strategies/mod.rs +++ b/crates/evm/fuzz/src/strategies/mod.rs @@ -26,4 +26,4 @@ mod literals; pub use literals::{LiteralMaps, LiteralsCollector, LiteralsDictionary}; mod gas_sampler; -pub use gas_sampler::{GasObservations, sample_gas_limit, sample_gas_price}; +pub use gas_sampler::sample_gas_limit; diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index a119dbc9392b4..b0f0584db6e1d 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -1876,15 +1876,11 @@ fn base_counterexamples_to_txes( warp: seq.warp, roll: seq.roll, sender: seq.sender.unwrap_or_default(), - call_details: { - let mut cd = CallDetails { - target: seq.addr.unwrap_or_default(), - calldata: seq.calldata.clone(), - value: seq.value, - gas: None, - }; - cd.set_gas_envelope(seq.gas_limit, seq.gas_price); - cd + call_details: CallDetails { + 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/test_cmd/invariant/gas.rs b/crates/forge/tests/cli/test_cmd/invariant/gas.rs index 4f7c23078001b..c39b949c2402f 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/gas.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/gas.rs @@ -171,367 +171,3 @@ contract GovernMentalReproTest is Test { "expected bulkPayout Max Gas > 500_000 (gas DoS), got {max_gas} from row: {row:?}", ); }); - -// Razor gas surfaces a swallowed inner-OOG state-corruption bug: the inner -// SSTORE loop OOGs, the outer keeps its retained 1/64 and silently advances -// `debited` without the matching `credited` write. -const SWALLOWED_OOG_SOURCE: &str = r#" -contract Inner { - uint256 public credited; - uint256[] public log; - - function credit(uint256 amount) external { - credited += amount; - for (uint256 i = 0; i < 80; i++) { - log.push(i); - } - } -} - -contract Outer { - Inner public inner; - uint256 public debited; - - constructor() { - inner = new Inner(); - } - - function transfer(uint256 amount) external { - debited += amount; - (bool ok, ) = address(inner).call( - abi.encodeWithSelector(Inner.credit.selector, amount) - ); - ok; // swallowed failure (the bug) - } -} -"#; - -const SWALLOWED_OOG_HANDLER: &str = r#" -import {Test} from "forge-std/Test.sol"; -import {Outer, Inner} from "../src/SwallowedOOG.sol"; - -contract SwallowedOOGHandler is Test { - Outer public outer; - constructor(Outer _o) { outer = _o; } - function doTransfer(uint256 amount) external { - outer.transfer(bound(amount, 1, 1e9)); - } -} -"#; - -forgetest_init!(should_surface_swallowed_oog_corruption_under_gas_fuzz, |prj, cmd| { - prj.add_source("SwallowedOOG.sol", SWALLOWED_OOG_SOURCE); - prj.add_test( - "SwallowedOOGUnderGasFuzz.t.sol", - &format!( - r#" -{SWALLOWED_OOG_HANDLER} - -contract SwallowedOOGUnderGasFuzzTest is Test {{ - Outer public outer; - SwallowedOOGHandler public handler; - - function setUp() public {{ - outer = new Outer(); - handler = new SwallowedOOGHandler(outer); - targetContract(address(handler)); - }} - - /// forge-config: default.invariant.runs = 16 - /// forge-config: default.invariant.depth = 200 - /// forge-config: default.invariant.fail-on-revert = false - /// forge-config: default.invariant.gas-fuzz = true - function invariant_oog_debitedEqualsCredited() public view {{ - assertEq( - outer.debited(), - outer.inner().credited(), - "swallowed OOG corruption" - ); - }} -}} - "# - ), - ); - - cmd.args(["test", "--mt", "invariant_oog_debitedEqualsCredited"]).assert_failure().stdout_eq( - str![[r#" -... -[FAIL: swallowed OOG corruption: [..]] -... - invariant_oog_debitedEqualsCredited() (runs: [..], calls: [..], reverts: [..]) -... -"#]], - ); -}); - -// Control: without gas-fuzz the natural ~30M gas always covers the inner; -// the same harness, depth, and seed never reach the razor and the invariant -// holds. -forgetest_init!(should_not_surface_swallowed_oog_corruption_without_gas_fuzz, |prj, cmd| { - prj.add_source("SwallowedOOG.sol", SWALLOWED_OOG_SOURCE); - prj.add_test( - "SwallowedOOGWithoutGasFuzz.t.sol", - &format!( - r#" -{SWALLOWED_OOG_HANDLER} - -contract SwallowedOOGWithoutGasFuzzTest is Test {{ - Outer public outer; - SwallowedOOGHandler public handler; - - function setUp() public {{ - outer = new Outer(); - handler = new SwallowedOOGHandler(outer); - targetContract(address(handler)); - }} - - /// forge-config: default.invariant.runs = 16 - /// forge-config: default.invariant.depth = 200 - /// forge-config: default.invariant.fail-on-revert = false - /// forge-config: default.invariant.gas-fuzz = false - function invariant_oog_natural_gas_passes() public view {{ - assertEq( - outer.debited(), - outer.inner().credited(), - "swallowed OOG corruption" - ); - }} -}} - "# - ), - ); - - cmd.args(["test", "--mt", "invariant_oog_natural_gas_passes"]).assert_success().stdout_eq( - str![[r#" -... -[PASS] invariant_oog_natural_gas_passes() (runs: 16, calls: [..], reverts: [..]) -... -"#]], - ); -}); - -// Per-call `tx.gasprice` actually reaches the handler under `gas_fuzz = true`. -// The handler records distinct `tx.gasprice` values into storage; the invariant -// asserts no diversity, so it must fire once the sampler delivers a second -// distinct value. If the sampled price were written to the wrong executor (the -// pre-fix bug) `uniqueCount` would stay at 1 and the invariant would never fire. -forgetest_init!(should_apply_sampled_gas_price_to_call, |prj, cmd| { - prj.add_test( - "GasPriceFuzzTest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract GasPriceObserver { - mapping(uint256 => bool) public seen; - uint256 public uniqueCount; - - function probe() external { - uint256 p = tx.gasprice; - if (!seen[p]) { - seen[p] = true; - uniqueCount++; - } - } -} - -contract GasPriceFuzzTest is Test { - GasPriceObserver public obs; - - function setUp() public { - obs = new GasPriceObserver(); - targetContract(address(obs)); - } - - /// forge-config: default.invariant.runs = 2 - /// forge-config: default.invariant.depth = 20 - /// forge-config: default.invariant.fail-on-revert = false - /// forge-config: default.invariant.gas-fuzz = true - function invariant_gasPriceStaysConstant() public view { - assertLe(obs.uniqueCount(), 1, "gas-price diversity observed"); - } -} - "#, - ); - - cmd.args(["test", "--mt", "invariant_gasPriceStaysConstant"]).assert_failure().stdout_eq(str![ - [r#" -... -[FAIL: gas-price diversity observed[..]] -... - invariant_gasPriceStaysConstant() (runs: [..], calls: [..], reverts: [..]) -... -"#] - ]); -}); - -forgetest_init!(should_not_leak_sampled_gas_price_to_invariant_call, |prj, cmd| { - prj.add_test( - "GasPriceIsolationTest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract GasPriceIsolationObserver { - mapping(uint256 => bool) public seen; - uint256 public uniqueCount; - - function probe() external { - uint256 p = tx.gasprice; - if (!seen[p]) { - seen[p] = true; - uniqueCount++; - } - } -} - -contract GasPriceIsolationTest is Test { - GasPriceIsolationObserver public obs; - - function setUp() public { - obs = new GasPriceIsolationObserver(); - targetContract(address(obs)); - } - - /// forge-config: default.invariant.runs = 2 - /// forge-config: default.invariant.depth = 20 - /// forge-config: default.invariant.fail-on-revert = false - /// forge-config: default.invariant.gas-fuzz = true - function invariant_sampledGasPriceIsHandlerOnly() public view { - assertEq(tx.gasprice, 0, "sampled gas price leaked"); - } - - function afterInvariant() public view { - assertGt(obs.uniqueCount(), 1, "handler saw sampled prices"); - } -} - "#, - ); - - cmd.args(["test", "--mt", "invariant_sampledGasPriceIsHandlerOnly"]) - .assert_success() - .stdout_eq(str![[r#" -... -[PASS] invariant_sampledGasPriceIsHandlerOnly() (runs: 2, calls: [..], reverts: [..]) -... -"#]]); -}); - -// Replay path must not observe a leaked sampled `tx.gasprice`. -// `replay_run` loops `execute_tx` + `executor.commit(&mut result)` per tx and -// then runs the invariant assertion. If `commit` re-armed `cheats.gas_price` -// from a sampled override, the invariant view would observe non-zero -// `tx.gasprice` instead of the natural `0`, and the surfaced failure would -// flip to the leak revert instead of the intended forced revert. -forgetest_init!(should_not_leak_sampled_gas_price_through_replay_commit, |prj, cmd| { - prj.add_test( - "GasPriceReplayLeakTest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract ReplayLeakHandler is Test { - // Storage write so the sampled gas envelope is actually exercised. - uint256 public n; - function poke(uint256 a) public { n = a; } -} - -contract GasPriceReplayLeakTest is Test { - ReplayLeakHandler public handler; - - function setUp() public { - handler = new ReplayLeakHandler(); - targetContract(address(handler)); - } - - /// forge-config: default.invariant.runs = 2 - /// forge-config: default.invariant.depth = 10 - /// forge-config: default.invariant.fail-on-revert = false - /// forge-config: default.invariant.gas-fuzz = true - function invariant_replayDoesNotObserveLeakedGasPrice() public view { - // Natural price is 0; any non-zero here means a sampled override - // leaked through `commit()` into this invariant call. - require(tx.gasprice == 0, "gas-price leaked across commit in replay"); - // Force a failure so shrink + replay actually run. - require(false, "forced failure to trigger shrink+replay"); - } -} - "#, - ); - - // Surfaced failure must be the forced one; a leak would flip it to the - // leak revert above. - cmd.args(["test", "--mt", "invariant_replayDoesNotObserveLeakedGasPrice"]) - .assert_failure() - .stdout_eq(str![[r#" -... -[FAIL: forced failure to trigger shrink+replay] invariant_replayDoesNotObserveLeakedGasPrice() (runs: [..], calls: [..], reverts: [..]) -... -"#]]); -}); - -forgetest_init!(should_replay_persisted_gas_price_failure, |prj, cmd| { - prj.update_config(|config| { - config.invariant.runs = 2; - config.invariant.depth = 20; - config.invariant.fail_on_revert = false; - config.invariant.gas_fuzz = true; - }); - prj.add_test( - "GasPricePersistedReplayTest.t.sol", - r#" -import {Test} from "forge-std/Test.sol"; - -contract PersistedGasPriceObserver { - mapping(uint256 => bool) public seen; - uint256 public uniqueCount; - - function probe() external { - uint256 p = tx.gasprice; - if (!seen[p]) { - seen[p] = true; - uniqueCount++; - } - } -} - -contract GasPricePersistedReplayTest is Test { - PersistedGasPriceObserver public obs; - - function setUp() public { - obs = new PersistedGasPriceObserver(); - targetContract(address(obs)); - } - - function invariant_gasPriceStaysConstant() public view { - assertLe(obs.uniqueCount(), 1, "gas-price diversity observed"); - } -} - "#, - ); - - cmd.args(["test", "--mt", "invariant_gasPriceStaysConstant"]).assert_failure(); - - let persisted = prj - .root() - .join("cache") - .join("invariant") - .join("failures") - .join("GasPricePersistedReplayTest") - .join("invariants") - .join("invariant_gasPriceStaysConstant"); - let json: serde_json::Value = - serde_json::from_reader(std::fs::File::open(&persisted).unwrap()).unwrap(); - let calls = json["call_sequence"].as_array().unwrap(); - assert!(calls.iter().any(|call| call.get("gas_price").is_some())); - - prj.update_config(|config| { - config.invariant.runs = 0; - }); - cmd.forge_fuse() - .args(["test", "--mt", "invariant_gasPriceStaysConstant"]) - .assert_failure() - .stderr_eq(str![[" -... -Warning: Replayed invariant failure from persisted file.\u{20} -Run `forge clean` or remove file to ignore failure and to continue invariant test campaign. -... -"]]); -}); From c7a839ed34511bece7c8e3ddd70de2c9f023aa50 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Thu, 28 May 2026 16:21:10 +0300 Subject: [PATCH 14/20] chore(invariant): comment why gas_fuzz forces the metrics path --- crates/evm/evm/src/executors/invariant/mod.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index fb2cc5245fe98..5c0e34f5952e6 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -671,6 +671,9 @@ 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; + // `gas_fuzz=true` forces the metrics path (independently of `show_metrics`) + // because the max-gas-per-handler tracking — used to surface the gas-DoS + // signal `gas_fuzz` exists to find — lives in `record_metrics`. if self.config.show_metrics || self.config.gas_fuzz { // gas_used / run_inputs are only meaningful under `gas_fuzz`; // `show_metrics` alone keeps the legacy table. From 9588925b1eeb80538af93ba3d4c8e87576c0f64d Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Thu, 28 May 2026 16:54:34 +0300 Subject: [PATCH 15/20] chore(invariant): drop vestigial gas_price field from BaseCounterExample --- crates/evm/fuzz/src/lib.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/crates/evm/fuzz/src/lib.rs b/crates/evm/fuzz/src/lib.rs index af932ed7c89ed..647f323d2165b 100644 --- a/crates/evm/fuzz/src/lib.rs +++ b/crates/evm/fuzz/src/lib.rs @@ -122,9 +122,6 @@ pub struct BaseCounterExample { /// Optional transaction gas limit override. #[serde(default, skip_serializing_if = "Option::is_none")] pub gas_limit: Option, - /// Optional transaction gas price override. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gas_price: Option, /// Contract name if it exists. pub contract_name: Option, /// Function name if it exists. @@ -159,7 +156,6 @@ impl BaseCounterExample { let bytes = &tx.call_details.calldata; let value = tx.call_details.value; let gas_limit = tx.call_details.gas_limit; - let gas_price = None; let warp = tx.warp; let roll = tx.roll; if let Some((name, abi)) = &contracts.get(&target) @@ -175,7 +171,6 @@ impl BaseCounterExample { calldata: bytes.clone(), value, gas_limit, - gas_price, contract_name: Some(name.clone()), func_name: Some(func.name.clone()), signature: Some(func.signature()), @@ -198,7 +193,6 @@ impl BaseCounterExample { calldata: bytes.clone(), value, gas_limit, - gas_price, contract_name: None, func_name: None, signature: None, @@ -224,7 +218,6 @@ impl BaseCounterExample { calldata: bytes, value: None, gas_limit: None, - gas_price: None, contract_name: None, func_name: None, signature: None, From 436e2e35bc6616d113a7a7379b2fc468933f90f6 Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Thu, 28 May 2026 17:22:15 +0300 Subject: [PATCH 16/20] fix(invariant): attach max-gas inputs on MaxAssumeRejects exit and refresh gas_fuzz doc Mirror the timeout exit path so the MaxAssumeRejects break surfaces the max-gas prefix collected so far instead of emitting an empty max_gas_sequence. Also update the gas_fuzz config doc to match the post-simplification design (uniform tx.gas_limit draw, no gasprice sampling). --- crates/config/src/invariant.rs | 5 +++-- crates/evm/evm/src/executors/invariant/mod.rs | 3 +++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/crates/config/src/invariant.rs b/crates/config/src/invariant.rs index 70ac52a449518..739317184edd3 100644 --- a/crates/config/src/invariant.rs +++ b/crates/config/src/invariant.rs @@ -53,8 +53,9 @@ pub struct InvariantConfig { /// Default `false` (no behavior change, no new output). /// /// Turns on three things at once: - /// 1. Per-call `tx.gas_limit` / `tx.gasprice` randomization, biased toward the OOG / - /// branch-on-gasprice bug regimes. + /// 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, diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 5c0e34f5952e6..b43ff17f70e48 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -714,6 +714,9 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { invariant_contract.anchor(), InvariantFuzzError::MaxAssumeRejects(self.config.max_assume_rejects), ); + // Mirror the timeout path: surface the max-gas prefix collected so + // far instead of emitting an empty `max_gas_sequence`. + invariant_test.attach_max_gas_inputs(runs, ¤t_run.inputs); break 'stop; } } else { From a45740ee391747ebfe88d347cc6bef3b1aee7e2a Mon Sep 17 00:00:00 2001 From: grandizzy <38490174+grandizzy@users.noreply.github.com> Date: Thu, 28 May 2026 17:57:14 +0300 Subject: [PATCH 17/20] fix(invariant): strip stale gas_limit from persisted corpus when gas_fuzz is off Plumb the campaign-level gas_fuzz flag into WorkerCorpus and drop any persisted call_details.gas_limit on corpus load (both master-worker initial replay and inter-worker sync) when the flag is false. Stops a prior gas_fuzz=true corpus from continuing to stamp the executor after the user turns gas_fuzz off. Failure-cache replay is unaffected so saved sequences still reproduce at their original gas envelope. --- crates/evm/evm/src/executors/corpus.rs | 23 ++++++++++++++++--- crates/evm/evm/src/executors/fuzz/mod.rs | 5 ++-- crates/evm/evm/src/executors/invariant/mod.rs | 1 + 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index 89613f97e9994..c257ff86dc33e 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -294,6 +294,8 @@ 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, } /// Refs used during corpus replay to register contracts deployed mid-sequence as fuzz targets, @@ -340,6 +342,7 @@ pub(crate) fn rollback_replay_created( } impl WorkerCorpus { + #[allow(clippy::too_many_arguments)] pub fn new( id: usize, config: FuzzCorpusConfig, @@ -349,6 +352,7 @@ impl WorkerCorpus { fuzzed_function: Option<&Function>, fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>, dynamic: Option>, + gas_fuzz: bool, ) -> Result { let mutation_generator = prop_oneof![ Just(MutationType::Splice), @@ -434,7 +438,7 @@ impl WorkerCorpus { // Then, [distribute]s it to workers. let executor = executor.expect("Executor required for master worker"); 'corpus_replay: for entry in read_corpus_dir(corpus_dir) { - let tx_seq = entry.read_tx_seq()?; + let mut tx_seq = entry.read_tx_seq()?; if tx_seq.is_empty() { continue; } @@ -443,7 +447,11 @@ impl WorkerCorpus { let mut cmp_seq = Vec::with_capacity(tx_seq.len()); // Targets deployed during this entry, cleared after the entry. let mut created: Vec
= Vec::new(); - for tx in &tx_seq { + for tx in &mut tx_seq { + // Strip stale `gas_limit` from a prior `gas_fuzz=true` corpus. + if !gas_fuzz { + tx.call_details.gas_limit = None; + } if Self::can_replay_tx(tx, fuzzed_function, fuzzed_contracts) { let mut call_result = execute_tx(&mut executor, tx)?; cmp_seq.push(call_result.evm_cmp_values.take().unwrap_or_default()); @@ -513,6 +521,7 @@ impl WorkerCorpus { last_sync_metrics: Default::default(), optimization_best_value, optimization_best_sequence, + gas_fuzz, }) } @@ -1097,11 +1106,17 @@ impl WorkerCorpus { if entry.timestamp <= self.last_sync_timestamp { continue; } - let tx_seq = entry.read_tx_seq()?; + let mut tx_seq = entry.read_tx_seq()?; if tx_seq.is_empty() { warn!(target: "corpus", "skipping empty corpus entry: {}", entry.path.display()); continue; } + // Mirror the master-worker load gate for sync entries. + if !self.gas_fuzz { + for tx in &mut tx_seq { + tx.call_details.gas_limit = None; + } + } imports.push((entry, tx_seq)); } @@ -1502,6 +1517,7 @@ mod tests { last_sync_metrics: CorpusMetrics::default(), optimization_best_value: None, optimization_best_sequence: vec![], + gas_fuzz: false, }; (manager, seed_uuid) @@ -1637,6 +1653,7 @@ mod tests { last_sync_metrics: CorpusMetrics::default(), optimization_best_value: None, optimization_best_sequence: vec![], + gas_fuzz: false, }; // First eviction should remove the non-favored one. diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 7c94140317d43..0b626a14f2b7c 100644 --- a/crates/evm/evm/src/executors/fuzz/mod.rs +++ b/crates/evm/evm/src/executors/fuzz/mod.rs @@ -466,8 +466,9 @@ impl FuzzedExecutor { // 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) + None, // fuzzed_contracts for invariant tests + None, // dynamic target ctx (invariant-only) + false, // gas_fuzz is invariant-only )?; let mut executor = self.executor_f.clone(); diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index b43ff17f70e48..7d40fb77c2583 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -1172,6 +1172,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { None, Some(&targeted_contracts), Some(self.dynamic_target_ctx()), + self.config.gas_fuzz, )?; let mut invariant_test = From e926e7ef37a73d9949b2528a6ef55c5b414217fe Mon Sep 17 00:00:00 2001 From: Mablr <59505383+mablr@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:50:58 +0200 Subject: [PATCH 18/20] fix: clippy failures in evm corpus tests --- crates/evm/evm/src/executors/corpus.rs | 31 +++++++++++++------ crates/evm/evm/src/executors/fuzz/mod.rs | 14 ++++----- .../evm/src/executors/invariant/campaign.rs | 17 +++++++--- 3 files changed, 40 insertions(+), 22 deletions(-) diff --git a/crates/evm/evm/src/executors/corpus.rs b/crates/evm/evm/src/executors/corpus.rs index f754aa9c089e8..ea100c5cf1ee0 100644 --- a/crates/evm/evm/src/executors/corpus.rs +++ b/crates/evm/evm/src/executors/corpus.rs @@ -469,6 +469,16 @@ pub struct WorkerCorpus { 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, /// mirroring the campaign loop so follow-up calls into them aren't dropped by `can_replay_tx`. #[derive(Clone, Copy)] @@ -646,13 +656,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>, - gas_fuzz: bool, + 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, @@ -1676,6 +1688,7 @@ mod tests { target: Address::ZERO, calldata: Bytes::new(), value: None, + gas_limit: None, }, } } @@ -1737,6 +1750,7 @@ mod tests { target: Address::ZERO, calldata, value: None, + gas_limit: None, }, }; let cmp = CmpOperands { @@ -1857,10 +1871,7 @@ mod tests { 1, corpus_config(corpus_root), Just(basic_tx()).boxed(), - None, - None, - None, - None, + WorkerCorpusReplayConfig::default(), ) .unwrap(); diff --git a/crates/evm/evm/src/executors/fuzz/mod.rs b/crates/evm/evm/src/executors/fuzz/mod.rs index 0b626a14f2b7c..512af40530624 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; @@ -463,12 +463,12 @@ impl FuzzedExecutor { 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) - false, // gas_fuzz is 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 6bc09ba71c551..0fde3a7311540 100644 --- a/crates/evm/evm/src/executors/invariant/campaign.rs +++ b/crates/evm/evm/src/executors/invariant/campaign.rs @@ -462,6 +462,7 @@ mod tests { target: Address::repeat_byte(sender.wrapping_add(1)), calldata: Bytes::from(vec![0, 0, 0, sender]), value: None, + gas_limit: None, }, } } @@ -673,7 +674,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, ), @@ -685,7 +686,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, ), @@ -697,7 +698,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, ), @@ -713,9 +714,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); From c59e242920bb8e346e2b739164d124ee0604b336 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Fri, 5 Jun 2026 10:43:59 +0000 Subject: [PATCH 19/20] fix: set gas fuzz flag in forge result test --- crates/forge/src/result.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index df506b9618cad..15f9db68bd726 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -294,6 +294,7 @@ mod tests { reverts: 0, workers: *workers, metrics: Map::new(), + gas_fuzz: false, failed_corpus_replays: 0, optimization_best_value: None, }, From e957739cf442c953e431376601b63d7510e53c17 Mon Sep 17 00:00:00 2001 From: Derek <256792747+decofe@users.noreply.github.com> Date: Fri, 5 Jun 2026 11:06:55 +0000 Subject: [PATCH 20/20] fix: stabilize invariant gas tests --- crates/forge/src/result.rs | 1 + .../forge/tests/cli/test_cmd/invariant/gas.rs | 28 +++++++++++++------ 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index 15f9db68bd726..1c30e722fee64 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -1456,6 +1456,7 @@ 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. diff --git a/crates/forge/tests/cli/test_cmd/invariant/gas.rs b/crates/forge/tests/cli/test_cmd/invariant/gas.rs index c39b949c2402f..c9f7029e0377e 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/gas.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/gas.rs @@ -82,8 +82,8 @@ contract GasFuzzAbsentTest is Test { "#]]); }); -// The GovernMental 2016 gas-DoS bug should surface as `bulkPayout` Max -// Gas > 500k (clears the bounded-fixed ~474k cap with margin). +// 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", @@ -155,19 +155,29 @@ contract GovernMentalReproTest is Test { .get_output() .stdout_lossy(); - // Last `|`-delimited cell of the `bulkPayout` row is its Max Gas value. let row = stdout .lines() .find(|l| l.contains("bulkPayout") && l.contains('|')) .unwrap_or_else(|| panic!("no bulkPayout metrics row found:\n{stdout}")); - let max_gas: u64 = row - .split('|') - .map(str::trim) - .rfind(|s| !s.is_empty()) + + 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!( - max_gas > 500_000, - "expected bulkPayout Max Gas > 500_000 (gas DoS), got {max_gas} from row: {row:?}", + row.split('|').map(str::trim).filter(|s| !s.is_empty()).count() >= 6, + "expected bulkPayout row to include Max Gas cell, got row: {row:?}", ); });