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