Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
3dfb548
feat(invariant): gas-envelope fuzzing via `invariant.gas_fuzz`
grandizzy May 25, 2026
4b55099
fix(invariant): route sampled gas-price through the run-local executor
grandizzy May 25, 2026
3b13d8d
fix(invariant): seed gas-envelope sampler from the campaign RNG
grandizzy May 25, 2026
56d0bcc
fix(invariant): isolate gas-fuzz gas price
decofe May 25, 2026
99d33b0
fix(invariant): persist gas fuzz replay envelope
decofe May 25, 2026
906d91f
perf(invariant): defer max gas sequence materialization
decofe May 25, 2026
1eab6c2
fix(invariant): serialize gas price as u64
decofe May 25, 2026
6de8707
fix(invariant): show max gas column when gas fuzz is enabled
decofe May 25, 2026
ebbc32f
test(invariant): fix persisted gas price replay snapshot
decofe May 25, 2026
0cfd30c
test(invariant): match replay warning whitespace
decofe May 25, 2026
05b1591
Merge remote-tracking branch 'origin/master' into feat/invariant-gas-…
decofe May 26, 2026
9301b44
Merge branch 'master' into feat/invariant-gas-fuzz
grandizzy May 26, 2026
4661bf3
perf(invariant): box gas-fuzz overrides to remove OFF-mode regression
grandizzy May 27, 2026
00d5a4b
Merge branch 'master' into feat/invariant-gas-fuzz
grandizzy May 27, 2026
aac8d91
fix(invariant): scrub sampled gas_price in execute_tx so commit canno…
grandizzy May 28, 2026
1e08588
feat(invariant): simplify gas_fuzz to flat tx.gas_limit sampling in […
grandizzy May 28, 2026
c7a839e
chore(invariant): comment why gas_fuzz forces the metrics path
grandizzy May 28, 2026
9588925
chore(invariant): drop vestigial gas_price field from BaseCounterExample
grandizzy May 28, 2026
436e2e3
fix(invariant): attach max-gas inputs on MaxAssumeRejects exit and re…
grandizzy May 28, 2026
a45740e
fix(invariant): strip stale gas_limit from persisted corpus when gas_…
grandizzy May 28, 2026
8850e54
Merge branch 'master' into feat/invariant-gas-fuzz
grandizzy Jun 1, 2026
201c79a
Merge master into feat/invariant-gas-fuzz
decofe Jun 2, 2026
e926e7e
fix: clippy failures in evm corpus tests
mablr Jun 2, 2026
e84ecc6
Merge remote-tracking branch 'origin/master' into feat/invariant-gas-…
decofe Jun 5, 2026
c59e242
fix: set gas fuzz flag in forge result test
decofe Jun 5, 2026
e957739
fix: stabilize invariant gas tests
decofe Jun 5, 2026
362d71c
Merge remote-tracking branch 'origin/master' into feat/invariant-gas-…
decofe Jun 9, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

11 changes: 11 additions & 0 deletions crates/config/src/invariant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,16 @@ pub struct InvariantConfig {
///
/// Example: `check_interval = 10` means assert after calls 10, 20, 30, ... and the last call.
pub check_interval: u32,
/// Enable gas-envelope fuzzing and per-selector gas reporting.
/// Default `false` (no behavior change, no new output).
///
/// Turns on three things at once:
/// 1. Per-call `tx.gas_limit` drawn uniformly from `[2^24, 2^25)` to exercise gas-conditional
/// EVM dispatch (refund accounting, EIP-150 1/64 retention, OOG dispatch at the EIP-7825
/// cap).
/// 2. Per-`(target, selector)` `max_gas` tracking (adds the "Max Gas" column).
/// 3. Per-selector reproducer block: the call sequence that produced each peak.
pub gas_fuzz: bool,
}

impl Default for InvariantConfig {
Expand All @@ -169,6 +179,7 @@ impl Default for InvariantConfig {
max_time_delay: None,
max_block_delay: None,
check_interval: 1,
gas_fuzz: false,
}
}
}
Expand Down
81 changes: 65 additions & 16 deletions crates/evm/evm/src/executors/corpus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ impl WorkerCorpusSeed {
fuzzed_function: Option<&Function>,
fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
dynamic: Option<DynamicTargetCtx<'_>>,
gas_fuzz: bool,
) -> Result<Self> {
let mut seed = Self::empty(config).with_optimization_state(config);
let Some(corpus_dir) = &config.corpus_dir else {
Expand Down Expand Up @@ -321,7 +322,7 @@ impl WorkerCorpusSeed {
metrics: Some(&mut seed.metrics),
};
let ReplayOutcome { keep_entry, cmp_seq, failed_replays, .. } =
replay_corpus_sequence(&tx_seq, executor, target, coverage)?;
replay_corpus_sequence(&tx_seq, executor, target, coverage, gas_fuzz)?;
seed.failed_replays += failed_replays;
if !keep_entry {
continue;
Expand Down Expand Up @@ -351,6 +352,7 @@ impl WorkerCorpusSeed {
entries: impl IntoIterator<Item = CampaignCorpusEntry>,
executor: &Executor<FEN>,
target: ReplayTarget<'_>,
gas_fuzz: bool,
optimization_best: Option<(I256, &[BasicTxDetails])>,
) -> Result<()> {
let mut history_map = self.history_map.clone();
Expand All @@ -367,7 +369,7 @@ impl WorkerCorpusSeed {
metrics: None,
};
let ReplayOutcome { keep_entry, new_coverage, .. } =
replay_corpus_sequence(&entry.tx_seq, executor, target, coverage)?;
replay_corpus_sequence(&entry.tx_seq, executor, target, coverage, gas_fuzz)?;
if !keep_entry || !new_coverage {
continue;
}
Expand Down Expand Up @@ -496,6 +498,18 @@ pub struct WorkerCorpus {
optimization_best_value: Option<I256>,
/// Optimization mode: the call sequence that produced the best value.
optimization_best_sequence: Vec<BasicTxDetails>,
/// `invariant.gas_fuzz` for this campaign; gates persisted `gas_limit` overrides on load.
gas_fuzz: bool,
}

/// Context used by the master worker to replay persisted corpus entries.
#[derive(Clone, Copy, Default)]
pub struct WorkerCorpusReplayConfig<'a, FEN: FoundryEvmNetwork> {
pub executor: Option<&'a Executor<FEN>>,
pub fuzzed_function: Option<&'a Function>,
pub fuzzed_contracts: Option<&'a FuzzRunIdentifiedContracts>,
pub dynamic: Option<DynamicTargetCtx<'a>>,
pub gas_fuzz: bool,
}

/// Refs used during corpus replay to register contracts deployed mid-sequence as fuzz targets,
Expand Down Expand Up @@ -573,9 +587,18 @@ fn replay_corpus_sequence<FEN: FoundryEvmNetwork>(
executor: &Executor<FEN>,
target: ReplayTarget<'_>,
coverage: ReplayCoverage<'_>,
gas_fuzz: bool,
) -> Result<ReplayOutcome> {
let mut executor = executor.clone();
replay_corpus_sequence_with_executor(tx_seq, &mut executor, target, coverage, false, true)
replay_corpus_sequence_with_executor(
tx_seq,
&mut executor,
target,
coverage,
false,
true,
gas_fuzz,
)
}

fn replay_corpus_sequence_with_executor<FEN: FoundryEvmNetwork>(
Expand All @@ -585,13 +608,22 @@ fn replay_corpus_sequence_with_executor<FEN: FoundryEvmNetwork>(
mut coverage: ReplayCoverage<'_>,
trace_sync: bool,
reject_unmatched_function: bool,
gas_fuzz: bool,
) -> Result<ReplayOutcome> {
let mut cmp_seq = Vec::with_capacity(tx_seq.len());
let mut failed_replays = 0;
let mut new_coverage_for_entry = false;
let mut created: Vec<Address> = Vec::new();

for tx in tx_seq {
let mut replay_tx;
let tx = if gas_fuzz {
tx
} else {
replay_tx = tx.clone();
replay_tx.call_details.gas_limit = None;
&replay_tx
};
if WorkerCorpus::can_replay_tx(tx, target.fuzzed_function, target.fuzzed_contracts) {
let mut call_result = execute_tx(executor, tx)?;
cmp_seq.push(call_result.evm_cmp_values.take().unwrap_or_default());
Expand Down Expand Up @@ -657,31 +689,36 @@ impl WorkerCorpus {
id: usize,
config: FuzzCorpusConfig,
tx_generator: BoxedStrategy<BasicTxDetails>,
// Only required by master worker (id = 0) to replay existing corpus.
executor: Option<&Executor<FEN>>,
fuzzed_function: Option<&Function>,
fuzzed_contracts: Option<&FuzzRunIdentifiedContracts>,
dynamic: Option<DynamicTargetCtx<'_>>,
replay: WorkerCorpusReplayConfig<'_, FEN>,
) -> Result<Self> {
let WorkerCorpusReplayConfig {
executor,
fuzzed_function,
fuzzed_contracts,
dynamic,
gas_fuzz,
} = replay;
let seed = if id == 0 {
WorkerCorpusSeed::load_from_disk(
&config,
executor,
fuzzed_function,
fuzzed_contracts,
dynamic,
gas_fuzz,
)?
} else {
WorkerCorpusSeed::empty(&config).with_optimization_state(&config)
};
Ok(Self::from_seed(id, config, tx_generator, seed))
Ok(Self::from_seed(id, config, tx_generator, seed, gas_fuzz))
}

pub(crate) fn from_seed(
id: usize,
config: FuzzCorpusConfig,
tx_generator: BoxedStrategy<BasicTxDetails>,
seed: WorkerCorpusSeed,
gas_fuzz: bool,
) -> Self {
let mutation_generator = prop_oneof![
Just(MutationType::Splice),
Expand Down Expand Up @@ -724,6 +761,7 @@ impl WorkerCorpus {
last_sync_metrics: Default::default(),
optimization_best_value: seed.optimization_best_value,
optimization_best_sequence: seed.optimization_best_sequence,
gas_fuzz,
}
}

Expand Down Expand Up @@ -1389,6 +1427,7 @@ impl WorkerCorpus {
coverage,
true,
false,
self.gas_fuzz,
)?;

let sync_path = &entry.path;
Expand Down Expand Up @@ -1700,6 +1739,7 @@ mod tests {
target: Address::ZERO,
calldata: Bytes::new(),
value: None,
gas_limit: None,
},
}
}
Expand All @@ -1721,7 +1761,13 @@ mod tests {
}

fn worker_corpus(id: usize, corpus_root: PathBuf, seed: WorkerCorpusSeed) -> WorkerCorpus {
WorkerCorpus::from_seed(id, corpus_config(corpus_root), Just(basic_tx()).boxed(), seed)
WorkerCorpus::from_seed(
id,
corpus_config(corpus_root),
Just(basic_tx()).boxed(),
seed,
false,
)
}

fn empty_worker_corpus(id: usize, corpus_root: PathBuf) -> WorkerCorpus {
Expand Down Expand Up @@ -1755,6 +1801,7 @@ mod tests {
target: Address::ZERO,
calldata,
value: None,
gas_limit: None,
},
};
let cmp = CmpOperands {
Expand Down Expand Up @@ -1875,10 +1922,7 @@ mod tests {
1,
corpus_config(corpus_root),
Just(basic_tx()).boxed(),
None,
None,
None,
None,
WorkerCorpusReplayConfig::default(),
)
.unwrap();

Expand Down Expand Up @@ -1921,8 +1965,13 @@ mod tests {
optimization_best_sequence: tx_seq,
};

let manager =
WorkerCorpus::from_seed(1, corpus_config(corpus_root), Just(basic_tx()).boxed(), seed);
let manager = WorkerCorpus::from_seed(
1,
corpus_config(corpus_root),
Just(basic_tx()).boxed(),
seed,
false,
);

assert_eq!(manager.in_memory_corpus.len(), 1);
assert_eq!(manager.history_map, vec![1, 2, 3]);
Expand Down
21 changes: 14 additions & 7 deletions crates/evm/evm/src/executors/fuzz/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -284,6 +284,7 @@ impl<FEN: FoundryEvmNetwork> FuzzedExecutor<FEN> {
target: address,
calldata: calldata.clone(),
value: None,
gas_limit: None,
},
}],
&[cmp_values],
Expand Down Expand Up @@ -459,18 +460,24 @@ impl<FEN: FoundryEvmNetwork> FuzzedExecutor<FEN> {
warp: None,
roll: None,
sender: Default::default(),
call_details: CallDetails { target: Default::default(), calldata, value: None },
call_details: CallDetails {
target: Default::default(),
calldata,
value: None,
gas_limit: None,
},
});

let mut corpus = WorkerCorpus::new(
worker_id,
self.config.corpus.clone(),
strategy.boxed(),
// Master worker replays the persisted corpus using the executor
(worker_id == 0).then_some(&self.executor_f),
Some(func),
None, // fuzzed_contracts for invariant tests
None, // dynamic target ctx (invariant-only)
WorkerCorpusReplayConfig {
// Master worker replays the persisted corpus using the executor.
executor: (worker_id == 0).then_some(&self.executor_f),
fuzzed_function: Some(func),
..Default::default()
},
)?;
let mut executor = self.executor_f.clone();

Expand Down
17 changes: 12 additions & 5 deletions crates/evm/evm/src/executors/invariant/campaign.rs
Original file line number Diff line number Diff line change
Expand Up @@ -469,6 +469,7 @@ mod tests {
target: Address::repeat_byte(sender.wrapping_add(1)),
calldata: Bytes::from(vec![0, 0, 0, sender]),
value: None,
gas_limit: None,
},
}
}
Expand Down Expand Up @@ -679,7 +680,7 @@ mod tests {
3,
0x30,
"transfer(address)",
InvariantMetrics { calls: 3, reverts: 1, discards: 0 },
InvariantMetrics { calls: 3, reverts: 1, discards: 0, ..Default::default() },
3,
0,
),
Expand All @@ -690,7 +691,7 @@ mod tests {
1,
0x10,
"transfer(address)",
InvariantMetrics { calls: 1, reverts: 0, discards: 2 },
InvariantMetrics { calls: 1, reverts: 0, discards: 2, ..Default::default() },
1,
4,
),
Expand All @@ -701,7 +702,7 @@ mod tests {
2,
0x20,
"approve(address)",
InvariantMetrics { calls: 2, reverts: 1, discards: 1 },
InvariantMetrics { calls: 2, reverts: 1, discards: 1, ..Default::default() },
2,
0,
),
Expand All @@ -716,9 +717,15 @@ mod tests {
assert_eq!(result.last_run_inputs[0].sender, Address::repeat_byte(0x30));

let transfer_metrics = result.metrics.get("transfer(address)").unwrap();
assert_eq!(transfer_metrics, &InvariantMetrics { calls: 4, reverts: 1, discards: 2 });
assert_eq!(
transfer_metrics,
&InvariantMetrics { calls: 4, reverts: 1, discards: 2, ..Default::default() }

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this default hides that the aggregator now drops max_gas and max_gas_sequence: merge_metrics only sums calls/reverts/discards. please seed distinct max-gas values here and assert the merged result keeps the largest peak and its sequence, so multi-worker gas_fuzz doesn't lose the new fields.

);
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);
Expand Down
Loading
Loading