Engineer. Investor. Writer.
No Peeking
How a rollup proves it ran correctly with a program that never touches the network, and a one-line bug in how it reads its own config that can silently poison a multi-hour proof.
A rollup's entire value proposition is that Ethereum does not re-execute its blocks. Base runs thousands of transactions per second on L2, posts a claim about the resulting state to L1, and Ethereum accepts that claim without redoing the work. Which raises the obvious question: if Ethereum never re-executes, what stops Base from posting a claim that is simply false?
The answer is a program that re-executes the blocks from scratch and derives the real answer, deterministically enough that anyone can run it and land on the identical result, bit for bit. A fault proof runs it to challenge a lie. A zkVM runs it to produce a validity proof. A secure enclave runs it and signs the output. This post is about that program: how it pulls gigabytes of chain data without touching the network, and a one-line bug in how it reads its own config that can silently poison a multi-hour proof.
Determinism is the whole game
The program — Base's is written in Rust — has exactly one non-negotiable requirement. Given the same inputs, it must produce the same output on every machine, every time, down to the last byte. That means no network, no disk, no clock, no random numbers, no syscalls that could vary. Inputs go in, an answer comes out, and nothing else is allowed to influence the result.
This isn't asceticism for its own sake. Determinism is the source of the guarantee. A proof is only meaningful if the statement it proves has exactly one answer; the moment the output could depend on which machine ran it or what the network was doing at the time, there is nothing solid to agree on and nothing worth proving. Reproducibility is the trust.
Which leaves a real problem. Re-executing a year of blocks needs a lot of data — historical headers, account state, receipts, gigabytes of it. How does a program with no I/O get any of it?
The preimage oracle
It never asks by name. It never requests "block 12,000." It requests the bytes whose hash is H, and it supplies H. A host on the other side of that channel looks up the matching bytes and returns them. The program then hashes what it received and checks the digest against the H it asked for. If they don't match, the bytes are discarded.
That check is the entire trust model. A cryptographic hash is not forgeable — you cannot produce different bytes that collide on the same digest — so the host has exactly two options: return the correct data, or return something that fails the check. It can withhold, but it cannot lie. This request/response channel is the preimage oracle, and its keys are content-addressed: the key is the fingerprint of the answer.
Content-addressing is why it's safe to let an untrusted stranger feed data to the program. The program extends zero trust to the host. Either the bytes hash to the key or they don't, and the math, not the host, decides. A malicious host degrades to a slow or stuck host; it can never corrupt the result.
Boot info: the inputs with no hash
A small set of inputs can't be content-addressed, because the program needs them before it knows what to ask for: which block it's checking, which chain it's on, which claim it's verifying, who submitted the proof. The host supplies these directly, asserted rather than verified against a hash. This is the boot info (also called local keys), and the program reads it first, at boot, before anything else.
Not every boot value carries the same weight, and the difference is where the bug lives:
| Boot value | If it's absent |
|---|---|
| Which block to check | Abort — no default makes sense |
| Which chain | Abort |
| Where to start from | Abort |
| Who submitted the proof | Use a default — older inputs predate this field |
The required values have no sane default; if one is missing, the only correct move is to stop. The last row is different. It was added later, so inputs created before it existed simply don't carry it, and absent is a legal, expected state that the program handles by substituting a default. Keep that row in mind.
One program, three backends
The same program runs in three settings, depending on what kind of proof you want. In a fault proof, a live host sits on the other side of the oracle and answers each request on demand. In a zkVM proof, there is no live host at all — every answer is served from a preimage store assembled ahead of time. In a TEE proof, a secure enclave runs the program and attests to the output.
One program, three hosts. And one detail that matters shortly: each backend words "I don't have that key" differently.
Witnessing: run it twice
The zkVM case looks paradoxical. If the program can't touch the network, how does the preimage store get populated in the first place? With a host/client split.
Run the program online first, in a recording pass. It asks for whatever it needs, the host fetches each answer from the real network, and every (request, response) pair is logged. That log is the witness. Then run the identical program offline, inside the sealed environment, answering every request from the witness instead of the network.
The reason the witness is guaranteed to be complete goes straight back to determinism. Because the program behaves identically on identical inputs, it issues the exact same requests in the exact same order every time. The recording pass already saw all of them, so the witness already contains all of them. It is nothing more than a replay of what the program needed the first time.
The trap
Now the part that's easy to get wrong. Go back to the optional boot values — the ones allowed to be absent.
When the program reads one, "not there" is a valid answer, so it uses the default and moves on. The problem is that genuinely absent and I failed to fetch it look nearly identical at the call site. One means the value truly doesn't exist, because the input predates the field. The other means the value exists fine, but the read itself failed — a timeout, a dropped connection, a backend hiccup. Collapse those two into one branch, default on both, and you've written a bug:
// Reading one optional boot value.
match host.get(key).await {
// Got bytes back — use them.
Ok(bytes) => Some(bytes),
// Genuinely absent. Backends word this differently, so accept
// every "no such key" spelling. The input simply predates this
// field, and the default is the correct answer.
Err(Error::NotFound | Error::NoSuchKey) => None,
// The read actually failed — timeout, dropped pipe, backend error.
// Do NOT default here. Abort before spending the expensive part.
Err(other) => return Err(other),
}Miss that last arm — lump every error into "just use the default" — and picture a flaky connection three seconds into a two-hour run. The program quietly continues with the wrong config. Re-execution isn't cheap; it runs for minutes or hours. You burn all of it, produce an output derived from a default you never intended, and it gets rejected at the very end when it fails to match. Worse, the failure looks like your claim was wrong, when the real story is that a transient network blip poisoned everything downstream of it.
The fix isn't clever, and it doesn't need to be: tell the two cases apart. Genuinely missing, use the default. Read failed, stop immediately and say so loudly — at boot, long before you spend the expensive part on a run you'll only throw away.
Absent is a normal state; it means an older input. A failed read is a malfunction. They're indistinguishable at the moment you ask and they mean opposite things. Any time you reach for a default because something was missing, make sure you actually know it was missing — not just that you failed to go look.
Wrong, but safe
One reassurance: even the buggy version can't cost anyone their money. A wrong config yields a wrong output, a wrong output won't match what Ethereum derives independently, and Ethereum rejects it. The system fails closed — it says no when something is off rather than waving through a falsehood. Nothing bad gets accepted; you've only torched compute and earned a misleading error message.
So this is never a safety bug. It's the other kind — the kind that eats an afternoon while you chase a ghost through a codebase. Which is reason enough to fix it.
The whole thing rests on one idea: a program that isn't allowed to peek, fed data it can verify for itself by fingerprint, made to behave identically every single time. The witness, the boot info, the required-versus-optional split, even the trap at the end — all of it hangs off that. A sealed room, a slot in the door, and a quiet way to prove you did the work.
✎ Test Yourself
15 questions · each scores the instant you answer.
Determinism
Q1
Why must the fault-proof program be bit-for-bit deterministic across every machine that runs it?
Q2
To preserve determinism, which of these is the program forbidden from doing?
The preimage oracle
Q3
How does a program with no I/O obtain the gigabytes of chain data re-execution requires?
Q4
Oracle keys are content-addressed. What property does that guarantee?
Q5
Given content-addressing, what is the worst a malicious preimage host can do?
Boot info
Q6
Why can't boot info (which block, which chain) be content-addressed like everything else?
Q7
A required boot value can't be read. What is the correct behavior?
Q8
Why do certain optional boot values get a default when they are genuinely absent?
Backends and witness
Q9
The same program runs across which three backends?
Q10
In the zkVM case there is no live host. How does the preimage store get populated?
Q11
Why is the witness guaranteed to contain everything the offline run needs?
The trap
Q12
An optional boot read returns 'not found.' What two distinct causes are indistinguishable at that call site?
Q13
What breaks if you default on every error instead of only on genuine absence?
Q14
What is the correct way to read an optional boot value?
Safety
Q15
Can this bug cause L1 to accept an invalid proof and lose funds?