Repository navigation
Add a stateless block verifier for openvm-eth witnesses - #148
Open
leonardoalt wants to merge 5 commits into
Open
leonardoalt wants to merge 5 commits into
leonardoalt wants to merge 5 commits into
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
stateless <witness.bin>verifies an Ethereum block against an openvm-ethstateless 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.leanrebuilds a trie froma complete key/value list, which cannot represent a state trie whose untouched
subtries are only known by hash. Adds the
digestnode, openvm-eth's nodestream 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).
fakeExponentialmoves here fromthe test helper and becomes total rather than
partial.Data/Bincode.leanandStateless/Input.lean: the witness wire format.Stateless/Verify.lean: the state transition plus every header commitment.Tx.rungains an optionalresolve : State → Statehook,idfor everyexisting 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.executenow reportsgasUsed, the final substate, and whether atransaction was rejected rather than executed. Receipts need the first two;
a block verifier must treat the third as a consensus failure.
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 thefull-trie root builder. It caught a real bug on its first run.
Testing
23992138 (240 txs, 60M gas, 2m23s). The recomputed block hashes match what
openvm-eth's
reth-benchmark --mode execute-hostreports for the same inputs.(via
eth_getBlockReceipts): 53/53 and 240/240 exact.eip7702_set_code_tx(1100) and Osaka (2134), 0 fail,0 crash. The 7702 fixes move 366 of them from the
pass_coretier topass_root(632 to 998).witnesstrietestspasses, the full CI target build is warning clean, andlake lintpasses.Notes
balance/state-root mismatch to a weaker pass tier instead of failing, so
failwas 0 before and after. VMTESTS.md now records that blind spot.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.
since neither block contains one.
are not performed, matching openvm-eth's guest, which does not call
validate_header_against_parenteither.all trie work account for about 2s.