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:
"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:
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:
// 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 payload is a constant. No id, no description, no hint about which property died or why.
- Two different bugs in one function collapse into one report, because the only deduplication signal is the panicking function.
requirecannot participate. A guard reverts withError(string), which is control flow, and the fuzzer correctly ignores it.
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:
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:
// 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:
// 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:
// 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:
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:
// 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"})
);
}
}
}// 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:
// 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:
// 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:
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 bytesStrings 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:
// 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:
// 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:
// 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:
// 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.
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.
| Tags | ripfuzz , solidity , fuzzing , evm , security , rust |
|---|