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