Skip to content

Commit cb8ff9f

Browse files
committed
feat(invariant): decouple handler-side assertions from invariant predicates
Handler-side assertion failures are now tracked in a dedicated broken_handlers map keyed by (reverter, selector) instead of being attributed to every live invariant. They surface in their own "Suite handlers:" report section, keeping invariant predicate breaks rendered separately. Under assert_all = true (the new default), the campaign continues for the full budget after a preflight invariant failure so handler-side bugs and still-live invariants can be discovered. The legacy abort-on-preflight behavior is preserved when assert_all = false. Live progress (progress bar + JSON pulse events) now surfaces unique handler bug counts alongside invariant failure counts so both classes are visible during the campaign. Tests: - New regression test assert_all_handler_assertion_routed_to_handler_section asserting the "handler bug != invariant break" semantics. - Existing handler-assert tests (invariant_fail_on_assert_panic, invariant_fail_on_vm_assert_*, etc.) updated to expect the new "Suite handlers:" rendering while keeping the failure-reason line. - should_exit_early_on_invariant_failure now sets assert_all = false explicitly so it continues to exercise the legacy abort-on-preflight path. Refs: #14437
1 parent ffb889a commit cb8ff9f

9 files changed

Lines changed: 424 additions & 62 deletions

File tree

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
use super::InvariantContract;
22
use crate::executors::RawCallResult;
33
use alloy_json_abi::Function;
4-
use alloy_primitives::{Address, Bytes};
4+
use alloy_primitives::{Address, Bytes, Selector};
55
use foundry_evm_core::{
66
decode::{ASSERTION_FAILED_PREFIX, EMPTY_REVERT_DATA, RevertDecoder},
77
evm::FoundryEvmNetwork,
@@ -10,6 +10,28 @@ use foundry_evm_fuzz::{BasicTxDetails, Reason, invariant::FuzzRunIdentifiedContr
1010
use proptest::test_runner::TestError;
1111
use std::{collections::HashMap, fmt};
1212

13+
/// Records a single handler-side assertion bug discovered during an invariant campaign.
14+
///
15+
/// Handler-side assertions (e.g. a `require`/`assert` inside a fuzzed handler that the campaign
16+
/// reaches with a malformed input) are bugs in their own right, but they are *not* invariant
17+
/// predicate violations. We record them once per `(reverter, selector)` so the campaign can keep
18+
/// running for the rest of the budget and surface deeper bugs without polluting the invariant
19+
/// `errors` map or stopping the run.
20+
#[derive(Clone, Debug)]
21+
pub struct HandlerAssertionFailure {
22+
/// Address of the handler contract whose call asserted/reverted with an assertion.
23+
pub reverter: Address,
24+
/// 4-byte selector of the failing handler function.
25+
pub selector: Selector,
26+
/// Full call sequence leading up to (and including) the failing call.
27+
pub call_sequence: Vec<BasicTxDetails>,
28+
/// Decoded revert/assert reason.
29+
pub revert_reason: String,
30+
/// Always `true` for entries in this struct; mirrored for symmetry with
31+
/// [`FailedInvariantCaseData::assertion_failure`].
32+
pub assertion_failure: bool,
33+
}
34+
1335
/// Stores information about failures and reverts of the invariant tests.
1436
#[derive(Clone, Default)]
1537
pub struct InvariantFailures {
@@ -19,6 +41,9 @@ pub struct InvariantFailures {
1941
pub revert_reason: Option<String>,
2042
/// Maps a broken invariant to its specific error.
2143
pub errors: HashMap<String, InvariantFuzzError>,
44+
/// Handler-side assertion bugs discovered during the campaign, keyed by
45+
/// `(reverter, selector)` so each unique handler bug is recorded once.
46+
pub broken_handlers: HashMap<(Address, Selector), HandlerAssertionFailure>,
2247
}
2348

2449
impl InvariantFailures {
@@ -46,6 +71,22 @@ impl InvariantFailures {
4671
debug_assert!(invariants > 0, "invariant_fns must not be empty");
4772
self.errors.len() < invariants
4873
}
74+
75+
/// Records a handler-side assertion bug. The first occurrence for a given
76+
/// `(reverter, selector)` wins; subsequent calls are no-ops to keep the report tidy.
77+
pub fn record_handler_failure(
78+
&mut self,
79+
key: (Address, Selector),
80+
failure: HandlerAssertionFailure,
81+
) {
82+
self.broken_handlers.entry(key).or_insert(failure);
83+
}
84+
85+
/// Returns true if a handler-side assertion bug has already been recorded for the given
86+
/// target/selector pair.
87+
pub fn has_handler_failure(&self, target: Address, selector: Selector) -> bool {
88+
self.broken_handlers.contains_key(&(target, selector))
89+
}
4990
}
5091

5192
impl fmt::Display for InvariantFailures {

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

Lines changed: 117 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ use std::{
4646
};
4747

4848
mod error;
49-
pub use error::{InvariantFailures, InvariantFuzzError};
49+
pub use error::{HandlerAssertionFailure, InvariantFailures, InvariantFuzzError};
5050
use foundry_evm_coverage::HitMaps;
5151

5252
mod replay;
@@ -203,19 +203,26 @@ fn first_broken_event<'a>(
203203
/// This keeps the existing corpus progress metrics together with cumulative and
204204
/// derived throughput fields so downstream benchmark tooling can consume a
205205
/// single JSON event shape.
206+
#[expect(clippy::too_many_arguments)]
206207
fn build_invariant_progress_json<M: Serialize>(
207208
timestamp_secs: u64,
208209
invariant_name: &str,
209210
corpus_metrics: &M,
210211
optimization_best: Option<I256>,
211212
throughput: InvariantThroughputMetrics,
212213
failure_metrics: &InvariantFailureMetrics,
214+
broken_handlers: usize,
213215
elapsed: Duration,
214216
) -> serde_json::Value {
215217
let mut metrics = serde_json::to_value(corpus_metrics).unwrap_or_default();
216218
if let Some(obj) = metrics.as_object_mut() {
217219
obj.insert("failures".to_string(), json!(failure_metrics.failures));
218220
obj.insert("unique_failures".to_string(), json!(failure_metrics.unique_failures.len()));
221+
// Phase E: surface unique handler-side assertion bugs in live progress so users
222+
// can watch them accumulate without waiting for the campaign to finish. These are
223+
// distinct from invariant predicate violations (counted by `failures` above) and
224+
// are routed via `InvariantFailures::broken_handlers`.
225+
obj.insert("broken_handlers".to_string(), json!(broken_handlers));
219226
}
220227

221228
let mut payload = json!({
@@ -513,17 +520,22 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
513520
break 'stop;
514521
}
515522

523+
// Clone the latest input so that `tx` does not borrow from `current_run.inputs`.
524+
// This lets us pass `&tx` into `can_continue` alongside `&mut current_run`
525+
// without conflicting borrows (`can_continue` needs the tx to attribute
526+
// handler-side assertion failures to a specific `(target, selector)`).
516527
let tx = current_run
517528
.inputs
518529
.last()
519-
.ok_or_else(|| eyre!("no input generated to call fuzzed target."))?;
530+
.ok_or_else(|| eyre!("no input generated to call fuzzed target."))?
531+
.clone();
520532

521533
// Execute call from the randomly generated sequence without committing state.
522534
// State is committed only if call is not a magic assume.
523-
let mut call_result = execute_tx(&mut current_run.executor, tx)?;
535+
let mut call_result = execute_tx(&mut current_run.executor, &tx)?;
524536
let discarded = call_result.result.as_ref() == MAGIC_ASSUME;
525537
if self.config.show_metrics {
526-
invariant_test.record_metrics(tx, call_result.reverted, discarded);
538+
invariant_test.record_metrics(&tx, call_result.reverted, discarded);
527539
}
528540

529541
// Collect line coverage from last fuzzed call.
@@ -562,7 +574,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
562574
collect_data(
563575
&invariant_test,
564576
&mut state_changeset,
565-
tx,
577+
&tx,
566578
&call_result,
567579
self.config.depth,
568580
);
@@ -614,15 +626,64 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
614626
&self.config,
615627
call_result,
616628
&state_changeset,
629+
&tx,
617630
)
618631
.map_err(|e| eyre!(e.to_string()))?
619632
} else {
620633
// Skip invariant check but still track reverts
621634
if call_result.reverted {
622635
invariant_test.test_data.failures.reverts += 1;
623636
}
624-
if assertion_failure || (call_result.reverted && self.config.fail_on_revert)
625-
{
637+
if assertion_failure {
638+
// Handler-side assertion: record once, keyed by the failing call's
639+
// `(target, selector)`. Same routing as the `can_continue` path so
640+
// the campaign keeps running for the full budget instead of
641+
// attributing the assertion to the primary invariant.
642+
let target = tx.call_details.target;
643+
let selector = tx
644+
.call_details
645+
.calldata
646+
.get(..4)
647+
.and_then(|s| Selector::try_from(s).ok())
648+
.unwrap_or_default();
649+
let call_reverted = call_result.reverted;
650+
if !invariant_test
651+
.test_data
652+
.failures
653+
.has_handler_failure(target, selector)
654+
{
655+
let case_data = error::FailedInvariantCaseData::new(
656+
&invariant_contract,
657+
self.config.shrink_run_limit,
658+
self.config.fail_on_revert,
659+
&invariant_test.targeted_contracts,
660+
&current_run.inputs,
661+
call_result,
662+
&[],
663+
)
664+
.with_assertion_failure(true);
665+
let revert_reason = case_data.revert_reason.clone();
666+
invariant_test.test_data.failures.revert_reason =
667+
Some(revert_reason.clone());
668+
invariant_test.test_data.failures.record_handler_failure(
669+
(target, selector),
670+
HandlerAssertionFailure {
671+
reverter: target,
672+
selector,
673+
call_sequence: current_run.inputs.clone(),
674+
revert_reason,
675+
assertion_failure: true,
676+
},
677+
);
678+
}
679+
if call_reverted
680+
&& !invariant_contract.is_optimization()
681+
&& !self.config.has_delay()
682+
{
683+
current_run.inputs.pop();
684+
}
685+
true
686+
} else if call_result.reverted && self.config.fail_on_revert {
626687
let case_data = error::FailedInvariantCaseData::new(
627688
&invariant_contract,
628689
self.config.shrink_run_limit,
@@ -632,16 +693,12 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
632693
call_result,
633694
&[],
634695
)
635-
.with_assertion_failure(assertion_failure);
696+
.with_assertion_failure(false);
636697
invariant_test.test_data.failures.revert_reason =
637698
Some(case_data.revert_reason.clone());
638699
invariant_test.set_error(
639700
invariant_contract.primary_invariant_fn,
640-
if assertion_failure {
641-
InvariantFuzzError::BrokenInvariant(case_data)
642-
} else {
643-
InvariantFuzzError::Revert(case_data)
644-
},
701+
InvariantFuzzError::Revert(case_data),
645702
);
646703
false
647704
} else if call_result.reverted
@@ -661,7 +718,19 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
661718
if !result || current_run.depth == self.config.depth - 1 {
662719
invariant_test.set_last_run_inputs(&current_run.inputs);
663720
}
664-
// If test cannot continue then stop current run and exit test suite.
721+
// Phase A: decouple "record failure" from "stop campaign" so the campaign
722+
// can keep using its budget to surface handler-side bugs even after all
723+
// invariant predicates are broken. Continuation is gated on
724+
// `assert_all && !fail_on_revert`:
725+
// - `assert_all = false` (single-invariant runs) → preserve the legacy "exit
726+
// on first broken invariant" behavior so output is unchanged.
727+
// - `fail_on_revert = true` → user opted into "fail fast on revert"; the
728+
// outer-loop early-exit check (top of campaign loop) would fire on the next
729+
// iteration anyway, so exit cleanly here instead of surfacing a confusing
730+
// "failed to set up invariant testing environment: call reverted" error.
731+
//
732+
// Handler-side assertions never reach this branch — they are routed into
733+
// `failures.broken_handlers` and return `Ok(true)`.
665734
if !result {
666735
// Attribute the failure event to the first invariant in declaration
667736
// order whose entry is in `failures.errors`. Avoids the nondeterminism
@@ -672,6 +741,9 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
672741
&invariant_test.test_data.failures,
673742
);
674743
failure_metrics.record_failure(name, invariant_contract.name, &reason);
744+
if self.config.assert_all && !self.config.fail_on_revert {
745+
break;
746+
}
675747
break 'stop;
676748
}
677749
current_run.depth += 1;
@@ -728,8 +800,12 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
728800
// Display current best value, corpus metrics, and failure counts.
729801
let best = invariant_test.test_data.optimization_best_value;
730802
let broken = invariant_test.test_data.failures.errors.len();
803+
// Phase E: live count of unique handler-side assertion bugs so users see
804+
// them accumulate during the campaign (separate from invariant predicate
805+
// breaks tracked by `broken` above).
806+
let handler_bugs = invariant_test.test_data.failures.broken_handlers.len();
731807
let total_invariants = invariant_contract.invariant_fns.len();
732-
if edge_coverage_enabled || best.is_some() || broken > 0 {
808+
if edge_coverage_enabled || best.is_some() || broken > 0 || handler_bugs > 0 {
733809
let mut msg = String::new();
734810
if let Some(best) = best {
735811
msg.push_str(&format!("best: {best}"));
@@ -746,6 +822,12 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
746822
}
747823
msg.push_str(&format!("❌ {broken}/{total_invariants} broken"));
748824
}
825+
if handler_bugs > 0 {
826+
if !msg.is_empty() {
827+
msg.push_str(", ");
828+
}
829+
msg.push_str(&format!("⚠ {handler_bugs} handler bug(s)"));
830+
}
749831
progress.set_message(msg);
750832
}
751833
} else if edge_coverage_enabled
@@ -759,6 +841,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
759841
invariant_test.test_data.optimization_best_value,
760842
throughput,
761843
&failure_metrics,
844+
invariant_test.test_data.failures.broken_handlers.len(),
762845
campaign_start.elapsed(),
763846
);
764847
let _ = sh_println!("{}", serde_json::to_string(&metrics)?);
@@ -774,6 +857,7 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
774857
let result = invariant_test.test_data;
775858
Ok(InvariantFuzzTestResult {
776859
errors: result.failures.errors,
860+
handler_errors: result.failures.broken_handlers,
777861
cases: result.fuzz_cases,
778862
reverts: result.failures.reverts,
779863
last_run_inputs: result.last_run_inputs,
@@ -848,7 +932,19 @@ impl<'a, FEN: FoundryEvmNetwork> InvariantExecutor<'a, FEN> {
848932
invariant_contract.invariant_fns.iter().find_map(|(f, _)| failures.get_failure(f))
849933
})
850934
{
851-
return Err(eyre!(error.revert_reason().unwrap_or_default()));
935+
// Under `assert_all` the campaign is expected to keep running for the full
936+
// budget so that handler-side bugs (and other still-live invariants) can be
937+
// discovered. An always-failing canary invariant must not abort the entire run.
938+
// Record the preflight failure(s) and continue; the campaign loop's
939+
// `can_continue` will keep going as long as at least one invariant is still
940+
// live (or, with all of them broken, until handler bugs are exhausted via the
941+
// dedicated `broken_handlers` path).
942+
//
943+
// Without `assert_all` we preserve the legacy behavior of aborting on a broken
944+
// preflight invariant.
945+
if !self.config.assert_all {
946+
return Err(eyre!(error.revert_reason().unwrap_or_default()));
947+
}
852948
}
853949

854950
// NOW enable call_override after the initial invariant check has passed.
@@ -1356,12 +1452,14 @@ mod tests {
13561452
Some(I256::try_from(42).unwrap()),
13571453
throughput,
13581454
&InvariantFailureMetrics::default(),
1455+
0,
13591456
Duration::from_secs(10),
13601457
);
13611458

13621459
assert_eq!(payload["timestamp"], json!(123));
13631460
assert_eq!(payload["invariant"], json!("invariant_balance"));
13641461
assert_eq!(payload["metrics"]["corpus_count"], json!(7));
1462+
assert_eq!(payload["metrics"]["broken_handlers"], json!(0));
13651463
assert_eq!(payload["total_txs"], json!(2));
13661464
assert_eq!(payload["total_gas"], json!(50));
13671465
assert!((payload["tx_per_sec"].as_f64().unwrap() - 0.2).abs() < 1e-12);
@@ -1381,6 +1479,7 @@ mod tests {
13811479
None,
13821480
throughput,
13831481
&InvariantFailureMetrics::default(),
1482+
0,
13841483
Duration::ZERO,
13851484
);
13861485

@@ -1403,11 +1502,13 @@ mod tests {
14031502
None,
14041503
InvariantThroughputMetrics::default(),
14051504
&failure_metrics,
1505+
7,
14061506
Duration::from_secs(1),
14071507
);
14081508

14091509
assert_eq!(payload["metrics"]["failures"], json!(3));
14101510
assert_eq!(payload["metrics"]["unique_failures"], json!(2));
1511+
assert_eq!(payload["metrics"]["broken_handlers"], json!(7));
14111512
}
14121513

14131514
#[test]

0 commit comments

Comments
 (0)