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