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