Skip to content

Add a stateless block verifier for openvm-eth witnesses - #148

Open
leonardoalt wants to merge 5 commits into
mainfrom
worktree-stateless-verifier
Open

leonardoalt wants to merge 5 commits into
mainfrom
worktree-stateless-verifier

Conversation

@leonardoalt

Copy link
Copy Markdown
Member

Summary

stateless <witness.bin> verifies an Ethereum block against an openvm-eth
stateless witness and prints the block hash, the same job openvm-eth's zkVM
guest does (reading a file instead of a hint stream, and using this repo's Lean
crypto rather than ZK precompiles). Both cached mainnet witnesses verify end to
end, and building it surfaced two real EIP-7702 gas bugs in the semantics.

Changes

  • Data/WitnessTrie.lean: a partial MPT. Data/Mpt.lean rebuilds a trie from
    a complete key/value list, which cannot represent a state trie whose untouched
    subtries are only known by hash. Adds the digest node, openvm-eth's node
    stream decoder with per-node reference checking, incremental insert/delete,
    and a reference cache so the post-block rehash only touches changed paths.
  • Block.lean: the consensus block layer (full header, five EIP-2718 envelopes,
    withdrawals, receipts, logs bloom, the RLP encodings behind the block hash and
    four roots, sender and authority recovery). fakeExponential moves here from
    the test helper and becomes total rather than partial.
  • Data/Bincode.lean and Stateless/Input.lean: the witness wire format.
  • Stateless/Verify.lean: the state transition plus every header commitment.
  • Tx.run gains an optional resolve : State → State hook, id for every
    existing driver. A witness keys state by keccak with no preimages, so the
    world cannot be populated before execution starts; the verifier fills in each
    step's reads just before it runs. The module header explains why that needs
    both stack-operand reading and trial-stepping.
  • Tx.execute now reports gasUsed, the final substate, and whether a
    transaction was rejected rather than executed. Receipts need the first two;
    a block verifier must treat the third as a consensus failure.
  • Two semantics fixes: the missing EIP-7702 authorization refund (12500 when the
    authority already exists in the trie), and warming a delegated recipient's
    delegate, which the top-level frame resolves to get the code it runs.
  • witnesstrietests: differential test of the partial trie against the
    full-trie root builder. It caught a real bug on its first run.

Testing

  • Both cached mainnet witnesses verify: block 24171384 (53 txs, 11s) and
    23992138 (240 txs, 60M gas, 2m23s). The recomputed block hashes match what
    openvm-eth's reth-benchmark --mode execute-host reports for the same inputs.
  • Per-transaction status, gas used and log count match the chain's own receipts
    (via eth_getBlockReceipts): 53/53 and 240/240 exact.
  • EEST state tests: eip7702_set_code_tx (1100) and Osaka (2134), 0 fail,
    0 crash. The 7702 fixes move 366 of them from the pass_core tier to
    pass_root (632 to 998).
  • witnesstrietests passes, the full CI target build is warning clean, and
    lake lint passes.

