Friendship ended with Panic(0x1), now BrokenInvariantError is my best friend

I have been building ripfuzz, an extremely fast smart contract fuzzer. A fuzzer is only as good as the format it uses to report broken invariants. If the format cannot answer “which property broke, and why should I care”, every downstream stage is guesswork: deduplication, shrinking, reporting, triage.

ripfuzz changed how it reports broken invariants three times. The commit history reads like the meme:

Plain Text
"Friendship ended with Panic(0x1).
 Now BrokenInvariantError is my best friend."

This post is the design review behind the breakup: what each format could and could not express, where the payload physically lived, what that cost, and the one semantic that changed when the payload moved into the revert data.

The panic era

In the beginning a broken invariant was reported with a Solidity assert:

Solidity
function invariant_total() external view {
    assert(total <= 100);
}

A failed assert reverts with Panic(uint256), code 0x01. The detection lived on the transaction result and was exactly this:

Rust
// src/evm/result.rs, before the rewrite

/// Solidity `Panic(uint256)` selector: keccak256("Panic(uint256)")[:4]
const PANIC_SELECTOR: [u8; 4] = [0x4e, 0x48, 0x7b, 0x71];

impl TransactionResult {
    /// Detect a Solidity `assert` failure (`Panic(0x01)`) in revert output.
    pub fn is_assert_failure(&self) -> bool {
        match &self.output {
            Some(output) => {
                output.len() >= 36 && output[..4] == PANIC_SELECTOR && output[35] == 0x01
            }
            None => false,
        }
    }
}

Eleven lines that know exactly one thing: a panic happened. The information budget of the report is the second argument of that panic, one uint256 code, and 0x01 is the “generic” code. The costs:

The fuzzer knew something broke. It never knew what. That ceiling is the reason every later format exists.

The cheatcode interlude

The second attempt moved the payload into a cheatcode. The harness declared an interface, computed a magic address, and called a method:

Solidity
interface RVM {
    struct Invariant {
        string id;
        string description;
    }

    function bail(Invariant calldata invariant) external;
}

address constant RVM_ADDRESS =
    address(uint160(uint256(keccak256("ripfuzz cheatcode"))));

function invariant_total() external view {
    if (total > 100) {
        rvm.bail(Invariant({id: "INV-001", description: "total exceeded 100"}));
    }
}

Broken invariants got names. Deduplication keyed on the id, and a one-line gate could replace a paragraph of assert gymnastics. Bail with an empty id reverted with a scolding instead of a report, because an unnamed bug cannot serve as a dedup key.

Then look at what happened on the fuzzer side of that call. The cheatcode inspector intercepted calls to the cheatcode address, decoded them against its Solidity interface, and dispatched:

Rust
// src/evm/cheatcode/calls/bail.rs, deleted

//! `rvm.bail` cheatcode - report a broken invariant and abort the call.

/// Handle `rvm.bail(Invariant)` by recording the report and reverting the
/// call, so the harness replaces the `assert(false)` panic with a finding
/// that carries an id and description.
pub fn handle(
    state: &mut ExecutionState,
    invariant: Vm::Invariant,
) -> Option<revm::interpreter::CallOutcome> {
    if invariant.id.is_empty() {
        return Some(outcome::revert("rvm.bail: id must not be empty"));
    }
    state.broken_invariants.push(BrokenInvariant {
        id: invariant.id,
        description: invariant.description,
    });
    Some(outcome::revert("invariant broken"))
}

Read the two return paths carefully, because they are the whole critique.

The report goes to state.broken_invariants, a field on the inspector’s execution state that accumulated across the whole run:

Rust
// src/evm/cheatcode/state.rs, before the rewrite

/// Broken invariants emitted via `rvm.bail` during the current `exec`.
pub broken_invariants: Vec<BrokenInvariant>,

The revert the EVM actually sees carries none of it. outcome::revert encodes the standard Error(string) selector with the literal "invariant broken". Every broken invariant reverted with the same four bytes of personality. The identity of the bug existed only inside ripfuzz’s memory, and Chain::exec had to reconstruct which reports belonged to which transaction by slicing that shared vector with manual bookkeeping:

Rust
// src/evm/chain/mod.rs, before the rewrite

// 1. Capture broken invariants emitted during this transaction.
let current_len = evm.inspector.0.0.state.broken_invariants.len();
let new_broken_invariants = if current_len > prev_broken_invariants_len {
    evm.inspector.0.0.state.broken_invariants[prev_broken_invariants_len..current_len]
        .to_vec()
} else {
    Vec::new()
};
prev_broken_invariants_len = current_len;
broken_invariants.push(new_broken_invariants);

That slice dance exists because the report and the transaction are only connected by a running length. Why could one transaction produce several reports? Because the harness could catch the bail revert and keep running, then bail again. The report survived the catch by accident: it was written before the revert, so it escaped regardless of what the contract decided to do with the error.

