From 4bb256fb6a86abef98a42791579b7e8523a40bad Mon Sep 17 00:00:00 2001 From: Karl Yu <43113774+0xKarl98@users.noreply.github.com> Date: Sat, 11 Apr 2026 17:18:15 +0800 Subject: [PATCH] fix(invariant): preserve delay semantics during shrinking (#14218) * fix(invariant): preserve delay semantics during shrinking * fix tests --------- Co-authored-by: zerosnacks <95942363+zerosnacks@users.noreply.github.com> --- crates/config/src/invariant.rs | 5 + crates/evm/evm/src/executors/invariant/mod.rs | 9 +- .../evm/evm/src/executors/invariant/result.rs | 8 +- .../evm/evm/src/executors/invariant/shrink.rs | 173 +++++++++++++++--- crates/forge/src/runner.rs | 1 + .../tests/cli/test_cmd/invariant/common.rs | 117 ++++++++++-- 6 files changed, 268 insertions(+), 45 deletions(-) diff --git a/crates/config/src/invariant.rs b/crates/config/src/invariant.rs index 591af88efdee4..11ddc999ab8a9 100644 --- a/crates/config/src/invariant.rs +++ b/crates/config/src/invariant.rs @@ -79,4 +79,9 @@ impl InvariantConfig { pub fn new(cache_dir: PathBuf) -> Self { Self { failure_persist_dir: Some(cache_dir), ..Default::default() } } + + /// Returns true if generated invariant calls may advance block time or height. + pub fn has_delay(&self) -> bool { + self.max_block_delay.is_some() || self.max_time_delay.is_some() + } } diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 9a984d6799e13..e61f5443791ef 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -527,9 +527,12 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> { invariant_test.test_data.failures.error = Some(InvariantFuzzError::Revert(case_data)); result::RichInvariantResults::new(false, None) - } else if !invariant_contract.is_optimization() { - // In optimization mode, keep reverted calls to preserve - // warp/roll values for correct replay during shrinking. + } else if !invariant_contract.is_optimization() + && !self.config.has_delay() + { + // Delay-enabled campaigns keep reverted calls so shrinking can + // preserve their warp/roll contribution when building the final + // counterexample. current_run.inputs.pop(); result::RichInvariantResults::new(true, None) } else { diff --git a/crates/evm/evm/src/executors/invariant/result.rs b/crates/evm/evm/src/executors/invariant/result.rs index 75e4bd2cc1130..3578e7aa0e56b 100644 --- a/crates/evm/evm/src/executors/invariant/result.rs +++ b/crates/evm/evm/src/executors/invariant/result.rs @@ -183,10 +183,10 @@ pub(crate) fn can_continue( invariant_data.failures.error = Some(InvariantFuzzError::Revert(case_data)); return Ok(RichInvariantResults::new(false, None)); - } else if call_result.reverted && !is_optimization { - // If we don't fail test on revert then remove last reverted call from inputs. - // In optimization mode, we keep reverted calls to preserve warp/roll values - // for correct replay during shrinking. + } else if call_result.reverted && !is_optimization && !invariant_config.has_delay() { + // If we don't fail test on revert then remove the reverted call from inputs. + // Delay-enabled campaigns keep reverted calls so shrinking can preserve their + // warp/roll contribution when building the final counterexample. invariant_run.inputs.pop(); } } diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index 2badd12300432..ad14b46002c8a 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -87,6 +87,37 @@ fn apply_warp_roll_to_env( } } +/// Builds the final shrunk sequence from the shrinker state. +/// +/// When `accumulate_warp_roll` is enabled, warp/roll from removed calls is folded into the next +/// kept call so the final sequence remains reproducible. +fn build_shrunk_sequence( + calls: &[BasicTxDetails], + shrinker: &CallSequenceShrinker, + accumulate_warp_roll: bool, +) -> Vec { + if !accumulate_warp_roll { + return shrinker.current().map(|idx| calls[idx].clone()).collect(); + } + + let mut result = Vec::new(); + let mut accumulated_warp = U256::ZERO; + let mut accumulated_roll = U256::ZERO; + + for (idx, call) in calls.iter().enumerate() { + accumulated_warp += call.warp.unwrap_or(U256::ZERO); + accumulated_roll += call.roll.unwrap_or(U256::ZERO); + + if shrinker.included_calls.test(idx) { + result.push(apply_warp_roll(call, accumulated_warp, accumulated_roll)); + accumulated_warp = U256::ZERO; + accumulated_roll = U256::ZERO; + } + } + + result +} + pub(crate) fn shrink_sequence( config: &InvariantConfig, invariant_contract: &InvariantContract<'_>, @@ -108,6 +139,7 @@ pub(crate) fn shrink_sequence( return Ok(vec![]); } + let accumulate_warp_roll = config.has_delay(); let mut call_idx = 0; let mut shrinker = CallSequenceShrinker::new(calls.len()); @@ -125,6 +157,7 @@ pub(crate) fn shrink_sequence( target_address, calldata.clone(), CheckSequenceOptions { + accumulate_warp_roll, fail_on_revert: config.fail_on_revert, call_after_invariant: invariant_contract.call_after_invariant, rd: None, @@ -144,7 +177,7 @@ pub(crate) fn shrink_sequence( call_idx = shrinker.next_index(call_idx); } - Ok(shrinker.current().map(|idx| &calls[idx]).cloned().collect()) + Ok(build_shrunk_sequence(calls, &shrinker, accumulate_warp_roll)) } /// Checks if the given call sequence breaks the invariant. @@ -153,7 +186,25 @@ pub(crate) fn shrink_sequence( /// persisted failures. /// Returns the result of invariant check (and afterInvariant call if needed) and if sequence was /// entirely applied. +/// +/// When `options.accumulate_warp_roll` is enabled, warp/roll from removed calls is folded into the +/// next kept call so the candidate sequence stays representable as a concrete counterexample. pub fn check_sequence( + executor: Executor, + calls: &[BasicTxDetails], + sequence: Vec, + test_address: Address, + calldata: Bytes, + options: CheckSequenceOptions<'_>, +) -> eyre::Result<(bool, bool, Option)> { + if options.accumulate_warp_roll { + check_sequence_with_accumulation(executor, calls, sequence, test_address, calldata, options) + } else { + check_sequence_simple(executor, calls, sequence, test_address, calldata, options) + } +} + +fn check_sequence_simple( mut executor: Executor, calls: &[BasicTxDetails], sequence: Vec, @@ -179,9 +230,59 @@ pub fn check_sequence( } } - // Check the invariant for call sequence. + finish_sequence_check(&executor, test_address, calldata, &options) +} + +fn check_sequence_with_accumulation( + mut executor: Executor, + calls: &[BasicTxDetails], + sequence: Vec, + test_address: Address, + calldata: Bytes, + options: CheckSequenceOptions<'_>, +) -> eyre::Result<(bool, bool, Option)> { + let mut accumulated_warp = U256::ZERO; + let mut accumulated_roll = U256::ZERO; + let mut seq_iter = sequence.iter().peekable(); + + for (idx, tx) in calls.iter().enumerate() { + accumulated_warp += tx.warp.unwrap_or(U256::ZERO); + accumulated_roll += tx.roll.unwrap_or(U256::ZERO); + + if seq_iter.peek() != Some(&&idx) { + continue; + } + + seq_iter.next(); + + let tx_with_accumulated = apply_warp_roll(tx, accumulated_warp, accumulated_roll); + let mut call_result = execute_tx(&mut executor, &tx_with_accumulated)?; + + if call_result.reverted { + if options.fail_on_revert && call_result.result.as_ref() != MAGIC_ASSUME { + return Ok((false, false, call_failure_reason(call_result, options.rd))); + } + } else { + executor.commit(&mut call_result); + } + + accumulated_warp = U256::ZERO; + accumulated_roll = U256::ZERO; + } + + // Unlike optimization mode we intentionally do not apply trailing warp/roll before the + // invariant call: those delays would not be representable in the final shrunk sequence. + finish_sequence_check(&executor, test_address, calldata, &options) +} + +fn finish_sequence_check( + executor: &Executor, + test_address: Address, + calldata: Bytes, + options: &CheckSequenceOptions<'_>, +) -> eyre::Result<(bool, bool, Option)> { let (invariant_result, mut success) = - call_invariant_function(&executor, test_address, calldata)?; + call_invariant_function(executor, test_address, calldata)?; if !success { return Ok((false, true, call_failure_reason(invariant_result, options.rd))); } @@ -190,7 +291,7 @@ pub fn check_sequence( // declared. if success && options.call_after_invariant { let (after_invariant_result, after_invariant_success) = - call_after_invariant_function(&executor, test_address)?; + call_after_invariant_function(executor, test_address)?; success = after_invariant_success; if !success { return Ok((false, true, call_failure_reason(after_invariant_result, options.rd))); @@ -201,6 +302,7 @@ pub fn check_sequence( } pub struct CheckSequenceOptions<'a> { + pub accumulate_warp_roll: bool, pub fail_on_revert: bool, pub call_after_invariant: bool, pub rd: Option<&'a RevertDecoder>, @@ -279,23 +381,7 @@ pub(crate) fn shrink_sequence_value( call_idx = shrinker.next_index(call_idx); } - // Build the final shrunk sequence, accumulating warp/roll from removed calls. - let mut result = Vec::new(); - let mut accumulated_warp = U256::ZERO; - let mut accumulated_roll = U256::ZERO; - - for (idx, call) in calls.iter().enumerate() { - accumulated_warp += call.warp.unwrap_or(U256::ZERO); - accumulated_roll += call.roll.unwrap_or(U256::ZERO); - - if shrinker.included_calls.test(idx) { - result.push(apply_warp_roll(call, accumulated_warp, accumulated_roll)); - accumulated_warp = U256::ZERO; - accumulated_roll = U256::ZERO; - } - } - - Ok(result) + Ok(build_shrunk_sequence(calls, &shrinker, true)) } /// Executes a call sequence and returns the optimization value (int256) from the invariant @@ -347,3 +433,48 @@ pub fn check_sequence_value( Ok(None) } + +#[cfg(test)] +mod tests { + use super::{CallSequenceShrinker, build_shrunk_sequence}; + use alloy_primitives::{Address, Bytes, U256}; + use foundry_evm_fuzz::{BasicTxDetails, CallDetails}; + use proptest::bits::BitSetLike; + + fn tx(warp: Option, roll: Option) -> BasicTxDetails { + BasicTxDetails { + warp: warp.map(U256::from), + roll: roll.map(U256::from), + sender: Address::ZERO, + call_details: CallDetails { target: Address::ZERO, calldata: Bytes::new() }, + } + } + + #[test] + fn build_shrunk_sequence_accumulates_removed_delay_into_next_kept_call() { + let calls = vec![tx(Some(3), Some(5)), tx(Some(7), Some(11)), tx(Some(13), Some(17))]; + let mut shrinker = CallSequenceShrinker::new(calls.len()); + shrinker.included_calls.clear(0); + + let shrunk = build_shrunk_sequence(&calls, &shrinker, true); + + assert_eq!(shrunk.len(), 2); + assert_eq!(shrunk[0].warp, Some(U256::from(10))); + assert_eq!(shrunk[0].roll, Some(U256::from(16))); + assert_eq!(shrunk[1].warp, Some(U256::from(13))); + assert_eq!(shrunk[1].roll, Some(U256::from(17))); + } + + #[test] + fn build_shrunk_sequence_does_not_move_trailing_delay_backward() { + let calls = vec![tx(Some(3), Some(5)), tx(Some(7), Some(11))]; + let mut shrinker = CallSequenceShrinker::new(calls.len()); + shrinker.included_calls.clear(1); + + let shrunk = build_shrunk_sequence(&calls, &shrinker, true); + + assert_eq!(shrunk.len(), 1); + assert_eq!(shrunk[0].warp, Some(U256::from(3))); + assert_eq!(shrunk[0].roll, Some(U256::from(5))); + } +} diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index 2f426581c2916..678b65996fe74 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -800,6 +800,7 @@ impl<'a, FEN: FoundryEvmNetwork> FunctionRunner<'a, FEN> { invariant_contract.address, invariant_contract.invariant_function.selector().to_vec().into(), CheckSequenceOptions { + accumulate_warp_roll: invariant_config.has_delay(), fail_on_revert: invariant_config.fail_on_revert, call_after_invariant: invariant_contract.call_after_invariant, rd: Some(self.revert_decoder()), diff --git a/crates/forge/tests/cli/test_cmd/invariant/common.rs b/crates/forge/tests/cli/test_cmd/invariant/common.rs index 9f8450817eba8..8f71030bc0a7c 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/common.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/common.rs @@ -1653,46 +1653,39 @@ contract InvariantWarpAndRoll { } function invariant_warp() public view { - require(block.number < 200000, "max block"); + require(block.timestamp < 500000, "max timestamp"); } /// forge-config: default.invariant.show_solidity = true function invariant_roll() public view { - require(block.timestamp < 500000, "max timestamp"); + require(block.number < 200000, "max block"); } } "#, ); cmd.args(["test", "--mt", "invariant_warp"]).assert_failure().stdout_eq(str![[r#" -[COMPILING_FILES] with [SOLC_VERSION] -[SOLC_VERSION] [ELAPSED] -Compiler run successful! - -Ran 1 test for test/InvariantWarpAndRoll.t.sol:InvariantWarpAndRoll -[FAIL: max block] - [Sequence] (original: 6, shrunk: 6) - sender=[..] addr=[test/InvariantWarpAndRoll.t.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f warp=6280 roll=21461 calldata=setNumber(uint256) args=[200000 [2e5]] +... +[FAIL: max timestamp] + [Sequence] (original: 5, shrunk: 5) + sender=[..] addr=[test/InvariantWarpAndRoll.t.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f warp=6280 roll=21461 calldata=setNumber(uint256) args=[500000 [5e5]] sender=[..] addr=[test/InvariantWarpAndRoll.t.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f warp=92060 roll=51816 calldata=setNumber(uint256) args=[0] sender=[..] addr=[test/InvariantWarpAndRoll.t.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f warp=198040 roll=60259 calldata=increment() args=[] sender=[..] addr=[test/InvariantWarpAndRoll.t.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f warp=20609 roll=27086 calldata=setNumber(uint256) args=[26717227324157985679793128079000084308648530834088529513797156275625002 [2.671e70]] sender=[..] addr=[test/InvariantWarpAndRoll.t.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f warp=409368 roll=24864 calldata=increment() args=[] - sender=[..] addr=[test/InvariantWarpAndRoll.t.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f warp=218105 roll=17834 calldata=setNumber(uint256) args=[24752675372815722001736610830 [2.475e28]] invariant_warp() (runs: 0, calls: 0, reverts: 0) ... "#]]); cmd.forge_fuse().args(["test", "--mt", "invariant_roll"]).assert_failure().stdout_eq(str![[r#" -No files changed, compilation skipped - -Ran 1 test for test/InvariantWarpAndRoll.t.sol:InvariantWarpAndRoll -[FAIL: max timestamp] - [Sequence] (original: 5, shrunk: 5) +... +[FAIL: max block] + [Sequence] (original: 6, shrunk: 6) vm.warp(block.timestamp + 6280); vm.roll(block.number + 21461); vm.prank([..]); - Counter(0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f).setNumber(200000); + Counter(0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f).setNumber(500000); vm.warp(block.timestamp + 92060); vm.roll(block.number + 51816); vm.prank([..]); @@ -1709,6 +1702,10 @@ Ran 1 test for test/InvariantWarpAndRoll.t.sol:InvariantWarpAndRoll vm.roll(block.number + 24864); vm.prank([..]); Counter(0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f).increment(); + vm.warp(block.timestamp + 218105); + vm.roll(block.number + 17834); + vm.prank([..]); + Counter(0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f).setNumber(24752675372815722001736610830); invariant_roll() (runs: 0, calls: 0, reverts: 0) ... @@ -2069,3 +2066,89 @@ contract InvariantOptimizeWarpTest is Test { ... "#]]); }); + +// Regression test for delay-aware shrinking in check mode. +// Removed calls may still contribute warp/roll, so the final shrunk sequence must preserve those +// values in the remaining call. +forgetest_init!(invariant_shrink_preserves_warp_roll, |prj, cmd| { + prj.add_test( + "InvariantRollWarpShrink.t.sol", + r#" +import "forge-std/Test.sol"; + +contract Roll { + uint256 public number; + + function increment() public { + require(block.number > 50000, "wrong block"); + number++; + } +} + +contract Warp { + uint256 public number; + + function increment() public { + require(block.timestamp > 500000, "wrong timestamp"); + number++; + } +} + +contract InvariantRoll is Test { + Roll public roll; + + function setUp() public { + roll = new Roll(); + } + + /// forge-config: default.fuzz.seed = "119" + /// forge-config: default.invariant.max_block_delay = 60480 + /// forge-config: default.invariant.show_solidity = true + function invariant_roll() public view { + require(roll.number() == 0, "number is not zero"); + } +} + +contract InvariantWarp is Test { + Warp public warp; + + function setUp() public { + warp = new Warp(); + } + + /// forge-config: default.fuzz.seed = "119" + /// forge-config: default.invariant.max_time_delay = 604800 + /// forge-config: default.invariant.show_solidity = true + function invariant_warp() public view { + require(warp.number() == 0, "max time"); + } +} +"#, + ); + + cmd.args(["test", "--mt", "invariant_roll"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: number is not zero] + [Sequence] (original: 3, shrunk: 1) + vm.roll(block.number + 52068); + vm.prank([..]); + Roll(0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f).increment(); + invariant_roll() (runs: 0, calls: 0, reverts: 2) +... + +"#]]); + + cmd.forge_fuse().args(["test", "--mt", "invariant_warp"]).assert_failure().stdout_eq(str![[ + r#" +... +[FAIL: max time] + [Sequence] (original: 3, shrunk: 1) + vm.warp(block.timestamp + 656868); + vm.prank(0x00000000000000000000000000000000000012d2); + Warp(0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f).increment(); + invariant_warp() (runs: 0, calls: 0, reverts: 2) +... + +"# + ]]); +});