← writing

Auditing my own bridge: from “mints money from nothing” to all-criticals-closed

A cross-chain lock-and-mint bridge is, underneath all the ceremony, one accounting invariant:

wrapped tokens minted on the destination chain must never exceed the collateral locked on the source chain.

Break that, and the bridge prints money. So before writing a single fix, I wrote that invariant as a Foundry stateful fuzz test — deploying the source vault, the destination mint adapter, and the wrapped token together, with the relayer modeled as the adversary:

function invariant_supply_le_collateral() public view {
    assertLe(wrapped.totalSupply(), vault.totalLocked(asset));
}

It went red in under a second. The fuzzer found a one-call counterexample: the relayer calling mint(arbitraryCommitId, attacker, 1e30, ...). The contract had a declared CommitIdMismatch error — and never used it. The mint trusted the relayer completely.

The bug class

Every critical reduced to the same root cause: no on-chain binding between source-lock state, destination-mint state, and a verified operator. Mint, unlock, and finalize all trusted a relayer set with no sound on-chain proof that the event they claimed had actually happened. That’s the Ronin / Wormhole family — externally-verified bridges, the most expensive bug class in the space — whether the operator set is stolen outright (Ronin) or its signature check is bypassed by a bug (Wormhole), the contract mints on a say-so it never really verified.

A second invariant caught the cross-domain twin: a user could get their wrapped tokens minted and claim a refund of the original collateral, because the source vault’s refund path had no idea the destination mint occurred.

The fix pattern

The fix isn’t “recompute the commitId” — a relayer can forge that too. It’s an attestation gate: every value-moving call requires an authorized operator’s signature over a digest that binds all the parameters plus block.chainid and the contract address:

bytes32 digest = keccak256(abi.encode(
    DOMAIN, block.chainid, address(this),
    commitId, recipient, amount, sourceChainId
));
require(verifier.verify(digest, attestation), "unauthorized");

I made the verifier pluggable. On the home chain — a DAG L1 I control — it calls a native ML-DSA-65 (FIPS 204) precompile, so the check is genuinely post-quantum. On EVM destinations, where on-chain lattice verification costs 5–12M gas, it falls back to ECDSA and inherits PQ integrity transitively through consensus. (The tempting shortcut — an SP1→Groth16 proof — is not post-quantum: the BN254 wrapper is broken by Shor. A 270k-gas “PQ verify” that isn’t PQ is worse than no claim.)

The fix: an attestation gate that binds every value-moving call to an operator signature One invariant, one gate supply minted on destination ≤ collateral locked on source relayer untrusted attestation gate verify(digest, signature) binds commitId · recipient · amount · chainId · address mint · unlock finalize the old path — relayer trusted directly, no on-chain proof — is removed Without the gate the invariant breaks in one call: mint(arbitraryCommitId, attacker, 1e30). The digest binds the parameters an operator signed, so a forged request no longer verifies.
Every critical reduced to one root cause — no on-chain binding between lock state, mint state, and a verified operator. The gate supplies it.

What the invariants taught me

The discipline that mattered most was revert-fails: every fix has a test that goes green when the fix lands and red again when you revert the fix. A passing test proves nothing if it never had the chance to fail. (The formal name is mutation testing: revert the fix to inject the “mutant,” and a test that stays green has just told you it tests nothing.)

By the end: all four criticals closed, the supply and double-spend invariants green over 512 runs × depth 100, each backed by a revert-fails proof. The adversarial pass surfaced a few more — a token admin that was a parallel unconstrained minter, an unbounded release path — all closed the same way.

Two honest caveats I kept throughout. The cross-domain “minted XOR refunded” guarantee is now an operator-coordination property the gates make enforceable — not eliminated. And no internal audit, however thorough, clears funds-holding code on its own; that’s what independent audits and bug bounties are for.

The invariant that went red in a second is green now over 400 runs. That tells me the gate holds. It tells me nothing about whether the bug I should fear lives behind an invariant I never thought to write — which is the one I’d hire someone else to find.

A minimal, runnable version of the invariant suite and the attestation-gate pattern — the supply≤collateral fuzz test, the gate, and Ronin/Wormhole/Nomad reproductions — is public at lock-mint-bridge-lab. More on the production architecture in Suwappu.