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>> {
190    let inner_sequence = invariant_inner_sequence(executor);
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 !success {
211            let case =
212                ctx.failed_case(invariant, *fail_on_revert, false, call_result, &inner_sequence);
213            invariant_failures.record_failure(invariant, InvariantFuzzError::BrokenInvariant(case));
214            if first_broken.is_none() {
215                first_broken = Some(*invariant);
216            }
217        }
218    }
219
220    Ok(first_broken)
221}
222
223/// Helper function to initialize invariant inner sequence.
224fn invariant_inner_sequence<FEN: FoundryEvmNetwork>(
225    executor: &Executor<FEN>,
226) -> Vec<Option<BasicTxDetails>> {
227    let mut seq = vec![];
228    if let Some(fuzzer) = &executor.inspector().fuzzer
229        && let Some(call_generator) = &fuzzer.call_generator
230    {
231        seq.extend(call_generator.last_sequence.read().iter().cloned());
232    }
233    seq
234}
235
236/// Outcome of a per-call invariant check.
237#[derive(Debug)]
238pub(crate) struct ContinueOutcome<'a> {
239    /// Whether the invariant campaign should keep running after this call.
240    pub continues: bool,
241    /// First newly-broken invariant produced by this call, in declaration order. Used by the
242    /// executor to record the failure event without re-scanning the failures map.
243    pub broken: Option<&'a Function>,
244}
245
246/// Returns if invariant test can continue and last successful call result of the invariant test
247/// function (if it can continue).
248///
249/// For optimization mode (int256 return), tracks the max value but never fails on invariant.
250/// For check mode, asserts the invariant and fails if broken.
251///
252/// `handler_target` / `handler_selector` identify the just-executed call, used to
253/// attribute handler-side assertion failures.
254#[allow(clippy::too_many_arguments)]
255pub(crate) fn can_continue<'a, FEN: FoundryEvmNetwork>(
256    invariant_contract: &InvariantContract<'a>,
257    invariant_test: &mut InvariantTest,
258    invariant_run: &mut InvariantTestRun<FEN>,
259    invariant_config: &InvariantConfig,
260    call_result: RawCallResult<FEN>,
261    state_changeset: &StateChangeset,
262    handler_target: Address,
263    handler_selector: Selector,
264    pre_merge_edges_hash: Option<B256>,
265) -> Result<ContinueOutcome<'a>> {
266    let is_optimization = invariant_contract.is_optimization();
267    let mut broken: Option<&'a Function> = None;
268
269    // Use the handler-gate variant so a stale committed `GLOBAL_FAIL_SLOT` from a
270    // previously-recorded handler bug doesn't poison this gate (which would otherwise silently
271    // skip every subsequent `assert_invariants` evaluation under `assertions_revert = false`).
272    // Handler bugs are tracked separately in `failures.broken_handlers`.
273    let handlers_succeeded = || {
274        invariant_test.targeted_contracts.targets().keys().all(|address| {
275            invariant_run.executor.is_success_handler_gate(
276                *address,
277                false,
278                Cow::Borrowed(state_changeset),
279            )
280        })
281    };
282
283    if !call_result.reverted && handlers_succeeded() {
284        if let Some(traces) = call_result.traces {
285            invariant_run.run_traces.push(traces);
286        }
287
288        if is_optimization {
289            // Optimization mode: call invariant and track max value, never fail.
290            let (inv_result, success) = call_invariant_function(
291                &invariant_run.executor,
292                invariant_contract.address,
293                invariant_contract.anchor().abi_encode_input(&[])?.into(),
294            )?;
295            if success
296                && inv_result.result.len() >= 32
297                && let Some(value) = I256::try_from_be_slice(&inv_result.result[..32])
298            {
299                invariant_test.update_optimization_value(value, &invariant_run.inputs);
300                // Track the best value and its prefix length for this run
301                // (used for corpus persistence — materialized once at run end).
302                if invariant_run.optimization_value.is_none_or(|prev| value > prev) {
303                    invariant_run.optimization_value = Some(value);
304                    invariant_run.optimization_prefix_len = invariant_run.inputs.len();
305                }
306            }
307        } else {
308            // Check mode: assert invariants and fail if broken.
309            broken = assert_invariants(
310                invariant_contract,
311                invariant_config,
312                &invariant_test.targeted_contracts,
313                &invariant_run.executor,
314                &invariant_run.inputs,
315                &mut invariant_test.test_data.failures,
316            )?;
317        }
318    } else {
319        let is_assert_failure = did_fail_on_assert(&call_result, state_changeset);
320        let reverted = call_result.reverted;
321
322        if reverted {
323            invariant_test.test_data.failures.reverts += 1;
324        }
325
326        if is_assert_failure {
327            // Handler-side assertion: deduped by `(reverter, selector)` site, shortest
328            // sequence wins on collision.
329            record_handler_assertion_bug(
330                invariant_contract,
331                invariant_config,
332                &invariant_test.targeted_contracts,
333                &mut invariant_test.test_data.failures,
334                &mut invariant_run.inputs,
335                handler_target,
336                handler_selector,
337                pre_merge_edges_hash,
338                call_result,
339                reverted,
340                is_optimization,
341            );
342
343            // No invariant predicate broke; `broken = None`.
344            let continues = invariant_test
345                .test_data
346                .failures
347                .can_continue(invariant_contract.invariant_fns.len());
348            return Ok(ContinueOutcome { continues, broken: None });
349        }
350
351        // Non-assertion revert: per-invariant `fail_on_revert` still marks affected
352        // invariants as broken.
353        let failing_invariants: Vec<_> = invariant_contract
354            .invariant_fns
355            .iter()
356            .filter(|(invariant, fail_on_revert)| {
357                *fail_on_revert && !invariant_test.test_data.failures.has_failure(invariant)
358            })
359            .collect();
360
361        if let Some((first_invariant, _)) = failing_invariants.first() {
362            broken = Some(*first_invariant);
363            // Build a base case_data attributed to the first failing invariant; clone it for
364            // each subsequent broken invariant, retagging name/selector/`fail_on_revert` so
365            // every recorded failure points at its own invariant body.
366            let base = InvariantRunCtx {
367                contract: invariant_contract,
368                config: invariant_config,
369                targeted_contracts: &invariant_test.targeted_contracts,
370                calldata: &invariant_run.inputs,
371            }
372            .failed_case(
373                first_invariant,
374                invariant_config.fail_on_revert,
375                is_assert_failure,
376                call_result,
377                &[],
378            );
379
380            for (invariant, fail_on_revert) in failing_invariants {
381                let mut data = base.clone();
382                data.fail_on_revert = *fail_on_revert;
383                data.calldata = invariant.selector().to_vec().into();
384                data.test_error = TestError::Fail(
385                    format!("{}, reason: {}", invariant.name, data.revert_reason).into(),
386                    invariant_run.inputs.clone(),
387                );
388                // Handler asserts go to `broken_handlers` above; `BrokenInvariant` arm kept
389                // for non-handler-routed assertion paths.
390                invariant_test.test_data.failures.record_failure(
391                    invariant,
392                    if is_assert_failure {
393                        InvariantFuzzError::BrokenInvariant(data)
394                    } else {
395                        InvariantFuzzError::Revert(data)
396                    },
397                );
398            }
399        }
400
401        if reverted && !is_optimization && !invariant_config.has_delay() {
402            // If we don't fail test on revert then remove the reverted call from inputs.
403            // Delay-enabled campaigns keep reverted calls so shrinking can preserve their
404            // warp/roll contribution when building the final counterexample.
405            invariant_run.inputs.pop();
406        }
407    }
408
409    let continues =
410        invariant_test.test_data.failures.can_continue(invariant_contract.invariant_fns.len());
411    Ok(ContinueOutcome { continues, broken })
412}
413
414/// Given the executor state, asserts conditions within `afterInvariant` function.
415///
416/// Returns `Some(anchor)` if the hook failed (so the caller can record the failure event
417/// without re-scanning the failures map), or `None` if the hook succeeded.
418pub(crate) fn assert_after_invariant<'a, FEN: FoundryEvmNetwork>(
419    invariant_contract: &InvariantContract<'a>,
420    invariant_test: &mut InvariantTest,
421    invariant_run: &InvariantTestRun<FEN>,
422    invariant_config: &InvariantConfig,
423) -> Result<Option<&'a Function>> {
424    let (call_result, success) =
425        call_after_invariant_function(&invariant_run.executor, invariant_contract.address)?;
426    // Fail the test case if `afterInvariant` doesn't succeed.
427    if success {
428        return Ok(None);
429    }
430    // `afterInvariant` failures are contract-wide (no specific invariant body executed),
431    // so attribute to the campaign anchor.
432    let anchor = invariant_contract.anchor();
433    let case_data = InvariantRunCtx {
434        contract: invariant_contract,
435        config: invariant_config,
436        targeted_contracts: &invariant_test.targeted_contracts,
437        calldata: &invariant_run.inputs,
438    }
439    .failed_case(anchor, invariant_config.fail_on_revert, false, call_result, &[]);
440    invariant_test
441        .test_data
442        .failures
443        .record_failure(anchor, InvariantFuzzError::BrokenInvariant(case_data));
444    Ok(Some(anchor))
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use alloy_primitives::Bytes;
451    use foundry_evm_core::evm::EthEvmNetwork;
452
453    fn panic_payload(code: u8) -> Bytes {
454        let mut payload = vec![0_u8; 36];
455        payload[..4].copy_from_slice(&[0x4e, 0x48, 0x7b, 0x71]);
456        payload[35] = code;
457        payload.into()
458    }
459
460    #[test]
461    fn detects_assert_panic_code() {
462        let call_result = RawCallResult::<EthEvmNetwork> {
463            reverted: true,
464            result: panic_payload(0x01),
465            ..Default::default()
466        };
467        assert!(is_assertion_failure(&call_result));
468    }
469
470    #[test]
471    fn ignores_non_assert_panic_code() {
472        let call_result = RawCallResult::<EthEvmNetwork> {
473            reverted: true,
474            result: panic_payload(0x11),
475            ..Default::default()
476        };
477        assert!(!is_assertion_failure(&call_result));
478    }
479
480    #[test]
481    fn detects_legacy_invalid_opcode_assert() {
482        let call_result = RawCallResult::<EthEvmNetwork> {
483            reverted: true,
484            exit_reason: Some(InstructionResult::InvalidFEOpcode),
485            ..Default::default()
486        };
487        assert!(is_assertion_failure(&call_result));
488    }
489
490    #[test]
491    fn detects_vm_assert_revert() {
492        let call_result = RawCallResult::<EthEvmNetwork> {
493            reverted: true,
494            result: Vm::CheatcodeError { message: format!("{ASSERTION_FAILED_PREFIX}: 1 != 2") }
495                .abi_encode()
496                .into(),
497            reverter: Some(CHEATCODE_ADDRESS),
498            ..Default::default()
499        };
500        assert!(is_assertion_failure(&call_result));
501    }
502
503    #[test]
504    fn detects_assertion_failure_revert_reason() {
505        let call_result = RawCallResult::<EthEvmNetwork> {
506            reverted: true,
507            result: Revert { reason: format!("{ASSERTION_FAILED_PREFIX}: expected") }
508                .abi_encode()
509                .into(),
510            ..Default::default()
511        };
512        assert!(is_assertion_failure(&call_result));
513    }
514
515    #[test]
516    fn ignores_empty_cheatcode_revert() {
517        let call_result = RawCallResult::<EthEvmNetwork> {
518            reverted: true,
519            result: Bytes::new(),
520            reverter: Some(CHEATCODE_ADDRESS),
521            ..Default::default()
522        };
523        assert!(!is_assertion_failure(&call_result));
524    }
525}