From e025c95579ecb70189434fe1e929d9452663a3b1 Mon Sep 17 00:00:00 2001 From: sudeepdino008 Date: Tue, 11 Aug 2026 07:03:20 +0200 Subject: [PATCH 1/2] execution/stagedsync: read the block-finalize IBS through the version map (#23140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The block-complete engine.Finalize IBS (withdrawals, EIP-7002/7251 system calls) read prior-tx in-block state through the BlockStateCache write buffer via an empty local version map. Give it be.versionMap and a pre-block-only reader: at block end every cell is Done and versionedReadCore applies self-destruct/revival, so the version map is a strict superset of the old blockCache read for this reader — and fixes the block 24839300 trie-root race (a tip-adjacent historic block's withdrawal read the pre-block balance and stomped a prior tx's update). TestFinalizeIBSSeesVersionMapWrite pins it. The per-tx calcFees/finalize readers keep the block write buffer. Dropping it there (folded-out #23147) regressed the 2016 EIP-161 empty-account sweep window: CachedReaderV3.ReadAccountData populates the buffer's committed tier as a read side-effect that feeds the deferred-commitment touchmap, so a nil base corrupted the commitment structure and produced a wrong trie root some blocks later (block 2676607 on a from-0 mainnet re-execution). --- execution/stagedsync/exec3_parallel.go | 52 +++++++++---------- .../state/finalize_reader_blockcache_test.go | 40 ++++++++++++++ 2 files changed, 64 insertions(+), 28 deletions(-) diff --git a/execution/stagedsync/exec3_parallel.go b/execution/stagedsync/exec3_parallel.go index a78e298cd06..2b52dd6e9d8 100644 --- a/execution/stagedsync/exec3_parallel.go +++ b/execution/stagedsync/exec3_parallel.go @@ -2752,6 +2752,13 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r return nil, fmt.Errorf("apply loop: unexpected task type for tx %d: result.Task=%T", tx, txResult.Task) } if stateReader == nil { + // Shared base reader for this tx's calcFees and finalize (finalize + // reuses this stateReader). The version map is not a strict superset + // of the block write buffer in the EIP-161 empty-account sweep window + // (2016 Spurious Dragon): a finalize read there can miss the accumulated + // in-block state and fall to a stale pre-block value, corrupting the + // commitment structure and surfacing as a wrong trie root some blocks + // later. Keep the buffer as the fallback base. if txTask.IsHistoric() { stateReader = state.NewHistoryReaderV3WithBlockCache(applyTx, pe.rs.Domains(), be.blockStateCache, txTask.Version().TxNum) } else { @@ -2867,14 +2874,13 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r } if stateReader == nil { + // Same shared base as the calcFees block above (used when calcFees + // did not run for this tx). Keep the buffer as the fallback base: + // the version map is not a strict superset of it in the EIP-161 + // empty-account sweep window. if txTask.IsHistoric() { stateReader = state.NewHistoryReaderV3WithBlockCache(applyTx, pe.rs.Domains(), be.blockStateCache, txTask.Version().TxNum) } else { - // Use CachedReaderV3 with readCurrent=true so the - // finalize (including system TXs) reads from the - // BlockStateCache write buffer. This ensures the - // system TX sees all accumulated state from prior - // TXs in the block, not stale sd.mem values. stateReader = state.NewCurrentCachedReaderV3(pe.rs.Domains().AsGetterNoMetrics(applyTx), be.blockStateCache) } } @@ -3119,42 +3125,32 @@ func (be *blockExecutor) nextResult(ctx context.Context, pe *parallelExecutor, r pe.RLock() var reader state.StateReader + // The block-finalize IBS (withdrawals, EIP-7002/7251 system calls) + // must see every prior-tx write of this block. It gets them from + // be.versionMap (set below); at block end all cells are Done and + // versionedReadCore applies self-destruct/revival. So this reader is + // only the pre-block base. if finalTask.IsHistoric() { - // Chain blockCache → sd.mem → applyTx so the block-finalize - // IBS (withdrawals, EIP-7002/7251 system calls) sees every - // prior-tx write from the current block. Omitting blockCache - // here was the root cause of the trie-root race at block - // 24839300: a tip-adjacent historic block's withdrawal read - // the pre-block balance and stomped tx 28's in-block update. - reader = state.NewHistoryReaderV3WithBlockCache(applyTx, pe.rs.Domains(), be.blockStateCache, finalVersion.TxNum) + reader = state.NewHistoryReaderV3WithBlockCache(applyTx, pe.rs.Domains(), nil, finalVersion.TxNum) } else { - reader = state.NewCurrentCachedReaderV3(pe.rs.Domains().AsGetterNoMetrics(applyTx), be.blockStateCache) + reader = state.NewCurrentCachedReaderV3(pe.rs.Domains().AsGetterNoMetrics(applyTx), nil) } pe.RUnlock() ibs := state.New(reader) defer ibs.Close() ibs.SetVersion(finalVersion.Incarnation) - localVersionMap := state.NewVersionMap(nil) - ibs.SetVersionMap(localVersionMap) + ibs.SetVersionMap(be.versionMap) ibs.SetTxContext(finalVersion.BlockNum, finalVersion.TxIndex) ibs.StartAccessRecording() if tt, ok := lastResult.Task.(*taskVersion).Task.(*exec.TxTask); ok { // Syscalls share the main ibs so their writes (EIP-7002/7251 - // dequeue, EIP-4788 beacon root) land in ibs.VersionedWrites - // and then in finalizeWrites via Normalize below. If we instead - // create a separate syscallIBS in historic mode, the syscall - // writes land only in BlockStateCache and never reach the - // commitment calculator's txResult feed — producing a wrong - // trie root whenever an EIP-7002/7251 SSTORE changes a - // previously-untouched slot (see the 24839762 race where - // slots 0x01/0x03 of the EIP-7002 predeploy ended with - // stale value 0x01 instead of cleared). - // - // Main ibs uses HistoryReaderV3WithBlockCache in historic - // mode (see finalTask.IsHistoric() branch above), so it can - // still see intra-batch writes from the blockCache. + // dequeue, EIP-4788 beacon root) land in ibs.VersionedWrites and + // then in finalizeWrites via Normalize below. A separate syscall + // IBS would keep those writes out of the versioned write-set, so + // the commitment calculator would never see them — a wrong trie + // root when a syscall SSTORE changes a previously-untouched slot. syscallIBS := ibs syscall := func(contract accounts.Address, data []byte) ([]byte, error) { diff --git a/execution/state/finalize_reader_blockcache_test.go b/execution/state/finalize_reader_blockcache_test.go index a20af29de3a..14a830da950 100644 --- a/execution/state/finalize_reader_blockcache_test.go +++ b/execution/state/finalize_reader_blockcache_test.go @@ -138,3 +138,43 @@ func TestFinalizeReaderSeesBlockCacheWrite(t *testing.T) { "NewHistoryReaderV3WithSharedDomains is expected to NOT see the blockCache write; "+ "it reads only from sd.GetAsOf → ttx.GetAsOf and therefore returns the stale pre-block balance") } + +// TestFinalizeIBSSeesVersionMapWrite pins the invariant the block-finalize IBS +// relies on after it stopped reading the BlockStateCache: an IBS backed by the +// block version map (over a pre-block reader) must read a prior-tx in-block +// write at the final txNum, not the stale pre-block value. The IBS reads its own +// writes from its state objects, prior-tx writes from the version map (all cells +// are Done at block end), and only the pre-block base from the reader. +func TestFinalizeIBSSeesVersionMapWrite(t *testing.T) { + t.Parallel() + + _, tx, domains := NewTestRwTx(t) + + addr := accounts.InternAddress(common.HexToAddress("0x6be457e04092b28865e0cba84e3b2cfa0f871e67")) + addrValue := addr.Value() + + preBlockBalance := uint256.NewInt(7290) + preAcc := &accounts.Account{Nonce: 1, Balance: *preBlockBalance, CodeHash: accounts.EmptyCodeHash} + require.NoError(t, + domains.DomainPut(kv.AccountsDomain, tx, addrValue[:], accounts.SerialiseV3(preAcc), 10, nil), + ) + + const tx28TxIndex = 28 + const finalTxIndex = 30 + postTx28Balance := uint256.NewInt(6707) + + mvhm := NewVersionMap(nil) + // tx 28's SubBalance lands in the version map as a completed (Done) cell. + mvhm.WriteBalance(addr, Version{TxIndex: tx28TxIndex, Incarnation: 0}, *postTx28Balance, true) + + reader := NewReaderV3(domains.AsGetter(tx)) // pre-block base only + ibs := NewWithVersionMap(reader, mvhm) + defer ibs.Close() + ibs.SetTxContext(1, finalTxIndex) + + bal, err := ibs.GetBalance(addr) + require.NoError(t, err) + require.Equal(t, *postTx28Balance, bal, + "finalize IBS at the final txNum must read tx 28's in-block balance from the version map, "+ + "not the stale pre-block balance; otherwise finalize stomps the in-block update") +} From 53e47345d0c2be5b7d4be4d2736f417198ddb8a8 Mon Sep 17 00:00:00 2001 From: sudeepdino008 Date: Tue, 11 Aug 2026 17:24:03 +0530 Subject: [PATCH 2/2] execution/state: fix normalize resurrecting a self-destructed account's fields --- execution/state/writeset_normalize.go | 10 ++- .../writeset_normalize_selfdestruct_test.go | 85 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 execution/state/writeset_normalize_selfdestruct_test.go diff --git a/execution/state/writeset_normalize.go b/execution/state/writeset_normalize.go index 534b4e8ee34..c93360b394f 100644 --- a/execution/state/writeset_normalize.go +++ b/execution/state/writeset_normalize.go @@ -341,7 +341,15 @@ func (writes *WriteSet) Normalize(vm *VersionMap, txIndex int, incarnation int, if SetAccountFieldFromMap(filtered, vm, addr, path, ver, txIndex+1) { continue } - // Fall back to stateReader for pre-block account + // Destroyed earlier in this block with no cell for this field: the + // value is the post-destruction default. The stateReader below serves + // the pre-block record, which would resurrect the destroyed + // contract's nonce and codeHash. + if sdEarlier { + SetAccountFieldZero(filtered, addr, path, ver) + continue + } + // Fall back to stateReader for the pre-block account. if stateReader != nil { if !fallbackLoaded { acc, err := stateReader.ReadAccountData(addr) diff --git a/execution/state/writeset_normalize_selfdestruct_test.go b/execution/state/writeset_normalize_selfdestruct_test.go new file mode 100644 index 00000000000..48cf1e5853d --- /dev/null +++ b/execution/state/writeset_normalize_selfdestruct_test.go @@ -0,0 +1,85 @@ +// Copyright 2026 The Erigon Authors +// This file is part of Erigon. +// +// Erigon is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Erigon is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with Erigon. If not, see . + +package state + +import ( + "testing" + + "github.com/holiman/uint256" + "github.com/stretchr/testify/require" + + "github.com/erigontech/erigon/common" + "github.com/erigontech/erigon/execution/types/accounts" +) + +// preBlockContractReader answers with a live contract account, as the domain +// still holds it for an address destroyed later in the same block. +type preBlockContractReader struct { + minimalStateReader + acc accounts.Account +} + +func (r *preBlockContractReader) ReadAccountData(addr accounts.Address) (*accounts.Account, error) { + a := r.acc + return &a, nil +} + +// A write set that credits an address self-destructed by an earlier tx of the +// same block must not recover that address's pre-destruct fields. The block +// finalize does exactly this when the coinbase is a contract a tx destroyed: +// it writes only a balance, so hasCreateContract is false and the version map +// holds no nonce or code-hash cell. Without this rule the completion loop falls +// through to the state reader, which serves the pre-block record and resurrects +// the destroyed contract's code hash into the commitment. +func TestNormalize_SelfDestructedEarlierKeepsFieldsZero(t *testing.T) { + t.Parallel() + + addr := accounts.InternAddress(common.HexToAddress("0x8888f1f195afa192cfee860698584c030f4c9db1")) + const destructTx, finalizeTx = 0, 1 + ver := Version{TxIndex: finalizeTx} + + vm := NewVersionMap(nil) + vm.WriteSelfDestruct(addr, Version{TxIndex: destructTx}, true, true) + + // The finalize write set credits the block reward and nothing else. + ws := &WriteSet{} + ws.SetBalance(addr, &VersionedWrite[uint256.Int]{ + WriteHeader: WriteHeader{Address: addr, Path: BalancePath, Version: ver}, + Val: *uint256.NewInt(5_000_000_000), + }) + + liveCodeHash := accounts.InternCodeHash(common.HexToHash("0x4618e9572bed7958746ccb36021d590b2b0c3b416b771a34c9a463c7b1f8ad40")) + reader := &preBlockContractReader{ + acc: accounts.Account{Nonce: 3, Balance: *uint256.NewInt(1000), CodeHash: liveCodeHash}, + } + + out, err := ws.Normalize(vm, finalizeTx, 0, reader, nil, false /*emptyRemoval*/, false /*isAura*/, false /*eip8246*/) + require.NoError(t, err) + + gotCodeHash, ok := out.GetCodeHash(addr) + require.True(t, ok, "code hash must be emitted so the commitment sees a full account") + require.Equal(t, accounts.EmptyCodeHash, gotCodeHash.Val, + "a destroyed account must not recover its pre-destruct code hash from the state reader") + + gotNonce, ok := out.GetNonce(addr) + require.True(t, ok) + require.Zero(t, gotNonce.Val, "a destroyed account must not recover its pre-destruct nonce") + + gotBalance, ok := out.GetBalance(addr) + require.True(t, ok, "the credit itself must survive") + require.Equal(t, uint256.NewInt(5_000_000_000).String(), gotBalance.Val.String()) +}