Technical · 11 min

We nearly broke
our own fraud proof

We replaced an interactive bisection game with a one-shot disproof, and the argument for doing it was sound. The premise it rested on was not. An account of how a correct deduction from a false assumption nearly shipped.


The setting

Croom's chain does not store prices. It stores a 32-byte Merkle root per epoch — a batch of rounds — posted by somebody who escrows a bond asserting it is the correct continuation of the previously finalised state. If nobody objects within a challenge window, it finalises. Optimistic, in the usual sense.

The reference implementation, which runs on Solana, resolves disputes by bisection: the challenger and the poster alternately commit to the midpoint of a disputed range until they disagree about a single round, which is then re-executed on-chain. Seven rounds of interaction for a hundred-round epoch.

When porting to the EVM under a deadline, we cut bisection. This is the argument we used, and I still think every sentence of it is true.

The argument for removing it

Bisection exists for games where the challenger cannot cheaply compute the truth and must search for the divergence interactively. That is the situation in an optimistic rollup: re-executing a disputed block is expensive, so you binary-search to isolate one instruction.

It is not our situation. The derivation is deterministic, pure, and defined entirely by public data — a frozen specification plus a public randomness beacon. A challenger running the reference implementation already knows precisely which round diverges. There is nothing to search for.

Better still, a correct epoch is exactly one where three locally-checkable things hold:

  1. the first leaf follows from the previously finalised state,
  2. every leaf follows from the leaf before it, and
  3. the claimed end state matches the last leaf.

Each is checkable in a single transaction. So if a committed root is wrong, at least one of them fails — and naming the failure is a complete disproof, not an approximation of one. There is nothing bisection could find that a challenger with the reference cannot name directly.

Two consequences follow, and both are improvements:

  • The proof is one shot and atomic. A challenger submits the disproof itself, not a claim they will later substantiate. An invalid disproof simply reverts.
  • The challenger posts no bond. Bonds on both sides of a bisection game exist to punish stalling. With no rounds to stall, there is nothing to punish — so watching costs only gas, which is exactly the property you want, since the system's safety rests on somebody bothering to watch.

We implemented it, wrote an adversarial suite that plants a lie at every index in the epoch and asserts the poster loses at each one, fuzzed the bond conservation, and shipped it to testnet. A liar posted a false checkpoint and a second address took their bond. All green.

What was wrong with it

Three days before the deadline, re-reading that argument looking for its premises rather than its conclusion, I found this:

A disproof must exhibit a committed leaf together with its Merkle path. Nobody can construct a path for a leaf they have never seen.

The contract stored a root. Our post function took a bytes32 and emitted it. A poster could therefore commit a root and simply never reveal its preimages.

Walk through what happens then.

An honest watcher re-derives the epoch from the beacon, computes its own root, and sees that it differs from the committed one. They know the checkpoint is false. Now they try to prove it — and every entry point demands a leaf and a path. They have their own leaves, which are not in the poster's tree, so their proofs fail membership. They have no way to obtain the poster's leaves, because the poster never published them and is not obliged to.

The checkpoint survives the window and finalises. And from that moment the poster is the only party who can produce a valid inclusion proof against that root — which means the poster alone decides what price any market settles against.

Every test still passed, because every test constructed the liar's leaves itself. The test harness was supplying the data availability that the protocol did not.

What bisection had been doing

This is the part worth internalising. Bisection is usually described as a search procedure, and that framing is what misled us. It is also a forced-disclosure procedure. Every round of the game compels the poster to commit to a midpoint state, on-chain, or forfeit by timeout. By the time the game terminates, enough of the disputed range has been disclosed for the final step to be checkable.

Data availability was never an explicit feature of the reference's design. It fell out of the interaction pattern. We removed the interaction pattern and the property went with it, silently, because nothing in the code was labelled "this is what makes disproofs constructible".

The deduction was valid. The premise — that a challenger can exhibit the committed leaves — was an assumption we never wrote down, and therefore never checked.

The fix, and why it is better than restoring bisection

post now takes the leaves themselves and derives the root from them:

function post(
    RoomSpec.Spec calldata spec,
    uint64 epoch,
    bytes32 prevStateHash,
    bytes32 endStateHash,
    PostedLeaf[] calldata leaves
) external payable

Three properties follow, and the second was a bonus we had not been looking for.

Data availability

The preimages are in the transaction's calldata and in the emitted log. A disproof is always constructible by anyone who can read the chain. The premise is now enforced rather than assumed.

Structural validity by construction

Because the contract walks the leaves to build the root, it can insist that the rounds are exactly the epoch's, contiguous and ascending. That closes a hole we had not noticed either.

Our Merkle construction uses sorted pairs — node(a,b) = H(min(a,b) ‖ max(a,b)) — so proofs carry no direction bits. The usual advantage is smaller proofs and one fewer class of bug. The cost, which we had not thought through, is that a sorted-pair proof cannot bind a leaf's position.

So a poster could have committed a tree containing two different leaves for the same round. Both would verify. At settlement they would present whichever suited them. Checking the shape at post time makes that tree unpostable, rather than merely disprovable by somebody paying attention.

The root cannot disagree with the leaves

It is computed from them. There is no longer a poster-supplied root to be inconsistent with anything.

What it costs

Measured against the live contract on Monad, with eth_estimateGas rather than a local simulation — a distinction that matters, and which we learned the expensive way:

Variantgas
Root only107,421
With 20 leaves published (3,044 bytes of calldata)209,976
Price of the guarantee+102,555

About 0.011 MON per checkpoint at current prices. It scales at 56 bytes per round, so a 100-round epoch costs roughly five times as much and remains comfortably inside Monad's 30M per-transaction limit.

That is cheap for turning a fraud proof from plausible into sound. It is also cheaper than bisection, which would have cost seven on-chain interactions per dispute plus bonds on both sides plus the timeout machinery to handle a party that walks away.

A footnote on gas estimation

Measuring that figure produced a second lesson. forge script estimated 273,128 for the heavier post, against a real cost of 209,976 — it over-estimated by 1.3×.

Two days earlier the same tool had under-estimated Entropy.proveRound by 2.42×, and the transaction died out of gas. Monad charges the gas limit rather than the gas used, so that failure was billed in full.

The correction factor is a property of the transaction, not the chain. Monad reprices BN254 operations upward by up to 5× while calldata is priced identically to Ethereum — so a pairing-heavy call swings one way and a calldata-heavy call swings the other. Across three real transactions the range is 0.77× to 2.42×, in a direction you cannot infer from reading the source. There is no safe multiplier. Measure each call shape against the deployed contract.

What we took from it

The failure was not a coding error and no test would have caught it, because the tests shared the assumption. It was a design argument whose conclusion we checked and whose premises we did not.

The practical version, which we now apply deliberately: when you remove a mechanism because you have an argument that it is unnecessary, enumerate what that mechanism was incidentally providing. Interactive protocols in particular tend to supply liveness and data-availability properties as side effects of their interaction, and those properties rarely have a name anywhere in the code.

The one-shot disproof survives, and it is genuinely a better fit for a deterministic, publicly-derivable state function than bisection is. But it only works because posters are forced to publish — and that sentence is now in the contract's documentation, immediately above the function that enforces it, where the next person to consider an optimisation will read it.


The commit that fixed this is fix(ladder): publish every leaf at post time so a disproof is always possible, and the live fraud proof on the hardened contracts is linked from the front page.

Live on Monad testnet Open app