@@ -46,7 +46,7 @@ use std::{
4646} ;
4747
4848mod error;
49- pub use error:: { InvariantFailures , InvariantFuzzError } ;
49+ pub use error:: { HandlerAssertionFailure , InvariantFailures , InvariantFuzzError } ;
5050use foundry_evm_coverage:: HitMaps ;
5151
5252mod 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) ]
206207fn 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