An autonomous audit of the Ethereum execution layer across six production clients at the Fusaka (Osaka + Fulu) hard fork, driven entirely by a large language model.
Clients audited: geth · erigon · besu · nethermind · ethrex · revm/reth
The audit is structured as a sequence of hypothesis-driven items. Each item picks a candidate divergence surface, audits six client source trees in parallel, records the finding, and where a divergence is confirmed, produces a reproducing fixture (EF state test, EF blockchain test, Kurtosis devnet demo, or code-level reproducer).
Full methodology, prompt templates, and repository conventions: METHODOLOGY.md
Every item in the audit (whether or not it produced a divergence): ITEM_TOC.md
| # | Finding | Split | Mainnet reach |
|---|---|---|---|
| #44 | revm/reth bytecode-decoder rejects malformed 0xef01... prefixes as bad EIP-7702 designators; 5 strict clients accept as legacy bytecode |
reth (1-vs-5) | D — custom chain |
| #45 | tx-level consequence of #44: revm/reth refuse state with malformed 0xef01... sender bytecode; 5 strict clients reject the tx instead |
reth (1-vs-5) | D — custom chain |
| #45b | revm/reth fake_exponential u128 overflow → wrong BLOBBASEFEE |
reth (1-vs-5) | D — custom chain |
| #52 | ethrex !was_empty guard: pre-existing empty accounts not deleted by EIP-161 |
ethrex (1-vs-5) | D — custom chain |
| #60 | ethrex amount > 0 filter in process_withdrawals composes with #52 EIP-161 bug; forks on zero-amount withdrawal to pre-existing empty account |
ethrex (1-vs-5) | D — custom chain |
| #61 | revm/reth fake_exponential u128 overflow surface (#45b carryover) under Prague EIP-7691/7918 schedule |
reth (1-vs-5) | D — custom chain |
| #62 | revm/reth missing EIP-7610 storage-root collision check in CREATE | geth, reth (2-vs-4) | D — custom chain |
| #86 | EIP-4788 system-call halt-on-revert: only reth strict-rejects per spec; geth/erigon/ethrex/nethermind tolerate; besu crashes | besu, erigon, ethrex, geth, nethermind (5-vs-1) | D — custom chain |
| #90 | EIP-2935 syscall-revert: erigon/nethermind bypass EVM and extend the bad-block chain; geth/besu/reth/ethrex halt | erigon, nethermind (2-vs-4) | D — custom chain |
| #112 | erigon parses tx type 0x05 (gated by --aa, off on mainnet); ethrex parses 0x7d/0x7e for L2 — by-design custom-chain extensions |
erigon, ethrex (2-vs-4) | D — custom chain |
| #89 | Second fake_exponential overflow at ~900M excess_blob_gas → 4-way BLOBBASEFEE split |
erigon, ethrex, nethermind, reth (4-vs-2) | D — synthetic state |
| #96 | ethrex skips zero-amount withdrawal; pre-existing empty account not deleted | ethrex (1-vs-5) | D — synthetic state |
| #99 | codeHash=0 in EIP-161 predicate: 3-vs-3 split |
besu, erigon, reth (3-vs-3) | D — synthetic state |
| #106 | EIP-6110 deposit log ABI offset validation: geth blind-extracts, others validate | geth (1-vs-5) | D — synthetic state |
| #107 | geth has no empty-code pre-check for EIP-7002/7251 system calls | geth (1-vs-5) | D — synthetic state |
| #108 | T2.2 pre-Prague-disallow of requestsHash header missing in geth/besu (T2.1 closed; unreachable on post-Pectra) |
besu, geth (2-vs-4) | D — synthetic state |
| #121 | withdrawalsRoot absent from header: ethrex lacks fork-gating check |
ethrex (1-vs-5) | D — synthetic state |
| #122 | parentBeaconBlockRoot absent from header: besu lacks fork-gating check; silently skips EIP-4788, state diverges on subsequent blocks |
besu (1-vs-5) | D — synthetic state |
| #169 | EIP-7928 says withdrawal recipients MUST be in BAL regardless of amount. All 6 clients record them — BAL hashes match. Ethrex composes with #60: BAL recorder records the recipient (extend_touched_addresses bypasses the amount filter), but the state-transition pipeline filters amount > 0 in process_withdrawals — recipient address is NOT actually touched in state, so EIP-161 cleanup of pre-existing empty accounts doesn't fire. The divergence shows up as a state-root mismatch, NOT a BAL hash mismatch. | ethrex (1-vs-5) | D — synthetic state |
| #175 | EIP-7928 Engine API: all 6 clients use camelCase blockAccessList JSON field (alloy serde(rename_all = "camelCase") applies to reth despite snake_case Rust field names). Hex-encoded RLP everywhere. Empty-BAL serialization differs: erigon omitempty, ethrex skip_serializing_if, besu conditional, nethermind JsonRequired always-include. Validation strictness varies (erigon/reth explicit, besu/ethrex implicit RLP failure). All differences are operational, not consensus-level. |
reth (1-vs-5) | D — contained upstream |
| #185 | EIP-7594 mandates a 6-blob-per-tx limit enforced at THREE checkpoints (submission, network, block). All 6 clients use the constant 6 correctly; geth/besu/erigon/nethermind/reth enforce at all three checkpoints. ethrex enforces ONLY at block-execution time (default_hook.rs:636); its mempool validate_transaction at blockchain.rs:2411 has no per-tx blob count check. Spec-text violation but consensus-preserving — block-validation catches it. Relay-interop / mempool-DoS surface only. |
ethrex (1-vs-5) | D — contained upstream |
| #187 | EIP-7594 §"Networking" requires blob-tx senders to compute cell KZG proofs and include them in the EIP-4844 tx-pool wrapper. All 6 clients implement cell-proof support; geth/besu/reth/ethrex/nethermind hard-cutoff V0 wrappers post-Osaka. erigon auto-upgrades V0→V1 server-side at JSON-RPC submission (rpc/jsonrpc/send_transaction.go:64-70) for tooling backward compat — spec-text deviation but consensus-preserving (V1 wrappers propagate normally). | erigon (1-vs-5) | D — contained upstream |
| #161 | EIP-8037 reinterprets EIP-7825's 2^24 tx.gas_limit cap as a regular-gas-only cap. ethrex (validation.rs:150) disables the cap entirely on Amsterdam — accepts unbounded tx.gas_limit. geth (state_transition.go:392) skips the pre-execution check on Amsterdam and relies on buyGas() pool-deduction at line 362 — soft cap only. besu/erigon/reth enforce intrinsic regular-gas cap; only besu adds an explicit runtime regular-gas check. nethermind unclear. Multi-way; splits [geth, ethrex] as the clearest outliers. | geth, ethrex (2-vs-4) | mainnet-glamsterdam |
| #164 | revm's post-hoc state-delta BAL recorder correctly handles accessed-but-unchanged ACCOUNTS via empty BalWrites entries (initial account-level hypothesis refuted), but does NOT capture SLOAD-only STORAGE reads — update_reads() exists at bal/account.rs:353 but is never invoked during commit(). Storage reads only enter via external try_from_alloy(). 1-vs-5 split on every block with any pure SLOAD. | reth (1-vs-5) | mainnet-glamsterdam |
| #165 | EIP-7928 SELFDESTRUCT-in-tx requires no nonce/code changes for the destroyed account, balance entry only if pre-tx > 0, and intra-tx storage writes demoted to storage_reads. nethermind and reth/revm both record nonce_changes and code_changes for SELFDESTRUCT'd accounts (rule 2/3 violations); ethrex's track_selfdestruct() and geth's selfDestructed-skip-finalise are spec-aligned. besu/erigon agree on rules 2-3. | nethermind, reth (2-vs-4) | mainnet-glamsterdam |
| #166 | EIP-7928 says zero block reward MUST NOT cause coinbase inclusion. geth (state_transition.go:720 — explicit GetBalance with TODO), besu (MainnetTransactionProcessor.java:569 — getOrCreate+addTouchedAccount unconditional), reth (load_account_mut+incr_balance(0) creates EvmState entry → coinbase in BAL via #164's empty-BalWrites mechanism) all violate. erigon spec-aligned (skips AddBalance when tipAmount=0). ethrex spec-aligned via gas_price≠0 heuristic. nethermind unclear. | geth, besu, reth (3-vs-3) | mainnet-glamsterdam |
| #167 | EIP-7928 says authority MUST be in BAL only if loaded into accessed_addresses; auth-fail-before-load MUST NOT be included. besu records preemptively at CodeDelegationProcessor.java:128 — confirmed 1-vs-5 spec violation. geth, erigon, nethermind, ethrex, reth/revm all record only after ecrecover succeeds. | besu (1-vs-5) | mainnet-glamsterdam |
| #170 | EIP-7928 mandates EIP-7002 / EIP-7251 dequeue's slot-4+ reads in storage_reads. nethermind suppresses them via predicate exemption at BlockAccessList.cs:626 (IsFreeStorageReadAccount, hardcoded against the two predeploy addresses); reth omits them via the broader item-#164 storage_reads bug. geth/besu/erigon/ethrex emit them. 4-vs-2 split on every block with active withdrawal/consolidation queues. | nethermind, reth (2-vs-4) | mainnet-glamsterdam |
| #171 | reth omits the post-exec BAL-index bump after apply_post_execution_changes() at execute.rs:618 and at the eth_blockAccessList RPC helper bal.rs:58 — confirmed source-level; post-exec EIP-7002/7251 entries get block_access_index = n instead of n+1, diverging from the canonical 5 on every Glamsterdam block with non-empty post-exec queues. | reth (1-vs-5) | mainnet-glamsterdam |
| #173 | EIP-7928 demands no-op SSTORE (post=pre) be demoted to storage_reads, not storage_changes. nethermind keeps the slot in storage_changes (per-index dedup ignores pre-tx baseline); reth/revm omits the slot entirely (journal diff sees no mutation, then never calls update_reads()). geth, besu, erigon, ethrex all demote correctly. 4-vs-2 split. | nethermind, reth (2-vs-4) | mainnet-glamsterdam |
| #176 | EIP-7928 BAL recording for the two pre-execution system contracts (EIP-2935 history storage, EIP-4788 beacon root). Symmetric to item #170 (post-exec EIP-7002/7251) but at block_access_index=0. Source-level audit (2026-05-10): geth/besu/erigon/ethrex/nethermind spec-aligned (pre-exec system calls flow through the standard recorder; nethermind's IsFreeStorageReadAccount predicate at BlockAccessList.cs:624-629 hardcodes only EIP-7002/7251, so EIP-2935/4788 reads land normally). reth/revm omits storage_reads via the broader item #164 bug (update_reads() at bal/account.rs:353 never invoked by commit()). 5-vs-1 split on every Glamsterdam block. | reth (1-vs-5) | mainnet-glamsterdam |
| # | Finding | Split | Mainnet reach |
|---|---|---|---|
| #15 | ethrex BLS12-381 from_uncompressed misinterpreted byte[0]=0x40 as the infinity flag, returning the identity point instead of rejecting coords ≥ p (5-vs-1; fixed upstream in ethrex 28f3e58677) |
ethrex (1-vs-5) | Active — anyone |
| #163 | ethrex restore_cache_state omitted exists flag; reverted tx that touched a non-existent address leaked exists=true into the per-tx cache, causing a same-block EIP-7702 SetCode tx with that authority to apply the spurious 12500-gas refund (5-vs-1; fixed upstream in ethrex d71b53d68 / v12.0.0-rc.2) |
ethrex (1-vs-5) | Active — anyone |
| #127 | ethrex accepted blob versioned hash with 0x02 prefix (5-vs-1; fixed upstream in ethrex 0458d5e67) | ethrex (1-vs-5) | Active — proposer |
Header-field fork-gating class (#121, #122): every new-at-fork header field has at least one permissive client, but the permissive set differs per field — no shared abstraction forces consistency. Only erigon and reth are strict on all three audited fields. (Note: #108 — requestsHash — was also part of this class but is closed: T2.1 patched in geth, T2.2 impossible on post-Pectra chains.) For why the divergence doesn't propagate through the engine API on mainnet, see each item's own Engine-API non-exploitability section.
| Field | Fork | Permissive |
|---|---|---|
withdrawalsRoot |
Shanghai | ethrex |
parentBeaconBlockRoot |
Cancun | besu |
requestsHash |
Prague |
ethrex EIP-161 class (#52, #96): two independent code paths (!was_empty guard and filter(amount>0) in withdrawals) share the same root cause — EIP-161 never fires on accounts ethrex doesn't touch.
geth system-call asymmetry (#86, #90): ProcessBeaconBlockRoot silently discards errors; ProcessParentBlockHash panics. Opposite error semantics in the same file.
itemNN/ per-item audit and fixtures
README.md finding, hypotheses, client code references
fixture/ reproducing test (EF state/blockchain test, Go/Rust/Python)
WORKLOG.md full sequential audit log (152 items)
METHODOLOGY.md prompt templates and process documentation
AGENTS.md agent instructions
items/045b/kurtosis/ live devnet demo (blob-fee fork, revm/reth fake_exponential overflow)