Skip to main content
← Back to Learn

The Proof Nobody Had Verified

Our browser zero-knowledge prover shipped proofs that passed every check we ran — because the check was structural, not cryptographic. The first real verification found five latent bugs, and the fix changed what 'fast' means.

For months our settlement flow generated zero-knowledge proofs in the browser and "verified" them server-side. The verification passed every time. It checked hex formatting, field count, and layout — a structural audit, not a cryptographic one. Nothing was running the pairing check that makes a proof a proof.

The first time we ran real verification, it failed. On everything.

What the first real verify found

A prover that never faces a verifier can harbor bugs indefinitely. Ours had five:

  • An unsatisfiable gate. One constraint asked two private witnesses to multiply to a public constant — a product that was never true for any real input. The proof system builds proofs without checking witness satisfaction; only the verifier's constraint check catches it. Since nothing ever verified, nothing ever caught it.
  • A mismatched verification key. We derived a fresh verification key instead of reading the one baked into each proving key. The two diverge silently.
  • Misaligned public inputs. Gates read the instance column at the current row — which only aligns if every field happens to occupy exactly one row. Committed fields take two, shifting everything after them.
  • Byte-order confusion, twice. Semantic big-endian values fed a little-endian decoder in the circuit, and the reverse direction on the wire parser.
  • A deterministic "blinding" factor derived publicly from the same fields it was supposed to hide — it concealed nothing from anyone who could read the input.

None of these were subtle. All of them were invisible, because the verifier that would have caught them was never invoked.

Rebuilding the statement

The repaired circuit binds the intent hash in-circuit: public field two is constrained to be the Poseidon hash of the other semantic fields, so a proof attests to the intent it claims to attest — not merely that some prover knew some numbers. Random per-field blinding replaced the deterministic scalar. A test now proves a wrong intent hash fails real verification — not that it should fail, that it does.

We also deleted the stub fallback. The previous code path could emit a shape-valid, cryptographically-meaningless artifact when the browser prover failed to load. Now the fallback is a server-side endpoint running the identical circuit natively: a real artifact, or a loud failure. An invalid proof is worse than an error.

Then we measured the cost

The old browser prover benchmarked at under fifty milliseconds. That number was real — for a circuit on a 32-row domain that couldn't prove anything. The real statement needs roughly 480 rows, most of them a Poseidon fold binding the intent hash.

Proving cost tracks the domain size — 2^k — not the rows a circuit actually uses. Our first pass ran at k=10 (1024 rows) because that was the comfortable choice. Tightening to k=9 (512) halved the domain: browser proving dropped from 2.4s to 1.4s, proving keys halved to about 2MB per privacy mode, and the whole test suite runs noticeably faster. Assembly field arithmetic and link-time optimization on the server path roughly halved native proving again.

The honest conclusion isn't a number — it's a discipline. The sub-50ms figure wasn't lying; it was measuring a different, vacuous circuit. Performance claims about a proof system mean nothing unless the proof itself is real. Every latency number we now report comes attached to the verification that ran on it.

What we'd tell past us

Ship the verifier first. A prover with no verifier isn't a proof system — it's a random number generator with extra steps. And when a benchmark looks surprisingly good, check what it's actually proving.

Put it to work

The research above powers the product — try it or compare it: