Skip to main content

foundry_evm/executors/invariant/
result.rs

1use super::{
2    InvariantFailures, InvariantFuzzError, InvariantMetrics, InvariantTest, InvariantTestRun,
3    call_after_invariant_function, call_invariant_function,
4    error::{InvariantRunCtx, record_handler_assertion_bug},
5};
6use crate::executors::{Executor, RawCallResult};
7use alloy_json_abi::Function;
8use alloy_primitives::{Address, B256, I256, Selector};
9use alloy_sol_types::{Panic, PanicKind, Revert, SolError, SolInterface};
10use eyre::Result;
11use foundry_config::InvariantConfig;
12use foundry_evm_core::{
13    abi::Vm,
14    constants::CHEATCODE_ADDRESS,
15    decode::{ASSERTION_FAILED_PREFIX, decode_console_log},
16    evm::FoundryEvmNetwork,
17    utils::StateChangeset,
18};
19use foundry_evm_coverage::HitMaps;
20use foundry_evm_fuzz::{
21    BasicTxDetails,
22    invariant::{FuzzRunIdentifiedContracts, InvariantContract},
23};
24use proptest::test_runner::TestError;
25use revm::interpreter::InstructionResult;
26use revm_inspectors::tracing::CallTraceArena;
27use std::{borrow::Cow, collections::HashMap};
28
29/// The outcome of an invariant fuzz test
30#[derive(Debug)]
31pub struct InvariantFuzzTestResult {
32    /// Errors recorded per invariant.
33    pub errors: HashMap<String, InvariantFuzzError>,
34    /// Handler-side assertion bugs, keyed by `(reverter, selector)` site (deduped per
35    /// handler function). Each entry is [`InvariantFuzzError::HandlerAssertion`].
36    pub handler_errors: HashMap<(Address, Selector), InvariantFuzzError>,
37    /// Number of completed invariant runs.
38    pub runs: usize,
39    /// Number of completed fuzzed calls across all invariant runs.
40    pub calls: usize,
41    /// Number of reverted fuzz calls
42    pub reverts: usize,
43    /// The entire inputs of the last run of the invariant campaign, used for
44    /// replaying the run for collecting traces.
45    pub last_run_inputs: Vec<BasicTxDetails>,
46    /// Additional traces used for gas report construction.
47    pub gas_report_traces: Vec<Vec<CallTraceArena>>,
48    /// The coverage info collected during the invariant test runs.
49    pub line_coverage: Option<HitMaps>,
50    /// Fuzzed selectors metrics collected during the invariant test runs.
51    pub metrics: HashMap<String, InvariantMetrics>,
52    /// Number of failed replays from persisted corpus.
53    pub failed_corpus_replays: usize,
54    /// Actual number of workers used for this logical campaign.
55    pub workers: usize,
56    /// Common fork block for all recorded failures, if they agree on one.
57    pub fork_block_number: Option<u64>,
58    /// For optimization mode (int256 return): the best (maximum) value achieved.
59    /// None means standard invariant check mode.
60    pub optimization_best_value: Option<I256>,
61    /// For optimization mode: the call sequence that produced the best value.
62    pub optimization_best_sequence: Vec<BasicTxDetails>,
63}
64
65impl InvariantFuzzTestResult {
66    #[expect(clippy::too_many_arguments)]
67    pub(crate) fn new(
68        errors: HashMap<String, InvariantFuzzError>,
69        handler_errors: HashMap<(Address, Selector), InvariantFuzzError>,
70        runs: usize,
71        calls: usize,
72        reverts: usize,
73        last_run_inputs: Vec<BasicTxDetails>,
74        gas_report_traces: Vec<Vec<CallTraceArena>>,
75        line_coverage: Option<HitMaps>,
76        metrics: HashMap<String, InvariantMetrics>,
77        failed_corpus_replays: usize,
78        workers: usize,
79        optimization_best_value: Option<I256>,
80        optimization_best_sequence: Vec<BasicTxDetails>,
81    ) -> Self {
82        let mut failure_blocks = errors
83            .values()
84            .chain(handler_errors.values())
85            .map(InvariantFuzzError::fork_block_number);
86        let fork_block_number = failure_blocks
87            .next()
88            .flatten()
89            .filter(|first| failure_blocks.all(|block| block == Some(*first)));
90
91        Self {
92            errors,
93            handler_errors,
94            runs,
95            calls,
96            reverts,
97            last_run_inputs,
98            gas_report_traces,
99            line_coverage,
100            metrics,
101            failed_corpus_replays,
102            workers,
103            fork_block_number,
104            optimization_best_value,
105            optimization_best_sequence,
106        }
107    }
108}
109
110/// Given the executor state, asserts that no invariant has been broken. Otherwise, it fills the
111/// external `invariant_failures.failed_invariant` map and returns a generic error.
112/// Either returns the call result if successful, or nothing if there was an error.
113pub(crate) fn invariant_preflight_check<FEN: FoundryEvmNetwork>(
114    invariant_contract: &InvariantContract<'_>,
115    invariant_config: &InvariantConfig,
116    targeted_contracts: &FuzzRunIdentifiedContracts,
117    executor: &Executor<FEN>,
118    calldata: &[BasicTxDetails],
119    invariant_failures: &mut InvariantFailures,
120) -> Result<()> {
121    assert_invariants(
122        invariant_contract,
123        invariant_config,
124        targeted_contracts,
125        executor,
126        calldata,
127        invariant_failures,
128    )?;
129    Ok(())
130}
131
132/// Returns true if this call failed due to a Solidity assertion:
133/// - `Panic(0x01)`, or
134/// - legacy invalid opcode assert behavior.
135pub(crate) fn is_assertion_failure<FEN: FoundryEvmNetwork>(
136    call_result: &RawCallResult<FEN>,
137) -> bool {
138    if !call_result.reverted {
139        return false;
140    }
141
142    is_assert_panic(call_result.result.as_ref())
143        || matches!(call_result.exit_reason, Some(InstructionResult::InvalidFEOpcode))
144        || is_revert_assertion_failure(call_result.result.as_ref())
145        || is_cheatcode_assert_revert(call_result)
146}
147
148fn is_assert_panic(data: &[u8]) -> bool {
149    Panic::abi_decode(data).is_ok_and(|panic| panic == PanicKind::Assert.into())
150}
151
152fn is_revert_assertion_failure(data: &[u8]) -> bool {
153    Revert::abi_decode(data).is_ok_and(|revert| revert.reason.contains(ASSERTION_FAILED_PREFIX))
154}
155
156fn is_cheatcode_assert_revert<FEN: FoundryEvmNetwork>(call_result: &RawCallResult<FEN>) -> bool {
157    call_result.reverter == Some(CHEATCODE_ADDRESS)
158        && Vm::VmErrors::abi_decode(call_result.result.as_ref())
159            .ok()
160            .map(|error| error.to_string())
161            .is_some_and(|message| message.starts_with(ASSERTION_FAILED_PREFIX))
162}
163
164fn logged_assertion_failure<FEN: FoundryEvmNetwork>(call_result: &RawCallResult<FEN>) -> bool {
165    call_result
166        .logs
167        .iter()
168        .filter_map(decode_console_log)
169        .any(|msg| msg.starts_with(ASSERTION_FAILED_PREFIX))
170}
171
172/// Returns whether the current fuzz call should be treated as an assertion failure.
173///
174/// This covers Solidity `assert`, legacy invalid-opcode assertions, `vm.assert*` reverts, and the
175/// non-reverting `GLOBAL_FAIL_SLOT` path used when `assertions_revert = false`.
176pub fn did_fail_on_assert<FEN: FoundryEvmNetwork>(
177    call_result: &RawCallResult<FEN>,
178    state_changeset: &StateChangeset,
179) -> bool {
180    is_assertion_failure(call_result)
181        || call_result.has_state_snapshot_failure
182        || Executor::<FEN>::has_pending_global_failure(state_changeset)
183        || logged_assertion_failure(call_result)
184}
185
186/// Given the executor state, asserts that no invariant has been broken. Otherwise, it fills the
187/// external `invariant_failures.failed_invariant` map.
188///
189/// Returns the first newly-broken invariant in declaration order (if any), so callers can
190/// attribute the failure event without re-scanning `invariant_failures.errors` afterwards.
191pub(crate) fn assert_invariants<'a, FEN: FoundryEvmNetwork>(
192    invariant_contract: &InvariantContract<'a>,
193    invariant_config: &InvariantConfig,
194    targeted_contracts: &FuzzRunIdentifiedContracts,
195    executor: &Executor<FEN>,
196    calldata: &[BasicTxDetails],
197    invariant_failures: &mut InvariantFailures,
198) -> Result<(Option<&'a Function>, bool)> {
199    let mut inner_sequence = None;
200    let mut first_broken: Option<&'a Function> = None;
201    let ctx = InvariantRunCtx {
202        contract: invariant_contract,
203        config: invariant_config,
204        targeted_contracts,
205        calldata,
206    };
207
208    for (idx, (invariant, fail_on_revert)) in invariant_contract.invariant_fns.iter().enumerate() {
209        // We only care about invariants which we haven't broken yet.
210        if invariant_failures.has_failure(invariant) {
211            continue;
212        }
213
214        let (call_result, success) = call_invariant_function(
215            executor,
216            invariant_contract.address,
217            invariant_contract.invariant_calldata(idx),
218        )?;
219        if call_result.execution_cancelled {
220            return Ok((first_broken, true));
221        }
222        if !success {
223            let inner_sequence =
224                inner_sequence.get_or_insert_with(|| invariant_inner_sequence(executor));
225            let case =
226                ctx.failed_case(invariant, *fail_on_revert, false, call_result, inner_sequence);
227            invariant_failures.record_failure(invariant, InvariantFuzzError::BrokenInvariant(case));
228            if first_broken.is_none() {
229                first_broken = Some(*invariant);
230            }
231        }
232    }
233
234    Ok((first_broken, false))
235}
236
237/// Helper function to initialize invariant inner sequence.
238fn invariant_inner_sequence<FEN: FoundryEvmNetwork>(
239    executor: &Executor<FEN>,
240) -> Vec<Option<BasicTxDetails>> {
241    let mut seq = vec![];
242    if let Some(fuzzer) = &executor.inspector().fuzzer
243        && let Some(call_generator) = &fuzzer.call_generator
244    {
245        seq.extend(call_generator.last_sequence.read().iter().cloned());
246    }
247    seq
248}
249
250/// Outcome of a per-call invariant check.
251#[derive(Debug)]
252pub(crate) struct ContinueOutcome {
253    /// Whether the invariant campaign should keep running after this call.
254    pub continues: bool,
255    /// Whether an invariant or optimization call was halted by cancellation.
256    pub cancelled: bool,
257}
258
259/// Returns if invariant test can continue and last successful call result of the invariant test
260/// function (if it can continue).
261///
262/// For optimization mode (int256 return), tracks the max value but never fails on invariant.
263/// For check mode, asserts the invariant and fails if broken.
264///
265/// `handler_target` / `handler_selector` identify the just-executed call, used to
266/// attribute handler-side assertion failures.
267#[allow(clippy::too_many_arguments)]
268pub(crate) fn can_continue<'a, FEN: FoundryEvmNetwork>(
269    invariant_contract: &InvariantContract<'a>,
270    invariant_test: &mut InvariantTest,
271    invariant_run: &mut InvariantTestRun<FEN>,
272    invariant_config: &InvariantConfig,
273    call_result: RawCallResult<FEN>,
274    state_changeset: &StateChangeset,
275    handler_target: Address,
276    handler_selector: Selector,
277    assertion_failure: bool,
278    pre_merge_edges_hash: Option<B256>,
279) -> Result<ContinueOutcome> {
280    let is_optimization = invariant_contract.is_optimization();
281
282    // Use the handler-gate variant so a stale committed `GLOBAL_FAIL_SLOT` from a
283    // previously-recorded handler bug doesn't poison this gate (which would otherwise silently
284    // skip every subsequent `assert_invariants` evaluation under `assertions_revert = false`).
285    // Handler bugs are tracked separately in `failures.broken_handlers`.
286    let handlers_succeeded = || {
287        if !invariant_run.executor.legacy_assertions() {
288            return invariant_run.executor.is_success_handler_gate(
289                invariant_contract.address,
290                false,
291                Cow::Borrowed(state_changeset),
292            );
293        }
294
295        invariant_test.targeted_contracts.targets().keys().all(|address| {
296            invariant_run.executor.is_success_handler_gate(
297                *address,
298                false,
299                Cow::Borrowed(state_changeset),
300            )
301        })
302    };
303
304    if !call_result.reverted && handlers_succeeded() {
305        if let Some(traces) = call_result.traces {
306            invariant_run.run_traces.push(traces);
307        }
308
309        if is_optimization {
310            // Optimization mode: call invariant and track max value, never fail.
311            let (inv_result, success) = call_invariant_function(
312                &invariant_run.executor,
313                invariant_contract.address,
314                invariant_contract.anchor_calldata(),
315            )?;
316            if inv_result.execution_cancelled {
317                return Ok(ContinueOutcome { continues: true, cancelled: true });
318            }
319            if success
320                && inv_result.result.len() >= 32
321                && let Some(value) = I256::try_from_be_slice(&inv_result.result[..32])
322            {
323                // Track the best value and its prefix length for this run
324                // (used for corpus persistence — materialized once at run end).
325                if invariant_run.optimization_value.is_none_or(|prev| value > prev) {
326                    invariant_run.optimization_value = Some(value);
327                    invariant_run.optimization_prefix_len = invariant_run.inputs.len();
328                }
329            }
330        } else {
331            // Check mode: assert invariants and fail if broken.
332            let (_, cancelled) = assert_invariants(
333                invariant_contract,
334                invariant_config,
335                &invariant_test.targeted_contracts,
336                &invariant_run.executor,
337                &invariant_run.inputs,
338                &mut invariant_test.test_data.failures,
339            )?;
340            if cancelled {
341                return Ok(ContinueOutcome { continues: true, cancelled: true });
342            }
343        }
344    } else {
345        let is_assert_failure = assertion_failure;
346        let reverted = call_result.reverted;
347
348        if reverted {
349            invariant_test.test_data.failures.reverts += 1;
350        }
351
352        if is_assert_failure {
353            // Handler-side assertion: deduped by `(reverter, selector)` site, shortest
354            // sequence wins on collision.
355            record_handler_assertion_bug(
356                invariant_contract,
357                invariant_config,
358                &invariant_test.targeted_contracts,
359                &mut invariant_test.test_data.failures,
360                &mut invariant_run.inputs,
361                handler_target,
362                handler_selector,
363                pre_merge_edges_hash,
364                call_result,
365                reverted,
366                is_optimization,
367            );
368
369            // No invariant predicate broke; `broken = None`.
370            let continues = invariant_test
371                .test_data
372                .failures
373                .can_continue(invariant_contract.invariant_fns.len());
374            return Ok(ContinueOutcome { continues, cancelled: false });
375        }
376
377        // Non-assertion revert: per-invariant `fail_on_revert` still marks affected
378        // invariants as broken.
379        let failing_invariants: Vec<_> = invariant_contract
380            .invariant_fns
381            .iter()
382            .filter(|(invariant, fail_on_revert)| {
383                *fail_on_revert && !invariant_test.test_data.failures.has_failure(invariant)
384            })
385            .collect();
386
387        if let Some((first_invariant, _)) = failing_invariants.first() {
388            // Build a base case_data attributed to the first failing invariant; clone it for
389            // each subsequent broken invariant, retagging name/selector/`fail_on_revert` so
390            // every recorded failure points at its own invariant body.
391            let base = InvariantRunCtx {
392                contract: invariant_contract,
393                config: invariant_config,
394                targeted_contracts: &invariant_test.targeted_contracts,
395                calldata: &invariant_run.inputs,
396            }
397            .failed_case(
398                first_invariant,
399                invariant_config.fail_on_revert,
400                is_assert_failure,
401                call_result,
402                &[],
403            );
404
405            for (invariant, fail_on_revert) in failing_invariants {
406                let mut data = base.clone();
407                data.fail_on_revert = *fail_on_revert;
408                data.calldata = invariant.selector().to_vec().into();
409                data.test_error = TestError::Fail(
410                    format!("{}, reason: {}", invariant.name, data.revert_reason).into(),
411                    invariant_run.inputs.clone(),
412                );
413                // Handler asserts go to `broken_handlers` above; `BrokenInvariant` arm kept
414                // for non-handler-routed assertion paths.
415                invariant_test.test_data.failures.record_failure(
416                    invariant,
417                    if is_assert_failure {
418                        InvariantFuzzError::BrokenInvariant(data)
419                    } else {
420                        InvariantFuzzError::Revert(data)
421                    },
422                );
423            }
424        }
425
426        if reverted && !is_optimization && !invariant_config.has_delay() {
427            // If we don't fail the test on revert, remove the reverted call from inputs.
428            // Delay-enabled campaigns keep reverted calls so their warp/roll contribution can be
429            // replayed.
430            invariant_run.inputs.pop();
431        }
432    }
433
434    let continues =
435        invariant_test.test_data.failures.can_continue(invariant_contract.invariant_fns.len());
436    Ok(ContinueOutcome { continues, cancelled: false })
437}
438
439/// Given the executor state, asserts conditions within `afterInvariant` function.
440///
441/// Returns `Some(anchor)` if the hook failed (so the caller can record the failure event
442/// without re-scanning the failures map), or `None` if the hook succeeded.
443pub(crate) fn assert_after_invariant<'a, FEN: FoundryEvmNetwork>(
444    invariant_contract: &InvariantContract<'a>,
445    invariant_test: &mut InvariantTest,
446    invariant_run: &InvariantTestRun<FEN>,
447    invariant_config: &InvariantConfig,
448) -> Result<(Option<&'a Function>, bool)> {
449    let (call_result, success) =
450        call_after_invariant_function(&invariant_run.executor, invariant_contract.address)?;
451    if call_result.execution_cancelled {
452        return Ok((None, true));
453    }
454    // Fail the test case if `afterInvariant` doesn't succeed.
455    if success {
456        return Ok((None, false));
457    }
458    // `afterInvariant` failures are contract-wide (no specific invariant body executed),
459    // so attribute to the campaign anchor.
460    let anchor = invariant_contract.anchor();
461    let case_data = InvariantRunCtx {
462        contract: invariant_contract,
463        config: invariant_config,
464        targeted_contracts: &invariant_test.targeted_contracts,
465        calldata: &invariant_run.inputs,
466    }
467    .failed_case(anchor, invariant_config.fail_on_revert, false, call_result, &[]);
468    invariant_test
469        .test_data
470        .failures
471        .record_failure(anchor, InvariantFuzzError::BrokenInvariant(case_data));
472    Ok((Some(anchor), false))
473}
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478    use crate::executors::{EarlyExit, ExecutorBuilder};
479    use alloy_dyn_abi::JsonAbiExt;
480    use alloy_primitives::{Bytes, U256};
481    use alloy_sol_types::SolCall;
482    use foundry_cheatcodes::{CheatsConfig, Vm::expectRevert_0Call};
483    use foundry_config::Config;
484    use foundry_evm_core::{
485        backend::Backend,
486        constants::CALLER,
487        evm::{EthEvmNetwork, EvmEnvFor, TxEnvFor},
488        opts::EvmOpts,
489    };
490    use foundry_evm_fuzz::invariant::TargetedContracts;
491    use revm::bytecode::Bytecode;
492    use std::sync::Arc;
493
494    fn panic_payload(code: u8) -> Bytes {
495        let mut payload = vec![0_u8; 36];
496        payload[..4].copy_from_slice(&[0x4e, 0x48, 0x7b, 0x71]);
497        payload[35] = code;
498        payload.into()
499    }
500
501    #[test]
502    fn cancellation_does_not_record_call_end_rewrite_as_invariant_failure() {
503        let cheats_config =
504            Arc::new(CheatsConfig::new(&Config::default(), EvmOpts::default(), None, None, false));
505        let backend = Backend::<EthEvmNetwork>::spawn(None).unwrap();
506        let mut executor = ExecutorBuilder::default()
507            .inspectors(|stack| stack.cheatcodes(cheats_config))
508            .gas_limit(1 << 24)
509            .build(
510                EvmEnvFor::<EthEvmNetwork>::default(),
511                TxEnvFor::<EthEvmNetwork>::default(),
512                backend,
513                Default::default(),
514            );
515        let invariant_address = Address::repeat_byte(0x11);
516        executor
517            .set_code(
518                invariant_address,
519                Bytecode::new_raw(Bytes::from_static(&[0x5b, 0x60, 0x00, 0x56])),
520            )
521            .unwrap();
522        let expect_result = executor
523            .transact_raw(
524                CALLER,
525                CHEATCODE_ADDRESS,
526                expectRevert_0Call {}.abi_encode().into(),
527                U256::ZERO,
528            )
529            .unwrap();
530        assert!(!expect_result.reverted);
531
532        let early_exit = EarlyExit::new(false);
533        executor.inspector_mut().set_early_exit(early_exit.clone());
534        early_exit.record_ctrl_c();
535
536        let invariant = Function::parse("invariant_ok() view returns (bool)").unwrap();
537        let mut abi = alloy_json_abi::JsonAbi::new();
538        abi.functions.entry(invariant.name.clone()).or_default().push(invariant.clone());
539        let invariant_contract = InvariantContract::new(
540            invariant_address,
541            "InvariantTest",
542            vec![(&invariant, false)],
543            0,
544            false,
545            &abi,
546        );
547
548        let (_, success) = call_invariant_function(
549            &executor.clone(),
550            invariant_address,
551            invariant.abi_encode_input(&[]).unwrap().into(),
552        )
553        .unwrap();
554        assert!(!success, "pending expectRevert should rewrite the interrupted call");
555
556        let targets = FuzzRunIdentifiedContracts::new(TargetedContracts::new(), false);
557        let mut failures = InvariantFailures::new();
558        let broken = assert_invariants(
559            &invariant_contract,
560            &InvariantConfig::default(),
561            &targets,
562            &executor,
563            &[],
564            &mut failures,
565        )
566        .unwrap();
567
568        assert!(broken.0.is_none());
569        assert!(broken.1);
570        assert_eq!(failures.invariant_count(), 0);
571    }
572
573    #[test]
574    fn detects_assert_panic_code() {
575        let call_result = RawCallResult::<EthEvmNetwork> {
576            reverted: true,
577            result: panic_payload(0x01),
578            ..Default::default()
579        };
580        assert!(is_assertion_failure(&call_result));
581    }
582
583    #[test]
584    fn ignores_non_assert_panic_code() {
585        let call_result = RawCallResult::<EthEvmNetwork> {
586            reverted: true,
587            result: panic_payload(0x11),
588            ..Default::default()
589        };
590        assert!(!is_assertion_failure(&call_result));
591    }
592
593    #[test]
594    fn detects_legacy_invalid_opcode_assert() {
595        let call_result = RawCallResult::<EthEvmNetwork> {
596            reverted: true,
597            exit_reason: Some(InstructionResult::InvalidFEOpcode),
598            ..Default::default()
599        };
600        assert!(is_assertion_failure(&call_result));
601    }
602
603    #[test]
604    fn detects_vm_assert_revert() {
605        let call_result = RawCallResult::<EthEvmNetwork> {
606            reverted: true,
607            result: Vm::CheatcodeError { message: format!("{ASSERTION_FAILED_PREFIX}: 1 != 2") }
608                .abi_encode()
609                .into(),
610            reverter: Some(CHEATCODE_ADDRESS),
611            ..Default::default()
612        };
613        assert!(is_assertion_failure(&call_result));
614    }
615
616    #[test]
617    fn detects_assertion_failure_revert_reason() {
618        let call_result = RawCallResult::<EthEvmNetwork> {
619            reverted: true,
620            result: Revert { reason: format!("{ASSERTION_FAILED_PREFIX}: expected") }
621                .abi_encode()
622                .into(),
623            ..Default::default()
624        };
625        assert!(is_assertion_failure(&call_result));
626    }
627
628    #[test]
629    fn ignores_empty_cheatcode_revert() {
630        let call_result = RawCallResult::<EthEvmNetwork> {
631            reverted: true,
632            result: Bytes::new(),
633            reverter: Some(CHEATCODE_ADDRESS),
634            ..Default::default()
635        };
636        assert!(!is_assertion_failure(&call_result));
637    }
638}