Skip to main content

foundry_evm/executors/invariant/
error.rs

1use super::InvariantContract;
2use crate::{
3    executors::RawCallResult,
4    inspectors::{EdgeCovHit, EdgeCoverage},
5};
6use alloy_json_abi::Function;
7use alloy_primitives::{Address, B256, Bytes, Selector, keccak256};
8use foundry_config::InvariantConfig;
9use foundry_evm_core::{
10    decode::{ASSERTION_FAILED_PREFIX, EMPTY_REVERT_DATA, RevertDecoder},
11    evm::FoundryEvmNetwork,
12};
13use foundry_evm_fuzz::{BasicTxDetails, Reason, invariant::FuzzRunIdentifiedContracts};
14use proptest::test_runner::TestError;
15use std::{collections::HashMap, fmt};
16
17/// A handler-side assertion bug: a `require`/`assert` inside a fuzzed handler that the
18/// campaign reached. Deduped by `(reverter, selector)` site (Echidna/Medusa semantics),
19/// shortest sequence wins on collision.
20#[derive(Clone, Debug)]
21pub struct HandlerAssertionFailure {
22    /// Handler contract whose call asserted.
23    pub reverter: Address,
24    /// 4-byte selector of the failing function.
25    pub selector: Selector,
26    /// Call sequence including the failing call (post-shrink: minimal prefix).
27    pub call_sequence: Vec<BasicTxDetails>,
28    /// Pre-shrink length, for the `(original: N, shrunk: M)` renderer.
29    pub original_sequence_len: usize,
30    /// Decoded revert/assert reason.
31    pub revert_reason: String,
32    /// Stable hash of edge coverage at the asserting call (falls back to `(reverter,
33    /// selector)`). Used by the shrinker to preserve path identity, not for dedup.
34    pub edge_fingerprint: B256,
35}
36
37impl HandlerAssertionFailure {
38    /// Builds a failure from a replayed sequence whose last call asserted.
39    pub fn from_replayed_sequence(
40        call_sequence: Vec<BasicTxDetails>,
41        edge_fingerprint: B256,
42        revert_reason: String,
43    ) -> Self {
44        let last = call_sequence.last().expect("replayed sequence is non-empty");
45        let reverter = last.call_details.target;
46        let selector_bytes: [u8; 4] =
47            last.call_details.calldata.get(..4).and_then(|s| s.try_into().ok()).unwrap_or_default();
48        let original_sequence_len = call_sequence.len();
49        Self {
50            reverter,
51            selector: Selector::from(selector_bytes),
52            call_sequence,
53            original_sequence_len,
54            revert_reason,
55            edge_fingerprint,
56        }
57    }
58}
59
60/// Run-scoped references shared by failure-recording paths in an invariant run.
61pub struct InvariantRunCtx<'a> {
62    /// The invariant test contract.
63    pub contract: &'a InvariantContract<'a>,
64    /// Active invariant configuration.
65    pub config: &'a InvariantConfig,
66    /// Fuzz targets discovered for this run.
67    pub targeted_contracts: &'a FuzzRunIdentifiedContracts,
68    /// Inputs of the current run, used as the failing call sequence.
69    pub calldata: &'a [BasicTxDetails],
70}
71
72impl<'a> InvariantRunCtx<'a> {
73    /// Builds a [`FailedInvariantCaseData`] attributed to `broken_fn`. `fail_on_revert` is
74    /// passed in because `assert_invariants` overrides it with the per-invariant flag.
75    /// `assertion_failure=true` normalizes empty revert data so output is not blank.
76    pub fn failed_case<FEN: FoundryEvmNetwork>(
77        &self,
78        broken_fn: &Function,
79        fail_on_revert: bool,
80        assertion_failure: bool,
81        call_result: RawCallResult<FEN>,
82        inner_sequence: &[Option<BasicTxDetails>],
83    ) -> FailedInvariantCaseData {
84        let revert_reason = self.decode_revert_reason(&call_result, assertion_failure);
85        let origin = broken_fn.name.as_str();
86        FailedInvariantCaseData {
87            test_error: TestError::Fail(
88                format!("{origin}, reason: {revert_reason}").into(),
89                self.calldata.to_vec(),
90            ),
91            return_reason: "".into(),
92            revert_reason,
93            addr: self.contract.address,
94            calldata: broken_fn.selector().to_vec().into(),
95            inner_sequence: inner_sequence.to_vec(),
96            shrink_run_limit: self.config.shrink_run_limit,
97            fail_on_revert,
98            assertion_failure,
99        }
100    }
101
102    /// Decodes the revert/assert reason without allocating a full [`FailedInvariantCaseData`].
103    /// Used by callers that only need the reason (e.g. handler-bug recording).
104    pub fn decode_revert_reason<FEN: FoundryEvmNetwork>(
105        &self,
106        call_result: &RawCallResult<FEN>,
107        assertion_failure: bool,
108    ) -> String {
109        let revert_reason = RevertDecoder::new()
110            .with_abis(self.targeted_contracts.targets().values().map(|c| &c.abi))
111            .with_abi(self.contract.abi)
112            .decode(call_result.result.as_ref(), call_result.exit_reason);
113        // Non-reverting assertion failures surface through Foundry's failure flags, not
114        // revert data — fall back so invariant output is not blank.
115        let needs_fallback = matches!(revert_reason.as_str(), "" | EMPTY_REVERT_DATA);
116        if needs_fallback && (!call_result.reverted || assertion_failure) {
117            ASSERTION_FAILED_PREFIX.to_string()
118        } else {
119            revert_reason
120        }
121    }
122}
123
124/// Edge-coverage fingerprint for a handler-side assertion call. Prefers a pre-merge
125/// edges hash; falls back to `keccak(target || selector)` when edge coverage is disabled.
126pub fn handler_edge_fingerprint(
127    pre_merge_edges_hash: Option<B256>,
128    target: Address,
129    selector: Selector,
130) -> B256 {
131    if let Some(hash) = pre_merge_edges_hash {
132        return hash;
133    }
134    let mut buf = [0u8; 24];
135    buf[..20].copy_from_slice(target.as_slice());
136    buf[20..].copy_from_slice(selector.as_slice());
137    keccak256(buf)
138}
139
140/// Records a handler-side assertion bug (if strictly shorter than the existing repro for
141/// this site) and pops the just-asserted reverted input from `inputs`. Shared by the
142/// periodic-check path and the inline check-skipped path.
143#[expect(clippy::too_many_arguments)]
144pub(crate) fn record_handler_assertion_bug<FEN: FoundryEvmNetwork>(
145    invariant_contract: &InvariantContract<'_>,
146    config: &InvariantConfig,
147    targeted_contracts: &FuzzRunIdentifiedContracts,
148    failures: &mut InvariantFailures,
149    inputs: &mut Vec<BasicTxDetails>,
150    handler_target: Address,
151    handler_selector: Selector,
152    pre_merge_edges_hash: Option<B256>,
153    call_result: RawCallResult<FEN>,
154    call_reverted: bool,
155    is_optimization: bool,
156) {
157    let fingerprint =
158        handler_edge_fingerprint(pre_merge_edges_hash, handler_target, handler_selector);
159
160    if !handler_site_already_minimal(
161        &failures.failures,
162        (handler_target, handler_selector),
163        inputs.len(),
164    ) {
165        // Handler bugs go through `FailureKey::Handler`; we only need the reason.
166        let revert_reason = InvariantRunCtx {
167            contract: invariant_contract,
168            config,
169            targeted_contracts,
170            calldata: inputs,
171        }
172        .decode_revert_reason(&call_result, true);
173        let call_sequence = inputs.clone();
174        let original_sequence_len = call_sequence.len();
175        failures.record_handler_failure(HandlerAssertionFailure {
176            reverter: handler_target,
177            selector: handler_selector,
178            call_sequence,
179            original_sequence_len,
180            revert_reason,
181            edge_fingerprint: fingerprint,
182        });
183    }
184
185    // Standard reverted-input pop. Delay-enabled campaigns keep reverted calls so
186    // shrinking can preserve their warp/roll contribution.
187    if call_reverted && !is_optimization && !config.has_delay() {
188        inputs.pop();
189    }
190}
191
192/// True iff there is already a [`HandlerAssertionFailure`] for `site` no longer than
193/// `candidate_len`. Used to skip inserting a not-strictly-shorter repro.
194pub fn handler_site_already_minimal(
195    failures: &HashMap<FailureKey, InvariantFuzzError>,
196    site: (Address, Selector),
197    candidate_len: usize,
198) -> bool {
199    failures
200        .get(&FailureKey::Handler(site.0, site.1))
201        .and_then(InvariantFuzzError::as_handler_assertion)
202        .is_some_and(|existing| existing.call_sequence.len() <= candidate_len)
203}
204
205/// Stable hash of the call's edge coverage, taken *before* `merge_edge_coverage`
206/// zeroes the buffer. Returns `None` when edge coverage is disabled.
207pub fn snapshot_edge_fingerprint<FEN: FoundryEvmNetwork>(
208    call_result: &RawCallResult<FEN>,
209) -> Option<B256> {
210    let edges = call_result.edge_coverage.as_ref()?;
211    if edges.is_empty() {
212        return None;
213    }
214    match edges {
215        EdgeCoverage::Hash(edges) => Some(keccak256(edges)),
216        EdgeCoverage::CollisionFree(hits) => {
217            // `From<EdgeCovInspector>` does not sort on the per-call drain path,
218            // so sort here for a deterministic fingerprint across runs regardless
219            // of HashMap iteration order. Cold path — only invoked on handler
220            // assertion failure.
221            let mut sorted: Vec<&EdgeCovHit> = hits.iter().collect();
222            sorted.sort_unstable_by_key(|hit| hit.edge);
223
224            // address(20) + pc(8) + jump_dest(32) + depth_tag(1) + depth(8) + count(1)
225            let mut bytes = Vec::with_capacity(sorted.len() * (20 + 8 + 32 + 1 + 8 + 1));
226            for hit in sorted {
227                bytes.extend_from_slice(hit.edge.address.as_slice());
228                bytes.extend_from_slice(&hit.edge.pc.to_le_bytes());
229                bytes.extend_from_slice(&hit.edge.jump_dest.to_be_bytes::<32>());
230                // Tag byte disambiguates `None` from `Some(0)` so configs with
231                // `include_call_depth` toggled don't collide in the fingerprint.
232                bytes.push(u8::from(hit.edge.depth.is_some()));
233                bytes.extend_from_slice(&hit.edge.depth.unwrap_or(0).to_le_bytes());
234                bytes.push(hit.count);
235            }
236            Some(keccak256(bytes))
237        }
238    }
239}
240
241/// Identifies a single entry in the [`InvariantFailures`] map. Invariant predicate
242/// failures and handler-side assertion bugs share one map keyed by this enum.
243#[derive(Clone, Debug, Eq, Hash, PartialEq)]
244pub enum FailureKey {
245    /// Keyed by invariant function name.
246    Invariant(String),
247    /// Keyed by handler `(reverter, selector)` site (Echidna/Medusa semantics: one bug
248    /// per handler function regardless of code path).
249    Handler(Address, Selector),
250}
251
252/// Stores invariant test failures and revert counts.
253///
254/// TODO: dedup multiple distinct `assert(...)` within the same `(reverter, selector)`
255/// handler if callers ever need finer attribution (e.g. per-assertion-label).
256#[derive(Clone, Default)]
257pub struct InvariantFailures {
258    /// Total number of reverts.
259    pub reverts: usize,
260    /// Invariant predicate failures and handler-side assertion bugs share one map.
261    /// Mutate only via `record_failure` / `record_handler_failure` / `seed_handler_failure`
262    /// so the cached counters stay in sync.
263    pub(crate) failures: HashMap<FailureKey, InvariantFuzzError>,
264    /// Cached `FailureKey::Invariant` count, kept O(1) on the hot path.
265    invariant_count: usize,
266    /// Cached `FailureKey::Handler` count, read on progress/metrics ticks.
267    handler_count: usize,
268}
269
270impl InvariantFailures {
271    pub fn new() -> Self {
272        Self::default()
273    }
274
275    /// Splits `self.failures` into the legacy `(invariant_errors, handler_errors)` pair.
276    pub fn partition(
277        self,
278    ) -> (HashMap<String, InvariantFuzzError>, HashMap<(Address, Selector), InvariantFuzzError>)
279    {
280        let mut invariant_errors = HashMap::new();
281        let mut handler_errors = HashMap::new();
282        for (key, err) in self.failures {
283            match key {
284                FailureKey::Invariant(name) => {
285                    invariant_errors.insert(name, err);
286                }
287                FailureKey::Handler(addr, sel) => {
288                    handler_errors.insert((addr, sel), err);
289                }
290            }
291        }
292        (invariant_errors, handler_errors)
293    }
294
295    pub fn record_failure(&mut self, invariant: &Function, failure: InvariantFuzzError) {
296        let prev = self.failures.insert(FailureKey::Invariant(invariant.name.clone()), failure);
297        if prev.is_none() {
298            self.invariant_count += 1;
299        }
300    }
301
302    pub fn has_failure(&self, invariant: &Function) -> bool {
303        self.failures.contains_key(&FailureKey::Invariant(invariant.name.clone()))
304    }
305
306    pub fn get_failure(&self, invariant: &Function) -> Option<&InvariantFuzzError> {
307        self.failures.get(&FailureKey::Invariant(invariant.name.clone()))
308    }
309
310    /// Recorded revert reason for `invariant`, or empty when none. Used by failure events
311    /// so the metrics payload mirrors the persisted failure.
312    pub fn broken_reason(&self, invariant: &Function) -> String {
313        self.get_failure(invariant).and_then(|e| e.revert_reason()).unwrap_or_default()
314    }
315
316    pub const fn can_continue(&self, invariants: usize) -> bool {
317        self.invariant_count() < invariants
318    }
319
320    /// Number of unique broken invariant predicates (O(1), cached).
321    pub const fn invariant_count(&self) -> usize {
322        self.invariant_count
323    }
324
325    /// Number of unique handler-side assertion bugs (O(1), cached).
326    pub const fn handler_count(&self) -> usize {
327        self.handler_count
328    }
329
330    pub fn handler_failures_mut(&mut self) -> impl Iterator<Item = &mut InvariantFuzzError> {
331        self.failures.iter_mut().filter_map(|(key, error)| match key {
332            FailureKey::Handler(_, _) => Some(error),
333            FailureKey::Invariant(_) => None,
334        })
335    }
336
337    /// Records a handler-side assertion bug. Deduped by `(reverter, selector)` site;
338    /// shortest sequence wins on collision.
339    pub fn record_handler_failure(&mut self, failure: HandlerAssertionFailure) {
340        let site = (failure.reverter, failure.selector);
341        if !handler_site_already_minimal(&self.failures, site, failure.call_sequence.len()) {
342            let prev = self.failures.insert(
343                FailureKey::Handler(site.0, site.1),
344                InvariantFuzzError::HandlerAssertion(failure),
345            );
346            if prev.is_none() {
347                self.handler_count += 1;
348            }
349        }
350    }
351
352    /// Inserts a persisted-replay handler bug. Skips dedup (caller seeds an empty map)
353    /// but bumps `handler_count` so the live counter is correct from the first tick.
354    pub fn seed_handler_failure(
355        &mut self,
356        target: Address,
357        selector: Selector,
358        err: InvariantFuzzError,
359    ) {
360        let prev = self.failures.insert(FailureKey::Handler(target, selector), err);
361        if prev.is_none() {
362            self.handler_count += 1;
363        }
364    }
365
366    /// Returns true if a handler bug has already been recorded for the given site.
367    pub fn has_handler_failure(&self, target: Address, selector: Selector) -> bool {
368        self.failures.contains_key(&FailureKey::Handler(target, selector))
369    }
370}
371
372impl fmt::Display for InvariantFailures {
373    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
374        writeln!(f)?;
375        writeln!(f, "      ❌ Failures: {}", self.invariant_count())?;
376        Ok(())
377    }
378}
379
380#[derive(Clone, Debug)]
381pub enum InvariantFuzzError {
382    /// A handler call reverted under `fail_on_revert = true`.
383    Revert(FailedInvariantCaseData),
384    /// An `invariant_*` predicate returned `false` (or asserted).
385    BrokenInvariant(FailedInvariantCaseData),
386    /// A handler-side `assert(...)` / `vm.assert*` failed (bug inside a handler, not in
387    /// an `invariant_*` predicate). Recorded per `(reverter, selector)` site.
388    HandlerAssertion(HandlerAssertionFailure),
389    /// `vm.assume` rejected more inputs than allowed.
390    MaxAssumeRejects(u32),
391}
392
393impl InvariantFuzzError {
394    pub fn revert_reason(&self) -> Option<String> {
395        match self {
396            Self::BrokenInvariant(case_data) | Self::Revert(case_data) => {
397                (!case_data.revert_reason.is_empty()).then(|| case_data.revert_reason.clone())
398            }
399            Self::HandlerAssertion(failure) => {
400                (!failure.revert_reason.is_empty()).then(|| failure.revert_reason.clone())
401            }
402            Self::MaxAssumeRejects(allowed) => {
403                Some(format!("`vm.assume` rejected too many inputs ({allowed} allowed)"))
404            }
405        }
406    }
407
408    /// Wrapped `HandlerAssertionFailure` if this is the [`Self::HandlerAssertion`] variant.
409    pub const fn as_handler_assertion(&self) -> Option<&HandlerAssertionFailure> {
410        match self {
411            Self::HandlerAssertion(failure) => Some(failure),
412            _ => None,
413        }
414    }
415
416    /// Mutable counterpart of [`Self::as_handler_assertion`]. Used by post-campaign shrinking.
417    pub const fn as_handler_assertion_mut(&mut self) -> Option<&mut HandlerAssertionFailure> {
418        match self {
419            Self::HandlerAssertion(failure) => Some(failure),
420            _ => None,
421        }
422    }
423}
424
425#[derive(Clone, Debug)]
426pub struct FailedInvariantCaseData {
427    /// The proptest error occurred as a result of a test case.
428    pub test_error: TestError<Vec<BasicTxDetails>>,
429    /// The return reason of the offending call.
430    pub return_reason: Reason,
431    /// The revert string of the offending call.
432    pub revert_reason: String,
433    /// Address of the invariant asserter.
434    pub addr: Address,
435    /// Function calldata for invariant check.
436    pub calldata: Bytes,
437    /// Inner fuzzing Sequence coming from overriding calls.
438    pub inner_sequence: Vec<Option<BasicTxDetails>>,
439    /// Shrink run limit
440    pub shrink_run_limit: u32,
441    /// Fail on revert, used to check sequence when shrinking.
442    pub fail_on_revert: bool,
443    /// Whether this failure originated from a handler assertion.
444    pub assertion_failure: bool,
445}