Skip to content

Commit bf9239e

Browse files
authored
fix(forge): preserve invariant replay failure reason (#14136)
* fix(forge): preserve invariant replay failure reason * test(forge): add invariant replay reason regression tests * test(forge): add replay test for invariant-level custom error * fix(forge): decode invariant replay reason with revert decoder * refactor(evm): reduce check_sequence args for clippy * test(forge): relax invariant replay snapshots
1 parent 545deca commit bf9239e

6 files changed

Lines changed: 209 additions & 28 deletions

File tree

crates/evm/evm/src/executors/invariant/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ mod result;
5555
pub use result::InvariantFuzzTestResult;
5656

5757
mod shrink;
58-
pub use shrink::{check_sequence, check_sequence_value};
58+
pub use shrink::{CheckSequenceOptions, check_sequence, check_sequence_value};
5959

6060
sol! {
6161
interface IInvariantTest {

crates/evm/evm/src/executors/invariant/shrink.rs

Lines changed: 48 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
use crate::executors::{
2-
EarlyExit, Executor,
2+
EarlyExit, EvmError, Executor, RawCallResult,
33
invariant::{call_after_invariant_function, call_invariant_function, execute_tx},
44
};
55
use alloy_primitives::{Address, Bytes, I256, U256};
66
use foundry_config::InvariantConfig;
7-
use foundry_evm_core::{FoundryBlock, constants::MAGIC_ASSUME, evm::FoundryEvmNetwork};
7+
use foundry_evm_core::{
8+
FoundryBlock, constants::MAGIC_ASSUME, decode::RevertDecoder, evm::FoundryEvmNetwork,
9+
};
810
use foundry_evm_fuzz::{BasicTxDetails, invariant::InvariantContract};
911
use indicatif::ProgressBar;
1012
use proptest::bits::{BitSetLike, VarBitSet};
@@ -122,13 +124,16 @@ pub(crate) fn shrink_sequence<FEN: FoundryEvmNetwork>(
122124
shrinker.current().collect(),
123125
target_address,
124126
calldata.clone(),
125-
config.fail_on_revert,
126-
invariant_contract.call_after_invariant,
127+
CheckSequenceOptions {
128+
fail_on_revert: config.fail_on_revert,
129+
call_after_invariant: invariant_contract.call_after_invariant,
130+
rd: None,
131+
},
127132
) {
128133
// If candidate sequence still fails, shrink until shortest possible.
129-
Ok((false, _)) if shrinker.included_calls.count() == 1 => break,
134+
Ok((false, _, _)) if shrinker.included_calls.count() == 1 => break,
130135
// Restore last removed call as it caused sequence to pass invariant.
131-
Ok((true, _)) => shrinker.included_calls.set(call_idx),
136+
Ok((true, _, _)) => shrinker.included_calls.set(call_idx),
132137
_ => {}
133138
}
134139

@@ -154,9 +159,8 @@ pub fn check_sequence<FEN: FoundryEvmNetwork>(
154159
sequence: Vec<usize>,
155160
test_address: Address,
156161
calldata: Bytes,
157-
fail_on_revert: bool,
158-
call_after_invariant: bool,
159-
) -> eyre::Result<(bool, bool)> {
162+
options: CheckSequenceOptions<'_>,
163+
) -> eyre::Result<(bool, bool, Option<String>)> {
160164
// Apply the call sequence.
161165
for call_index in sequence {
162166
let tx = &calls[call_index];
@@ -165,22 +169,51 @@ pub fn check_sequence<FEN: FoundryEvmNetwork>(
165169
// Ignore calls reverted with `MAGIC_ASSUME`. This is needed to handle failed scenarios that
166170
// are replayed with a modified version of test driver (that use new `vm.assume`
167171
// cheatcodes).
168-
if call_result.reverted && fail_on_revert && call_result.result.as_ref() != MAGIC_ASSUME {
172+
if call_result.reverted
173+
&& options.fail_on_revert
174+
&& call_result.result.as_ref() != MAGIC_ASSUME
175+
{
169176
// Candidate sequence fails test.
170177
// We don't have to apply remaining calls to check sequence.
171-
return Ok((false, false));
178+
return Ok((false, false, call_failure_reason(call_result, options.rd)));
172179
}
173180
}
174181

175182
// Check the invariant for call sequence.
176-
let (_, mut success) = call_invariant_function(&executor, test_address, calldata)?;
183+
let (invariant_result, mut success) =
184+
call_invariant_function(&executor, test_address, calldata)?;
185+
if !success {
186+
return Ok((false, true, call_failure_reason(invariant_result, options.rd)));
187+
}
188+
177189
// Check after invariant result if invariant is success and `afterInvariant` function is
178190
// declared.
179-
if success && call_after_invariant {
180-
(_, success) = call_after_invariant_function(&executor, test_address)?;
191+
if success && options.call_after_invariant {
192+
let (after_invariant_result, after_invariant_success) =
193+
call_after_invariant_function(&executor, test_address)?;
194+
success = after_invariant_success;
195+
if !success {
196+
return Ok((false, true, call_failure_reason(after_invariant_result, options.rd)));
197+
}
181198
}
182199

183-
Ok((success, true))
200+
Ok((success, true, None))
201+
}
202+
203+
pub struct CheckSequenceOptions<'a> {
204+
pub fail_on_revert: bool,
205+
pub call_after_invariant: bool,
206+
pub rd: Option<&'a RevertDecoder>,
207+
}
208+
209+
fn call_failure_reason<FEN: FoundryEvmNetwork>(
210+
call_result: RawCallResult<FEN>,
211+
rd: Option<&RevertDecoder>,
212+
) -> Option<String> {
213+
match call_result.into_evm_error(rd) {
214+
EvmError::Execution(err) => Some(err.reason),
215+
_ => None,
216+
}
184217
}
185218

186219
/// Shrinks a call sequence to the shortest sequence that still produces the target optimization

crates/forge/src/result.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -681,6 +681,7 @@ impl TestResult {
681681
&mut self,
682682
replayed_entirely: bool,
683683
invariant_name: &String,
684+
replay_reason: Option<String>,
684685
call_sequence: Vec<BaseCounterExample>,
685686
) {
686687
self.kind = TestKind::Invariant {
@@ -692,11 +693,13 @@ impl TestResult {
692693
optimization_best_value: None,
693694
};
694695
self.status = TestStatus::Failure;
695-
self.reason = if replayed_entirely {
696-
Some(format!("{invariant_name} replay failure"))
697-
} else {
698-
Some(format!("{invariant_name} persisted failure revert"))
699-
};
696+
self.reason = replay_reason.or_else(|| {
697+
if replayed_entirely {
698+
Some(format!("{invariant_name} replay failure"))
699+
} else {
700+
Some(format!("{invariant_name} persisted failure revert"))
701+
}
702+
});
700703
self.counterexample = Some(CounterExample::Sequence(call_sequence.len(), call_sequence));
701704
}
702705

crates/forge/src/runner.rs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ use foundry_evm::{
2323
CallResult, EvmError, Executor, ITest, RawCallResult,
2424
fuzz::FuzzedExecutor,
2525
invariant::{
26-
InvariantExecutor, InvariantFuzzError, check_sequence, replay_error, replay_run,
26+
CheckSequenceOptions, InvariantExecutor, InvariantFuzzError, check_sequence,
27+
replay_error, replay_run,
2728
},
2829
},
2930
fuzz::{
@@ -787,14 +788,17 @@ impl<'a> FunctionRunner<'a> {
787788
}
788789
})
789790
.collect::<Vec<BasicTxDetails>>();
790-
if let Ok((success, replayed_entirely)) = check_sequence(
791+
if let Ok((success, replayed_entirely, replay_reason)) = check_sequence(
791792
self.clone_executor(),
792793
&txes,
793794
(0..min(txes.len(), invariant_config.depth as usize)).collect(),
794795
invariant_contract.address,
795796
invariant_contract.invariant_function.selector().to_vec().into(),
796-
invariant_config.fail_on_revert,
797-
invariant_contract.call_after_invariant,
797+
CheckSequenceOptions {
798+
fail_on_revert: invariant_config.fail_on_revert,
799+
call_after_invariant: invariant_contract.call_after_invariant,
800+
rd: Some(self.revert_decoder()),
801+
},
798802
) && !success
799803
{
800804
let warn = format!(
@@ -845,6 +849,7 @@ impl<'a> FunctionRunner<'a> {
845849
self.result.invariant_replay_fail(
846850
replayed_entirely,
847851
&invariant_contract.invariant_function.name,
852+
replay_reason,
848853
call_sequence,
849854
);
850855
return self.result;

crates/forge/tests/cli/test_cmd/invariant/common.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1426,7 +1426,7 @@ Ran 1 test for test/InvariantShrinkBigSequence.t.sol:ShrinkBigSequenceTest
14261426
cmd.assert_failure().stdout_eq(str![[r#"
14271427
...
14281428
Ran 1 test for test/InvariantShrinkBigSequence.t.sol:ShrinkBigSequenceTest
1429-
[FAIL: invariant_shrink_big_sequence replay failure]
1429+
[FAIL: condition met]
14301430
[Sequence] (original: [..], shrunk: 77)
14311431
...
14321432
"#]]);

crates/forge/tests/cli/test_cmd/invariant/mod.rs

Lines changed: 142 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -474,7 +474,7 @@ Tip: Run `forge test --rerun` to retry only the 1 failed test
474474
...
475475
Failing tests:
476476
Encountered 1 failing test in test/InvariantSequenceLenTest.t.sol:InvariantSequenceLenTest
477-
[FAIL: invariant_increment replay failure]
477+
[FAIL: invariant increment failure]
478478
[Sequence] (original: 3, shrunk: 3)
479479
sender=0x00000000000000000000000000000000000014aD addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=increment() args=[]
480480
sender=0x8ef7F804bAd9183981A366EA618d9D47D3124649 addr=[src/Counter.sol:Counter]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=increment() args=[]
@@ -542,7 +542,7 @@ contract OwnableTest is Test {
542542
// Should replay failure if same test.
543543
cmd.assert_failure().stdout_eq(str![[r#"
544544
...
545-
[FAIL: invariant_never_owner replay failure]
545+
[FAIL: never owner]
546546
...
547547
"#]]);
548548

@@ -582,6 +582,146 @@ Warning: Failure from "[..]/invariant/failures/OwnableTest/invariant_never_owner
582582
"#]]);
583583
});
584584

585+
forgetest_init!(invariant_replay_preserves_fail_reason, |prj, cmd| {
586+
prj.update_config(|config| {
587+
config.invariant.runs = 1;
588+
config.invariant.depth = 1;
589+
});
590+
prj.add_test(
591+
"InvariantReplayFailReason.t.sol",
592+
r#"
593+
import {Test} from "forge-std/Test.sol";
594+
595+
contract InvariantReplayFailReason is Test {
596+
function setUp() public {
597+
targetContract(address(this));
598+
}
599+
600+
function callTarget(uint256) external {}
601+
602+
function invariant_fail_reason() public {
603+
fail();
604+
}
605+
}
606+
"#,
607+
);
608+
609+
cmd.args(["test", "--mt", "invariant_fail_reason"]).assert_failure().stdout_eq(str![[r#"
610+
...
611+
[FAIL: failed to set up invariant testing environment: <empty revert data>][..]
612+
...
613+
"#]]);
614+
615+
// Replay should preserve failure reason instead of generic replay message.
616+
cmd.assert_failure().stdout_eq(str![[r#"
617+
...
618+
[FAIL: failed to set up invariant testing environment: <empty revert data>][..]
619+
...
620+
"#]]);
621+
});
622+
623+
forgetest_init!(invariant_replay_preserves_custom_error_reason, |prj, cmd| {
624+
prj.update_config(|config| {
625+
config.invariant.runs = 1;
626+
config.invariant.depth = 1;
627+
config.invariant.fail_on_revert = true;
628+
});
629+
prj.add_test(
630+
"InvariantReplayCustomError.t.sol",
631+
r#"
632+
import {Test} from "forge-std/Test.sol";
633+
634+
contract CustomErrorTarget {
635+
error InvariantCustomError(uint256, string);
636+
637+
function breakInvariant() external {
638+
revert InvariantCustomError(111, "custom");
639+
}
640+
}
641+
642+
contract CustomErrorHandler is Test {
643+
CustomErrorTarget target;
644+
645+
constructor() {
646+
target = new CustomErrorTarget();
647+
}
648+
649+
function callTarget() external {
650+
target.breakInvariant();
651+
}
652+
}
653+
654+
contract InvariantReplayCustomError is Test {
655+
CustomErrorHandler handler;
656+
657+
function setUp() public {
658+
handler = new CustomErrorHandler();
659+
targetContract(address(handler));
660+
}
661+
662+
function invariant_custom_error_reason() public view {}
663+
}
664+
"#,
665+
);
666+
667+
cmd.args(["test", "--mt", "invariant_custom_error_reason"]).assert_failure().stdout_eq(str![[
668+
r#"
669+
...
670+
[FAIL: [..]custom[..]][..]
671+
...
672+
"#
673+
]]);
674+
675+
// Replay should preserve custom error string too.
676+
cmd.assert_failure().stdout_eq(str![[r#"
677+
...
678+
[FAIL: [..]custom[..]][..]
679+
...
680+
"#]]);
681+
});
682+
683+
forgetest_init!(invariant_replay_preserves_invariant_custom_error_reason, |prj, cmd| {
684+
prj.update_config(|config| {
685+
config.invariant.runs = 1;
686+
config.invariant.depth = 1;
687+
});
688+
prj.add_test(
689+
"InvariantReplayInvariantCustomError.t.sol",
690+
r#"
691+
import {Test} from "forge-std/Test.sol";
692+
693+
contract InvariantReplayInvariantCustomError is Test {
694+
error InvariantCustomError(uint256, string);
695+
696+
function setUp() public {
697+
targetContract(address(this));
698+
}
699+
700+
function touch(uint256) external {}
701+
702+
function invariant_custom_error_reason_from_invariant() public pure {
703+
revert InvariantCustomError(222, "invariant custom");
704+
}
705+
}
706+
"#,
707+
);
708+
709+
cmd.args(["test", "--mt", "invariant_custom_error_reason_from_invariant"])
710+
.assert_failure()
711+
.stdout_eq(str![[r#"
712+
...
713+
[FAIL: failed to set up invariant testing environment: InvariantCustomError(222, "invariant custom")][..]
714+
...
715+
"#]]);
716+
717+
// Replay should preserve invariant-level custom error string too.
718+
cmd.assert_failure().stdout_eq(str![[r#"
719+
...
720+
[FAIL: failed to set up invariant testing environment: InvariantCustomError(222, "invariant custom")][..]
721+
...
722+
"#]]);
723+
});
724+
585725
// <https://github.com/foundry-rs/foundry/issues/10253>
586726
forgetest_init!(invariant_test_target, |prj, cmd| {
587727
prj.update_config(|config| {

0 commit comments

Comments
 (0)