Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 24 additions & 28 deletions execution/stagedsync/exec3_parallel.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down Expand Up @@ -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) {
Expand Down
40 changes: 40 additions & 0 deletions execution/state/finalize_reader_blockcache_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
10 changes: 9 additions & 1 deletion execution/state/writeset_normalize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
85 changes: 85 additions & 0 deletions execution/state/writeset_normalize_selfdestruct_test.go
Original file line number Diff line number Diff line change
@@ -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 <http://www.gnu.org/licenses/>.

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())
}
Loading