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