Notes

  • Both gas bugs were invisible to CI: the state-test runner downgrades a
    balance/state-root mismatch to a weaker pass tier instead of failing, so
    fail was 0 before and after. VMTESTS.md now records that blind spot.
  • Only the field-encoded block variant of the witness is decoded, which is what
    the powdr fork writes and what both cached inputs use. Upstream openvm-eth's
    RLP-encoded block variant is detected and rejected with a clear message;
    supporting it needs an RLP envelope decoder.
  • Type-1 (EIP-2930) envelope decoding is written by analogy but unvalidated,
    since neither block contains one.
  • Parent-relative header checks (gas limit band, base fee formula, timestamp)
    are not performed, matching openvm-eth's guest, which does not call
    validate_header_against_parent either.
  • Execution dominates the runtime (140s of the large block's 143s); decoding and
    all trie work account for about 2s.

Leo Alt and others added 5 commits September 3, 2026 17:50
First stage of a native stateless block verifier with openvm-eth's API:
decode the witness, verify the pre-state it commits to, and recompute the
block hash.

* `Data/Bincode.lean` — reader for bincode's `config::standard()` wire
  format, the shape openvm-eth's host caches its witness in.
* `Data/WitnessTrie.lean` — a *partial* Merkle-Patricia trie: the fifth
  node shape (`digest`) a stateless verifier needs, over an arena layout,
  with per-node reference verification on decode, get/insert/delete, and
  a reference cache so a post-block rehash only touches changed paths.
* `Block.lean` — the consensus block layer: the full header, the five
  EIP-2718 transaction envelopes, withdrawals, receipts and the logs
  bloom, with the RLP encodings that define the block hash and the four
  roots a block commits to, plus EIP-155/2718/7702 sender recovery.
* `Stateless/Input.lean` — the witness structure and its decoder.

Validated against the two cached mainnet witnesses and the block hashes
the reference implementation reports for them: both files decode to the
exact byte, and the recomputed block hash, transactions root, parent
state root and every per-account storage root match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Completes the stateless block verifier: it now executes the block and
checks every commitment the header makes about it — receipts root, logs
bloom, gas used and the post-state root — then prints the block hash.

Mainnet block 24171384 (53 transactions, Osaka/BPO1) verifies in 11s and
produces the block hash the reference implementation reports, byte for
byte. All 53 transactions agree with the chain's own receipts on status,
gas used and log count.

Three divergences had to be found to get there, each fixed at its cause:

* A reverting frame **rolls back its EIP-2929 accessed sets**, so the
  replay-based state discovery this started with could never learn what a
  failing transaction read. Replaced with a `resolve` hook on `Tx.run`
  (`id` for every other driver) that fills in what the *next step* is
  about to read.
* `CALL` computes its own gas cost *before* warming its target, so an
  unresolved target is charged the 25000-gas `G_newaccount` surcharge it
  does not owe, runs out of gas, and reports no address for a trial step
  to learn from. The resolver therefore also reads address and slot
  operands straight off the stack.
* An **EIP-7702 delegated recipient** enters its frame from
  `buildInitState`, outside the step loop, so its delegate has to be
  resolved when the pre-state is materialized — otherwise the top-level
  frame runs with no code at all.

`Tx.execute` additionally reports `gasUsed`, the final substate and
whether the transaction was *rejected* rather than executed: a receipt
needs the first two, and a block verifier must treat the third as a
consensus failure rather than an ordinary exceptional halt.

`witnesstrietests` differentially tests the partial trie against
`Data/Mpt.lean`'s full-trie root builder over pseudo-random corpora with
deletions. It immediately earned its keep: node 0 doubled as both the
empty root and the "absent child" sentinel, so the first insert into an
empty trie made every later absent child alias the root.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mainnet block 23992138 (240 transactions, 60M gas) now verifies: its
recomputed post-state root and block hash match, and all 240
transactions agree with the chain's own receipts on status, gas used and
log count. It was 65000 gas over before, entirely in this block's five
EIP-7702 transactions.

**The authorization refund was missing.** `txIntrinsicGas` charges
`PER_EMPTY_ACCOUNT_COST` (25000) per authorization on the assumption
that the authority is a new account; EIP-7702 step 7 refunds the
difference down to `PER_AUTH_BASE_COST` (12500) when the authority
already exists in the trie. `applyAuthorization` now reports that
refund, `buildInitState` seeds it into the substate counter so it flows
through the ordinary end-of-transaction accounting (EIP-3529 cap
included), and `applyAuthorizations` accumulates it in order — an
authorization bumps its authority's nonce, so ordering matters.

**A delegated recipient's delegate was not warmed.** Building the
top-level frame resolves the recipient's designator to get the code it
runs, and resolving a designator warms its target — exactly as
`State.warmCallTarget` does for a CALL. Without it a later call back
into the recipient paid a cold access (2600) instead of a warm one
(100). Reachable through a self-sponsored EIP-7702 transaction: the
sender authorizes its own account and sends to it. Found by diffing our
step trace against the reference implementation's: one divergence in
51968 steps, worth exactly 2500 gas.

Both are exercised by the EEST `eip7702_set_code_tx` corpus, and the way
they hid is worth recording (now in VMTESTS.md): a pure gas-accounting
bug leaves storage, nonce and code correct and only moves balances, so
the runner *downgrades* the test to a weaker pass tier instead of
failing it. Across those 1100 tests `fail` was 0 before and after — but
998 now reach the full state-root tier where 632 did.

`fakeExponential` also moves into the library (the blob base fee is a
consensus quantity the verifier needs) and becomes total rather than
`partial`, so its regression pins can be `#eval`ed beside it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The last header commitment the verifier was not checking. The block's
execution requests are the EIP-6110 deposits its receipts log (read out
of the deposit contract's `DepositEvent`, whose five ABI-encoded `bytes`
fields all have fixed lengths, so the flat 192-byte request sits at
constant offsets) followed by whatever the EIP-7002 and EIP-7251 system
calls returned — which `systemCall` already hands back and the block
runner now keeps.

Both cached mainnet blocks commit to the empty-requests hash, so what
this establishes for them is that execution produced no requests: no
deposit logs, and both request system calls returned nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Storage is diffed slot by slot, which is right for an account that keeps
its identity but not for one destroyed and recreated inside a single
transaction — that trie must be reset wholesale. EIP-6780 confines
SELFDESTRUCT's deletion to same-transaction creations, whose storage is
empty, so it cannot arise on the forks this verifier targets; say so
rather than leave it implicit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant