From 0625ea86d0b66c37c646866e098278dfdd5bb242 Mon Sep 17 00:00:00 2001 From: mh0lt Date: Tue, 9 Jun 2026 08:33:34 +0000 Subject: [PATCH 1/8] execution/stagedsync: recover CodePath with CodeHashPath in normalizeWriteSet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit normalizeWriteSet recovered an account's CodeHashPath from the versionMap (via the CodeHashPath case and the fill-missing-fields loop) but had no equivalent recovery for CodePath: the CodePath case kept the write only at the validated incarnation, and the fill loop never emitted code. A tx whose validated writeset lacked a fresh CodePath — e.g. an EIP-7702 delegating tx that re-executes, where SetCode short-circuits because so.Code() already returns the designator written by the prior incarnation (bytes.Equal(prevcode, code)) — therefore persisted a non-empty codeHash with no code bytes. A later block then read empty code for the delegated account, and the EIP-3607 sender check wrongly rejected the 7702 sender ("sender not an eoa"). Recover the code this tx wrote from the versionMap (incarnation-agnostic, scoped to this tx so a merely-touched contract's prior-tx code is not re-emitted) whenever an account has a non-empty codeHash but no code in the normalized output, mirroring CodeHashPath. Code can no longer be lost while its hash survives. Co-Authored-By: Claude Opus 4.8 --- execution/stagedsync/exec3_finalize_test.go | 76 +++++++++++++++++++++ execution/stagedsync/exec3_parallel.go | 49 +++++++++++++ 2 files changed, 125 insertions(+) diff --git a/execution/stagedsync/exec3_finalize_test.go b/execution/stagedsync/exec3_finalize_test.go index 221e57353df..608769ef980 100644 --- a/execution/stagedsync/exec3_finalize_test.go +++ b/execution/stagedsync/exec3_finalize_test.go @@ -1811,6 +1811,82 @@ func countPath(writes state.VersionedWrites, path state.AccountPath) int { return n } +// TestNormalizeWriteSet_CodePathTravelsWithCodeHash reproduces the EIP-7702 +// write-drop that wrongly rejected a delegated sender with "sender not an eoa". +// +// A delegating tx re-executes in parallel. On the re-exec, SetCode short-circuits +// (so.Code() already returns the designator written by the prior incarnation, so +// bytes.Equal(prevcode, code) is true), so the validated incarnation emits the +// authority's nonce bump but NO fresh CodePath/CodeHashPath. The raw writeset +// therefore carries the stale prior-incarnation code writes. normalizeWriteSet +// keeps CodeHashPath (resolved from the versionMap) but the incarnation filter +// drops the stale CodePath — leaving a codeHash with no code. The account then +// persists a non-empty codeHash whose code bytes are missing from CodeDomain, so +// a later block's GetDelegatedDesignation reads empty and EIP-3607 rejects the +// 7702 sender. CodePath must be recovered so code always travels with its hash. +func TestNormalizeWriteSet_CodePathTravelsWithCodeHash(t *testing.T) { + vm := state.NewVersionMap(nil) + authority := accounts.InternAddress([20]byte{0x42}) + + // EIP-7702 delegation designator: 0xef0100 || target(20 bytes). + designator := types.AddressToDelegation(accounts.InternAddress([20]byte{0x69, 0x00, 0x77, 0x02})) + designatorHash := accounts.InternCodeHash(crypto.Keccak256Hash(designator)) + + const txIndex = 5 + + // Incarnation 0 delegates: designator code + its hash + authority nonce bump. + vm.FlushVersionedWrites(state.VersionedWrites{ + {Address: authority, Path: state.CodePath, Val: designator, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + {Address: authority, Path: state.CodeHashPath, Val: designatorHash, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + {Address: authority, Path: state.NoncePath, Val: uint64(1), + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + }, true, "") + + // Incarnation 1 (validated) re-executes; SetCode short-circuits, so only the + // nonce is re-emitted — no fresh CodePath/CodeHashPath. + vm.FlushVersionedWrites(state.VersionedWrites{ + {Address: authority, Path: state.NoncePath, Val: uint64(1), + Version: state.Version{TxIndex: txIndex, Incarnation: 1}}, + }, true, "") + + // blockIO.WriteSet retains both incarnations' entries (versionMap doesn't + // clear old), so the validated tx's raw writeset carries the stale inc-0 + // code writes alongside the inc-1 nonce. + rawWrites := state.VersionedWrites{ + {Address: authority, Path: state.NoncePath, Val: uint64(1), + Version: state.Version{TxIndex: txIndex, Incarnation: 1}}, + {Address: authority, Path: state.CodePath, Val: designator, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, // stale incarnation + {Address: authority, Path: state.CodeHashPath, Val: designatorHash, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, // stale incarnation + } + + result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true) + + var gotHash *accounts.CodeHash + var gotCode []byte + for _, w := range result { + switch w.Path { + case state.CodeHashPath: + h := w.Val.(accounts.CodeHash) + gotHash = &h + case state.CodePath: + gotCode = w.Val.([]byte) + } + } + + require.NotNil(t, gotHash, "codeHash must be present in the normalized writeset") + assert.Equal(t, designatorHash, *gotHash, "codeHash is the 7702 designator hash") + + // The regression: code was dropped while the hash survived. Code must travel + // with its hash so the account is never persisted with a codeHash but no code. + require.Equal(t, 1, countPath(result, state.CodePath), + "CodePath must be recovered so code is never persisted without its codeHash") + assert.Equal(t, designator, gotCode, "recovered code is the 7702 designator bytes") +} + // TestCalcFees_EmitsAddressPathForCoinbase pins the fix for the mainnet // block 25151825 tx 31 +25k gas bug. Background: // diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 6006715616b..526977935a9 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -3409,6 +3409,55 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd } } + // CodePath travels with CodeHashPath. The CodeHashPath case and the + // fill-missing loop above both recover an account's codeHash from the + // versionMap, but CodePath has no equivalent recovery: the case above keeps + // it only when this tx's CodePath write is present at the validated + // incarnation (the incarnation filter), and the fill loop never emits it. + // So a tx whose validated writeset lacks a fresh CodePath — e.g. an EIP-7702 + // delegating tx that re-executes: SetCode short-circuits because so.Code() + // already returns the designator written by the prior incarnation + // (bytes.Equal(prevcode, code)), so the validated incarnation emits no new + // CodePath — still recovers CodeHashPath here while CodePath is dropped. The + // account then persists a non-empty codeHash with no code bytes; a later + // block's GetCode/GetDelegatedDesignation reads empty, and for a 7702 sender + // the EIP-3607 check wrongly rejects the tx ("sender not an eoa"). Recover + // the code this tx wrote from the versionMap — regardless of incarnation, + // scoped to this tx (Version().TxIndex == txIndex) so we never re-emit a + // prior tx's code for a merely-touched contract — mirroring CodeHashPath so + // code can never be lost while its hash survives. + codeInOutput := make(map[accounts.Address]bool) + codeHashInOutput := make(map[accounts.Address]accounts.CodeHash) + for _, w := range filtered { + switch w.Path { + case state.CodePath: + codeInOutput[w.Address] = true + case state.CodeHashPath: + if h, ok := w.Val.(accounts.CodeHash); ok { + codeHashInOutput[w.Address] = h + } + } + } + for addr, h := range codeHashInOutput { + if h.IsEmpty() || codeInOutput[addr] || sdSet[addr] { + continue + } + rr := vm.Read(addr, state.CodePath, accounts.NilKey, txIndex+1) + if rr.Status() != state.MVReadResultDone || rr.Version().TxIndex != txIndex { + continue + } + code, ok := rr.Value().([]byte) + if !ok || len(code) == 0 { + continue + } + filtered = append(filtered, &state.VersionedWrite{ + Address: addr, + Path: state.CodePath, + Val: code, + Version: state.Version{TxIndex: txIndex, Incarnation: incarnation}, + }) + } + // EIP-161 empty account removal: if an account has Balance=0, Nonce=0, // and empty CodeHash, it should be deleted — not written as a regular // account with zero values. Serial's updateAccount checks Empty() and From 61dd7bc0f0a799f20cb9e87d10a021d338bcceb9 Mon Sep 17 00:00:00 2001 From: mh0lt Date: Tue, 9 Jun 2026 16:57:57 +0000 Subject: [PATCH 2/8] execution/stagedsync: recover dropped 7702 CodePath via stateReader too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier recovery only re-emitted code found in THIS tx's versionMap (rr.Version().TxIndex == txIndex). On the real failure path that guard misses: a re-executing 7702 delegation whose code equals the already- committed designator makes IBS.SetCode short-circuit (bytes.Equal), so the validated incarnation writes no CodePath and the prior incarnation's versionMap entry is invalidated on re-exec — the versionMap holds nothing for this tx. The fill-missing loop still fills CodeHashPath from committed state, so the account persists a codeHash with no code; a later 7702 sender then reads empty code and is wrongly rejected "sender not an eoa" (observed re-executing mainnet blocks 25277235 / 25279079 / 25280960). Recover the designator from the versionMap, else fall back to the post-state via stateReader.ReadAccountCode (mirroring how CodeHashPath is recovered). Gate emission on types.ParseDelegation so only 7702 designators are re-emitted — never ordinary unchanged contract code for a touched contract (no write amplification, no callee-code misattribution). This prevents the drop during forward execution. It cannot repair state already collated into immutable snapshots with codeHash-but-no-code; that needs a snapshot unwind (separate, in development). Adds TestNormalizeWriteSet_CodePathRecoveredFromStateReader for the short-circuit/stateReader path; the existing versionMap-path test stays. Co-Authored-By: Claude Opus 4.8 --- execution/stagedsync/exec3_finalize_test.go | 45 +++++++++++++++++++++ execution/stagedsync/exec3_parallel.go | 41 ++++++++++++++----- 2 files changed, 76 insertions(+), 10 deletions(-) diff --git a/execution/stagedsync/exec3_finalize_test.go b/execution/stagedsync/exec3_finalize_test.go index 608769ef980..a38ea47cf3d 100644 --- a/execution/stagedsync/exec3_finalize_test.go +++ b/execution/stagedsync/exec3_finalize_test.go @@ -1887,6 +1887,51 @@ func TestNormalizeWriteSet_CodePathTravelsWithCodeHash(t *testing.T) { assert.Equal(t, designator, gotCode, "recovered code is the 7702 designator bytes") } +// The SetCode short-circuit variant: the designator is already committed (so a +// re-delegating tx's SetCode short-circuits and the versionMap holds NO +// CodePath for this tx at all). The fill-missing loop still fills CodeHashPath +// from committed state, so recovery must fall back to stateReader.ReadAccountCode +// — the versionMap path alone (the original fix) would miss this and persist a +// codeHash with no code. +func TestNormalizeWriteSet_CodePathRecoveredFromStateReader(t *testing.T) { + vm := state.NewVersionMap(nil) + authority := accounts.InternAddress([20]byte{0x42}) + designator := types.AddressToDelegation(accounts.InternAddress([20]byte{0x69, 0x00, 0x77, 0x02})) + designatorHash := accounts.InternCodeHash(crypto.Keccak256Hash(designator)) + + const txIndex = 5 + + // authority is an already-committed 7702 delegation: its designator code + + // codeHash live in committed state, NOT in this batch's versionMap. + reader := newMapStateReader() + reader.accounts[authority] = &accounts.Account{Nonce: 1, CodeHash: designatorHash} + reader.code[authority] = designator + + // Re-delegating tx: SetCode short-circuits (code unchanged), so the only + // write is the nonce bump — no CodePath/CodeHashPath, and nothing for + // CodePath in the versionMap. + vm.FlushVersionedWrites(state.VersionedWrites{ + {Address: authority, Path: state.NoncePath, Val: uint64(2), + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + }, true, "") + rawWrites := state.VersionedWrites{ + {Address: authority, Path: state.NoncePath, Val: uint64(2), + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + } + + result := normalizeWriteSet(rawWrites, vm, txIndex, 0, reader, nil, true) + + require.Equal(t, 1, countPath(result, state.CodeHashPath), + "codeHash is filled from committed state for the modified account") + require.Equal(t, 1, countPath(result, state.CodePath), + "CodePath must be recovered via stateReader when the versionMap has none") + for _, w := range result { + if w.Path == state.CodePath { + assert.Equal(t, designator, w.Val.([]byte), "recovered code is the committed designator") + } + } +} + // TestCalcFees_EmitsAddressPathForCoinbase pins the fix for the mainnet // block 25151825 tx 31 +25k gas bug. Background: // diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 526977935a9..79190ebdcfd 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -3422,10 +3422,13 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd // account then persists a non-empty codeHash with no code bytes; a later // block's GetCode/GetDelegatedDesignation reads empty, and for a 7702 sender // the EIP-3607 check wrongly rejects the tx ("sender not an eoa"). Recover - // the code this tx wrote from the versionMap — regardless of incarnation, - // scoped to this tx (Version().TxIndex == txIndex) so we never re-emit a - // prior tx's code for a merely-touched contract — mirroring CodeHashPath so - // code can never be lost while its hash survives. + // the designator code (versionMap, else the post-state via stateReader) and + // re-emit CodePath, mirroring CodeHashPath so a delegation's code can never + // be lost while its hash survives. Bounded to 7702 designators (see below) + // so we never re-emit ordinary unchanged contract code for a touched + // contract. NOTE: this prevents the drop during forward execution; it + // cannot repair state already collated into immutable snapshot files with + // codeHash-but-no-code (that needs a snapshot unwind). codeInOutput := make(map[accounts.Address]bool) codeHashInOutput := make(map[accounts.Address]accounts.CodeHash) for _, w := range filtered { @@ -3442,12 +3445,30 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd if h.IsEmpty() || codeInOutput[addr] || sdSet[addr] { continue } - rr := vm.Read(addr, state.CodePath, accounts.NilKey, txIndex+1) - if rr.Status() != state.MVReadResultDone || rr.Version().TxIndex != txIndex { - continue - } - code, ok := rr.Value().([]byte) - if !ok || len(code) == 0 { + // Recover the code whose hash this tx emitted. Prefer the versionMap + // (this batch's writes); on the SetCode short-circuit path — a + // re-executing 7702 delegation whose code equals the already-committed + // designator, so the validated incarnation writes no CodePath and the + // prior incarnation's versionMap entry was invalidated on re-exec — the + // versionMap holds nothing for this tx, so fall back to the post-state + // via stateReader. + var code []byte + if rr := vm.Read(addr, state.CodePath, accounts.NilKey, txIndex+1); rr.Status() == state.MVReadResultDone { + if c, ok := rr.Value().([]byte); ok { + code = c + } + } + if len(code) == 0 && stateReader != nil { + if c, err := stateReader.ReadAccountCode(addr); err == nil { + code = c + } + } + // Only recover EIP-7702 delegation designators — the demonstrated drop + // (a 7702 sender wrongly rejected as "not an eoa"). Gating on the + // designator shape keeps this from re-emitting ordinary unchanged + // contract code for every modified contract (write amplification with no + // correctness benefit) and never misattributes a callee's code. + if _, ok := types.ParseDelegation(code); !ok { continue } filtered = append(filtered, &state.VersionedWrite{ From ef87408fc3f00fb72fdbada4f0445585b3314756 Mon Sep 17 00:00:00 2001 From: mh0lt Date: Fri, 12 Jun 2026 11:21:18 +0000 Subject: [PATCH 3/8] execution/stagedsync: recover dropped CodePath for created contracts, not just 7702 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CodePath recovery in normalizeWriteSet was bounded to EIP-7702 designators (types.ParseDelegation), so an ordinary CREATE/CREATE2 contract whose validated incarnation's SetCode short-circuited (re-execution: so.Code() already returns the prior incarnation's bytes) had its stale-incarnation CodePath dropped by the incarnation filter while CodeHashPath survived — persisting a non-empty codeHash with no code (codeHash-no-code). A later block that CALLs the contract then runs it as empty and diverges: mainnet 25291004 deploys a SafeProxyFactory CREATE2 proxy whose code is dropped; block 25297789 calls it, the call reverts on the missing code, and the block fails with a gas mismatch (−346,536 gas) — a deterministic wedge under both parallel and serial re-exec. Generalize the recovery: when the versionMap holds CodePath for the address (the code was written in THIS block — a deploy, code change, or 7702 designator), recover it unconditionally; it is always genuine in-block code, never an unchanged contract's bytes. Keep the ParseDelegation gate only on the stateReader fallback (versionMap miss), since stateReader also returns an unchanged contract's existing code and re-emitting that for every touched contract would be write amplification. Forward-prevention only — cannot repair codeHash-no-code already collated into immutable snapshot files (that needs a snapshot unwind). Adds TestNormalizeWriteSet_CodePathRecoveredForCreatedContract (ordinary bytecode, versionMap-hit path; fails under the old 7702-only gate). Co-Authored-By: Claude Opus 4.8 --- execution/stagedsync/exec3_finalize_test.go | 68 +++++++++++++++++ execution/stagedsync/exec3_parallel.go | 85 +++++++++++---------- 2 files changed, 111 insertions(+), 42 deletions(-) diff --git a/execution/stagedsync/exec3_finalize_test.go b/execution/stagedsync/exec3_finalize_test.go index a38ea47cf3d..4bacae985b5 100644 --- a/execution/stagedsync/exec3_finalize_test.go +++ b/execution/stagedsync/exec3_finalize_test.go @@ -1932,6 +1932,74 @@ func TestNormalizeWriteSet_CodePathRecoveredFromStateReader(t *testing.T) { } } +// The CREATE/CREATE2 variant of the codeHash-no-code drop: an ORDINARY contract +// (not a 7702 designator) deployed this block, re-executed so the validated +// incarnation's SetCode short-circuits and only the stale-incarnation CodePath +// survives in the raw writeset (dropped by the incarnation filter) while +// CodeHashPath is kept. The versionMap holds the code (written this block), so +// recovery must fire for ordinary code too — the original ParseDelegation gate +// would have skipped it, persisting a codeHash with no code (mainnet 25291004: +// a SafeProxyFactory CREATE2 proxy, called and reverted 6785 blocks later at +// 25297789). +func TestNormalizeWriteSet_CodePathRecoveredForCreatedContract(t *testing.T) { + vm := state.NewVersionMap(nil) + contract := accounts.InternAddress([20]byte{0x8e, 0x75, 0x5f, 0x34}) + + // Ordinary contract bytecode (standard Solidity prologue) — NOT a 7702 + // designator, so types.ParseDelegation rejects it. + code := []byte{0x60, 0x80, 0x60, 0x40, 0x52, 0x34, 0x80, 0x15, 0x61, 0x00, 0x10} + if _, ok := types.ParseDelegation(code); ok { + t.Fatal("test fixture must not be a 7702 designator") + } + codeHash := accounts.InternCodeHash(crypto.Keccak256Hash(code)) + + const txIndex = 7 + + // Incarnation 0 deploys: code + its hash + nonce=1 (EIP-161 CREATE bump). + vm.FlushVersionedWrites(state.VersionedWrites{ + {Address: contract, Path: state.CodePath, Val: code, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + {Address: contract, Path: state.CodeHashPath, Val: codeHash, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + {Address: contract, Path: state.NoncePath, Val: uint64(1), + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + }, true, "") + + // Incarnation 1 (validated) re-executes; SetCode short-circuits → only nonce. + vm.FlushVersionedWrites(state.VersionedWrites{ + {Address: contract, Path: state.NoncePath, Val: uint64(1), + Version: state.Version{TxIndex: txIndex, Incarnation: 1}}, + }, true, "") + + rawWrites := state.VersionedWrites{ + {Address: contract, Path: state.NoncePath, Val: uint64(1), + Version: state.Version{TxIndex: txIndex, Incarnation: 1}}, + {Address: contract, Path: state.CodePath, Val: code, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, // stale incarnation + {Address: contract, Path: state.CodeHashPath, Val: codeHash, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, // stale incarnation + } + + result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true) + + var gotCode []byte + var gotHash *accounts.CodeHash + for _, w := range result { + switch w.Path { + case state.CodePath: + gotCode = w.Val.([]byte) + case state.CodeHashPath: + h := w.Val.(accounts.CodeHash) + gotHash = &h + } + } + require.NotNil(t, gotHash, "codeHash must be present") + assert.Equal(t, codeHash, *gotHash) + require.Equal(t, 1, countPath(result, state.CodePath), + "ordinary created-contract code must be recovered, not just 7702 designators") + assert.Equal(t, code, gotCode, "recovered code is the deployed bytecode") +} + // TestCalcFees_EmitsAddressPathForCoinbase pins the fix for the mainnet // block 25151825 tx 31 +25k gas bug. Background: // diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 79190ebdcfd..8a50d6f93a8 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -3413,22 +3413,26 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd // fill-missing loop above both recover an account's codeHash from the // versionMap, but CodePath has no equivalent recovery: the case above keeps // it only when this tx's CodePath write is present at the validated - // incarnation (the incarnation filter), and the fill loop never emits it. - // So a tx whose validated writeset lacks a fresh CodePath — e.g. an EIP-7702 - // delegating tx that re-executes: SetCode short-circuits because so.Code() - // already returns the designator written by the prior incarnation - // (bytes.Equal(prevcode, code)), so the validated incarnation emits no new - // CodePath — still recovers CodeHashPath here while CodePath is dropped. The - // account then persists a non-empty codeHash with no code bytes; a later - // block's GetCode/GetDelegatedDesignation reads empty, and for a 7702 sender - // the EIP-3607 check wrongly rejects the tx ("sender not an eoa"). Recover - // the designator code (versionMap, else the post-state via stateReader) and - // re-emit CodePath, mirroring CodeHashPath so a delegation's code can never - // be lost while its hash survives. Bounded to 7702 designators (see below) - // so we never re-emit ordinary unchanged contract code for a touched - // contract. NOTE: this prevents the drop during forward execution; it - // cannot repair state already collated into immutable snapshot files with - // codeHash-but-no-code (that needs a snapshot unwind). + // incarnation (the incarnation filter), and the fill loop never emits it. So a + // tx whose validated writeset lacks a fresh CodePath persists a non-empty + // codeHash with no code bytes (codeHash-no-code) — and a later block that + // CALLs the contract executes it as empty and diverges (wrong gas / wrong + // root). Two ways the fresh CodePath goes missing, both on re-execution where + // SetCode short-circuits (so.Code() already returns the prior incarnation's + // bytes, bytes.Equal): + // - CREATE/CREATE2 of an ordinary contract (mainnet 25291004: a Safe proxy + // deployed via the SafeProxyFactory, called 6785 blocks later at 25297789); + // - an EIP-7702 delegating tx (the 7702 sender then fails EIP-3607 as "not an + // eoa"). + // Recover the code whose hash this tx emitted so code can never be lost while + // its hash survives. The versionMap holds CodePath iff the code was written in + // THIS block, so a versionMap hit is always genuine in-block code — recover it + // for any contract. Only the stateReader fallback (versionMap miss) is bounded + // to 7702 designators, since stateReader also returns an unchanged contract's + // existing code and re-emitting that for every touched contract would be write + // amplification with no correctness benefit. NOTE: forward-prevention only; it + // cannot repair codeHash-no-code already collated into immutable snapshot files + // (that needs a snapshot unwind). codeInOutput := make(map[accounts.Address]bool) codeHashInOutput := make(map[accounts.Address]accounts.CodeHash) for _, w := range filtered { @@ -3441,42 +3445,39 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd } } } + emit := func(addr accounts.Address, code []byte) { + filtered = append(filtered, &state.VersionedWrite{ + Address: addr, + Path: state.CodePath, + Val: code, + Version: state.Version{TxIndex: txIndex, Incarnation: incarnation}, + }) + } for addr, h := range codeHashInOutput { if h.IsEmpty() || codeInOutput[addr] || sdSet[addr] { continue } - // Recover the code whose hash this tx emitted. Prefer the versionMap - // (this batch's writes); on the SetCode short-circuit path — a - // re-executing 7702 delegation whose code equals the already-committed - // designator, so the validated incarnation writes no CodePath and the - // prior incarnation's versionMap entry was invalidated on re-exec — the - // versionMap holds nothing for this tx, so fall back to the post-state - // via stateReader. - var code []byte + // versionMap hit = the code was written in THIS block (deploy / code change + // / 7702 designator); recover it for any contract — it's genuine in-block + // code, never an unchanged contract's bytes. if rr := vm.Read(addr, state.CodePath, accounts.NilKey, txIndex+1); rr.Status() == state.MVReadResultDone { - if c, ok := rr.Value().([]byte); ok { - code = c + if c, ok := rr.Value().([]byte); ok && len(c) > 0 { + emit(addr, c) + continue } } - if len(code) == 0 && stateReader != nil { + // versionMap miss (e.g. a re-executing 7702 delegation whose prior + // incarnation's CodePath entry was invalidated): fall back to the committed + // post-state, bounded to 7702 designators — stateReader returns an + // unchanged contract's existing code too, and re-emitting that for every + // touched contract is write amplification with no correctness benefit. + if stateReader != nil { if c, err := stateReader.ReadAccountCode(addr); err == nil { - code = c + if _, ok := types.ParseDelegation(c); ok { + emit(addr, c) + } } } - // Only recover EIP-7702 delegation designators — the demonstrated drop - // (a 7702 sender wrongly rejected as "not an eoa"). Gating on the - // designator shape keeps this from re-emitting ordinary unchanged - // contract code for every modified contract (write amplification with no - // correctness benefit) and never misattributes a callee's code. - if _, ok := types.ParseDelegation(code); !ok { - continue - } - filtered = append(filtered, &state.VersionedWrite{ - Address: addr, - Path: state.CodePath, - Val: code, - Version: state.Version{TxIndex: txIndex, Incarnation: incarnation}, - }) } // EIP-161 empty account removal: if an account has Balance=0, Nonce=0, From 9dc83e5c5bb56cd16d1508d21ec2544f0b9aa67a Mon Sep 17 00:00:00 2001 From: mh0lt Date: Fri, 12 Jun 2026 18:43:42 +0000 Subject: [PATCH 4/8] execution/stagedsync: validate recovered code against its hash; trim comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses review on #21706: - Copilot: the CodePath recovery now validates that the recovered code hashes to the CodeHashPath it is recovered for; a mismatch is skipped rather than emitted, so recovery can never write code that disagrees with its hash. - AskAlexSharov: drop the cryptic InternCodeHash(hh) != h comparison — compare crypto.Keccak256Hash(code) against the codeHash's raw Value() (no interning, no heap escape). - Comment policy: trim the recovery comment to the invariant + gating rationale; the forward-only limitation and the mainnet reproduction move to the PR body. New test TestNormalizeWriteSet_CodePathRecoveryRejectsHashMismatch pins the reject-on-mismatch guard. Co-Authored-By: Claude Opus 4.8 --- execution/stagedsync/exec3_finalize_test.go | 37 +++++++++++++ execution/stagedsync/exec3_parallel.go | 57 ++++++++------------- 2 files changed, 58 insertions(+), 36 deletions(-) diff --git a/execution/stagedsync/exec3_finalize_test.go b/execution/stagedsync/exec3_finalize_test.go index 4bacae985b5..6c65851daec 100644 --- a/execution/stagedsync/exec3_finalize_test.go +++ b/execution/stagedsync/exec3_finalize_test.go @@ -2000,6 +2000,43 @@ func TestNormalizeWriteSet_CodePathRecoveredForCreatedContract(t *testing.T) { assert.Equal(t, code, gotCode, "recovered code is the deployed bytecode") } +// Recovery must reject code that does not hash to the emitted CodeHashPath: +// emitting mismatched code would itself break code/codeHash consistency, so a +// candidate whose keccak != the recovered hash is skipped, not written. +func TestNormalizeWriteSet_CodePathRecoveryRejectsHashMismatch(t *testing.T) { + vm := state.NewVersionMap(nil) + addr := accounts.InternAddress([20]byte{0x42}) + + codeA := types.AddressToDelegation(accounts.InternAddress([20]byte{0xaa})) + hashA := accounts.InternCodeHash(crypto.Keccak256Hash(codeA)) + codeB := types.AddressToDelegation(accounts.InternAddress([20]byte{0xbb})) // different code + + const txIndex = 5 + + // The versionMap holds codeB for this tx, but the output's CodeHashPath is + // hashA — the candidate code disagrees with the hash being recovered. + vm.FlushVersionedWrites(state.VersionedWrites{ + {Address: addr, Path: state.CodePath, Val: codeB, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + {Address: addr, Path: state.CodeHashPath, Val: hashA, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + {Address: addr, Path: state.NoncePath, Val: uint64(1), + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + }, true, "") + + rawWrites := state.VersionedWrites{ + {Address: addr, Path: state.NoncePath, Val: uint64(1), + Version: state.Version{TxIndex: txIndex, Incarnation: 1}}, + {Address: addr, Path: state.CodeHashPath, Val: hashA, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + } + + result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true) + + require.Equal(t, 0, countPath(result, state.CodePath), + "code whose keccak != the recovered codeHash must not be emitted") +} + // TestCalcFees_EmitsAddressPathForCoinbase pins the fix for the mainnet // block 25151825 tx 31 +25k gas bug. Background: // diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 8a50d6f93a8..9591581f182 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -18,6 +18,7 @@ import ( "golang.org/x/sync/errgroup" "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/common/crypto" "github.com/erigontech/erigon/common/dbg" "github.com/erigontech/erigon/common/log/v3" "github.com/erigontech/erigon/db/consensuschain" @@ -3409,30 +3410,16 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd } } - // CodePath travels with CodeHashPath. The CodeHashPath case and the - // fill-missing loop above both recover an account's codeHash from the - // versionMap, but CodePath has no equivalent recovery: the case above keeps - // it only when this tx's CodePath write is present at the validated - // incarnation (the incarnation filter), and the fill loop never emits it. So a - // tx whose validated writeset lacks a fresh CodePath persists a non-empty - // codeHash with no code bytes (codeHash-no-code) — and a later block that - // CALLs the contract executes it as empty and diverges (wrong gas / wrong - // root). Two ways the fresh CodePath goes missing, both on re-execution where - // SetCode short-circuits (so.Code() already returns the prior incarnation's - // bytes, bytes.Equal): - // - CREATE/CREATE2 of an ordinary contract (mainnet 25291004: a Safe proxy - // deployed via the SafeProxyFactory, called 6785 blocks later at 25297789); - // - an EIP-7702 delegating tx (the 7702 sender then fails EIP-3607 as "not an - // eoa"). - // Recover the code whose hash this tx emitted so code can never be lost while - // its hash survives. The versionMap holds CodePath iff the code was written in - // THIS block, so a versionMap hit is always genuine in-block code — recover it - // for any contract. Only the stateReader fallback (versionMap miss) is bounded - // to 7702 designators, since stateReader also returns an unchanged contract's - // existing code and re-emitting that for every touched contract would be write - // amplification with no correctness benefit. NOTE: forward-prevention only; it - // cannot repair codeHash-no-code already collated into immutable snapshot files - // (that needs a snapshot unwind). + // CodePath must travel with CodeHashPath. The CodeHashPath case and the + // fill-missing loop recover a codeHash from the versionMap, but nothing + // recovers CodePath: a tx whose validated writeset lacks a fresh CodePath + // (SetCode short-circuits on re-execution — bytes.Equal of the prior + // incarnation's code) would persist a non-empty codeHash with no code, and a + // later CALL would execute it as empty (wrong gas/root). A versionMap hit means + // the code was written in this block — recover it for any contract. On a miss, + // fall back to committed state only for 7702 designators: stateReader also + // returns an unchanged contract's code, and re-emitting that for every touched + // contract is write amplification with no correctness benefit. codeInOutput := make(map[accounts.Address]bool) codeHashInOutput := make(map[accounts.Address]accounts.CodeHash) for _, w := range filtered { @@ -3445,36 +3432,34 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd } } } - emit := func(addr accounts.Address, code []byte) { + // emit recovers code only if it hashes to the codeHash being recovered: + // emitting code that doesn't match its hash would itself break code/codeHash + // consistency, so a mismatch is skipped. + emit := func(addr accounts.Address, code []byte, want accounts.CodeHash) bool { + if crypto.Keccak256Hash(code) != want.Value() { + return false + } filtered = append(filtered, &state.VersionedWrite{ Address: addr, Path: state.CodePath, Val: code, Version: state.Version{TxIndex: txIndex, Incarnation: incarnation}, }) + return true } for addr, h := range codeHashInOutput { if h.IsEmpty() || codeInOutput[addr] || sdSet[addr] { continue } - // versionMap hit = the code was written in THIS block (deploy / code change - // / 7702 designator); recover it for any contract — it's genuine in-block - // code, never an unchanged contract's bytes. if rr := vm.Read(addr, state.CodePath, accounts.NilKey, txIndex+1); rr.Status() == state.MVReadResultDone { - if c, ok := rr.Value().([]byte); ok && len(c) > 0 { - emit(addr, c) + if c, ok := rr.Value().([]byte); ok && len(c) > 0 && emit(addr, c, h) { continue } } - // versionMap miss (e.g. a re-executing 7702 delegation whose prior - // incarnation's CodePath entry was invalidated): fall back to the committed - // post-state, bounded to 7702 designators — stateReader returns an - // unchanged contract's existing code too, and re-emitting that for every - // touched contract is write amplification with no correctness benefit. if stateReader != nil { if c, err := stateReader.ReadAccountCode(addr); err == nil { if _, ok := types.ParseDelegation(c); ok { - emit(addr, c) + emit(addr, c, h) } } } From 0f11be0b657c6d44b24d116fdb94d89c3fc0881e Mon Sep 17 00:00:00 2001 From: Mark Holt Date: Mon, 22 Jun 2026 16:47:50 +0000 Subject: [PATCH 5/8] execution/stagedsync: narrow CodePath recovery gate + trim docstrings Gate the recovery loop on the address having a CodePath/CodeHashPath entry in the raw writeset, and log when it fires. When the codeHash reaches the output via the fill-missing loop the code is already committed in CodeDomain, so recovery would be redundant and would otherwise iterate stateReader.ReadAccountCode for every touched non-empty-code account. Flip TestNormalizeWriteSet_CodePathRecoveredFromStateReader to assert no CodePath emission, matching the narrowed gate. Strip the false "blockIO.WriteSet retains both incarnations' entries" mechanism claim and the mainnet block-number forensics from the other docstrings per the project comment policy. --- execution/stagedsync/exec3_finalize_test.go | 70 +++++---------------- execution/stagedsync/exec3_parallel.go | 27 ++++---- 2 files changed, 27 insertions(+), 70 deletions(-) diff --git a/execution/stagedsync/exec3_finalize_test.go b/execution/stagedsync/exec3_finalize_test.go index 6c65851daec..b8cf86ae267 100644 --- a/execution/stagedsync/exec3_finalize_test.go +++ b/execution/stagedsync/exec3_finalize_test.go @@ -1811,30 +1811,18 @@ func countPath(writes state.VersionedWrites, path state.AccountPath) int { return n } -// TestNormalizeWriteSet_CodePathTravelsWithCodeHash reproduces the EIP-7702 -// write-drop that wrongly rejected a delegated sender with "sender not an eoa". -// -// A delegating tx re-executes in parallel. On the re-exec, SetCode short-circuits -// (so.Code() already returns the designator written by the prior incarnation, so -// bytes.Equal(prevcode, code) is true), so the validated incarnation emits the -// authority's nonce bump but NO fresh CodePath/CodeHashPath. The raw writeset -// therefore carries the stale prior-incarnation code writes. normalizeWriteSet -// keeps CodeHashPath (resolved from the versionMap) but the incarnation filter -// drops the stale CodePath — leaving a codeHash with no code. The account then -// persists a non-empty codeHash whose code bytes are missing from CodeDomain, so -// a later block's GetDelegatedDesignation reads empty and EIP-3607 rejects the -// 7702 sender. CodePath must be recovered so code always travels with its hash. +// Pins normalizeWriteSet's contract that an EIP-7702 designator never produces +// a CodeHashPath without its matching CodePath, even when the raw writeset only +// carries stale-incarnation code entries. func TestNormalizeWriteSet_CodePathTravelsWithCodeHash(t *testing.T) { vm := state.NewVersionMap(nil) authority := accounts.InternAddress([20]byte{0x42}) - // EIP-7702 delegation designator: 0xef0100 || target(20 bytes). designator := types.AddressToDelegation(accounts.InternAddress([20]byte{0x69, 0x00, 0x77, 0x02})) designatorHash := accounts.InternCodeHash(crypto.Keccak256Hash(designator)) const txIndex = 5 - // Incarnation 0 delegates: designator code + its hash + authority nonce bump. vm.FlushVersionedWrites(state.VersionedWrites{ {Address: authority, Path: state.CodePath, Val: designator, Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, @@ -1844,16 +1832,11 @@ func TestNormalizeWriteSet_CodePathTravelsWithCodeHash(t *testing.T) { Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, }, true, "") - // Incarnation 1 (validated) re-executes; SetCode short-circuits, so only the - // nonce is re-emitted — no fresh CodePath/CodeHashPath. vm.FlushVersionedWrites(state.VersionedWrites{ {Address: authority, Path: state.NoncePath, Val: uint64(1), Version: state.Version{TxIndex: txIndex, Incarnation: 1}}, }, true, "") - // blockIO.WriteSet retains both incarnations' entries (versionMap doesn't - // clear old), so the validated tx's raw writeset carries the stale inc-0 - // code writes alongside the inc-1 nonce. rawWrites := state.VersionedWrites{ {Address: authority, Path: state.NoncePath, Val: uint64(1), Version: state.Version{TxIndex: txIndex, Incarnation: 1}}, @@ -1887,12 +1870,9 @@ func TestNormalizeWriteSet_CodePathTravelsWithCodeHash(t *testing.T) { assert.Equal(t, designator, gotCode, "recovered code is the 7702 designator bytes") } -// The SetCode short-circuit variant: the designator is already committed (so a -// re-delegating tx's SetCode short-circuits and the versionMap holds NO -// CodePath for this tx at all). The fill-missing loop still fills CodeHashPath -// from committed state, so recovery must fall back to stateReader.ReadAccountCode -// — the versionMap path alone (the original fix) would miss this and persist a -// codeHash with no code. +// Pins that recovery does NOT fire when the raw writeset has no +// CodePath/CodeHashPath entry: the committed designator is already in +// CodeDomain, so re-emitting CodePath would be redundant write-amplification. func TestNormalizeWriteSet_CodePathRecoveredFromStateReader(t *testing.T) { vm := state.NewVersionMap(nil) authority := accounts.InternAddress([20]byte{0x42}) @@ -1901,15 +1881,10 @@ func TestNormalizeWriteSet_CodePathRecoveredFromStateReader(t *testing.T) { const txIndex = 5 - // authority is an already-committed 7702 delegation: its designator code + - // codeHash live in committed state, NOT in this batch's versionMap. reader := newMapStateReader() reader.accounts[authority] = &accounts.Account{Nonce: 1, CodeHash: designatorHash} reader.code[authority] = designator - // Re-delegating tx: SetCode short-circuits (code unchanged), so the only - // write is the nonce bump — no CodePath/CodeHashPath, and nothing for - // CodePath in the versionMap. vm.FlushVersionedWrites(state.VersionedWrites{ {Address: authority, Path: state.NoncePath, Val: uint64(2), Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, @@ -1923,30 +1898,16 @@ func TestNormalizeWriteSet_CodePathRecoveredFromStateReader(t *testing.T) { require.Equal(t, 1, countPath(result, state.CodeHashPath), "codeHash is filled from committed state for the modified account") - require.Equal(t, 1, countPath(result, state.CodePath), - "CodePath must be recovered via stateReader when the versionMap has none") - for _, w := range result { - if w.Path == state.CodePath { - assert.Equal(t, designator, w.Val.([]byte), "recovered code is the committed designator") - } - } + require.Equal(t, 0, countPath(result, state.CodePath), + "no raw CodePath/CodeHashPath entry — recovery must skip; code lives in CodeDomain") } -// The CREATE/CREATE2 variant of the codeHash-no-code drop: an ORDINARY contract -// (not a 7702 designator) deployed this block, re-executed so the validated -// incarnation's SetCode short-circuits and only the stale-incarnation CodePath -// survives in the raw writeset (dropped by the incarnation filter) while -// CodeHashPath is kept. The versionMap holds the code (written this block), so -// recovery must fire for ordinary code too — the original ParseDelegation gate -// would have skipped it, persisting a codeHash with no code (mainnet 25291004: -// a SafeProxyFactory CREATE2 proxy, called and reverted 6785 blocks later at -// 25297789). +// Pins that recovery covers ordinary CREATE/CREATE2 code, not just EIP-7702 +// designators: the versionMap-hit branch must not gate on ParseDelegation. func TestNormalizeWriteSet_CodePathRecoveredForCreatedContract(t *testing.T) { vm := state.NewVersionMap(nil) contract := accounts.InternAddress([20]byte{0x8e, 0x75, 0x5f, 0x34}) - // Ordinary contract bytecode (standard Solidity prologue) — NOT a 7702 - // designator, so types.ParseDelegation rejects it. code := []byte{0x60, 0x80, 0x60, 0x40, 0x52, 0x34, 0x80, 0x15, 0x61, 0x00, 0x10} if _, ok := types.ParseDelegation(code); ok { t.Fatal("test fixture must not be a 7702 designator") @@ -1955,7 +1916,6 @@ func TestNormalizeWriteSet_CodePathRecoveredForCreatedContract(t *testing.T) { const txIndex = 7 - // Incarnation 0 deploys: code + its hash + nonce=1 (EIP-161 CREATE bump). vm.FlushVersionedWrites(state.VersionedWrites{ {Address: contract, Path: state.CodePath, Val: code, Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, @@ -1965,7 +1925,6 @@ func TestNormalizeWriteSet_CodePathRecoveredForCreatedContract(t *testing.T) { Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, }, true, "") - // Incarnation 1 (validated) re-executes; SetCode short-circuits → only nonce. vm.FlushVersionedWrites(state.VersionedWrites{ {Address: contract, Path: state.NoncePath, Val: uint64(1), Version: state.Version{TxIndex: txIndex, Incarnation: 1}}, @@ -1975,9 +1934,9 @@ func TestNormalizeWriteSet_CodePathRecoveredForCreatedContract(t *testing.T) { {Address: contract, Path: state.NoncePath, Val: uint64(1), Version: state.Version{TxIndex: txIndex, Incarnation: 1}}, {Address: contract, Path: state.CodePath, Val: code, - Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, // stale incarnation + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, {Address: contract, Path: state.CodeHashPath, Val: codeHash, - Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, // stale incarnation + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, } result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true) @@ -2000,9 +1959,8 @@ func TestNormalizeWriteSet_CodePathRecoveredForCreatedContract(t *testing.T) { assert.Equal(t, code, gotCode, "recovered code is the deployed bytecode") } -// Recovery must reject code that does not hash to the emitted CodeHashPath: -// emitting mismatched code would itself break code/codeHash consistency, so a -// candidate whose keccak != the recovered hash is skipped, not written. +// Pins that recovery rejects candidate code whose keccak does not match the +// emitted CodeHashPath, so a mismatched recovery never persists. func TestNormalizeWriteSet_CodePathRecoveryRejectsHashMismatch(t *testing.T) { vm := state.NewVersionMap(nil) addr := accounts.InternAddress([20]byte{0x42}) diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 9591581f182..5ec29bf34fb 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -3410,16 +3410,16 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd } } - // CodePath must travel with CodeHashPath. The CodeHashPath case and the - // fill-missing loop recover a codeHash from the versionMap, but nothing - // recovers CodePath: a tx whose validated writeset lacks a fresh CodePath - // (SetCode short-circuits on re-execution — bytes.Equal of the prior - // incarnation's code) would persist a non-empty codeHash with no code, and a - // later CALL would execute it as empty (wrong gas/root). A versionMap hit means - // the code was written in this block — recover it for any contract. On a miss, - // fall back to committed state only for 7702 designators: stateReader also - // returns an unchanged contract's code, and re-emitting that for every touched - // contract is write amplification with no correctness benefit. + // CodePath must travel with CodeHashPath; gated on raw-writeset + // CodePath/CodeHashPath presence to skip the fill-missing-loop case where + // the code is already in CodeDomain. + codeAddrInRaw := make(map[accounts.Address]bool) + for _, w := range writes { + switch w.Path { + case state.CodePath, state.CodeHashPath: + codeAddrInRaw[w.Address] = true + } + } codeInOutput := make(map[accounts.Address]bool) codeHashInOutput := make(map[accounts.Address]accounts.CodeHash) for _, w := range filtered { @@ -3432,9 +3432,6 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd } } } - // emit recovers code only if it hashes to the codeHash being recovered: - // emitting code that doesn't match its hash would itself break code/codeHash - // consistency, so a mismatch is skipped. emit := func(addr accounts.Address, code []byte, want accounts.CodeHash) bool { if crypto.Keccak256Hash(code) != want.Value() { return false @@ -3445,10 +3442,12 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd Val: code, Version: state.Version{TxIndex: txIndex, Incarnation: incarnation}, }) + log.Warn("[codepath-recovery] re-emitted dropped CodePath", + "addr", common.Address(addr.Value()), "txIndex", txIndex, "incarnation", incarnation) return true } for addr, h := range codeHashInOutput { - if h.IsEmpty() || codeInOutput[addr] || sdSet[addr] { + if h.IsEmpty() || codeInOutput[addr] || sdSet[addr] || !codeAddrInRaw[addr] { continue } if rr := vm.Read(addr, state.CodePath, accounts.NilKey, txIndex+1); rr.Status() == state.MVReadResultDone { From 68d589733089e6ca6d0cf3366e1e24bdcf4ae224 Mon Sep 17 00:00:00 2001 From: Alexey Sharov Date: Tue, 23 Jun 2026 11:27:08 +0700 Subject: [PATCH 6/8] execution/stagedsync: fix normalizeWriteSet call arity in new tests Add missing isAura=false argument to the 4 new test calls added by this branch; main gained the isAura bool parameter after this branch was authored. --- execution/stagedsync/exec3_finalize_test.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/execution/stagedsync/exec3_finalize_test.go b/execution/stagedsync/exec3_finalize_test.go index b8cf86ae267..3de57733ae9 100644 --- a/execution/stagedsync/exec3_finalize_test.go +++ b/execution/stagedsync/exec3_finalize_test.go @@ -1846,7 +1846,7 @@ func TestNormalizeWriteSet_CodePathTravelsWithCodeHash(t *testing.T) { Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, // stale incarnation } - result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true) + result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true, false) var gotHash *accounts.CodeHash var gotCode []byte @@ -1894,7 +1894,7 @@ func TestNormalizeWriteSet_CodePathRecoveredFromStateReader(t *testing.T) { Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, } - result := normalizeWriteSet(rawWrites, vm, txIndex, 0, reader, nil, true) + result := normalizeWriteSet(rawWrites, vm, txIndex, 0, reader, nil, true, false) require.Equal(t, 1, countPath(result, state.CodeHashPath), "codeHash is filled from committed state for the modified account") @@ -1939,7 +1939,7 @@ func TestNormalizeWriteSet_CodePathRecoveredForCreatedContract(t *testing.T) { Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, } - result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true) + result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true, false) var gotCode []byte var gotHash *accounts.CodeHash @@ -1989,7 +1989,7 @@ func TestNormalizeWriteSet_CodePathRecoveryRejectsHashMismatch(t *testing.T) { Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, } - result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true) + result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true, false) require.Equal(t, 0, countPath(result, state.CodePath), "code whose keccak != the recovered codeHash must not be emitted") From c68af1fce04eb4f057d09f6bfbbc158beac42038 Mon Sep 17 00:00:00 2001 From: mh0lt Date: Tue, 23 Jun 2026 15:13:25 +0000 Subject: [PATCH 7/8] execution/stagedsync: address codepath-recovery review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - log.Debug instead of Warn for the [codepath-recovery] re-emit (it runs in the per-tx hot path; reorg/re-exec shouldn't spam Warn), and drop the redundant common.Address() cast — addr.Value() already returns common.Address. - Rename TestNormalizeWriteSet_CodePathRecoveredFromStateReader → …NoCodePathRecoveryWithoutRawCodeWrite: it asserts recovery does NOT fire when the raw writeset has no code entry, so the old name was misleading. - Make …RejectsHashMismatch non-vacuous: assert the CodeHashPath is present (recovery eligible) so the 0 CodePath is a genuine hash-mismatch rejection. Co-Authored-By: Claude Opus 4.8 --- execution/stagedsync/exec3_finalize_test.go | 6 +++++- execution/stagedsync/exec3_parallel.go | 4 ++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/execution/stagedsync/exec3_finalize_test.go b/execution/stagedsync/exec3_finalize_test.go index 3de57733ae9..b3b048383e6 100644 --- a/execution/stagedsync/exec3_finalize_test.go +++ b/execution/stagedsync/exec3_finalize_test.go @@ -1873,7 +1873,7 @@ func TestNormalizeWriteSet_CodePathTravelsWithCodeHash(t *testing.T) { // Pins that recovery does NOT fire when the raw writeset has no // CodePath/CodeHashPath entry: the committed designator is already in // CodeDomain, so re-emitting CodePath would be redundant write-amplification. -func TestNormalizeWriteSet_CodePathRecoveredFromStateReader(t *testing.T) { +func TestNormalizeWriteSet_NoCodePathRecoveryWithoutRawCodeWrite(t *testing.T) { vm := state.NewVersionMap(nil) authority := accounts.InternAddress([20]byte{0x42}) designator := types.AddressToDelegation(accounts.InternAddress([20]byte{0x69, 0x00, 0x77, 0x02})) @@ -1991,6 +1991,10 @@ func TestNormalizeWriteSet_CodePathRecoveryRejectsHashMismatch(t *testing.T) { result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true, false) + // Recovery was eligible (a CodeHashPath is present with no CodePath), so the + // 0 below is a genuine rejection, not a case where recovery never ran. + require.Equal(t, 1, countPath(result, state.CodeHashPath), + "CodeHashPath must be present so recovery is eligible to run") require.Equal(t, 0, countPath(result, state.CodePath), "code whose keccak != the recovered codeHash must not be emitted") } diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 5ec29bf34fb..8e6399f44a0 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -3442,8 +3442,8 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd Val: code, Version: state.Version{TxIndex: txIndex, Incarnation: incarnation}, }) - log.Warn("[codepath-recovery] re-emitted dropped CodePath", - "addr", common.Address(addr.Value()), "txIndex", txIndex, "incarnation", incarnation) + log.Debug("[codepath-recovery] re-emitted dropped CodePath", + "addr", addr.Value(), "txIndex", txIndex, "incarnation", incarnation) return true } for addr, h := range codeHashInOutput { From 1b8740cbb26bf65992a871d1e35ac936e5e2b778 Mon Sep 17 00:00:00 2001 From: mh0lt Date: Fri, 26 Jun 2026 09:37:44 +0000 Subject: [PATCH 8/8] execution/stagedsync: address review on CodePath recovery - Prealloc codeAddrInRaw/codeInOutput/codeHashInOutput maps (hot path). - Add TestNormalizeWriteSet_CodePathRecoveredFromStateReader covering the stateReader+ParseDelegation fallback when the versionMap misses. - Fix the reversed assertion message (regression is codeHash-without-code). Co-Authored-By: Claude Opus 4.8 --- execution/stagedsync/exec3_finalize_test.go | 50 ++++++++++++++++++++- execution/stagedsync/exec3_parallel.go | 6 +-- 2 files changed, 52 insertions(+), 4 deletions(-) diff --git a/execution/stagedsync/exec3_finalize_test.go b/execution/stagedsync/exec3_finalize_test.go index b3b048383e6..94ff63a376e 100644 --- a/execution/stagedsync/exec3_finalize_test.go +++ b/execution/stagedsync/exec3_finalize_test.go @@ -1866,10 +1866,58 @@ func TestNormalizeWriteSet_CodePathTravelsWithCodeHash(t *testing.T) { // The regression: code was dropped while the hash survived. Code must travel // with its hash so the account is never persisted with a codeHash but no code. require.Equal(t, 1, countPath(result, state.CodePath), - "CodePath must be recovered so code is never persisted without its codeHash") + "CodePath must be recovered so the account is never persisted with a codeHash but no code") assert.Equal(t, designator, gotCode, "recovered code is the 7702 designator bytes") } +// Pins the stateReader fallback: when the versionMap has no CodePath for the +// account (so the vm.Read recovery branch misses), a surviving CodeHashPath +// whose code is an EIP-7702 designator is recovered from +// stateReader.ReadAccountCode and re-emitted as CodePath. This is the +// re-executing-delegation path the versionMap-hit branch cannot cover. +func TestNormalizeWriteSet_CodePathRecoveredFromStateReader(t *testing.T) { + vm := state.NewVersionMap(nil) + authority := accounts.InternAddress([20]byte{0x42}) + + designator := types.AddressToDelegation(accounts.InternAddress([20]byte{0x69, 0x00, 0x77, 0x02})) + designatorHash := accounts.InternCodeHash(crypto.Keccak256Hash(designator)) + + const txIndex = 5 + + // versionMap carries the codeHash and nonce but NOT the CodePath — so the + // vm.Read(CodePath) recovery branch misses and the stateReader fallback runs. + vm.FlushVersionedWrites(state.VersionedWrites{ + {Address: authority, Path: state.CodeHashPath, Val: designatorHash, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + {Address: authority, Path: state.NoncePath, Val: uint64(1), + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + }, true, "") + + // The committed designator lives in CodeDomain, reachable via stateReader. + reader := newMapStateReader() + reader.accounts[authority] = &accounts.Account{Nonce: 1, CodeHash: designatorHash} + reader.code[authority] = designator + + rawWrites := state.VersionedWrites{ + {Address: authority, Path: state.NoncePath, Val: uint64(1), + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + {Address: authority, Path: state.CodeHashPath, Val: designatorHash, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + } + + result := normalizeWriteSet(rawWrites, vm, txIndex, 0, reader, nil, true, false) + + require.Equal(t, 1, countPath(result, state.CodeHashPath), + "codeHash survives so recovery is eligible") + require.Equal(t, 1, countPath(result, state.CodePath), + "CodePath must be recovered from the stateReader when the versionMap misses") + for _, w := range result { + if w.Path == state.CodePath { + assert.Equal(t, designator, w.Val.([]byte), "recovered code is the 7702 designator bytes") + } + } +} + // Pins that recovery does NOT fire when the raw writeset has no // CodePath/CodeHashPath entry: the committed designator is already in // CodeDomain, so re-emitting CodePath would be redundant write-amplification. diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index 8e6399f44a0..5fc8bb11d0d 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -3413,15 +3413,15 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd // CodePath must travel with CodeHashPath; gated on raw-writeset // CodePath/CodeHashPath presence to skip the fill-missing-loop case where // the code is already in CodeDomain. - codeAddrInRaw := make(map[accounts.Address]bool) + codeAddrInRaw := make(map[accounts.Address]bool, len(writes)) for _, w := range writes { switch w.Path { case state.CodePath, state.CodeHashPath: codeAddrInRaw[w.Address] = true } } - codeInOutput := make(map[accounts.Address]bool) - codeHashInOutput := make(map[accounts.Address]accounts.CodeHash) + codeInOutput := make(map[accounts.Address]bool, len(filtered)) + codeHashInOutput := make(map[accounts.Address]accounts.CodeHash, len(filtered)) for _, w := range filtered { switch w.Path { case state.CodePath: