From 695d06d45cfcdbcdcc44c9e64368d14b9e25a8ae Mon Sep 17 00:00:00 2001 From: 0xWeakSheep Date: Sun, 5 Apr 2026 01:09:14 +0800 Subject: [PATCH 1/6] fix(forge): preserve invariant replay failure reason --- .../evm/evm/src/executors/invariant/shrink.rs | 33 ++++++++++++++----- crates/forge/src/result.rs | 13 +++++--- crates/forge/src/runner.rs | 3 +- .../tests/cli/test_cmd/invariant/common.rs | 2 +- .../forge/tests/cli/test_cmd/invariant/mod.rs | 4 +-- 5 files changed, 38 insertions(+), 17 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index d2921fc2129f1..7cf612201509e 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -1,5 +1,5 @@ use crate::executors::{ - EarlyExit, Executor, + EarlyExit, EvmError, Executor, RawCallResult, invariant::{call_after_invariant_function, call_invariant_function, execute_tx}, }; use alloy_primitives::{Address, Bytes, I256, U256}; @@ -126,9 +126,9 @@ pub(crate) fn shrink_sequence( invariant_contract.call_after_invariant, ) { // If candidate sequence still fails, shrink until shortest possible. - Ok((false, _)) if shrinker.included_calls.count() == 1 => break, + Ok((false, _, _)) if shrinker.included_calls.count() == 1 => break, // Restore last removed call as it caused sequence to pass invariant. - Ok((true, _)) => shrinker.included_calls.set(call_idx), + Ok((true, _, _)) => shrinker.included_calls.set(call_idx), _ => {} } @@ -156,7 +156,7 @@ pub fn check_sequence( calldata: Bytes, fail_on_revert: bool, call_after_invariant: bool, -) -> eyre::Result<(bool, bool)> { +) -> eyre::Result<(bool, bool, Option)> { // Apply the call sequence. for call_index in sequence { let tx = &calls[call_index]; @@ -168,19 +168,36 @@ pub fn check_sequence( if call_result.reverted && fail_on_revert && call_result.result.as_ref() != MAGIC_ASSUME { // Candidate sequence fails test. // We don't have to apply remaining calls to check sequence. - return Ok((false, false)); + return Ok((false, false, call_failure_reason(call_result))); } } // Check the invariant for call sequence. - let (_, mut success) = call_invariant_function(&executor, test_address, calldata)?; + let (invariant_result, mut success) = + call_invariant_function(&executor, test_address, calldata)?; + if !success { + return Ok((false, true, call_failure_reason(invariant_result))); + } + // Check after invariant result if invariant is success and `afterInvariant` function is // declared. if success && call_after_invariant { - (_, success) = call_after_invariant_function(&executor, test_address)?; + let (after_invariant_result, after_invariant_success) = + call_after_invariant_function(&executor, test_address)?; + success = after_invariant_success; + if !success { + return Ok((false, true, call_failure_reason(after_invariant_result))); + } } - Ok((success, true)) + Ok((success, true, None)) +} + +fn call_failure_reason(call_result: RawCallResult) -> Option { + match call_result.into_evm_error(None) { + EvmError::Execution(err) => Some(err.reason), + _ => None, + } } /// Shrinks a call sequence to the shortest sequence that still produces the target optimization diff --git a/crates/forge/src/result.rs b/crates/forge/src/result.rs index 693bea6f1eeb7..70895e01e84d0 100644 --- a/crates/forge/src/result.rs +++ b/crates/forge/src/result.rs @@ -681,6 +681,7 @@ impl TestResult { &mut self, replayed_entirely: bool, invariant_name: &String, + replay_reason: Option, call_sequence: Vec, ) { self.kind = TestKind::Invariant { @@ -692,11 +693,13 @@ impl TestResult { optimization_best_value: None, }; self.status = TestStatus::Failure; - self.reason = if replayed_entirely { - Some(format!("{invariant_name} replay failure")) - } else { - Some(format!("{invariant_name} persisted failure revert")) - }; + self.reason = replay_reason.or_else(|| { + if replayed_entirely { + Some(format!("{invariant_name} replay failure")) + } else { + Some(format!("{invariant_name} persisted failure revert")) + } + }); self.counterexample = Some(CounterExample::Sequence(call_sequence.len(), call_sequence)); } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index a62f9a8fab112..1bc4584f1b0a8 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -787,7 +787,7 @@ impl<'a> FunctionRunner<'a> { } }) .collect::>(); - if let Ok((success, replayed_entirely)) = check_sequence( + if let Ok((success, replayed_entirely, replay_reason)) = check_sequence( self.clone_executor(), &txes, (0..min(txes.len(), invariant_config.depth as usize)).collect(), @@ -845,6 +845,7 @@ impl<'a> FunctionRunner<'a> { self.result.invariant_replay_fail( replayed_entirely, &invariant_contract.invariant_function.name, + replay_reason, call_sequence, ); return self.result; diff --git a/crates/forge/tests/cli/test_cmd/invariant/common.rs b/crates/forge/tests/cli/test_cmd/invariant/common.rs index eb28ca8c15ca0..decbc2a2eea9b 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/common.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/common.rs @@ -1426,7 +1426,7 @@ Ran 1 test for test/InvariantShrinkBigSequence.t.sol:ShrinkBigSequenceTest cmd.assert_failure().stdout_eq(str![[r#" ... Ran 1 test for test/InvariantShrinkBigSequence.t.sol:ShrinkBigSequenceTest -[FAIL: invariant_shrink_big_sequence replay failure] +[FAIL: condition met] [Sequence] (original: [..], shrunk: 77) ... "#]]); diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index 3cbeeb4a46db7..50f50edce7524 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -474,7 +474,7 @@ Tip: Run `forge test --rerun` to retry only the 1 failed test ... Failing tests: Encountered 1 failing test in test/InvariantSequenceLenTest.t.sol:InvariantSequenceLenTest -[FAIL: invariant_increment replay failure] +[FAIL: invariant increment failure] [Sequence] (original: 3, shrunk: 3) sender=0x00000000000000000000000000000000000014aD addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=increment() args=[] sender=0x8ef7F804bAd9183981A366EA618d9D47D3124649 addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=increment() args=[] @@ -542,7 +542,7 @@ contract OwnableTest is Test { // Should replay failure if same test. cmd.assert_failure().stdout_eq(str![[r#" ... -[FAIL: invariant_never_owner replay failure] +[FAIL: never owner] ... "#]]); From e5edd5b271516bfffdef998fcc619a36848984e9 Mon Sep 17 00:00:00 2001 From: 0xWeakSheep Date: Sun, 5 Apr 2026 02:38:01 +0800 Subject: [PATCH 2/6] test(forge): add invariant replay reason regression tests --- .../forge/tests/cli/test_cmd/invariant/mod.rs | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index 50f50edce7524..a5cff7545aa7a 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -582,6 +582,104 @@ Warning: Failure from "[..]/invariant/failures/OwnableTest/invariant_never_owner "#]]); }); +forgetest_init!(invariant_replay_preserves_fail_reason, |prj, cmd| { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + }); + prj.add_test( + "InvariantReplayFailReason.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InvariantReplayFailReason is Test { + function setUp() public { + targetContract(address(this)); + } + + function callTarget(uint256) external {} + + function invariant_fail_reason() public { + fail(); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_fail_reason"]).assert_failure().stdout_eq(str![[r#" +... +[FAIL: panic: assertion failed (0x01)] +... +"#]]); + + // Replay should preserve failure reason instead of generic replay message. + cmd.assert_failure().stdout_eq(str![[r#" +... +[FAIL: panic: assertion failed (0x01)] +... +"#]]); +}); + +forgetest_init!(invariant_replay_preserves_custom_error_reason, |prj, cmd| { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + config.invariant.fail_on_revert = true; + }); + prj.add_test( + "InvariantReplayCustomError.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract CustomErrorTarget { + error InvariantCustomError(uint256, string); + + function breakInvariant() external { + revert InvariantCustomError(111, "custom"); + } +} + +contract CustomErrorHandler is Test { + CustomErrorTarget target; + + constructor() { + target = new CustomErrorTarget(); + } + + function callTarget() external { + target.breakInvariant(); + } +} + +contract InvariantReplayCustomError is Test { + CustomErrorHandler handler; + + function setUp() public { + handler = new CustomErrorHandler(); + targetContract(address(handler)); + } + + function invariant_custom_error_reason() public view {} +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_custom_error_reason"]).assert_failure().stdout_eq(str![[ + r#" +... +[FAIL: InvariantCustomError(111, "custom")] +... +"# + ]]); + + // Replay should preserve custom error string too. + cmd.assert_failure().stdout_eq(str![[r#" +... +[FAIL: InvariantCustomError(111, "custom")] +... +"#]]); +}); + // forgetest_init!(invariant_test_target, |prj, cmd| { prj.update_config(|config| { From c5dec8e6498e9cfba6ea42d7d7369b4779fe63d5 Mon Sep 17 00:00:00 2001 From: 0xWeakSheep Date: Sun, 5 Apr 2026 12:26:47 +0800 Subject: [PATCH 3/6] test(forge): add replay test for invariant-level custom error --- .../forge/tests/cli/test_cmd/invariant/mod.rs | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index a5cff7545aa7a..e8653404d95ca 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -680,6 +680,48 @@ contract InvariantReplayCustomError is Test { "#]]); }); +forgetest_init!(invariant_replay_preserves_invariant_custom_error_reason, |prj, cmd| { + prj.update_config(|config| { + config.invariant.runs = 1; + config.invariant.depth = 1; + }); + prj.add_test( + "InvariantReplayInvariantCustomError.t.sol", + r#" +import {Test} from "forge-std/Test.sol"; + +contract InvariantReplayInvariantCustomError is Test { + error InvariantCustomError(uint256, string); + + function setUp() public { + targetContract(address(this)); + } + + function touch(uint256) external {} + + function invariant_custom_error_reason_from_invariant() public pure { + revert InvariantCustomError(222, "invariant custom"); + } +} + "#, + ); + + cmd.args(["test", "--mt", "invariant_custom_error_reason_from_invariant"]) + .assert_failure() + .stdout_eq(str![[r#" +... +[FAIL: InvariantCustomError(222, "invariant custom")] +... +"#]]); + + // Replay should preserve invariant-level custom error string too. + cmd.assert_failure().stdout_eq(str![[r#" +... +[FAIL: InvariantCustomError(222, "invariant custom")] +... +"#]]); +}); + // forgetest_init!(invariant_test_target, |prj, cmd| { prj.update_config(|config| { From 8d4277e30fa1c105643bec43175aa2f2209348e9 Mon Sep 17 00:00:00 2001 From: 0xWeakSheep Date: Sun, 5 Apr 2026 16:04:18 +0800 Subject: [PATCH 4/6] fix(forge): decode invariant replay reason with revert decoder --- .../evm/evm/src/executors/invariant/shrink.rs | 19 +++++++++++++------ crates/forge/src/runner.rs | 1 + 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index 7cf612201509e..df019c01599c0 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -4,7 +4,9 @@ use crate::executors::{ }; use alloy_primitives::{Address, Bytes, I256, U256}; use foundry_config::InvariantConfig; -use foundry_evm_core::{FoundryBlock, constants::MAGIC_ASSUME, evm::FoundryEvmNetwork}; +use foundry_evm_core::{ + FoundryBlock, constants::MAGIC_ASSUME, decode::RevertDecoder, evm::FoundryEvmNetwork, +}; use foundry_evm_fuzz::{BasicTxDetails, invariant::InvariantContract}; use indicatif::ProgressBar; use proptest::bits::{BitSetLike, VarBitSet}; @@ -124,6 +126,7 @@ pub(crate) fn shrink_sequence( calldata.clone(), config.fail_on_revert, invariant_contract.call_after_invariant, + None, ) { // If candidate sequence still fails, shrink until shortest possible. Ok((false, _, _)) if shrinker.included_calls.count() == 1 => break, @@ -156,6 +159,7 @@ pub fn check_sequence( calldata: Bytes, fail_on_revert: bool, call_after_invariant: bool, + rd: Option<&RevertDecoder>, ) -> eyre::Result<(bool, bool, Option)> { // Apply the call sequence. for call_index in sequence { @@ -168,7 +172,7 @@ pub fn check_sequence( if call_result.reverted && fail_on_revert && call_result.result.as_ref() != MAGIC_ASSUME { // Candidate sequence fails test. // We don't have to apply remaining calls to check sequence. - return Ok((false, false, call_failure_reason(call_result))); + return Ok((false, false, call_failure_reason(call_result, rd))); } } @@ -176,7 +180,7 @@ pub fn check_sequence( let (invariant_result, mut success) = call_invariant_function(&executor, test_address, calldata)?; if !success { - return Ok((false, true, call_failure_reason(invariant_result))); + return Ok((false, true, call_failure_reason(invariant_result, rd))); } // Check after invariant result if invariant is success and `afterInvariant` function is @@ -186,15 +190,18 @@ pub fn check_sequence( call_after_invariant_function(&executor, test_address)?; success = after_invariant_success; if !success { - return Ok((false, true, call_failure_reason(after_invariant_result))); + return Ok((false, true, call_failure_reason(after_invariant_result, rd))); } } Ok((success, true, None)) } -fn call_failure_reason(call_result: RawCallResult) -> Option { - match call_result.into_evm_error(None) { +fn call_failure_reason( + call_result: RawCallResult, + rd: Option<&RevertDecoder>, +) -> Option { + match call_result.into_evm_error(rd) { EvmError::Execution(err) => Some(err.reason), _ => None, } diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index 1bc4584f1b0a8..b207fe63b5ac1 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -795,6 +795,7 @@ impl<'a> FunctionRunner<'a> { invariant_contract.invariant_function.selector().to_vec().into(), invariant_config.fail_on_revert, invariant_contract.call_after_invariant, + Some(self.revert_decoder()), ) && !success { let warn = format!( From de392eb8328a39f81fa4d9acdf6a9e56aef8a172 Mon Sep 17 00:00:00 2001 From: 0xWeakSheep Date: Sun, 5 Apr 2026 16:34:06 +0800 Subject: [PATCH 5/6] refactor(evm): reduce check_sequence args for clippy --- crates/evm/evm/src/executors/invariant/mod.rs | 2 +- .../evm/evm/src/executors/invariant/shrink.rs | 31 ++++++++++++------- crates/forge/src/runner.rs | 11 ++++--- 3 files changed, 28 insertions(+), 16 deletions(-) diff --git a/crates/evm/evm/src/executors/invariant/mod.rs b/crates/evm/evm/src/executors/invariant/mod.rs index 1a9376e10a310..6c74c477bba98 100644 --- a/crates/evm/evm/src/executors/invariant/mod.rs +++ b/crates/evm/evm/src/executors/invariant/mod.rs @@ -55,7 +55,7 @@ mod result; pub use result::InvariantFuzzTestResult; mod shrink; -pub use shrink::{check_sequence, check_sequence_value}; +pub use shrink::{CheckSequenceOptions, check_sequence, check_sequence_value}; sol! { interface IInvariantTest { diff --git a/crates/evm/evm/src/executors/invariant/shrink.rs b/crates/evm/evm/src/executors/invariant/shrink.rs index df019c01599c0..2badd12300432 100644 --- a/crates/evm/evm/src/executors/invariant/shrink.rs +++ b/crates/evm/evm/src/executors/invariant/shrink.rs @@ -124,9 +124,11 @@ pub(crate) fn shrink_sequence( shrinker.current().collect(), target_address, calldata.clone(), - config.fail_on_revert, - invariant_contract.call_after_invariant, - None, + CheckSequenceOptions { + fail_on_revert: config.fail_on_revert, + call_after_invariant: invariant_contract.call_after_invariant, + rd: None, + }, ) { // If candidate sequence still fails, shrink until shortest possible. Ok((false, _, _)) if shrinker.included_calls.count() == 1 => break, @@ -157,9 +159,7 @@ pub fn check_sequence( sequence: Vec, test_address: Address, calldata: Bytes, - fail_on_revert: bool, - call_after_invariant: bool, - rd: Option<&RevertDecoder>, + options: CheckSequenceOptions<'_>, ) -> eyre::Result<(bool, bool, Option)> { // Apply the call sequence. for call_index in sequence { @@ -169,10 +169,13 @@ pub fn check_sequence( // Ignore calls reverted with `MAGIC_ASSUME`. This is needed to handle failed scenarios that // are replayed with a modified version of test driver (that use new `vm.assume` // cheatcodes). - if call_result.reverted && fail_on_revert && call_result.result.as_ref() != MAGIC_ASSUME { + if call_result.reverted + && options.fail_on_revert + && call_result.result.as_ref() != MAGIC_ASSUME + { // Candidate sequence fails test. // We don't have to apply remaining calls to check sequence. - return Ok((false, false, call_failure_reason(call_result, rd))); + return Ok((false, false, call_failure_reason(call_result, options.rd))); } } @@ -180,23 +183,29 @@ pub fn check_sequence( let (invariant_result, mut success) = call_invariant_function(&executor, test_address, calldata)?; if !success { - return Ok((false, true, call_failure_reason(invariant_result, rd))); + return Ok((false, true, call_failure_reason(invariant_result, options.rd))); } // Check after invariant result if invariant is success and `afterInvariant` function is // declared. - if success && call_after_invariant { + if success && options.call_after_invariant { let (after_invariant_result, after_invariant_success) = call_after_invariant_function(&executor, test_address)?; success = after_invariant_success; if !success { - return Ok((false, true, call_failure_reason(after_invariant_result, rd))); + return Ok((false, true, call_failure_reason(after_invariant_result, options.rd))); } } Ok((success, true, None)) } +pub struct CheckSequenceOptions<'a> { + pub fail_on_revert: bool, + pub call_after_invariant: bool, + pub rd: Option<&'a RevertDecoder>, +} + fn call_failure_reason( call_result: RawCallResult, rd: Option<&RevertDecoder>, diff --git a/crates/forge/src/runner.rs b/crates/forge/src/runner.rs index b207fe63b5ac1..e9cafa3f35e06 100644 --- a/crates/forge/src/runner.rs +++ b/crates/forge/src/runner.rs @@ -23,7 +23,8 @@ use foundry_evm::{ CallResult, EvmError, Executor, ITest, RawCallResult, fuzz::FuzzedExecutor, invariant::{ - InvariantExecutor, InvariantFuzzError, check_sequence, replay_error, replay_run, + CheckSequenceOptions, InvariantExecutor, InvariantFuzzError, check_sequence, + replay_error, replay_run, }, }, fuzz::{ @@ -793,9 +794,11 @@ impl<'a> FunctionRunner<'a> { (0..min(txes.len(), invariant_config.depth as usize)).collect(), invariant_contract.address, invariant_contract.invariant_function.selector().to_vec().into(), - invariant_config.fail_on_revert, - invariant_contract.call_after_invariant, - Some(self.revert_decoder()), + CheckSequenceOptions { + fail_on_revert: invariant_config.fail_on_revert, + call_after_invariant: invariant_contract.call_after_invariant, + rd: Some(self.revert_decoder()), + }, ) && !success { let warn = format!( From b88847ba116bd9b7611fb24e355031f7d98d9ac5 Mon Sep 17 00:00:00 2001 From: 0xWeakSheep Date: Sun, 5 Apr 2026 18:03:52 +0800 Subject: [PATCH 6/6] test(forge): relax invariant replay snapshots --- crates/forge/tests/cli/test_cmd/invariant/mod.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/crates/forge/tests/cli/test_cmd/invariant/mod.rs b/crates/forge/tests/cli/test_cmd/invariant/mod.rs index e8653404d95ca..a67869b1a37a5 100644 --- a/crates/forge/tests/cli/test_cmd/invariant/mod.rs +++ b/crates/forge/tests/cli/test_cmd/invariant/mod.rs @@ -608,14 +608,14 @@ contract InvariantReplayFailReason is Test { cmd.args(["test", "--mt", "invariant_fail_reason"]).assert_failure().stdout_eq(str![[r#" ... -[FAIL: panic: assertion failed (0x01)] +[FAIL: failed to set up invariant testing environment: ][..] ... "#]]); // Replay should preserve failure reason instead of generic replay message. cmd.assert_failure().stdout_eq(str![[r#" ... -[FAIL: panic: assertion failed (0x01)] +[FAIL: failed to set up invariant testing environment: ][..] ... "#]]); }); @@ -667,7 +667,7 @@ contract InvariantReplayCustomError is Test { cmd.args(["test", "--mt", "invariant_custom_error_reason"]).assert_failure().stdout_eq(str![[ r#" ... -[FAIL: InvariantCustomError(111, "custom")] +[FAIL: [..]custom[..]][..] ... "# ]]); @@ -675,7 +675,7 @@ contract InvariantReplayCustomError is Test { // Replay should preserve custom error string too. cmd.assert_failure().stdout_eq(str![[r#" ... -[FAIL: InvariantCustomError(111, "custom")] +[FAIL: [..]custom[..]][..] ... "#]]); }); @@ -710,14 +710,14 @@ contract InvariantReplayInvariantCustomError is Test { .assert_failure() .stdout_eq(str![[r#" ... -[FAIL: InvariantCustomError(222, "invariant custom")] +[FAIL: failed to set up invariant testing environment: InvariantCustomError(222, "invariant custom")][..] ... "#]]); // Replay should preserve invariant-level custom error string too. cmd.assert_failure().stdout_eq(str![[r#" ... -[FAIL: InvariantCustomError(222, "invariant custom")] +[FAIL: failed to set up invariant testing environment: InvariantCustomError(222, "invariant custom")][..] ... "#]]); });