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    /// Common fork block for all recorded failures, if they agree on one.
58    pub fork_block_number: Option<u64>,
59    /// For optimization mode (int256 return): the best (maximum) value achieved.
60    /// None means standard invariant check mode.
61    pub optimization_best_value: Option<I256>,
62    /// For optimization mode: the call sequence that produced the best value.
63    pub optimization_best_sequence: Vec<BasicTxDetails>,
64}
65
66impl InvariantFuzzTestResult {
67    #[expect(clippy::too_many_arguments)]
68    pub(crate) fn new(
69        errors: HashMap<String, InvariantFuzzError>,
70        handler_errors: HashMap<(Address, Selector), InvariantFuzzError>,
71        runs: usize,
72        calls: usize,
73        reverts: usize,
74        last_run_inputs: Vec<BasicTxDetails>,
75        gas_report_traces: Vec<Vec<CallTraceArena>>,
76        line_coverage: Option<HitMaps>,
77        metrics: HashMap<String, InvariantMetrics>,
78        failed_corpus_replays: usize,
79        workers: usize,
80        optimization_best_value: Option<I256>,
81        optimization_best_sequence: Vec<BasicTxDetails>,
82    ) -> Self {
83        let mut failure_blocks = errors
84            .values()
85            .chain(handler_errors.values())
86            .map(InvariantFuzzError::fork_block_number);
87        let fork_block_number = failure_blocks
88            .next()
89            .flatten()
90            .filter(|first| failure_blocks.all(|block| block == Some(*first)));
91
92        Self {
93            errors,
94            handler_errors,
95            runs,
96            calls,
97            reverts,
98            last_run_inputs,
99            gas_report_traces,
100            line_coverage,
101            metrics,
102            failed_corpus_replays,
103            workers,
104            fork_block_number,
105            optimization_best_value,
106            optimization_best_sequence,
107        }
108    }
109}
110
111/// Given the executor state, asserts that no invariant has been broken. Otherwise, it fills the
112/// external `invariant_failures.failed_invariant` map and returns a generic error.
113/// Either returns the call result if successful, or nothing if there was an error.
114pub(crate) fn invariant_preflight_check<FEN: FoundryEvmNetwork>(
115    invariant_contract: &InvariantContract<'_>,
116    invariant_config: &InvariantConfig,
117    targeted_contracts: &FuzzRunIdentifiedContracts,
118    executor: &Executor<FEN>,
119    calldata: &[BasicTxDetails],
120    invariant_failures: &mut InvariantFailures,
121) -> Result<()> {
122    assert_invariants(
123        invariant_contract,
124        invariant_config,
125        targeted_contracts,
126        executor,
127        calldata,
128        invariant_failures,
129    )?;
130    Ok(())
131}
132
133/// Returns true if this call failed due to a Solidity assertion:
134/// - `Panic(0x01)`, or
135/// - legacy invalid opcode assert behavior.
136pub(crate) fn is_assertion_failure<FEN: FoundryEvmNetwork>(
137    call_result: &RawCallResult<FEN>,
138) -> bool {
139    if !call_result.reverted {
140        return false;
141    }
142
143    is_assert_panic(call_result.result.as_ref())
144        || matches!(call_result.exit_reason, Some(InstructionResult::InvalidFEOpcode))
145        || is_revert_assertion_failure(call_result.result.as_ref())
146        || is_cheatcode_assert_revert(call_result)
147}
148
149fn is_assert_panic(data: &[u8]) -> bool {
150    Panic::abi_decode(data).is_ok_and(|panic| panic == PanicKind::Assert.into())
151}
152
153fn is_revert_assertion_failure(data: &[u8]) -> bool {
154    Revert::abi_decode(data).is_ok_and(|revert| revert.reason.contains(ASSERTION_FAILED_PREFIX))
155}
156
157fn is_cheatcode_assert_revert<FEN: FoundryEvmNetwork>(call_result: &RawCallResult<FEN>) -> bool {
158    call_result.reverter == Some(CHEATCODE_ADDRESS)
159        && Vm::VmErrors::abi_decode(call_result.result.as_ref())
160            .ok()
161            .map(|error| error.to_string())
162            .is_some_and(|message| message.starts_with(ASSERTION_FAILED_PREFIX))
163}
164
165fn logged_assertion_failure<FEN: FoundryEvmNetwork>(call_result: &RawCallResult<FEN>) -> bool {
166    call_result
167        .logs
168        .iter()
169        .filter_map(decode_console_log)
170        .any(|msg| msg.starts_with(ASSERTION_FAILED_PREFIX))
171}
172
173/// Returns whether the current fuzz call should be treated as an assertion failure.
174///
175/// This covers Solidity `assert`, legacy invalid-opcode assertions, `vm.assert*` reverts, and the
176/// non-reverting `GLOBAL_FAIL_SLOT` path used when `assertions_revert = false`.
177pub(crate) fn did_fail_on_assert<FEN: FoundryEvmNetwork>(
178    call_result: &RawCallResult<FEN>,
179    state_changeset: &StateChangeset,
180) -> bool {
181    is_assertion_failure(call_result)
182        || call_result.has_state_snapshot_failure
183        || Executor::<FEN>::has_pending_global_failure(state_changeset)
184        || logged_assertion_failure(call_result)
185}
186
187/// Given the executor state, asserts that no invariant has been broken. Otherwise, it fills the
188/// external `invariant_failures.failed_invariant` map.
189///
190/// Returns the first newly-broken invariant in declaration order (if any), so callers can
191/// attribute the failure event without re-scanning `invariant_failures.errors` afterwards.
192pub(crate) fn assert_invariants<'a, FEN: FoundryEvmNetwork>(
193    invariant_contract: &InvariantContract<'a>,
194    invariant_config: &InvariantConfig,
195    targeted_contracts: &FuzzRunIdentifiedContracts,
196    executor: &Executor<FEN>,
197    calldata: &[BasicTxDetails],
198    invariant_failures: &mut InvariantFailures,
199) -> Result<(Option<&'a Function>, bool)> {
200    let mut inner_sequence = None;
201    let mut first_broken: Option<&'a Function> = None;
202    let ctx = InvariantRunCtx {
203        contract: invariant_contract,
204        config: invariant_config,
205        targeted_contracts,
206        calldata,
207    };
208
209    for (invariant, fail_on_revert) in &invariant_contract.invariant_fns {
210        // We only care about invariants which we haven't broken yet.
211        if invariant_failures.has_failure(invariant) {
212            continue;
213        }
214
215        let (call_result, success) = call_invariant_function(
216            executor,
217            invariant_contract.address,
218            invariant.abi_encode_input(&[])?.into(),
219        )?;
220        if call_result.execution_cancelled {
221            return Ok((first_broken, true));
222        }
223        if !success {
224            let inner_sequence =
225                inner_sequence.get_or_insert_with(|| invariant_inner_sequence(executor));
226            let case =
227                ctx.failed_case(invariant, *fail_on_revert, false, call_result, inner_sequence);
228            invariant_failures.record_failure(invariant, InvariantFuzzError::BrokenInvariant(case));
229            if first_broken.is_none() {
230                first_broken = Some(*invariant);
231            }
232        }
233    }
234
235    Ok((first_broken, false))
236}
237
238/// Helper function to initialize invariant inner sequence.
239fn invariant_inner_sequence<FEN: FoundryEvmNetwork>(
240    executor: &Executor<FEN>,
241) -> Vec<Option<BasicTxDetails>> {
242    let mut seq = vec![];
243    if let Some(fuzzer) = &executor.inspector().fuzzer
244        && let Some(call_generator) = &fuzzer.call_generator
245    {
246        seq.extend(call_generator.last_sequence.read().iter().cloned());
247    }
248    seq
249}
250
251/// Outcome of a per-call invariant check.
252#[derive(Debug)]
253pub(crate) struct ContinueOutcome {
254    /// Whether the invariant campaign should keep running after this call.
255    pub continues: bool,
256    /// Whether an invariant or optimization call was halted by cancellation.
257    pub cancelled: bool,
258}
259
260/// Returns if invariant test can continue and last successful call result of the invariant test
261/// function (if it can continue).
262///
263/// For optimization mode (int256 return), tracks the max value but never fails on invariant.
264/// For check mode, asserts the invariant and fails if broken.
265///
266/// `handler_target` / `handler_selector` identify the just-executed call, used to
267/// attribute handler-side assertion failures.
268#[allow(clippy::too_many_arguments)]
269pub(crate) fn can_continue<'a, FEN: FoundryEvmNetwork>(
270    invariant_contract: &InvariantContract<'a>,
271    invariant_test: &mut InvariantTest,
272    invariant_run: &mut InvariantTestRun<FEN>,
273    invariant_config: &InvariantConfig,
274    call_result: RawCallResult<FEN>,
275    state_changeset: &StateChangeset,
276    handler_target: Address,
277    handler_selector: Selector,
278    assertion_failure: bool,
279    pre_merge_edges_hash: Option<B256>,
280) -> Result<ContinueOutcome> {
281    let is_optimization = invariant_contract.is_optimization();
282
283    // Use the handler-gate variant so a stale committed `GLOBAL_FAIL_SLOT` from a
284    // previously-recorded handler bug doesn't poison this gate (which would otherwise silently
285    // skip every subsequent `assert_invariants` evaluation under `assertions_revert = false`).
286    // Handler bugs are tracked separately in `failures.broken_handlers`.
287    let handlers_succeeded = || {
288        if !invariant_run.executor.legacy_assertions() {
289            return invariant_run.executor.is_success_handler_gate(
290                invariant_contract.address,
291                false,
292                Cow::Borrowed(state_changeset),
293            );
294        }
295
296        invariant_test.targeted_contracts.targets().keys().all(|address| {
297            invariant_run.executor.is_success_handler_gate(
298                *address,
299                false,
300                Cow::Borrowed(state_changeset),
301            )
302        })
303    };
304
305    if !call_result.reverted && handlers_succeeded() {
306        if let Some(traces) = call_result.traces {
307            invariant_run.run_traces.push(traces);
308        }
309
310        if is_optimization {
311            // Optimization mode: call invariant and track max value, never fail.
312            let (inv_result, success) = call_invariant_function(
313                &invariant_run.executor,
314                invariant_contract.address,
315                invariant_contract.anchor().abi_encode_input(&[])?.into(),
316            )?;
317            if inv_result.execution_cancelled {
318                return Ok(ContinueOutcome { continues: true, cancelled: true });
319            }
320            if success
321                && inv_result.result.len() >= 32
322                && let Some(value) = I256::try_from_be_slice(&inv_result.result[..32])
323            {
324                // Track the best value and its prefix length for this run
325                // (used for corpus persistence — materialized once at run end).
326                if invariant_run.optimization_value.is_none_or(|prev| value > prev) {
327                    invariant_run.optimization_value = Some(value);
328                    invariant_run.optimization_prefix_len = invariant_run.inputs.len();
329                }
330            }
331        } else {
332            // Check mode: assert invariants and fail if broken.
333            let (_, cancelled) = assert_invariants(
334                invariant_contract,
335                invariant_config,
336                &invariant_test.targeted_contracts,
337                &invariant_run.executor,
338                &invariant_run.inputs,
339                &mut invariant_test.test_data.failures,
340            )?;
341            if cancelled {
342                return Ok(ContinueOutcome { continues: true, cancelled: true });
343            }
344        }
345    } else {
346        let is_assert_failure = assertion_failure;
347        let reverted = call_result.reverted;
348
349        if reverted {
350            invariant_test.test_data.failures.reverts += 1;
351        }
352
353        if is_assert_failure {
354            // Handler-side assertion: deduped by `(reverter, selector)` site, shortest
355            // sequence wins on collision.
356            record_handler_assertion_bug(
357                invariant_contract,
358                invariant_config,
359                &invariant_test.targeted_contracts,
360                &mut invariant_test.test_data.failures,
361                &mut invariant_run.inputs,
362                handler_target,
363                handler_selector,
364                pre_merge_edges_hash,
365                call_result,
366                reverted,
367                is_optimization,
368            );
369
370            // No invariant predicate broke; `broken = None`.
371            let continues = invariant_test
372                .test_data
373                .failures
374                .can_continue(invariant_contract.invariant_fns.len());
375            return Ok(ContinueOutcome { continues, cancelled: false });
376        }
377
378        // Non-assertion revert: per-invariant `fail_on_revert` still marks affected
379        // invariants as broken.
380        let failing_invariants: Vec<_> = invariant_contract
381            .invariant_fns
382            .iter()
383            .filter(|(invariant, fail_on_revert)| {
384                *fail_on_revert && !invariant_test.test_data.failures.has_failure(invariant)
385            })
386            .collect();
387
388        if let Some((first_invariant, _)) = failing_invariants.first() {
389            // Build a base case_data attributed to the first failing invariant; clone it for
390            // each subsequent broken invariant, retagging name/selector/`fail_on_revert` so
391            // every recorded failure points at its own invariant body.
392            let base = InvariantRunCtx {
393                contract: invariant_contract,
394                config: invariant_config,
395                targeted_contracts: &invariant_test.targeted_contracts,
396                calldata: &invariant_run.inputs,
397            }
398            .failed_case(
399                first_invariant,
400                invariant_config.fail_on_revert,
401                is_assert_failure,
402                call_result,
403                &[],
404            );
405
406            for (invariant, fail_on_revert) in failing_invariants {
407                let mut data = base.clone();
408                data.fail_on_revert = *fail_on_revert;
409                data.calldata = invariant.selector().to_vec().into();
410                data.test_error = TestError::Fail(
411                    format!("{}, reason: {}", invariant.name, data.revert_reason).into(),
412                    invariant_run.inputs.clone(),
413                );
414                // Handler asserts go to `broken_handlers` above; `BrokenInvariant` arm kept
415                // for non-handler-routed assertion paths.
416                invariant_test.test_data.failures.record_failure(
417                    invariant,
418                    if is_assert_failure {
419                        InvariantFuzzError::BrokenInvariant(data)
420                    } else {
421                        InvariantFuzzError::Revert(data)
422                    },
423                );
424            }
425        }
426
427        if reverted && !is_optimization && !invariant_config.has_delay() {
428            // If we don't fail test on revert then remove the reverted call from inputs.
429            // Delay-enabled campaigns keep reverted calls so shrinking can preserve their
430            // warp/roll contribution when building the final counterexample.
431            invariant_run.inputs.pop();
432        }
433    }
434
435    let continues =
436        invariant_test.test_data.failures.can_continue(invariant_contract.invariant_fns.len());
437    Ok(ContinueOutcome { continues, cancelled: false })
438}
439
440/// Given the executor state, asserts conditions within `afterInvariant` function.
441///
442/// Returns `Some(anchor)` if the hook failed (so the caller can record the failure event
443/// without re-scanning the failures map), or `None` if the hook succeeded.
444pub(crate) fn assert_after_invariant<'a, FEN: FoundryEvmNetwork>(
445    invariant_contract: &InvariantContract<'a>,
446    invariant_test: &mut InvariantTest,
447    invariant_run: &InvariantTestRun<FEN>,
448    invariant_config: &InvariantConfig,
449) -> Result<(Option<&'a Function>, bool)> {
450    let (call_result, success) =
451        call_after_invariant_function(&invariant_run.executor, invariant_contract.address)?;
452    if call_result.execution_cancelled {
453        return Ok((None, true));
454    }
455    // Fail the test case if `afterInvariant` doesn't succeed.
456    if success {
457        return Ok((None, false));
458    }
459    // `afterInvariant` failures are contract-wide (no specific invariant body executed),
460    // so attribute to the campaign anchor.
461    let anchor = invariant_contract.anchor();
462    let case_data = InvariantRunCtx {
463        contract: invariant_contract,
464        config: invariant_config,
465        targeted_contracts: &invariant_test.targeted_contracts,
466        calldata: &invariant_run.inputs,
467    }
468    .failed_case(anchor, invariant_config.fail_on_revert, false, call_result, &[]);
469    invariant_test
470        .test_data
471        .failures
472        .record_failure(anchor, InvariantFuzzError::BrokenInvariant(case_data));
473    Ok((Some(anchor), false))
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479    use crate::executors::{EarlyExit, ExecutorBuilder};
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}