diff --git a/execution/stagedsync/exec3_finalize_test.go b/execution/stagedsync/exec3_finalize_test.go index 7c15b91ec2c..94ff63a376e 100644 --- a/execution/stagedsync/exec3_finalize_test.go +++ b/execution/stagedsync/exec3_finalize_test.go @@ -1811,19 +1811,18 @@ func countPath(writes state.VersionedWrites, path state.AccountPath) int { return n } -// Pins that normalizeWriteSet recovers CodePath alongside CodeHashPath for a -// 7702 designator, so an account is never left with a codeHash but no code. +// 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}}, @@ -1833,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}}, @@ -1872,33 +1866,73 @@ 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") } -// 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 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. +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})) 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}}, @@ -1912,13 +1946,105 @@ 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") + require.Equal(t, 0, countPath(result, state.CodePath), + "no raw CodePath/CodeHashPath entry — recovery must skip; code lives in CodeDomain") +} + +// 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}) + + 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 + + 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, "") + + 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}}, + {Address: contract, Path: state.CodeHashPath, Val: codeHash, + Version: state.Version{TxIndex: txIndex, Incarnation: 0}}, + } + + result := normalizeWriteSet(rawWrites, vm, txIndex, 1, nil, nil, true, false) + + var gotCode []byte + var gotHash *accounts.CodeHash for _, w := range result { - if w.Path == state.CodePath { - assert.Equal(t, designator, w.Val.([]byte), "recovered code is the committed designator") + 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") +} + +// 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}) + + 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, 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") } // TestCalcFees_EmitsAddressPathForCoinbase pins the fix for the mainnet diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index d0ccb3370e9..b4a259b9160 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" @@ -3433,15 +3434,18 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd } } - // CodePath must travel with CodeHashPath: the case above and the fill loop - // recover an account's codeHash but not its code, so a validated writeset - // lacking a fresh CodePath (e.g. a re-executing 7702 tx whose SetCode - // short-circuited) would persist a codeHash with no code. Recover the code - // (versionMap, else stateReader post-state) and re-emit CodePath, bounded to - // 7702 designators so unchanged contract code isn't re-emitted. Forward-only: - // it can't repair codeHash-no-code already collated into snapshots. - codeInOutput := make(map[accounts.Address]bool) - codeHashInOutput := make(map[accounts.Address]accounts.CodeHash) + // 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, len(writes)) + for _, w := range writes { + switch w.Path { + case state.CodePath, state.CodeHashPath: + codeAddrInRaw[w.Address] = true + } + } + 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: @@ -3452,47 +3456,36 @@ func normalizeWriteSet(writes state.VersionedWrites, vm *state.VersionMap, txInd } } } + 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}, + }) + log.Debug("[codepath-recovery] re-emitted dropped CodePath", + "addr", 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 } - // 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 c, ok := rr.Value().([]byte); ok && len(c) > 0 && emit(addr, c, h) { + continue } } - if len(code) == 0 && stateReader != nil { + if stateReader != nil { if c, err := stateReader.ReadAccountCode(addr); err == nil { - code = c - } - } - // The codeHash-without-code asymmetry only arises from the SetCode - // short-circuit (new code == existing → no CodePath written), since a - // regular deploy writes CodePath and CodeHashPath together at the same - // incarnation. When the short-circuit fires the emitted codeHash is backed - // by either (a) the EIP-7702 designator this re-exec must re-emit, or - // (b) already-committed identical bytes (CREATE2 redeploy / unchanged - // contract) whose code is already in CodeDomain — benign, re-emitting it - // would be an elided DomainPut per modified contract. So recovery is gated - // to 7702 designators: that's the only case that leaves uncommitted code - // without its CodePath. (Gating also never misattributes a callee's code.) - if _, ok := types.ParseDelegation(code); !ok { - continue + if _, ok := types.ParseDelegation(c); ok { + emit(addr, c, h) + } + } } - 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,