So the balance sheet for the cheatcode format: named broken invariants in exchange for boilerplate in every harness, an external call per report, a payload invisible to every other tool, catch semantics that were wrong on purpose, and accumulator bookkeeping in the executor to tie reports back to transactions.

rvm.bail shipped in ripfuzz 0.9.5 and is replaced in the next release. It lived for one version. The meme needed a middle friend.

The new best friend

A broken invariant is now reported with a custom error, declared in the harness itself:

Solidity
error BrokenInvariantError(string id, string description);

function invariant_total() external view {
    if (total > 100) {
        revert BrokenInvariantError({
            id: "INV-001",
            description: "total exceeded 100"
        });
    }
}

That is the whole API. No interface, no struct, no address constant, no cheatcode call. The real fixture diff shows the shape of a migration:

Solidity
// before: fixtures/tester/harness-deployment/HarnessWithFailingInvariant.sol

interface RVM {
    struct Invariant {
        string id;
        string description;
    }

    function bail(Invariant calldata invariant) external;
}

contract HarnessWithFailingInvariant {
    address constant RVM_ADDRESS =
        address(uint160(uint256(keccak256("ripfuzz cheatcode"))));

    function invariant_total_below_limit() external {
        if (total > 100) {
            RVM(RVM_ADDRESS).bail(
                RVM.Invariant({id: "INV-001", description: "total exceeded 100"})
            );
        }
    }
}
Solidity
// after
import {BrokenInvariantError} from "../challenges/Challenge.sol";

contract HarnessWithFailingInvariant {
    function invariant_total_below_limit() external {
        if (total > 100) {
            revert BrokenInvariantError({
                id: "INV-001",
                description: "total exceeded 100"
            });
        }
    }
}

Two lines of ceremony deleted per harness, and the report is now bytes on the wire that any EVM tool can decode: explorers, trace printers, cast, other fuzzers.

Decoding the report

The fuzzer side is one module. The error is declared once with alloy’s sol! macro, which generates the type and its selector at compile time:

Rust
// src/evm/chain/broken_invariant.rs
use alloy_sol_types::SolError;

alloy_sol_types::sol! {
    error BrokenInvariantError(string id, string description);
}

/// One broken invariant: the id and description carried by a
/// `BrokenInvariantError` revert.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BrokenInvariant {
    pub id: String,
    pub description: String,
}

impl BrokenInvariant {
    /// Decode the report from revert output, returning `None` when the
    /// output is not a `BrokenInvariantError` revert or the id is empty.
    pub fn from_revert(output: &[u8]) -> Option<Self> {
        // 1. Require the custom error selector.
        if output.len() < 4 || output[..4] != BrokenInvariantError::SELECTOR {
            return None;
        }

        // 2. Decode the id and description from the revert payload.
        let error = BrokenInvariantError::abi_decode(output).ok()?;

        // 3. Reject reports without an id, the dedup key must be meaningful.
        if error.id.is_empty() {
            return None;
        }

        Some(Self {
            id: error.id,
            description: error.description,
        })
    }
}

Three details carry the design.

First, BrokenInvariantError::SELECTOR is a const, computed by the sol! macro from keccak256("BrokenInvariantError(string,string)") at compile time. The check against unrelated reverts is four bytes of comparison, no runtime hashing. The explicit selector check before abi_decode also keeps the common case cheap: a plain require revert fails the prefix test and returns without attempting a decode.

Second, the transaction-level gate lives next to the other output classification:

Rust
// src/evm/result.rs
impl TransactionResult {
    /// The broken invariant reported by a `BrokenInvariantError` revert, if
    /// any.
    pub fn broken_invariant(&self) -> Option<BrokenInvariant> {
        if self.success {
            return None;
        }
        let output = self.output.as_ref()?;
        BrokenInvariant::from_revert(output)
    }
}

The success gate is not paranoia. output is Some for successful calls too, and a return value is arbitrary bytes. A successful call can never carry a broken invariant, so the check costs one branch and removes a whole class of false positives.

Third, the empty-id rule matches the old cheatcode’s behavior exactly: bail refused to report an unnamed bug, and the decoder refuses it now. The dedup key must be meaningful or the collection degenerates into one giant bucket.

The byte anatomy

It is worth looking at what actually travels. Encoding BrokenInvariantError("INV-001", "total exceeded 100") gives 196 bytes:

Plain Text
c4b3d98f    selector
0000...0040 offset of id, 64 bytes after the head
0000...0080 offset of description, 128 bytes after the head
0000...0007 length of "INV-001" (7)
494e...0000 "INV-001", padded to 32 bytes
0000...0012 length of "total exceeded 100" (18)
746f...16c2 padded to 32 bytes

Strings are dynamic, so the head carries offsets and the tail carries the data. The selector 0xc4b3d98f is the first four bytes of keccak256("BrokenInvariantError(string,string)"), and it is the entire identification cost. Two strings of payload for one call frame, no external call, no dispatcher.

What got deleted

The collection in Chain::exec went from slice bookkeeping across a shared inspector vector to one stateless line per transaction:

Rust
// src/evm/chain/mod.rs

// 1. Capture the broken invariant reported by this transaction,
//    at most one per revert.
broken_invariants.push(result.broken_invariant().into_iter().collect());

With it went the broken_invariants field on ExecutionState, the prev_broken_invariants_len cursor, the Vm::Invariant struct, the bail dispatch arm, and the is_assert_failure helper. The executor no longer needs to know how broken invariants travel. It reads them where every revert already is.

Downstream: unchanged on purpose

Because ExecOutput.broken_invariants kept its shape, the entire search layer needed no changes. Deduplication still keys on the id, and a shorter reproduction of a known id still replaces a longer one:

Rust
// src/tester/broken_invariant.rs

pub fn try_add(&self, broken: &BrokenInvariant) -> bool {
    let mut inner = self.lock();

    // 1. Replace a known id when the candidate is strictly shorter.
    if let Some(existing) = inner
        .broken_invariants
        .iter_mut()
        .find(|item| item.id() == broken.id())
    {
        if broken.sequence().len() < existing.sequence().len() {
            *existing = broken.clone();
        }
        return false;
    }

    // 2. Reject a new id when the collection is full.
    if inner.broken_invariants.len() >= self.max {
        return false;
    }

    // 3. Insert the new id.
    inner.keys.insert(broken.key());
    inner.broken_invariants.push(broken.clone());
    true
}

The shrinker uses the id as the reproduction certificate. A candidate sequence is accepted only when a clean-state replay still produces the exact same id, which makes shrinking sound against flaky paths:

Rust
// src/tester/shrinker.rs

/// Whether replaying the candidate sequence on a clean chain still emits the
/// broken invariant's exact id. The sequence ends with the reverting call, so
/// replaying it alone must reproduce the finding.
fn reproduces(
    execution: &Execution,
    sequence: &Sequence,
    broken: &BrokenInvariant,
) -> Result<bool> {
    let mut chain = execution.chain.clone();
    let transactions = sequence.transactions(execution.target, execution.deployer);
    let exec = chain.exec(&transactions)?;
    Ok(exec
        .broken_invariants
        .iter()
        .any(|per_tx| per_tx.iter().any(|report| report.id == broken.id())))
}

The fuzzer’s extraction loop reads the same per-transaction slots as before, so a broken invariant arrives with the sequence prefix that produced it:

Rust
// src/tester/fuzzer.rs

// 2. Record reports emitted via `BrokenInvariantError` in the handler.
//    The sequence ends with the reverting handler call.
if !exec.broken_invariants.is_empty() {
    for report in &exec.broken_invariants[0] {
        let sequence_prefix = Sequence::new(sequence.calls()[..=index].to_vec());
        let broken = BrokenInvariant::new()
            .with_calls(sequence_prefix)
            .with_id(&report.id)
            .with_description(&report.description);
        if execution.broken_invariants.try_add(&broken) {
            info!("found broken invariant {}", broken.id());
        }
    }
}

This is the payoff of picking the idiomatic format: the change is a decode layer, and nothing above it noticed.

The catch, literally

One semantic changed, and it is a trade I would make again. With the cheatcode, the report was recorded before the revert, so it survived an outer try/catch and the transaction could even keep going. Now the payload travels in the revert data, and revert semantics apply: a catch swallows it.

Solidity
try this.risky() {
    // fine
} catch {
    // BrokenInvariantError was caught here, so it was handled
}

I call that correct, not a regression. A broken invariant that your own harness catches and contains is not an unhandled violation. If you want the bug reported, let it propagate to the top of the call. The same rule explains the second change: a revert has one payload, so a transaction yields at most one broken invariant. The old multi-report accumulation existed to serve a pattern that should not have been rewarded.

What I did not ship

No generic error registry. A harness could declare its own error type and ripfuzz could decode it from the compiled ABI, but one canonical error keeps deduplication, shrinking, and reporting uniform across campaigns, and the signature (string, string) has not been a limitation yet.

No back-compat shim for rvm.bail. It existed for one release, the fixtures migrated in the same commit, and keeping two reporting paths doubles the places where catch semantics can disagree.

No multi-report reverts. If a harness wants to report several violations in one call, that is several calls. The revert data has room for exactly one truth.

That is the whole change: the broken invariant lives in the revert data where every tool can see it, the harness is plain Solidity with one error declaration, the decoder is a selector check and an ABI decode, and a caught revert is finally what it always claimed to be, handled. Rest in peace, Panic(0x1). You carried one byte and never told me which invariant it was.