diff --git a/consensus/bor/bor.go b/consensus/bor/bor.go
index 52d8f53839..3a0d5e86e1 100644
--- a/consensus/bor/bor.go
+++ b/consensus/bor/bor.go
@@ -59,7 +59,16 @@ const (
inmemorySnapshots = 128 // Number of recent vote snapshots to keep in memory
inmemorySignatures = 4096 // Number of recent block signatures to keep in memory
veblopBlockTimeout = time.Second * 8 // Timeout for new span check. DO NOT CHANGE THIS VALUE.
- minBlockBuildTime = 1 * time.Second // Minimum remaining time before extending the block deadline to avoid empty blocks
+ // minBlockBuildTime is the minimum remaining time before Prepare() extends
+ // the block deadline to avoid producing empty blocks. If time.Until(target)
+ // is less than this value, the target timestamp is pushed forward by one
+ // blockTime period.
+ //
+ // Abort-recovery rebuilds from pipelined SRC are exempt from this push. By the
+ // time speculative execution is discarded, most of the slot may already be
+ // gone; moving the header to the next slot would create avoidable 3-second
+ // blocks on 2-second devnets.
+ minBlockBuildTime = 1 * time.Second
)
// Bor protocol constants.
@@ -1034,6 +1043,37 @@ func (c *Bor) setGiuglianoExtraFields(header *types.Header, parent *types.Header
}
}
+func (c *Bor) parentActualTime(parent *types.Header, parentHash common.Hash) time.Time {
+ parentBlockTime := time.Unix(int64(parent.Time), 0)
+ parentActualBlockTime := parentBlockTime
+ if c.parentActualTimeCache != nil {
+ if v, ok := c.parentActualTimeCache.Get(parentHash); ok {
+ if at, ok := v.(time.Time); ok && at.After(parentBlockTime) {
+ parentActualBlockTime = at
+ }
+ }
+ }
+ return parentActualBlockTime
+}
+
+// EarliestAnnounceTime returns the earliest local time at which a prepared
+// block can be announced without violating Bor's post-Giugliano future-block
+// checks. Primary producers may announce before the block's own timestamp, but
+// not before the parent slot boundary.
+func (c *Bor) EarliestAnnounceTime(chain consensus.ChainHeaderReader, header *types.Header) time.Time {
+ if header == nil || header.Number == nil || header.Number.Sign() == 0 {
+ return time.Now()
+ }
+ if !c.config.IsGiugliano(header.Number) {
+ return header.GetActualTime()
+ }
+ parent := chain.GetHeader(header.ParentHash, header.Number.Uint64()-1)
+ if parent == nil {
+ return header.GetActualTime()
+ }
+ return c.parentActualTime(parent, header.ParentHash)
+}
+
// Prepare implements consensus.Engine, preparing all the consensus fields of the
// header for running the transactions on top.
func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, waitOnPrepare bool) error {
@@ -1147,17 +1187,7 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w
if c.blockTime > 0 && c.config.IsRio(header.Number) {
// Only enable custom block time for Rio and later
- parentBlockTime := time.Unix(int64(parent.Time), 0)
- // Default to parent block timestamp
- parentActualBlockTime := parentBlockTime
- // If we have the parent's ActualTime locally (by parent hash), prefer it
- if c.parentActualTimeCache != nil {
- if v, ok := c.parentActualTimeCache.Get(header.ParentHash); ok {
- if at, ok := v.(time.Time); ok && at.After(parentBlockTime) {
- parentActualBlockTime = at
- }
- }
- }
+ parentActualBlockTime := c.parentActualTime(parent, header.ParentHash)
actualNewBlockTime := parentActualBlockTime.Add(c.blockTime)
header.Time = uint64(actualNewBlockTime.Unix())
header.ActualTime = actualNewBlockTime
@@ -1175,7 +1205,11 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w
// Ensure minimum build time so the block has enough time to include transactions.
// The interrupt timer reserves 500ms for state root computation, so without
// sufficient remaining time the block would end up empty.
- if time.Until(header.GetActualTime()) < minBlockBuildTime {
+ //
+ // Abort-recovery rebuilds are different: speculative execution has already
+ // spent most of the slot, so pushing them again would create an avoidable
+ // extra block-time gap. Those late rebuilds should keep their original slot.
+ if !header.AbortRecovery && time.Until(header.GetActualTime()) < minBlockBuildTime {
header.Time = uint64(now.Add(blockTime).Unix())
belowMinBuildTimeCounter.Inc(1)
if c.blockTime > 0 && c.config.IsRio(header.Number) {
@@ -1183,11 +1217,17 @@ func (c *Bor) Prepare(chain consensus.ChainHeaderReader, header *types.Header, w
}
}
- // Wait before start the block production if needed (previously this wait was on Seal)
+ // Giugliano introduced early block announcements: primary producers wait
+ // until the parent slot boundary before building, then Seal can return
+ // immediately and announce the block before its own timestamp. Speculative
+ // and prefetch callers pass waitOnPrepare=false because they intentionally
+ // build ahead and perform their own parent-boundary wait before sealing.
if c.config.IsGiugliano(header.Number) && waitOnPrepare {
- // if signer is not empty (RPC nodes have empty signer)
if currentSigner.signer != (common.Address{}) {
- if succession == 0 {
+ // Avoid allocating a timer when the parent boundary has already
+ // passed. This is equivalent to develop's immediate time.After path
+ // for non-positive delays, just cheaper and more explicit.
+ if succession == 0 && delay > 0 {
<-time.After(delay)
}
}
@@ -1425,6 +1465,69 @@ func (c *Bor) finalizeAndAssemble(chain consensus.ChainHeaderReader, header *typ
return block, receipts, commitTime, nil
}
+// FinalizeForPipeline runs the same post-transaction state modifications as
+// FinalizeAndAssemble (state sync, span commits, contract code changes) but
+// does NOT compute IntermediateRoot or assemble the block. It returns the
+// stateSyncData so the caller can pass it to AssembleBlock later after the
+// background SRC goroutine has computed the state root.
+//
+// This is the pipelined SRC equivalent of the first half of FinalizeAndAssemble.
+func (c *Bor) FinalizeForPipeline(chain consensus.ChainHeaderReader, header *types.Header, statedb *state.StateDB, body *types.Body, receipts []*types.Receipt) ([]*types.StateSyncData, error) {
+ headerNumber := header.Number.Uint64()
+ if body.Withdrawals != nil || header.WithdrawalsHash != nil {
+ return nil, consensus.ErrUnexpectedWithdrawals
+ }
+ if header.RequestsHash != nil {
+ return nil, consensus.ErrUnexpectedRequests
+ }
+
+ var (
+ stateSyncData []*types.StateSyncData
+ err error
+ )
+
+ if IsSprintStart(headerNumber, c.config.CalculateSprint(headerNumber)) {
+ stateSyncData, err = c.commitSprintWork(chain, header, statedb)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ if err = c.changeContractCodeIfNeeded(headerNumber, statedb); err != nil {
+ log.Error("Error changing contract code", "error", err)
+ return nil, err
+ }
+
+ return stateSyncData, nil
+}
+
+// AssembleBlock constructs the final block from a pre-computed state root,
+// without calling IntermediateRoot. This is used by pipelined SRC where the
+// state root is computed by a background goroutine.
+//
+// stateSyncData is the state sync data collected during Finalize(). If non-nil
+// and the Madhugiri fork is active, a StateSyncTx is appended to the body.
+func (c *Bor) AssembleBlock(chain consensus.ChainHeaderReader, header *types.Header, statedb *state.StateDB, body *types.Body, receipts []*types.Receipt, stateRoot common.Hash, stateSyncData []*types.StateSyncData) (*types.Block, []*types.Receipt, error) {
+ headerNumber := header.Number.Uint64()
+
+ header.Root = stateRoot
+ header.UncleHash = types.CalcUncleHash(nil)
+
+ if len(stateSyncData) > 0 && c.config != nil && c.config.IsMadhugiri(big.NewInt(int64(headerNumber))) {
+ stateSyncTx := types.NewTx(&types.StateSyncTx{
+ StateSyncData: stateSyncData,
+ })
+ body.Transactions = append(body.Transactions, stateSyncTx)
+ receipts = insertStateSyncTransactionAndCalculateReceipt(stateSyncTx, header, body, statedb, receipts)
+ } else {
+ bc := chain.(core.BorStateSyncer)
+ bc.SetStateSync(stateSyncData)
+ }
+
+ block := types.NewBlock(header, body, receipts, trie.NewStackTrie(nil))
+ return block, receipts, nil
+}
+
// commitSprintWork commits the span (pre-Rio) and state-sync data at a
// sprint-start block during block assembly.
func (c *Bor) commitSprintWork(chain consensus.ChainHeaderReader, header *types.Header, state *state.StateDB) ([]*types.StateSyncData, error) {
@@ -1508,11 +1611,15 @@ func (c *Bor) SealWithStopHook(chain consensus.ChainHeaderReader, block *types.B
var delay time.Duration
- // Sweet, the protocol permits us to sign the block, wait for our time
+ // Sweet, the protocol permits us to sign the block, wait for our time.
+ // On Giugliano+ primary producers, the wait is performed before building
+ // in Prepare (or explicitly by the pipeline at the parent boundary), so Seal
+ // returns immediately and preserves early block announcement. Backups still
+ // wait until the block timestamp.
if c.config.IsGiugliano(header.Number) && successionNumber == 0 {
- delay = 0 // delay was moved to Prepare for giugliano and later
+ delay = 0
} else {
- delay = time.Until(header.GetActualTime()) // Wait until we reach header time
+ delay = time.Until(header.GetActualTime())
}
// wiggle was already accounted for in header.Time, this is just for logging
@@ -1529,7 +1636,13 @@ func (c *Bor) SealWithStopHook(chain consensus.ChainHeaderReader, block *types.B
}
// Wait until sealing is terminated or delay timeout.
- log.Info("Waiting for slot to sign and propagate", "number", number, "hash", header.Hash(), "delay-in-sec", uint(delay), "delay", common.PrettyDuration(delay))
+ log.Info(
+ "Waiting for slot to sign and propagate",
+ "number", number,
+ "hash", header.Hash(),
+ "delay-ms", float64(delay)/float64(time.Millisecond),
+ "delay", common.PrettyDuration(delay),
+ )
go func() {
select {
@@ -1545,7 +1658,7 @@ func (c *Bor) SealWithStopHook(chain consensus.ChainHeaderReader, block *types.B
"Sealing out-of-turn",
"number", number,
"hash", header.Hash,
- "wiggle-in-sec", uint(wiggle),
+ "wiggle-ms", float64(wiggle)/float64(time.Millisecond),
"wiggle", common.PrettyDuration(wiggle),
"in-turn-signer", snap.ValidatorSet.GetProposer().Address.Hex(),
)
@@ -1693,6 +1806,13 @@ func (c *Bor) checkAndCommitSpan(
tempState.IntermediateRoot(false)
+ // Propagate addresses accessed during GetCurrentSpan back to the original
+ // state so they appear in the FlatDiff ReadSet. Without this, the pipelined
+ // SRC goroutine's witness won't capture their trie proof nodes (the copy's
+ // reads aren't tracked on the original), causing stateless execution to fail
+ // with missing trie nodes for the validator contract.
+ tempState.PropagateReadsTo(state.Inner())
+
if c.needToCommitSpan(span, headerNumber) {
return c.FetchAndCommitSpan(ctx, span.Id+1, state, header, chain)
}
@@ -1838,6 +1958,12 @@ func (c *Bor) CommitStates(
tempState.IntermediateRoot(false)
+ // Propagate addresses accessed during LastStateId back to the original
+ // state so they appear in the FlatDiff ReadSet. Without this, the
+ // pipelined SRC goroutine's witness won't capture their trie proof
+ // nodes, causing stateless execution to fail with missing trie nodes.
+ tempState.PropagateReadsTo(state.Inner())
+
stateSyncDelay := c.config.CalculateStateSyncDelay(number)
to = time.Unix(int64(header.Time-stateSyncDelay), 0)
} else {
diff --git a/consensus/bor/bor_test.go b/consensus/bor/bor_test.go
index 05dfb34a7e..8eba83a3c0 100644
--- a/consensus/bor/bor_test.go
+++ b/consensus/bor/bor_test.go
@@ -1134,6 +1134,42 @@ func TestLateBlockTimestampFix(t *testing.T) {
require.True(t, header.ActualTime.After(expectedMin) || header.ActualTime.Equal(expectedMin),
"header.ActualTime should be at least blockTime from now")
})
+
+ t.Run("abort recovery keeps the original target", func(t *testing.T) {
+ sp := &fakeSpanner{vals: []*valset.Validator{{Address: addr1, VotingPower: 1}}}
+ rioCfg := ¶ms.BorConfig{
+ Sprint: map[string]uint64{"0": 64},
+ Period: map[string]uint64{"0": 2},
+ RioBlock: big.NewInt(0),
+ }
+ blockTime := 2 * time.Second
+
+ parentActualTime := time.Now().Add(-blockTime + 700*time.Millisecond)
+ genesisTime := uint64(parentActualTime.Unix())
+
+ chain, b := newChainAndBorForTest(t, sp, rioCfg, true, addr1, genesisTime)
+ b.blockTime = blockTime
+
+ genesis := chain.HeaderChain().GetHeaderByNumber(0)
+ parentHash := genesis.Hash()
+ b.parentActualTimeCache.Add(parentHash, parentActualTime)
+
+ expectedTargetWithoutExtension := parentActualTime.Add(blockTime)
+ remaining := time.Until(expectedTargetWithoutExtension)
+ require.Greater(t, remaining, 500*time.Millisecond, "test setup: remaining should be > 500ms")
+ require.Less(t, remaining, minBlockBuildTime, "test setup: remaining should be < minBlockBuildTime")
+
+ header := &types.Header{
+ Number: big.NewInt(1),
+ ParentHash: parentHash,
+ AbortRecovery: true,
+ }
+
+ require.NoError(t, b.Prepare(chain.HeaderChain(), header, false))
+ require.False(t, header.ActualTime.IsZero())
+ require.WithinDuration(t, expectedTargetWithoutExtension, header.ActualTime, 5*time.Millisecond)
+ require.Equal(t, uint64(expectedTargetWithoutExtension.Unix()), header.Time)
+ })
}
// setupFinalizeTest creates a test environment for FinalizeAndAssemble tests
@@ -5032,219 +5068,77 @@ func TestFinalize_CheckAndCommitSpanError(t *testing.T) {
require.Nil(t, result)
}
-// P1 Test: TestBorPrepare_WaitOnPrepareFlag validates the new waitOnPrepare
-// parameter in the Prepare method
-func TestBorPrepare_WaitOnPrepareFlag(t *testing.T) {
+// TestPrepare_PrimaryProducerWaitOnPrepareControlsEarlyAnnouncementDelay
+// verifies the Giugliano timing split used for early block announcements.
+// Normal production waits until the parent slot boundary before building, but
+// speculative/prefetch callers can opt out and do their own wait later.
+func TestPrepare_PrimaryProducerWaitOnPrepareControlsEarlyAnnouncementDelay(t *testing.T) {
t.Parallel()
- // Setup: Create a blockchain and Bor engine
addr := common.HexToAddress("0x1")
sp := &fakeSpanner{vals: []*valset.Validator{{Address: addr, VotingPower: 1}}}
+ blockTime := 2 * time.Second
borCfg := ¶ms.BorConfig{
- Sprint: map[string]uint64{"0": 64},
- Period: map[string]uint64{"0": 2},
+ Sprint: map[string]uint64{"0": 64},
+ Period: map[string]uint64{"0": 2},
+ GiuglianoBlock: big.NewInt(0),
+ RioBlock: big.NewInt(0),
}
- chain, b := newChainAndBorForTest(t, sp, borCfg, true, addr, uint64(time.Now().Unix()))
+ genesisTime := uint64(time.Now().Unix())
+ chain, b := newChainAndBorForTest(t, sp, borCfg, true, addr, genesisTime)
defer chain.Stop()
+ b.blockTime = blockTime
genesis := chain.HeaderChain().GetHeaderByNumber(0)
require.NotNil(t, genesis)
- // Test 1: Prepare with waitOnPrepare=false should return quickly
- t.Run("no_wait", func(t *testing.T) {
- testHeader := createTestHeader(genesis, 1, borCfg.Period["0"])
-
- start := time.Now()
- err := b.Prepare(chain, testHeader, false)
- elapsed := time.Since(start)
-
- if err != nil {
- t.Fatalf("Prepare with waitOnPrepare=false failed: %v", err)
- }
-
- // Should complete very quickly (< 100ms) since no waiting
- if elapsed > 100*time.Millisecond {
- t.Logf("Warning: Prepare took %v, expected < 100ms when waitOnPrepare=false", elapsed)
- }
-
- // Verify header is valid
- if testHeader.Time == 0 {
- t.Error("Header time should be set")
- }
-
- t.Logf("Prepare with waitOnPrepare=false completed in %v", elapsed)
- })
-
- // Test 2: Prepare with waitOnPrepare=true should wait for the proper block time
- t.Run("with_wait", func(t *testing.T) {
- // Create a config with Giugliano enabled to activate wait-in-Prepare logic
- borCfgWithBhilai := ¶ms.BorConfig{
- Sprint: map[string]uint64{"0": 64},
- Period: map[string]uint64{"0": 2},
- GiuglianoBlock: big.NewInt(0), // Enable Giugliano from block 0
- }
-
- // Set genesis time 3 seconds in the future to ensure enough wait time
- // even after test setup overhead
- genesisTime := uint64(time.Now().Add(3 * time.Second).Unix())
-
- // Use DevFakeAuthor=true so the signer is authorized and is the primary producer
- chainWithWait, bWithWait := newChainAndBorForTest(t, sp, borCfgWithBhilai, true, addr, genesisTime)
- defer chainWithWait.Stop()
-
- genesisWithWait := chainWithWait.HeaderChain().GetHeaderByNumber(0)
- require.NotNil(t, genesisWithWait)
-
- testHeader := createTestHeader(genesisWithWait, 1, borCfgWithBhilai.Period["0"])
-
- // Calculate expected wait time dynamically based on actual genesis time
- // This accounts for test setup overhead between setting genesis time and calling Prepare
- start := time.Now()
- genesisTimestamp := time.Unix(int64(genesisWithWait.Time), 0)
- expectedDelay := time.Until(genesisTimestamp)
-
- // If genesis time has already passed due to slow test setup, test won't wait
- if expectedDelay < 0 {
- t.Skipf("Test setup took too long (%v), genesis time already passed", time.Since(time.Unix(int64(genesisTime), 0)))
- }
-
- err := bWithWait.Prepare(chainWithWait, testHeader, true)
- elapsed := time.Since(start)
-
- if err != nil {
- t.Fatalf("Prepare with waitOnPrepare=true failed: %v", err)
- }
-
- // With Giugliano enabled, DevFakeAuthor=true (making this node the primary producer),
- // and waitOnPrepare=true, should wait until parent (genesis) time has passed
- // Allow 100ms tolerance for timing precision and scheduling overhead
- minWait := expectedDelay - 100*time.Millisecond
- maxWait := expectedDelay + 200*time.Millisecond // Allow extra time for scheduling
-
- if minWait < 0 {
- minWait = 0
- }
-
- if elapsed < minWait {
- t.Errorf("Prepare waited %v, expected at least %v (calculated from expectedDelay=%v)", elapsed, minWait, expectedDelay)
- }
- if elapsed > maxWait {
- t.Logf("Warning: Prepare took %v, expected around %v (calculated from expectedDelay=%v)", elapsed, expectedDelay, expectedDelay)
- }
-
- // Verify header is valid
- if testHeader.Time == 0 {
- t.Error("Header time should be set")
- }
-
- t.Logf("Prepare with waitOnPrepare=true completed in %v (expected delay was %v)", elapsed, expectedDelay)
- })
-
- // Test 3: Verify both produce compatible headers
- t.Run("compatibility", func(t *testing.T) {
- header1 := createTestHeader(genesis, 3, borCfg.Period["0"])
- header2 := createTestHeader(genesis, 3, borCfg.Period["0"])
-
- err1 := b.Prepare(chain, header1, false)
- err2 := b.Prepare(chain, header2, true)
-
- if err1 != nil || err2 != nil {
- t.Fatalf("Prepare failed: err1=%v, err2=%v", err1, err2)
- }
-
- // Both should produce valid headers with same block number
- if header1.Number.Cmp(header2.Number) != 0 {
- t.Error("Headers should have same block number")
- }
-
- t.Logf("Both waitOnPrepare modes produce compatible headers for block %d", header1.Number.Uint64())
- })
-}
-
-// TestPrepare_WaitGate_GiuglianoOnly verifies that the wait-in-Prepare
-// mechanism activates only when IsGiugliano is true.
-func TestPrepare_WaitGate_GiuglianoOnly(t *testing.T) {
- t.Parallel()
-
- addr := common.HexToAddress("0x1")
- sp := &fakeSpanner{vals: []*valset.Validator{{Address: addr, VotingPower: 1}}}
-
- t.Run("before Giugliano – waitOnPrepare=true returns quickly", func(t *testing.T) {
- borCfg := ¶ms.BorConfig{
- Sprint: map[string]uint64{"0": 64},
- Period: map[string]uint64{"0": 2},
- // GiuglianoBlock not set → IsGiugliano always false
- }
- // Set genesis time slightly in the future so there would be a non-trivial delay
- // if the wait were active.
- genesisTime := uint64(time.Now().Add(2 * time.Second).Unix())
- chain, b := newChainAndBorForTest(t, sp, borCfg, true, addr, genesisTime)
- defer chain.Stop()
-
- genesis := chain.HeaderChain().GetHeaderByNumber(0)
- require.NotNil(t, genesis)
-
- header := &types.Header{Number: big.NewInt(1), ParentHash: genesis.Hash()}
+ // Both subtests assert against the parent boundary itself rather than
+ // fixed elapsed-time bounds, so scheduling hiccups on loaded CI runners
+ // can't flake them: opt-out must return before the boundary (with a full
+ // second of headroom), normal production must not return until it.
+ t.Run("opt out stays fast", func(t *testing.T) {
+ parentActual := time.Now().Add(1 * time.Second)
+ b.parentActualTimeCache.Add(genesis.Hash(), parentActual)
+ header := createTestHeader(genesis, 1, borCfg.Period["0"])
- start := time.Now()
- err := b.Prepare(chain, header, true)
- elapsed := time.Since(start)
+ err := b.Prepare(chain, header, false)
require.NoError(t, err)
- // Without Giugliano the wait block is skipped; should return in < 200 ms
- require.Less(t, elapsed, 200*time.Millisecond,
- "Prepare should not wait when Giugliano is not active")
+ require.True(t, time.Now().Before(parentActual),
+ "Prepare(waitOnPrepare=false) must return before the parent slot boundary")
+ require.NotZero(t, header.Time, "Prepare should still populate header time")
})
- t.Run("at Giugliano – waitOnPrepare=true waits for primary producer", func(t *testing.T) {
- borCfg := ¶ms.BorConfig{
- Sprint: map[string]uint64{"0": 64},
- Period: map[string]uint64{"0": 2},
- GiuglianoBlock: big.NewInt(0),
- }
- // Genesis 3 s in the future → there will be a measurable wait.
- genesisTime := uint64(time.Now().Add(3 * time.Second).Unix())
- chain, b := newChainAndBorForTest(t, sp, borCfg, true, addr, genesisTime)
- defer chain.Stop()
-
- genesis := chain.HeaderChain().GetHeaderByNumber(0)
- require.NotNil(t, genesis)
-
- // Measure expected delay right before calling Prepare, same pattern as TestBorPrepare_WaitOnPrepareFlag.
- expectedDelay := time.Until(time.Unix(int64(genesis.Time), 0))
- if expectedDelay < 100*time.Millisecond {
- t.Skip("genesis time already passed due to slow setup")
- }
-
- header := &types.Header{Number: big.NewInt(1), ParentHash: genesis.Hash()}
+ t.Run("normal production waits for parent boundary", func(t *testing.T) {
+ parentActual := time.Now().Add(300 * time.Millisecond)
+ b.parentActualTimeCache.Add(genesis.Hash(), parentActual)
+ header := createTestHeader(genesis, 1, borCfg.Period["0"])
- start := time.Now()
err := b.Prepare(chain, header, true)
- elapsed := time.Since(start)
require.NoError(t, err)
- minWait := expectedDelay - 200*time.Millisecond
- if minWait < 0 {
- minWait = 0
- }
- require.Greater(t, elapsed, minWait,
- "Prepare should wait for primary producer when Giugliano is active")
+ require.False(t, time.Now().Before(parentActual),
+ "Prepare(waitOnPrepare=true) must wait until the parent slot boundary")
+ require.NotZero(t, header.Time, "Prepare should still populate header time")
})
}
-// TestSeal_PrimaryProducerDelay_GiuglianoBoundary verifies that delay=0 in Seal
-// for the primary producer (succession==0) is gated on IsGiugliano.
+// TestSeal_PrimaryProducerDelay_GiuglianoBoundary verifies that primary
+// producers wait in Seal before Giugliano, but return immediately after
+// Giugliano because the parent-boundary wait has moved to Prepare. This is the
+// mechanism that preserves early block announcement.
func TestSeal_PrimaryProducerDelay_GiuglianoBoundary(t *testing.T) {
t.Parallel()
addr := common.HexToAddress("0x1")
sp := &fakeSpanner{vals: []*valset.Validator{{Address: addr, VotingPower: 1}}}
- now := uint64(time.Now().Unix())
+ now := uint64(time.Now().Unix()) - 100
- makeHeader := func(borCfg *params.BorConfig) (*types.Header, *Bor, *core.BlockChain) {
+ makeBlock := func(borCfg *params.BorConfig) (*types.Block, *Bor, *core.BlockChain, time.Time) {
chain, b := newChainAndBorForTest(t, sp, borCfg, true, addr, now)
genesis := chain.HeaderChain().GetHeaderByNumber(0)
require.NotNil(t, genesis)
+ target := time.Now().Add(350 * time.Millisecond)
h := &types.Header{
Number: big.NewInt(1),
ParentHash: genesis.Hash(),
@@ -5252,55 +5146,52 @@ func TestSeal_PrimaryProducerDelay_GiuglianoBoundary(t *testing.T) {
UncleHash: uncleHash,
Difficulty: big.NewInt(1),
GasLimit: 8_000_000,
+ Time: uint64(target.Unix()),
+ ActualTime: target,
}
- // Set header.Time so GetActualTime() returns something in the past
- h.Time = now - 1
- return h, b, chain
+ body := &types.Body{Transactions: types.Transactions{types.NewTx(&types.LegacyTx{})}}
+ return types.NewBlock(h, body, nil, trie.NewStackTrie(nil)), b, chain, target
}
- t.Run("before Giugliano – primary producer has non-zero delay", func(t *testing.T) {
- borCfg := ¶ms.BorConfig{
- Sprint: map[string]uint64{"0": 64},
- Period: map[string]uint64{"0": 2},
- // GiuglianoBlock not set
- }
- h, b, chain := makeHeader(borCfg)
+ assertSealTiming := func(t *testing.T, borCfg *params.BorConfig, expectWait bool) {
+ block, b, chain, target := makeBlock(borCfg)
defer chain.Stop()
- snap, err := b.snapshot(chain.HeaderChain(), h, nil, false)
- require.NoError(t, err)
+ b.Authorize(addr, func(accounts.Account, string, []byte) ([]byte, error) {
+ return make([]byte, types.ExtraSealLength), nil
+ })
+
+ results := make(chan *consensus.NewSealedBlockEvent, 1)
+ stop := make(chan struct{})
- successionNumber, err := snap.GetSignerSuccessionNumber(addr)
+ err := b.Seal(chain.HeaderChain(), block, nil, results, stop)
require.NoError(t, err)
- require.Equal(t, 0, successionNumber, "DevFakeAuthor should be primary producer")
-
- // Before Giugliano the delay=0 branch should NOT be taken.
- // The else branch sets delay = time.Until(header.GetActualTime()).
- // Since header.Time is in the past, delay ≤ 0 — but the point is the branch
- // selected is the else, not the delay=0 one.
- isNewHF := b.config.IsGiugliano(h.Number)
- require.False(t, isNewHF, "IsGiugliano should be false before GiuglianoBlock")
- })
- t.Run("at Giugliano – primary producer gets delay=0", func(t *testing.T) {
- borCfg := ¶ms.BorConfig{
- Sprint: map[string]uint64{"0": 64},
- Period: map[string]uint64{"0": 2},
- GiuglianoBlock: big.NewInt(0),
+ select {
+ case result := <-results:
+ require.NotNil(t, result)
+ require.NotNil(t, result.Block)
+ if expectWait {
+ require.False(t, time.Now().Before(target.Add(-50*time.Millisecond)),
+ "seal result arrived before target time %v", target)
+ } else {
+ require.True(t, time.Now().Before(target.Add(-100*time.Millisecond)),
+ "seal result did not preserve early announcement before target time %v", target)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("timed out waiting for sealed block")
}
- h, b, chain := makeHeader(borCfg)
- defer chain.Stop()
-
- snap, err := b.snapshot(chain.HeaderChain(), h, nil, false)
- require.NoError(t, err)
+ }
- successionNumber, err := snap.GetSignerSuccessionNumber(addr)
- require.NoError(t, err)
- require.Equal(t, 0, successionNumber, "DevFakeAuthor should be primary producer")
+ t.Run("before Giugliano", func(t *testing.T) {
+ borCfg := borConfigWithDelays(64)
+ assertSealTiming(t, borCfg, true)
+ })
- isNewHF := b.config.IsGiugliano(h.Number)
- require.True(t, isNewHF, "IsGiugliano should be true at GiuglianoBlock=0")
- // The Seal function would take the delay=0 branch for this signer/header combination.
+ t.Run("at Giugliano", func(t *testing.T) {
+ borCfg := borConfigWithDelays(64)
+ borCfg.GiuglianoBlock = big.NewInt(0)
+ assertSealTiming(t, borCfg, false)
})
}
diff --git a/consensus/consensus.go b/consensus/consensus.go
index 1062f6d13d..6f3fa81c70 100644
--- a/consensus/consensus.go
+++ b/consensus/consensus.go
@@ -83,7 +83,9 @@ type Engine interface {
VerifyUncles(chain ChainReader, block *types.Block) error
// Prepare initializes the consensus fields of a block header according to the
- // rules of a particular engine. The changes are executed inline.
+ // rules of a particular engine. The changes are executed inline. Bor uses
+ // waitOnPrepare to let normal producers wait for the parent slot boundary
+ // before building, preserving early block announcement semantics.
Prepare(chain ChainHeaderReader, header *types.Header, waitOnPrepare bool) error
// Finalize runs any post-transaction state modifications (e.g. block rewards
diff --git a/core/block_validator.go b/core/block_validator.go
index d32abac879..be8955c6ad 100644
--- a/core/block_validator.go
+++ b/core/block_validator.go
@@ -134,6 +134,37 @@ func (v *BlockValidator) ValidateBody(block *types.Block) error {
return nil
}
+// ValidateStateCheap validates the cheap (non-trie) post-state checks: gas used,
+// bloom filter, receipt root, and requests hash. It does NOT compute the state
+// root (IntermediateRoot), which is the expensive operation. Used by the pipelined
+// import path where IntermediateRoot is deferred to a background SRC goroutine.
+func (v *BlockValidator) ValidateStateCheap(block *types.Block, statedb *state.StateDB, res *ProcessResult) error {
+ if res == nil {
+ return errors.New("nil ProcessResult value")
+ }
+ header := block.Header()
+ if block.GasUsed() != res.GasUsed {
+ return fmt.Errorf("%w (remote: %d local: %d)", ErrGasUsedMismatch, block.GasUsed(), res.GasUsed)
+ }
+ rbloom := types.MergeBloom(res.Receipts)
+ if rbloom != header.Bloom {
+ return fmt.Errorf("%w (remote: %x local: %x)", ErrBloomMismatch, header.Bloom, rbloom)
+ }
+ receiptSha := types.DeriveSha(res.Receipts, trie.NewStackTrie(nil))
+ if receiptSha != header.ReceiptHash {
+ return fmt.Errorf("%w (remote: %x local: %x)", ErrReceiptRootMismatch, header.ReceiptHash, receiptSha)
+ }
+ if header.RequestsHash != nil {
+ reqhash := types.CalcRequestsHash(res.Requests)
+ if reqhash != *header.RequestsHash {
+ return fmt.Errorf("%w (remote: %x local: %x)", ErrRequestsHashMismatch, *header.RequestsHash, reqhash)
+ }
+ } else if res.Requests != nil {
+ return errors.New("block has requests before prague fork")
+ }
+ return nil
+}
+
// ValidateState validates the various changes that happen after a state transition,
// such as amount of used gas, the receipt roots and the state root itself.
func (v *BlockValidator) ValidateState(block *types.Block, statedb *state.StateDB, res *ProcessResult, stateless bool) error {
diff --git a/core/blockchain.go b/core/blockchain.go
index f6873cdb1a..0a9530617f 100644
--- a/core/blockchain.go
+++ b/core/blockchain.go
@@ -69,7 +69,7 @@ var (
headSafeBlockGauge = metrics.NewRegisteredGauge("chain/head/safe", nil)
chainInfoGauge = metrics.NewRegisteredGaugeInfo("chain/info", nil)
- chainMgaspsMeter = metrics.NewRegisteredResettingTimer("chain/mgasps", nil) //nolint:unused
+ chainMgaspsMeter = metrics.NewRegisteredResettingTimer("chain/mgasps", nil)
accountReadTimer = metrics.NewRegisteredResettingTimer("chain/account/reads", nil)
accountHashTimer = metrics.NewRegisteredResettingTimer("chain/account/hashes", nil)
@@ -109,10 +109,20 @@ var (
blockImportTimer = metrics.NewRegisteredMeter("chain/imports", nil)
- blockInsertTimer = metrics.NewRegisteredTimer("chain/inserts", nil)
- blockValidationTimer = metrics.NewRegisteredTimer("chain/validation", nil)
- blockCrossValidationTimer = metrics.NewRegisteredResettingTimer("chain/crossvalidation", nil) //nolint:revive,unused
- blockExecutionTimer = metrics.NewRegisteredTimer("chain/execution", nil)
+ blockInsertTimer = metrics.NewRegisteredTimer("chain/inserts", nil)
+ // blockValidationTimer does NOT fire when pipelined SRC is enabled.
+ // Reason: pipelined import uses ValidateStateCheap (gas + bloom + receipt
+ // root only); the full root match happens later in the SRC goroutine.
+ // Closest pipeline signals: chain/imports/pipelined/collect (caller's wait
+ // on root verification) and chain/imports/pipelined/root_mismatch (must stay zero).
+ blockValidationTimer = metrics.NewRegisteredTimer("chain/validation", nil)
+ blockCrossValidationTimer = metrics.NewRegisteredResettingTimer("chain/crossvalidation", nil) //nolint:revive,unused
+ blockExecutionTimer = metrics.NewRegisteredTimer("chain/execution", nil)
+ // blockWriteTimer does NOT fire when pipelined SRC is enabled.
+ // Reason: pipelined import splits "write" across two code paths — metadata/batch
+ // write in writeBlockAndSetHeadPipelined and async state commit in the SRC
+ // goroutine — so there is no single "write phase" number. Approximate by summing
+ // chain/batch/write + chain/state/commit + chain/{account,storage}/commits.
blockWriteTimer = metrics.NewRegisteredTimer("chain/write", nil)
blockExecutionParallelCounter = metrics.NewRegisteredCounter("chain/execution/parallel", nil)
blockExecutionSerialCounter = metrics.NewRegisteredCounter("chain/execution/serial", nil)
@@ -147,6 +157,124 @@ var (
blockBatchWriteTimer = metrics.NewRegisteredTimer("chain/batch/write", nil) // time to flush the block batch to disk (blockBatch.Write) — spikes indicate DB compaction stalls
stateCommitTimer = metrics.NewRegisteredTimer("chain/state/commit", nil) // time for statedb.CommitWithUpdate — in pathdb mode, spikes indicate diff layer flushes
+ // Pipelined import SRC metrics
+ pipelineImportBlocksCounter = metrics.NewRegisteredCounter("chain/imports/pipelined/blocks", nil)
+ pipelineImportTotalTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/total", nil)
+ pipelineImportSRCTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/src", nil)
+ pipelineImportCollectTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/collect", nil)
+ pipelineImportFallbackCounter = metrics.NewRegisteredCounter("chain/imports/pipelined/fallback", nil)
+ pipelineImportHitCounter = metrics.NewRegisteredCounter("chain/imports/pipelined/hit", nil) // pending matched next block's parent — overlap achieved
+ pipelineImportMissCounter = metrics.NewRegisteredCounter("chain/imports/pipelined/miss", nil) // pending didn't match — flushed (reorg/gap)
+ pipelineImportRootMismatchCounter = metrics.NewRegisteredCounter("chain/imports/pipelined/root_mismatch", nil) // SRC goroutine returned wrong root — safety alarm, must stay zero
+ // Mode gauge — 1 when pipelined SRC import is enabled on this node, 0 otherwise.
+ // Dashboards can use this to distinguish "metric is zero because pipelining is off"
+ // from "metric is zero because the pipelined code path bypassed its emit site".
+ pipelineImportEnabledGauge = metrics.NewRegisteredGauge("chain/imports/pipelined/enabled", nil)
+
+ // Cheap-exec timer for pipelined import. Wraps the synchronous
+ // ProcessBlock call (FlatDiff overlay path). Disambiguates "cheap exec
+ // is itself slow" from "main path waited on prev SRC" — chain/imports/
+ // pipelined/collect covers only the wait, and the parity chain/execution
+ // timer wraps the entire persistPipelinedImport (which includes that wait),
+ // so neither pinpoints the cheap exec on its own.
+ pipelineImportCheapExecTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/cheap_exec", nil)
+ pipelineImportCheapValidationTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/cheap_validation", nil)
+ pipelineImportExecutionTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/execution", nil)
+ // Execution time split by whether the previous block's SRC was still
+ // running during this block's execution. with_overlap vs no_overlap is the
+ // direct A/B the percent buckets refine — they answer "is execution slower
+ // on overlapped blocks?" without relying on temporal correlation.
+ pipelineImportExecWithOverlapTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/execution/with_overlap", nil)
+ pipelineImportExecNoOverlapTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/execution/no_overlap", nil)
+ pipelineImportExecOverlap0Timer = metrics.NewRegisteredTimer("chain/imports/pipelined/execution/overlap_0_percent", nil)
+ pipelineImportExecOverlap1To25Timer = metrics.NewRegisteredTimer("chain/imports/pipelined/execution/overlap_1_25_percent", nil)
+ pipelineImportExecOverlap25To50Timer = metrics.NewRegisteredTimer("chain/imports/pipelined/execution/overlap_25_50_percent", nil)
+ pipelineImportExecOverlap50To75Timer = metrics.NewRegisteredTimer("chain/imports/pipelined/execution/overlap_50_75_percent", nil)
+ pipelineImportExecOverlap75To100Timer = metrics.NewRegisteredTimer("chain/imports/pipelined/execution/overlap_75_100_percent", nil)
+ pipelineImportPostExecTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/post_exec", nil)
+ pipelineImportPostExecResidualTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/post_exec/residual", nil)
+ pipelineImportWitnessCaptureTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/witness_capture", nil)
+ pipelineImportPrefetchDetachTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/prefetch_detach", nil)
+ pipelineImportPrefetchCleanupTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/prefetch_cleanup", nil)
+ pipelineImportSRCPrefetchWaitTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/src/prefetch_wait", nil)
+ pipelineImportSRCPrefetchReportTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/src/prefetch_report", nil)
+ pipelineImportSRCOpenStateDBTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/src/open_statedb", nil)
+ pipelineImportSRCApplyFlatDiffTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/src/apply_flatdiff", nil)
+ pipelineImportSRCCommitTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/src/commit", nil)
+ // SRC wall-clock split by whether the next block's execution overlapped it.
+ // The next-block-perspective counterpart to the execution split above.
+ pipelineImportSRCWithNextExecTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/src/with_next_exec_overlap", nil)
+ pipelineImportSRCNoNextExecTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/src/no_next_exec_overlap", nil)
+ pipelineImportOverlapExecutionTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/overlap/execution", nil)
+ pipelineImportCommitSnapshotTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/commit_snapshot", nil)
+ pipelineImportCollectTotalTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/collect_total", nil)
+ pipelineImportStateSyncFeedTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/state_sync_feed", nil)
+ pipelineImportReorgCheckTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/reorg_check", nil)
+ pipelineImportSetFlatDiffTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/set_flatdiff", nil)
+ pipelineImportWriteHeadTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/write_head", nil)
+ pipelineImportBuildSRCBlockTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/build_src_block", nil)
+ pipelineImportSpawnSRCTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/spawn_src", nil)
+ pipelineImportPendingPublishTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/pending_publish", nil)
+ pipelineImportWarmSnapshotCollect = metrics.NewRegisteredTimer("chain/imports/pipelined/warm_snapshot/collect", nil)
+ pipelineImportWarmSnapshotBuild = metrics.NewRegisteredTimer("chain/imports/pipelined/warm_snapshot/build", nil)
+ pipelineImportWarmSnapshotFetchers = metrics.NewRegisteredHistogram("chain/imports/pipelined/warm_snapshot/fetchers", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineImportOverlapBlocksCounter = metrics.NewRegisteredCounter("chain/imports/pipelined/overlap/blocks", nil)
+ pipelineImportNoOverlapBlocksCounter = metrics.NewRegisteredCounter("chain/imports/pipelined/overlap/no_overlap", nil)
+ pipelineImportOverlapExecutionPercent = metrics.NewRegisteredHistogram("chain/imports/pipelined/overlap/execution_percent", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineImportSRCPrefetchSubfetchers = metrics.NewRegisteredHistogram("chain/imports/pipelined/src/prefetch_subfetchers", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineImportWarmSnapshotNodes = metrics.NewRegisteredHistogram("chain/imports/pipelined/warm_snapshot/nodes", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineImportWarmSnapshotBytes = metrics.NewRegisteredHistogram("chain/imports/pipelined/warm_snapshot/bytes", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineImportWarmSnapshotAccountNodes = metrics.NewRegisteredHistogram("chain/imports/pipelined/warm_snapshot/account_nodes", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineImportWarmSnapshotStorageNodes = metrics.NewRegisteredHistogram("chain/imports/pipelined/warm_snapshot/storage_nodes", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineImportWarmSnapshotAccountBytes = metrics.NewRegisteredHistogram("chain/imports/pipelined/warm_snapshot/account_bytes", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineImportWarmSnapshotStorageBytes = metrics.NewRegisteredHistogram("chain/imports/pipelined/warm_snapshot/storage_bytes", nil, metrics.NewExpDecaySample(1028, 0.015))
+
+ // Normal import phase timers. These mirror the pipelined phase timers enough
+ // to compare the "Imported new chain segment" elapsed breakdown between
+ // develop-style import and pipelined import.
+ normalImportTotalTimer = metrics.NewRegisteredTimer("chain/imports/normal/total", nil)
+ normalImportProcessTimer = metrics.NewRegisteredTimer("chain/imports/normal/process", nil)
+ normalImportValidationTimer = metrics.NewRegisteredTimer("chain/imports/normal/validation", nil)
+ normalImportReorgCheckTimer = metrics.NewRegisteredTimer("chain/imports/normal/reorg_check", nil)
+ normalImportWriteTimer = metrics.NewRegisteredTimer("chain/imports/normal/write", nil)
+
+ // Auto-collection phase timers. The auto-collection goroutine runs
+ // asynchronously after persistPipelinedImport returns:
+ // WaitForSRC -> verifyImportSRCRoot -> publishImportWitness -> handleImportTrieGC
+ // The main path's collect-wait (chain/imports/pipelined/collect) blocks
+ // until ALL these phases finish, so a sustained main-path wait is not
+ // necessarily a slow SRC compute — it could be slow witness publish or
+ // trie GC. WaitForSRC duration is already covered by chain/imports/
+ // pipelined/src; total covers the whole runImportAutoCollection wall
+ // time so dashboards can verify verify+publish+gc sums to total minus src.
+ pipelineImportAutoCollectTotalTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/auto_collect/total", nil)
+ pipelineImportAutoCollectVerifyTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/auto_collect/verify", nil)
+ pipelineImportAutoCollectPublishTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/auto_collect/publish", nil)
+ pipelineImportAutoCollectGCTimer = metrics.NewRegisteredTimer("chain/imports/pipelined/auto_collect/gc", nil)
+
+ // preloadFlatDiffReads instrumentation.
+ pipelineSRCPreloadTimer = metrics.NewRegisteredTimer("chain/pipelined/src/preload", nil)
+ pipelineSRCPreloadReadAccountsHistogram = metrics.NewRegisteredHistogram("chain/pipelined/src/preload/read_accounts", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineSRCPreloadSlotsHistogram = metrics.NewRegisteredHistogram("chain/pipelined/src/preload/slots", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineSRCPreloadDestructsHistogram = metrics.NewRegisteredHistogram("chain/pipelined/src/preload/destructs", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineSRCPreloadNonexistentHistogram = metrics.NewRegisteredHistogram("chain/pipelined/src/preload/nonexistent", nil, metrics.NewExpDecaySample(1028, 0.015))
+ pipelineSRCPreloadSlotsPerAccountHistogram = metrics.NewRegisteredHistogram("chain/pipelined/src/preload/slots_per_account", nil, metrics.NewExpDecaySample(1028, 0.015))
+
+ // Throughput histograms (mode-agnostic — emitted from both normal and pipelined import paths).
+ gasUsedPerBlockHistogram = metrics.NewRegisteredHistogram("chain/gas_used_per_block", nil, metrics.NewExpDecaySample(1028, 0.015))
+ txsPerBlockHistogram = metrics.NewRegisteredHistogram("chain/txs_per_block", nil, metrics.NewExpDecaySample(1028, 0.015))
+ importSegmentBlocksHistogram = metrics.NewRegisteredHistogram("chain/imports/segment/blocks", nil, metrics.NewExpDecaySample(1028, 0.015))
+ importSegmentElapsedTimer = metrics.NewRegisteredTimer("chain/imports/segment/elapsed", nil)
+ importSegmentGasUsedHistogram = metrics.NewRegisteredHistogram("chain/imports/segment/gas_used", nil, metrics.NewExpDecaySample(1028, 0.015))
+ importSegmentMgaspsHistogram = metrics.NewRegisteredHistogram("chain/imports/segment/mgasps", nil, metrics.NewExpDecaySample(1028, 0.015))
+ // Witness size histogram in bytes. Spikes here directly drive stateless-peer bandwidth cost.
+ witnessSizeBytesHistogram = metrics.NewRegisteredHistogram("chain/witness/size_bytes", nil, metrics.NewExpDecaySample(1028, 0.015))
+ // End-to-end import timer: from block processing start until the witness is
+ // on disk and peer-visible (non-pipelined: end of writeBlockWithState;
+ // pipelined: after WitnessReadyEvent fires in the auto-collection goroutine).
+ // Apples-to-apples A/B metric between modes.
+ witnessReadyEndToEndTimer = metrics.NewRegisteredTimer("chain/imports/witness_ready_end_to_end", nil)
+
errInsertionInterrupted = errors.New("insertion is interrupted")
errChainStopped = errors.New("blockchain is stopped")
errInvalidOldChain = errors.New("invalid old chain")
@@ -161,6 +289,12 @@ const (
receiptsCacheLimit = 1024
txLookupCacheLimit = 1024
+ slowImportBlockThreshold = time.Second
+ slowImportPostExecThreshold = 500 * time.Millisecond
+ slowImportCollectThreshold = 100 * time.Millisecond
+ slowImportSnapshotThreshold = 100 * time.Millisecond
+ slowImportResidualThreshold = 100 * time.Millisecond
+
// BlockChainVersion ensures that an incompatible database forces a resync from scratch.
//
// Changelog:
@@ -259,8 +393,45 @@ type BlockChainConfig struct {
// MilestoneFetcher returns the latest milestone end block from Heimdall.
MilestoneFetcher func(ctx context.Context) (uint64, error)
+
+ // EnablePipelinedImportSRC enables pipelined state root computation during
+ // block import: overlap SRC(N) with tx execution of block N+1.
+ EnablePipelinedImportSRC bool
+
+ // PipelinedImportSRCLogs enables verbose logging for the import pipeline.
+ PipelinedImportSRCLogs bool
+
+ // PipelinedSRCWarmSnapshot enables a warm-node handoff to the pipelined
+ // SRC goroutine when witnesses are produced: persistPipelinedImport
+ // captures the trie nodes the execution-side prefetcher had loaded into a
+ // quiesced WarmSnapshot; SRC's NewTrieOnly reader consults it before
+ // falling through to pathdb. Lookups are hash-verified and fall through
+ // to pathdb on miss, so root determinism and witness completeness are
+ // unaffected. Witness-off import ignores this flag — the pathdb node
+ // index already serves diff-layer nodes in one probe, and A/B
+ // benchmarking showed a commit-fed warm ring on top of it cost SRC-lane
+ // time instead of saving it.
+ PipelinedSRCWarmSnapshot bool
+}
+
+// PipelineImportOpts configures ProcessBlock for pipelined import mode.
+// When non-nil, ProcessBlock opens state at CommittedParentRoot (with optional
+// FlatDiff overlay) and uses ValidateStateCheap instead of full ValidateState.
+type PipelineImportOpts struct {
+ CommittedParentRoot common.Hash // Last committed trie root (grandparent when FlatDiff is set)
+ FlatDiff *state.FlatDiff // Previous block's state overlay (nil for first block in pipeline)
+ Mode string // "flatdiff" when overlaying pending SRC state, "direct" otherwise
+ PendingBlock uint64 // Pending SRC block that supplied FlatDiff, if any
+ PendingHash common.Hash // Hash of PendingBlock, if any
+ PendingCollected bool // Whether PendingBlock's SRC collection had already completed at selection time
+ pendingSRC *pendingSRCState
}
+const (
+ pipelineImportModeDirect = "direct"
+ pipelineImportModeFlatDiff = "flatdiff"
+)
+
// DefaultConfig returns the default config.
// Note the returned object is safe to modify!
func DefaultConfig() *BlockChainConfig {
@@ -345,6 +516,52 @@ type txLookup struct {
transaction *types.Transaction
}
+// pendingSRCState tracks an in-flight pipelined state root computation goroutine.
+// root, witness, and err are written by the goroutine before wg.Done();
+// callers block on wg.Wait() and read them afterwards.
+type pendingSRCState struct {
+ blockHash common.Hash
+ blockNumber uint64
+ wg sync.WaitGroup
+ startNanos atomic.Int64
+ doneNanos atomic.Int64
+ // Set by the next block's execution-metrics recording so SRC wall-clock can
+ // be split by whether that execution overlapped this SRC. classified gates
+ // the read (overlapped is only meaningful once classified is true).
+ nextExecOverlapped atomic.Bool
+ nextExecClassified atomic.Bool
+ root common.Hash
+ witness []byte // RLP-encoded witness built by the SRC goroutine
+ err error
+}
+
+// pendingImportSRCState stores the state of a block whose SRC goroutine has
+// been spawned. Block metadata is written to DB immediately; the state commit
+// runs in the background. An auto-collection goroutine waits for SRC to finish
+// and immediately writes the witness + handles trie GC, so collection doesn't
+// depend on the arrival of the next block.
+type pendingImportSRCState struct {
+ block *types.Block
+ flatDiff *state.FlatDiff
+ committedRoot common.Hash // last committed trie root when SRC was spawned
+ procTime time.Duration // for gcproc accumulation
+ blockStart time.Time // block processing start — used for chain/imports/witness_ready_end_to_end
+ makeWitness bool // whether the SRC goroutine is producing a witness for this block
+ src *pendingSRCState
+
+ // collectedCh is closed when auto-collection completes (verify root,
+ // write witness, trie GC). Callers block on <-collectedCh.
+ collectedCh chan struct{}
+ collectedRoot common.Hash // verified root (set before closing collectedCh)
+ collectedErr error // non-nil if SRC failed or root mismatch
+}
+
+// pipelinedImportStateAvailabilityGrace bounds how long sync-mode checks may
+// treat a missing head state as "currently being committed by pipelined SRC".
+// It only suppresses transient false positives in the write-head -> SRC-commit
+// handoff; once the window expires, missing state is reported normally.
+const pipelinedImportStateAvailabilityGrace = 2 * time.Minute
+
// BlockChain represents the canonical chain given a database with a genesis
// block. The Blockchain manages chain imports, reverts, chain reorganisations.
//
@@ -379,6 +596,7 @@ type BlockChain struct {
chainHeadFeed event.Feed
logsFeed event.Feed
blockProcFeed event.Feed
+ witnessReadyFeed event.Feed
blockProcCounter int32
scope event.SubscriptionScope
genesisBlock *types.Block
@@ -430,6 +648,35 @@ type BlockChain struct {
chain2HeadFeed event.Feed // Reorg/NewHead/Fork data feed
chainSideFeed event.Feed // Side chain data feed (removed from geth but needed in bor)
milestoneFetcher func(ctx context.Context) (uint64, error) // Function to fetch the latest milestone end block from Heimdall.
+
+ // Pipelined SRC: concurrent state root calculation.
+ // pendingSRC tracks the in-flight SRC goroutine for the most recent block.
+ pendingSRC *pendingSRCState
+ pendingSRCMu sync.Mutex
+
+ // pendingImportSRC tracks a block whose SRC goroutine is in-flight during
+ // pipelined import. Persists across insertChain calls.
+ pendingImportSRC *pendingImportSRCState
+ // srcHoldForTesting, when non-nil, is invoked at the start of each SRC
+ // goroutine. Tests use it to hold the pipelined window (head advanced,
+ // state root not yet committed) open deterministically.
+ srcHoldForTesting func(blockNumber uint64)
+ // pendingImportHead* covers the short gap after block metadata/head are
+ // written and before pendingImportSRC is published for the same block.
+ pendingImportHeadHash common.Hash
+ pendingImportHeadRoot common.Hash
+ pendingImportHeadStart time.Time
+ pendingImportSRCMu sync.Mutex
+
+ // lastFlatDiff holds the FlatDiff from the most recently committed block.
+ // The miner uses it together with the grandparent's committed root to open
+ // a StateDB via NewWithFlatBase, allowing block N+1 execution to start
+ // before the SRC goroutine finishes.
+ lastFlatDiff *state.FlatDiff
+ lastFlatDiffBlockNum uint64
+ lastFlatDiffParentRoot common.Hash // committed root that the FlatDiff is based on
+ lastFlatDiffBlockRoot common.Hash // the block's own state root (from header)
+ lastFlatDiffMu sync.RWMutex
}
// NewBlockChain returns a fully initialised block chain using information
@@ -439,6 +686,11 @@ func NewBlockChain(db ethdb.Database, genesis *Genesis, engine consensus.Engine,
if cfg == nil {
cfg = DefaultConfig()
}
+ if cfg.EnablePipelinedImportSRC {
+ pipelineImportEnabledGauge.Update(1)
+ } else {
+ pipelineImportEnabledGauge.Update(0)
+ }
// Open trie database with provided config
enableVerkle, err := EnableVerkleAtGenesis(db, genesis)
@@ -725,30 +977,240 @@ func (bc *BlockChain) fireBlockStart(block *types.Block) {
// setupBlockReaders builds the three StateDBs needed for parallel block
// processing: throwaway (for prefetcher), statedb (for serial processor),
-// and parallelStatedb (for V2). The V2 statedb has concurrent reads
-// enabled before the prefetcher runs so the underlying trieReader uses
-// muSubTries throughout — switching mid-flight would race.
-func (bc *BlockChain) setupBlockReaders(parentRoot common.Hash) (
+// and parallelStatedb (for V2).
+func (bc *BlockChain) setupBlockReaders(parent *types.Header, pipeOpts *PipelineImportOpts) (
throwaway, statedb, parallelStatedb *state.StateDB,
prefetch, process, parallel state.ReaderWithStats, err error,
) {
- prefetch, process, parallel, err = bc.statedb.ReadersWithCacheStatsTriple(parentRoot)
+ // Under pipelined import parent.Root may not be committed yet. Open
+ // trie readers against the last committed root and install the FlatDiff
+ // overlay below so execution still sees the previous block's post-state.
+ readerRoot := pipelineReaderRoot(parent, pipeOpts)
+ prefetch, process, parallel, err = bc.statedb.ReadersWithCacheStatsTriple(readerRoot)
if err != nil {
return nil, nil, nil, nil, nil, nil, err
}
- if throwaway, err = state.NewWithReader(parentRoot, bc.statedb, prefetch); err != nil {
+ if throwaway, err = state.NewWithReader(readerRoot, bc.statedb, prefetch); err != nil {
return nil, nil, nil, nil, nil, nil, err
}
- if statedb, err = state.NewWithReader(parentRoot, bc.statedb, process); err != nil {
+ if statedb, err = state.NewWithReader(readerRoot, bc.statedb, process); err != nil {
return nil, nil, nil, nil, nil, nil, err
}
- if parallelStatedb, err = state.NewWithReader(parentRoot, bc.statedb, parallel); err != nil {
+ if parallelStatedb, err = state.NewWithReader(readerRoot, bc.statedb, parallel); err != nil {
return nil, nil, nil, nil, nil, nil, err
}
+ applyFlatDiffOverlayToAll(pipeOpts, throwaway, statedb, parallelStatedb)
parallelStatedb.EnableConcurrentReads()
return throwaway, statedb, parallelStatedb, prefetch, process, parallel, nil
}
+func pipelineImportMode(pipeOpts *PipelineImportOpts) string {
+ if pipeOpts == nil {
+ return "disabled"
+ }
+ if pipeOpts.Mode != "" {
+ return pipeOpts.Mode
+ }
+ if pipeOpts.FlatDiff != nil {
+ return pipelineImportModeFlatDiff
+ }
+ return pipelineImportModeDirect
+}
+
+func pendingImportSRCCollected(pending *pendingImportSRCState) bool {
+ if pending == nil {
+ return false
+ }
+ select {
+ case <-pending.collectedCh:
+ return true
+ default:
+ return false
+ }
+}
+
+func (p *pendingSRCState) markStarted(t time.Time) {
+ if p != nil {
+ p.startNanos.Store(t.UnixNano())
+ }
+}
+
+func (p *pendingSRCState) markDone(t time.Time) {
+ if p != nil {
+ p.doneNanos.Store(t.UnixNano())
+ }
+}
+
+func (p *pendingSRCState) executionOverlap(execStart, execEnd time.Time) time.Duration {
+ if p == nil || execStart.IsZero() || execEnd.IsZero() || !execEnd.After(execStart) {
+ return 0
+ }
+ srcStart := p.startNanos.Load()
+ if srcStart == 0 {
+ return 0
+ }
+ overlapStart := execStart.UnixNano()
+ if srcStart > overlapStart {
+ overlapStart = srcStart
+ }
+ overlapEnd := execEnd.UnixNano()
+ if srcDone := p.doneNanos.Load(); srcDone != 0 && srcDone < overlapEnd {
+ overlapEnd = srcDone
+ }
+ if overlapEnd <= overlapStart {
+ return 0
+ }
+ return time.Duration(overlapEnd - overlapStart)
+}
+
+func recordPipelinedImportExecutionMetrics(pipeOpts *PipelineImportOpts, execStart, execEnd time.Time) {
+ if pipeOpts == nil || execStart.IsZero() || execEnd.IsZero() || !execEnd.After(execStart) {
+ return
+ }
+ execDuration := execEnd.Sub(execStart)
+ pipelineImportExecutionTimer.Update(execDuration)
+
+ if pipeOpts.pendingSRC == nil {
+ return
+ }
+ src := pipeOpts.pendingSRC
+ overlap := src.executionOverlap(execStart, execEnd)
+ pipelineImportOverlapExecutionTimer.Update(overlap)
+ if overlap > 0 {
+ pipelineImportOverlapBlocksCounter.Inc(1)
+ pipelineImportExecWithOverlapTimer.Update(execDuration)
+ } else {
+ pipelineImportNoOverlapBlocksCounter.Inc(1)
+ pipelineImportExecNoOverlapTimer.Update(execDuration)
+ }
+
+ var percent int64
+ if execDuration > 0 {
+ percent = overlap.Nanoseconds() * 100 / execDuration.Nanoseconds()
+ pipelineImportOverlapExecutionPercent.Update(percent)
+ }
+ recordExecutionOverlapBucket(percent, execDuration)
+
+ // Record the classification so the SRC-side split can be emitted once the
+ // SRC goroutine is collected (its wall-clock isn't final yet here).
+ src.nextExecOverlapped.Store(overlap > 0)
+ src.nextExecClassified.Store(true)
+}
+
+// recordExecutionOverlapBucket files this block's execution time into the
+// bucket matching how much of it overlapped the previous SRC. percent is
+// already clamped to 0..100 (overlap can't exceed execution duration).
+func recordExecutionOverlapBucket(percent int64, execDuration time.Duration) {
+ switch {
+ case percent <= 0:
+ pipelineImportExecOverlap0Timer.Update(execDuration)
+ case percent < 25:
+ pipelineImportExecOverlap1To25Timer.Update(execDuration)
+ case percent < 50:
+ pipelineImportExecOverlap25To50Timer.Update(execDuration)
+ case percent < 75:
+ pipelineImportExecOverlap50To75Timer.Update(execDuration)
+ default:
+ pipelineImportExecOverlap75To100Timer.Update(execDuration)
+ }
+}
+
+// recordPipelinedImportSRCOverlapSplit files the SRC goroutine's full
+// wall-clock into the with/no-next-exec-overlap timer. Called after collection,
+// when doneNanos is final and the next block's execution has classified it.
+func recordPipelinedImportSRCOverlapSplit(src *pendingSRCState) {
+ if src == nil || !src.nextExecClassified.Load() {
+ return
+ }
+ start := src.startNanos.Load()
+ done := src.doneNanos.Load()
+ // done == start is legitimate (sub-resolution SRC); only reject an
+ // uninitialized start or an inverted window.
+ if start == 0 || done < start {
+ return
+ }
+ dur := time.Duration(done - start)
+ if src.nextExecOverlapped.Load() {
+ pipelineImportSRCWithNextExecTimer.Update(dur)
+ } else {
+ pipelineImportSRCNoNextExecTimer.Update(dur)
+ }
+}
+
+func flatDiffLogAttrs(diff *state.FlatDiff) []interface{} {
+ attrs := []interface{}{"hasFlatDiff", diff != nil}
+ if diff == nil {
+ return append(attrs,
+ "flatdiffAccounts", 0,
+ "flatdiffStorageAccounts", 0,
+ "flatdiffStorageSlots", 0,
+ "flatdiffReadSet", 0,
+ "flatdiffReadStorageAccounts", 0,
+ "flatdiffReadStorageSlots", 0,
+ "flatdiffDestructs", 0,
+ "flatdiffNonExistentReads", 0,
+ "flatdiffCode", 0,
+ )
+ }
+ storageSlots := 0
+ for _, slots := range diff.Storage {
+ storageSlots += len(slots)
+ }
+ readStorageSlots := 0
+ for _, slots := range diff.ReadStorage {
+ readStorageSlots += len(slots)
+ }
+ return append(attrs,
+ "flatdiffAccounts", len(diff.Accounts),
+ "flatdiffStorageAccounts", len(diff.Storage),
+ "flatdiffStorageSlots", storageSlots,
+ "flatdiffReadSet", len(diff.ReadSet),
+ "flatdiffReadStorageAccounts", len(diff.ReadStorage),
+ "flatdiffReadStorageSlots", readStorageSlots,
+ "flatdiffDestructs", len(diff.Destructs),
+ "flatdiffNonExistentReads", len(diff.NonExistentReads),
+ "flatdiffCode", len(diff.Code),
+ )
+}
+
+func pipelineImportLogAttrs(parent *types.Header, pipeOpts *PipelineImportOpts) []interface{} {
+ parentNumber := uint64(0)
+ parentHash := common.Hash{}
+ parentRoot := common.Hash{}
+ if parent != nil && parent.Number != nil {
+ parentNumber = parent.Number.Uint64()
+ }
+ if parent != nil {
+ parentHash = parent.Hash()
+ parentRoot = parent.Root
+ }
+ attrs := []interface{}{
+ "pipelineMode", pipelineImportMode(pipeOpts),
+ "parent", parentNumber,
+ "parentHash", parentHash,
+ "parentRoot", parentRoot,
+ }
+ if pipeOpts == nil {
+ return attrs
+ }
+ readerRoot := pipeOpts.CommittedParentRoot
+ if parent != nil {
+ readerRoot = pipelineReaderRoot(parent, pipeOpts)
+ }
+ attrs = append(attrs,
+ "readerRoot", readerRoot,
+ "committedParentRoot", pipeOpts.CommittedParentRoot,
+ )
+ if pipeOpts.PendingBlock != 0 {
+ attrs = append(attrs,
+ "pendingBlock", pipeOpts.PendingBlock,
+ "pendingHash", pipeOpts.PendingHash,
+ "pendingCollected", pipeOpts.PendingCollected,
+ )
+ }
+ return append(attrs, flatDiffLogAttrs(pipeOpts.FlatDiff)...)
+}
+
// reportReaderStats marks per-block cache hit/miss meters from prefetch,
// process, and parallel readers. Intended to be called via defer at the
// end of ProcessBlock.
@@ -825,7 +1287,7 @@ func (bc *BlockChain) startPrefetchGoroutine(block *types.Block, throwaway *stat
}(time.Now())
}
-func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, witness *stateless.Witness, followupInterrupt *atomic.Bool) (_ types.Receipts, _ []*types.Log, _ uint64, _ *state.StateDB, _ time.Duration, blockEndErr error) {
+func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, witness *stateless.Witness, followupInterrupt *atomic.Bool, pipeOpts *PipelineImportOpts) (_ types.Receipts, _ []*types.Log, _ uint64, _ *state.StateDB, vtime time.Duration, blockEndErr error) {
// Process the block using processor and parallelProcessor at the same time, take the one which finishes first, cancel the other, and return the result
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
@@ -838,7 +1300,7 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
defer func() { bc.logger.OnBlockEnd(blockEndErr) }()
}
- throwaway, statedb, parallelStatedb, prefetch, process, parallel, err := bc.setupBlockReaders(parent.Root)
+ throwaway, statedb, parallelStatedb, prefetch, process, parallel, err := bc.setupBlockReaders(parent, pipeOpts)
if err != nil {
return nil, nil, 0, nil, 0, err
}
@@ -857,6 +1319,8 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
counter *metrics.Counter
parallel bool
vtime time.Duration
+ execFrom time.Time
+ execTo time.Time
}
var resultChanLen int = 2
@@ -878,11 +1342,12 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
v2VmCfg := bc.cfg.VmConfig
sharedCaches.applyTo(&v2VmCfg)
res, err := bc.parallelProcessor.Process(block, parallelStatedb, v2VmCfg, nil, ctx)
- blockExecutionParallelTimer.UpdateSince(pstart)
+ pend := time.Now()
+ blockExecutionParallelTimer.Update(pend.Sub(pstart))
var localVtime time.Duration
if err == nil {
vstart := time.Now()
- err = bc.validator.ValidateState(block, parallelStatedb, res, false)
+ err = validateStateForPipeline(bc.validator, block, parallelStatedb, res, pipeOpts)
localVtime = time.Since(vstart)
}
// Stop prefetcher when either (a) ctx cancelled — we lost the race,
@@ -897,7 +1362,18 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
if res == nil {
res = &ProcessResult{}
}
- resultChan <- Result{res.Receipts, res.Logs, res.GasUsed, err, parallelStatedb, blockExecutionParallelCounter, true, localVtime}
+ resultChan <- Result{
+ receipts: res.Receipts,
+ logs: res.Logs,
+ usedGas: res.GasUsed,
+ err: err,
+ statedb: parallelStatedb,
+ counter: blockExecutionParallelCounter,
+ parallel: true,
+ vtime: localVtime,
+ execFrom: pstart,
+ execTo: pend,
+ }
}()
}
@@ -908,11 +1384,12 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
pstart := time.Now()
statedb.StartPrefetcher("chain", witness, nil)
res, err := bc.processor.Process(block, statedb, bc.cfg.VmConfig, nil, ctx)
- blockExecutionSerialTimer.UpdateSince(pstart)
+ pend := time.Now()
+ blockExecutionSerialTimer.Update(pend.Sub(pstart))
var localVtime time.Duration
if err == nil {
vstart := time.Now()
- err = bc.validator.ValidateState(block, statedb, res, false)
+ err = validateStateForPipeline(bc.validator, block, statedb, res, pipeOpts)
localVtime = time.Since(vstart)
}
if err != nil || ctx.Err() != nil {
@@ -921,7 +1398,18 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
if res == nil {
res = &ProcessResult{}
}
- resultChan <- Result{res.Receipts, res.Logs, res.GasUsed, err, statedb, blockExecutionSerialCounter, false, localVtime}
+ resultChan <- Result{
+ receipts: res.Receipts,
+ logs: res.Logs,
+ usedGas: res.GasUsed,
+ err: err,
+ statedb: statedb,
+ counter: blockExecutionSerialCounter,
+ parallel: false,
+ vtime: localVtime,
+ execFrom: pstart,
+ execTo: pend,
+ }
}()
}
@@ -934,7 +1422,9 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
// fallback would receive context.Canceled instead of a usable
// recovery. The fallback IS the recovery; it must run to completion.
if result.parallel && result.err != nil {
- log.Warn("Parallel state processor failed", "number", block.NumberU64(), "hash", block.Hash(), "err", result.err)
+ attrs := []interface{}{"number", block.NumberU64(), "hash", block.Hash(), "err", result.err}
+ attrs = append(attrs, pipelineImportLogAttrs(parent, pipeOpts)...)
+ log.Warn("Parallel state processor failed", attrs...)
blockExecutionParallelErrorCounter.Inc(1)
// Stop the failed V2 statedb's prefetcher before discarding the
// result. The V2 goroutine only stops it on ctx cancellation, so a
@@ -951,6 +1441,9 @@ func (bc *BlockChain) ProcessBlock(block *types.Block, parent *types.Header, wit
processorCount--
}
}
+ if result.err == nil {
+ recordPipelinedImportExecutionMetrics(pipeOpts, result.execFrom, result.execTo)
+ }
// With the result we plan to keep in hand, cancel the shared context
// so the loser (if any) stops at its next tx boundary, and signal the
@@ -1766,6 +2259,11 @@ func (bc *BlockChain) stopWithoutSaving() {
if bc.stateSizer != nil {
bc.stateSizer.Stop()
}
+ // Flush any pending import SRC before waiting for goroutines.
+ if err := bc.flushPendingImportSRC(); err != nil {
+ log.Error("Failed to flush pending import SRC during shutdown", "err", err)
+ }
+
// Now wait for all chain modifications to end and persistent goroutines to exit.
//
// Note: Close waits for the mutex to become available, i.e. any running chain
@@ -2364,47 +2862,10 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.
rawdb.WriteBlock(blockBatch, block)
rawdb.WriteReceipts(blockBatch, block.Hash(), block.NumberU64(), receipts)
- // System call appends state-sync logs into state. So, `state.Logs()` contains
- // all logs including system-call logs (state sync logs) while `logs` contains
- // only logs generated by transactions (receipts).
- //
- // That means that state.Logs() can have more logs than receipt logs.
- // In that case, we can safely assume that extra logs are from state sync logs.
- //
- // block logs = receipt logs + state sync logs = `state.Logs()`
- blockLogs := statedb.Logs()
-
- var stateSyncLogs []*types.Log
-
- if len(blockLogs) > 0 {
- // After Madhugiri HF we don't write bor receipts separately
- if !(bc.chainConfig.Bor != nil && bc.chainConfig.Bor.IsMadhugiri(block.Number())) && len(blockLogs) > len(logs) {
- sort.SliceStable(blockLogs, func(i, j int) bool {
- return blockLogs[i].Index < blockLogs[j].Index
- })
- stateSyncLogs = blockLogs[len(logs):] // get state-sync logs from `state.Logs()`
-
- // State sync logs don't have tx index, tx hash and other necessary fields
- // DeriveFieldsForBorLogs will fill those fields for websocket subscriptions
- types.DeriveFieldsForBorLogs(stateSyncLogs, block.Hash(), block.NumberU64(), uint(len(receipts)), uint(len(logs)))
-
- // Derive the cumulative gas used from last receipt of this block
- var cumulativeGasUsed uint64
- if len(receipts) > 0 {
- cumulativeGasUsed = receipts[len(receipts)-1].CumulativeGasUsed
- }
-
- // Write bor receipt
- rawdb.WriteBorReceipt(blockBatch, block.Hash(), block.NumberU64(), &types.ReceiptForStorage{
- Status: types.ReceiptStatusSuccessful, // make receipt status successful
- Logs: stateSyncLogs,
- CumulativeGasUsed: cumulativeGasUsed,
- })
-
- // Write bor tx reverse lookup
- rawdb.WriteBorTxLookupEntry(blockBatch, block.Hash(), block.NumberU64())
- }
- }
+ // Bor state-sync logs: system calls append state-sync logs into state, so
+ // state.Logs() may exceed the transaction-produced logs. Pre-Madhugiri we
+ // write a synthetic bor receipt + tx lookup entry for those.
+ stateSyncLogs := bc.writeBorStateSyncLogs(blockBatch, block, receipts, logs, statedb)
rawdb.WritePreimages(blockBatch, statedb.Preimages())
@@ -2426,6 +2887,7 @@ func (bc *BlockChain) writeBlockWithState(block *types.Block, receipts []*types.
bc.WriteWitness(block.Hash(), witnessBytes)
dbWriteDuration := time.Since(writeStart)
witnessDbWriteTimer.Update(dbWriteDuration)
+ witnessSizeBytesHistogram.Update(int64(len(witnessBytes)))
if encodeDuration > 100*time.Millisecond {
log.Warn("Slow witness encoding", "block", block.NumberU64(), "elapsed", common.PrettyDuration(encodeDuration), "size", common.StorageSize(len(witnessBytes)))
@@ -2547,69 +3009,11 @@ func (bc *BlockChain) writeBlockAndSetHead(block *types.Block, receipts []*types
if err != nil {
return NonStatTy, err
}
-
- currentBlock := bc.CurrentBlock()
- reorg, err := bc.forker.ReorgNeeded(currentBlock, block.Header())
+ status, err = bc.resolvePostWriteStatus(block, stateless)
if err != nil {
return NonStatTy, err
}
-
- if reorg {
- // Reorganise the chain if the parent is not the head block
- if block.ParentHash() != currentBlock.Hash() {
- if err = bc.reorg(currentBlock, block.Header()); err != nil {
- if !(stateless && err == errInvalidNewChain) { // fast forward may raise an invalid new chain error, skipping for stateless
- return NonStatTy, err
- }
- }
- }
-
- status = CanonStatTy
- } else {
- status = SideStatTy
- }
-
- // Set new head.
- if status == CanonStatTy {
- bc.writeHeadBlock(block)
-
- bc.chainFeed.Send(ChainEvent{
- Header: block.Header(),
- Receipts: receipts,
- Transactions: block.Transactions(),
- })
-
- if len(logs) > 0 {
- bc.logsFeed.Send(logs)
- }
- // send state sync logs into logs feed
- if len(stateSyncLogs) > 0 {
- bc.logsFeed.Send(stateSyncLogs)
- }
- // In theory, we should fire a ChainHeadEvent when we inject
- // a canonical block, but sometimes we can insert a batch of
- // canonical blocks. Avoid firing too many ChainHeadEvents,
- // we will fire an accumulated ChainHeadEvent and disable fire
- // event here.
- if emitHeadEvent {
- bc.chainHeadFeed.Send(ChainHeadEvent{Header: block.Header()})
- // BOR state sync feed related changes
- bc.stateSyncMu.RLock()
- for _, data := range bc.GetStateSync() {
- bc.stateSyncFeed.Send(StateSyncEvent{Data: data})
- }
- bc.stateSyncMu.RUnlock()
- // BOR
- }
- } else {
- bc.chainSideFeed.Send(ChainSideEvent{Header: block.Header()})
-
- bc.chain2HeadFeed.Send(Chain2HeadEvent{
- Type: Chain2HeadForkEvent,
- NewChain: []*types.Header{block.Header()},
- })
- }
-
+ bc.emitPostWriteEvents(block, receipts, logs, stateSyncLogs, status, emitHeadEvent)
return status, nil
}
@@ -2855,7 +3259,7 @@ func (bc *BlockChain) insertChainStatelessParallel(chain types.Blocks, witnesses
if witnesses[i].HeaderReader() != nil {
headerReader = witnesses[i].HeaderReader()
}
- if err := stateless.ValidateWitnessPreState(witnesses[i], headerReader); err != nil {
+ if err := stateless.ValidateWitnessPreState(witnesses[i], headerReader, block.Header()); err != nil {
stopHeaders()
return int(processed.Load()), fmt.Errorf("post-import witness validation failed for block %d: %w", block.NumberU64(), err)
}
@@ -3019,7 +3423,7 @@ func (bc *BlockChain) insertChainStatelessSequential(chain types.Blocks, witness
if witnesses[i].HeaderReader() != nil {
headerReader = witnesses[i].HeaderReader()
}
- if err := stateless.ValidateWitnessPreState(witnesses[i], headerReader); err != nil {
+ if err := stateless.ValidateWitnessPreState(witnesses[i], headerReader, block.Header()); err != nil {
return int(processed.Load()), fmt.Errorf("post-import witness validation failed for block %d: %w", block.NumberU64(), err)
}
}
@@ -3284,11 +3688,18 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
if parent == nil {
parent = bc.GetHeader(block.ParentHash(), block.NumberU64()-1)
}
- statedb, err := state.New(parent.Root, bc.statedb)
- if err != nil {
- return nil, it.index, err
+
+ // --- Pipelined import: check for pending SRC from previous block ---
+ pipelineActive := bc.cfg.EnablePipelinedImportSRC && setHead && !bc.cfg.Stateless
+ var pipeOpts *PipelineImportOpts
+ if pipelineActive {
+ pipeOpts = bc.buildPipelineImportOpts(block, parent)
}
+ // Note: ProcessBlock opens its own statedbs internally. The statedb
+ // created here in the original code was only used for activeState tracking.
+ // With pipelined import, ProcessBlock handles all state opening.
+
// If we are past Byzantium, enable prefetching to pull in trie node paths
// while processing transactions. Before Byzantium the prefetcher is mostly
// useless due to the intermediate root hashing after each transaction.
@@ -3302,12 +3713,7 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
return nil, it.index, err
}
}
- // Bor: We start the prefetcher in process block function called below
- // and not here as we copy state for block-stm in that function. Also,
- // we don't want to start duplicate prefetchers per block.
- // statedb.StartPrefetcher("chain", witness)
}
- activeState = statedb
var followupInterrupt atomic.Bool
@@ -3322,7 +3728,7 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
if witnesses[it.processed()-1].HeaderReader() != nil {
headerReader = witnesses[it.processed()-1].HeaderReader()
}
- if err := stateless.ValidateWitnessPreState(witnesses[it.processed()-1], headerReader); err != nil {
+ if err := stateless.ValidateWitnessPreState(witnesses[it.processed()-1], headerReader, block.Header()); err != nil {
log.Error("Witness validation failed during chain insertion", "blockNumber", block.Number(), "blockHash", block.Hash(), "err", err)
bc.reportBlock(block, &ProcessResult{}, err)
followupInterrupt.Store(true)
@@ -3343,7 +3749,16 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
}
}
- receipts, logs, usedGas, statedb, vtime, err := bc.ProcessBlock(block, parent, witness, &followupInterrupt)
+ cheapExecStart := time.Now()
+ receipts, logs, usedGas, statedb, vtime, err := bc.ProcessBlock(block, parent, witness, &followupInterrupt, pipeOpts)
+ cheapExecElapsed := time.Since(cheapExecStart)
+ if pipelineActive {
+ pipelineImportCheapExecTimer.Update(cheapExecElapsed)
+ pipelineImportCheapValidationTimer.Update(vtime)
+ } else {
+ normalImportProcessTimer.Update(cheapExecElapsed)
+ normalImportValidationTimer.Update(vtime)
+ }
bc.statedb.TrieDB().SetReadBackend(nil)
bc.statedb.EnableSnapInReader()
activeState = statedb
@@ -3351,9 +3766,47 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
if err != nil {
bc.reportBlock(block, &ProcessResult{Receipts: receipts}, err)
followupInterrupt.Store(true)
+ // Flush any pending import SRC before returning on error. Log any
+ // flush error (e.g., previous block's root mismatch) — the outer
+ // err takes precedence for the caller, but a silent flush failure
+ // would mask real corruption from the prior pipelined block.
+ if pipelineActive {
+ if flushErr := bc.flushPendingImportSRC(); flushErr != nil {
+ attrs := []interface{}{"block", block.NumberU64(), "flushErr", flushErr, "processErr", err}
+ attrs = append(attrs, pipelineImportLogAttrs(parent, pipeOpts)...)
+ log.Error("Pipelined import: flush failed after ProcessBlock error", attrs...)
+ }
+ }
return nil, it.index, err
}
+ // --- Pipelined import: extract FlatDiff, collect previous SRC, write metadata, spawn SRC ---
+ if pipelineActive {
+ adjustBack, err := bc.persistPipelinedImport(block, parent, statedb, receipts, logs, start, cheapExecElapsed, vtime, computeWitness)
+ if err != nil {
+ followupInterrupt.Store(true)
+ idx := it.index
+ if adjustBack {
+ idx--
+ }
+ return nil, idx, err
+ }
+ followupInterrupt.Store(true)
+ stats.processed++
+ stats.usedGas += usedGas
+ lastCanon = block
+ var snapDiffItems, snapBufItems common.StorageSize
+ if bc.snaps != nil {
+ snapDiffItems, snapBufItems = bc.snaps.Size()
+ }
+ trieDiffNodes, trieBufNodes, _ := bc.triedb.Size()
+ stats.report(chain, it.index, snapDiffItems, snapBufItems, trieDiffNodes, trieBufNodes, setHead, false)
+ emitPipelinedImportParityMetrics(statedb, start, pstart, vtime, block)
+ continue
+ }
+
+ // --- Normal (non-pipelined) write path ---
+
// BOR state sync feed related changes
bc.stateSyncMu.RLock()
for _, data := range bc.GetStateSync() {
@@ -3393,7 +3846,10 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
// available sometime before) and the block turns out to be invalid (i.e. not
// honouring the milestone or checkpoint). Use the block itself as current block
// so that it's considered as a `past` chain and the validation doesn't get bypassed.
+ reorgCheckStart := time.Now()
isValid, err = bc.forker.ValidateReorg(block.Header(), []*types.Header{block.Header()})
+ reorgCheckElapsed := time.Since(reorgCheckStart)
+ normalImportReorgCheckTimer.Update(reorgCheckElapsed)
if err != nil {
return nil, it.index, err
}
@@ -3408,6 +3864,8 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
} else {
status, err = bc.writeBlockAndSetHead(block, receipts, logs, statedb, false, false)
}
+ writeElapsed := time.Since(wstart)
+ normalImportWriteTimer.Update(writeElapsed)
followupInterrupt.Store(true)
@@ -3423,7 +3881,18 @@ func (bc *BlockChain) insertChainWithWitnesses(chain types.Blocks, setHead bool,
witnessCollectionTimer.Update(statedb.WitnessCollection)
blockWriteTimer.Update(time.Since(wstart) - statedb.AccountCommits - statedb.StorageCommits - statedb.SnapshotCommits - statedb.TrieDBCommits)
- blockInsertTimer.UpdateSince(start)
+ elapsedNormal := time.Since(start)
+ blockInsertTimer.Update(elapsedNormal)
+ normalImportTotalTimer.Update(elapsedNormal)
+ bc.logSlowNormalImport(block, cheapExecElapsed, vtime, reorgCheckElapsed, writeElapsed, elapsedNormal, statedb)
+ gasUsedPerBlockHistogram.Update(int64(block.GasUsed()))
+ txsPerBlockHistogram.Update(int64(len(block.Transactions())))
+ if elapsedNormal > 0 {
+ chainMgaspsMeter.Update(time.Duration(float64(block.GasUsed()) * 1000 / float64(elapsedNormal)))
+ }
+ // Witness has already been written inside writeBlockWithState by this point,
+ // so "witness ready" == "import complete" in the non-pipelined case.
+ witnessReadyEndToEndTimer.Update(elapsedNormal)
// Report the import stats before returning the various results
stats.processed++
@@ -4335,6 +4804,1304 @@ func (bc *BlockChain) SubscribeChain2HeadEvent(ch chan<- Chain2HeadEvent) event.
return bc.scope.Track(bc.chain2HeadFeed.Subscribe(ch))
}
+// WriteBlockAndSetHeadPipelined writes block data (header, body, receipts) to
+// the database and sets it as the chain head, WITHOUT committing trie state.
+// The state commit is handled separately by the SRC goroutine that already
+// called CommitWithUpdate. This avoids the "layer stale" error that occurs
+// when two CommitWithUpdate calls diverge from the same parent root.
+// WriteBlockAndSetHeadPipelined is the public variant that acquires the chain mutex.
+// Used by the miner pipeline (resultLoop) where the mutex is not already held.
+func (bc *BlockChain) WriteBlockAndSetHeadPipelined(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, emitHeadEvent bool, witnessBytes []byte) (WriteStatus, error) {
+ if !bc.chainmu.TryLock() {
+ return NonStatTy, errChainStopped
+ }
+ defer bc.chainmu.Unlock()
+
+ return bc.writeBlockAndSetHeadPipelined(block, receipts, logs, statedb, emitHeadEvent, witnessBytes)
+}
+
+// writeBlockAndSetHeadPipelined is the internal implementation. It writes block
+// data (header, body, receipts) to the database and sets it as the chain head,
+// WITHOUT committing trie state. The state commit is handled by the SRC goroutine.
+// This function does NOT acquire the chain mutex — the caller must ensure
+// proper synchronization (e.g., called from insertChainWithWitnesses).
+func (bc *BlockChain) writeBlockAndSetHeadPipelined(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, emitHeadEvent bool, witnessBytes []byte) (WriteStatus, error) {
+ status, stateSyncLogs, err := bc.writePipelinedBlockAndResolveStatus(block, receipts, logs, statedb, witnessBytes)
+ if err != nil {
+ return NonStatTy, err
+ }
+ bc.emitPostWriteEvents(block, receipts, logs, stateSyncLogs, status, emitHeadEvent)
+ return status, nil
+}
+
+func (bc *BlockChain) writePipelinedBlockAndResolveStatus(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, witnessBytes []byte) (WriteStatus, []*types.Log, error) {
+ ptd := bc.GetTd(block.ParentHash(), block.NumberU64()-1)
+ if ptd == nil {
+ return NonStatTy, nil, consensus.ErrUnknownAncestor
+ }
+ stateSyncLogs, err := bc.writePipelinedBlockBatch(block, receipts, logs, statedb, witnessBytes, new(big.Int).Add(block.Difficulty(), ptd))
+ if err != nil {
+ return NonStatTy, nil, err
+ }
+ status, err := bc.resolvePostWriteStatus(block, false)
+ if err != nil {
+ return NonStatTy, nil, err
+ }
+ return status, stateSyncLogs, nil
+}
+
+// writePipelinedBlockBatch assembles one atomic batch with the block, its
+// receipts, bor state-sync logs (pre-Madhugiri only), preimages, the SRC
+// goroutine's witness, and total difficulty — then flushes it. Returns the
+// stateSyncLogs slice so the caller can emit them on the logs feed.
+// The SRC witness replaces the execution-side witness because FlatDiff
+// overlay accounts bypass the trie during speculative execution, so their
+// MPT proof nodes are only captured during SRC's CommitWithUpdate.
+func (bc *BlockChain) writePipelinedBlockBatch(block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB, witnessBytes []byte, externTd *big.Int) ([]*types.Log, error) {
+ blockBatch := bc.db.NewBatch()
+ rawdb.WriteTd(blockBatch, block.Hash(), block.NumberU64(), externTd)
+ rawdb.WriteBlock(blockBatch, block)
+ rawdb.WriteReceipts(blockBatch, block.Hash(), block.NumberU64(), receipts)
+ stateSyncLogs := bc.writeBorStateSyncLogs(blockBatch, block, receipts, logs, statedb)
+ rawdb.WritePreimages(blockBatch, statedb.Preimages())
+ if len(witnessBytes) > 0 {
+ witWriteStart := time.Now()
+ bc.WriteWitness(block.Hash(), witnessBytes)
+ witnessDbWriteTimer.UpdateSince(witWriteStart)
+ witnessSizeBytesHistogram.Update(int64(len(witnessBytes)))
+ }
+ batchStart := time.Now()
+ if err := blockBatch.Write(); err != nil {
+ log.Crit("Failed to write block into disk", "err", err)
+ }
+ blockBatchWriteTimer.UpdateSince(batchStart)
+ rawdb.WriteBytecodeSyncLastBlock(bc.db, block.NumberU64())
+ return stateSyncLogs, nil
+}
+
+// writeBorStateSyncLogs emits a synthetic bor receipt + tx lookup entry for
+// state-sync logs (logs the node observed from Heimdall but no EVM tx
+// produced). Madhugiri replaces this with native receipt encoding and the
+// legacy path is skipped there. Returns the state-sync logs slice so the
+// caller can forward them on the logs feed.
+func (bc *BlockChain) writeBorStateSyncLogs(batch ethdb.Batch, block *types.Block, receipts []*types.Receipt, logs []*types.Log, statedb *state.StateDB) []*types.Log {
+ blockLogs := statedb.Logs()
+ if len(blockLogs) == 0 {
+ return nil
+ }
+ if bc.chainConfig.Bor != nil && bc.chainConfig.Bor.IsMadhugiri(block.Number()) {
+ return nil
+ }
+ if len(blockLogs) <= len(logs) {
+ return nil
+ }
+ sort.SliceStable(blockLogs, func(i, j int) bool {
+ return blockLogs[i].Index < blockLogs[j].Index
+ })
+ stateSyncLogs := blockLogs[len(logs):]
+ types.DeriveFieldsForBorLogs(stateSyncLogs, block.Hash(), block.NumberU64(), uint(len(receipts)), uint(len(logs)))
+ var cumulativeGasUsed uint64
+ if len(receipts) > 0 {
+ cumulativeGasUsed = receipts[len(receipts)-1].CumulativeGasUsed
+ }
+ rawdb.WriteBorReceipt(batch, block.Hash(), block.NumberU64(), &types.ReceiptForStorage{
+ Status: types.ReceiptStatusSuccessful,
+ Logs: stateSyncLogs,
+ CumulativeGasUsed: cumulativeGasUsed,
+ })
+ rawdb.WriteBorTxLookupEntry(batch, block.Hash(), block.NumberU64())
+ return stateSyncLogs
+}
+
+// resolvePostWriteStatus decides CanonStatTy vs SideStatTy for a freshly
+// written block and performs a reorg when needed. Shared by the standard
+// and pipelined write paths — non-deterministic tie-breaking here would
+// cause consensus splits between nodes. The stateless flag relaxes
+// errInvalidNewChain during fast-forward reorgs for stateless sync.
+func (bc *BlockChain) resolvePostWriteStatus(block *types.Block, stateless bool) (WriteStatus, error) {
+ currentBlock := bc.CurrentBlock()
+ reorg, err := bc.forker.ReorgNeeded(currentBlock, block.Header())
+ if err != nil {
+ return NonStatTy, err
+ }
+ if !reorg {
+ return SideStatTy, nil
+ }
+ if block.ParentHash() != currentBlock.Hash() {
+ if err := bc.reorg(currentBlock, block.Header()); err != nil {
+ if !(stateless && err == errInvalidNewChain) {
+ return NonStatTy, err
+ }
+ }
+ }
+ return CanonStatTy, nil
+}
+
+// emitPostWriteEvents publishes the correct event set for the resolved
+// write status. For CanonStatTy: writeHeadBlock + ChainEvent + (optional)
+// ChainHeadEvent + any state-sync events. For SideStatTy: chainSideFeed +
+// chain2HeadFeed. Shared by the standard and pipelined write paths.
+func (bc *BlockChain) emitPostWriteEvents(block *types.Block, receipts []*types.Receipt, logs, stateSyncLogs []*types.Log, status WriteStatus, emitHeadEvent bool) {
+ if status != CanonStatTy {
+ bc.chainSideFeed.Send(ChainSideEvent{Header: block.Header()})
+ bc.chain2HeadFeed.Send(Chain2HeadEvent{
+ Type: Chain2HeadForkEvent,
+ NewChain: []*types.Header{block.Header()},
+ })
+ return
+ }
+ bc.writeHeadBlock(block)
+ bc.chainFeed.Send(ChainEvent{
+ Header: block.Header(),
+ Receipts: receipts,
+ Transactions: block.Transactions(),
+ })
+ if len(logs) > 0 {
+ bc.logsFeed.Send(logs)
+ }
+ if len(stateSyncLogs) > 0 {
+ bc.logsFeed.Send(stateSyncLogs)
+ }
+ if !emitHeadEvent {
+ return
+ }
+ bc.chainHeadFeed.Send(ChainHeadEvent{Header: block.Header()})
+ bc.stateSyncMu.RLock()
+ for _, data := range bc.GetStateSync() {
+ bc.stateSyncFeed.Send(StateSyncEvent{Data: data})
+ }
+ bc.stateSyncMu.RUnlock()
+}
+
+// --- Pipelined SRC methods ---
+
+// PostExecState returns a StateDB representing the post-execution state
+// of the given block header. Under pipelined SRC, if the FlatDiff for this block
+// is still cached (i.e. this is the chain head), it returns a non-blocking
+// overlay state via NewWithFlatBase. Otherwise it falls back to resolving the
+// actual state root via StateAt.
+//
+// This is used by the txpool and RPC layer to get correct state when the chain
+// head was produced via the pipeline (where the committed trie root may lag
+// behind the actual post-execution state).
+func (bc *BlockChain) PostExecState(header *types.Header) (*state.StateDB, error) {
+ // Fast path: if we have the FlatDiff for this block, use it as an overlay.
+ // Matching by block number plus state root rather than hash because the
+ // hash may not be final at the time SetLastFlatDiff is called (the seal
+ // signature is added later). The miner's speculative path records a zero
+ // root (the root isn't computed yet when its FlatDiff is captured), so a
+ // zero stored root falls back to the number-only match; the import path
+ // always records the real root, which guards against serving a stale
+ // overlay for a different same-height block after a reorg.
+ bc.lastFlatDiffMu.RLock()
+ flatDiff := bc.lastFlatDiff
+ flatDiffBlockNum := bc.lastFlatDiffBlockNum
+ flatDiffParentRoot := bc.lastFlatDiffParentRoot
+ flatDiffBlockRoot := bc.lastFlatDiffBlockRoot
+ bc.lastFlatDiffMu.RUnlock()
+
+ if flatDiff != nil && flatDiffBlockNum == header.Number.Uint64() &&
+ (flatDiffBlockRoot == (common.Hash{}) || flatDiffBlockRoot == header.Root) {
+ // Open at the parent's committed root (which IS in the trie DB) and
+ // overlay the FlatDiff. We cannot use header.Root because it may not
+ // be committed yet (pipelined import SRC still running).
+ return state.NewWithFlatBase(flatDiffParentRoot, bc.statedb, flatDiff)
+ }
+
+ // Slow path: use the committed state root directly.
+ return bc.StateAt(header.Root)
+}
+
+// SpawnSRCGoroutine launches a background goroutine that computes the actual
+// state root for block by replaying flatDiff on top of parentRoot. When
+// makeWitness is true, the goroutine also completes (or, for legacy call
+// sites, produces) a stateless witness; when false, witness work, FlatDiff
+// read-surface preload, and witness encoding are all skipped — only deferred
+// state-root validation runs. The result is stored in pending.root;
+// pending.wg is decremented when finished.
+//
+// Witness ownership (when makeWitness=true) follows the LINEAR OWNERSHIP
+// INVARIANT documented at runSRCCompute. The import path passes execWitness =
+// the witness already populated by EVM execution (AddCode + AddBlockHash)
+// with allowOwnWitness=false; SRC then completes that single witness with
+// trie-proof nodes during ApplyFlatDiffForCommit + CommitWithUpdate. Call
+// sites with no execution witness in scope set execWitness=nil and
+// allowOwnWitness=true to explicitly permit SRC to create its own witness.
+// detachedPrefetcher is an optional execution-side prefetcher handoff. SRC
+// owns it after SpawnSRCGoroutine returns: it waits for the prefetcher to
+// finish and either discards the warm nodes (wait-only mode) or builds a
+// WarmSnapshot from them (useWarmSnapshot=true). This keeps the import thread
+// from blocking on prefetch completion while still letting SRC benefit from
+// the execution-side warmup.
+func (bc *BlockChain) SpawnSRCGoroutine(block *types.Block, parentRoot common.Hash, flatDiff *state.FlatDiff, makeWitness bool, execWitness *stateless.Witness, allowOwnWitness bool, detachedPrefetcher *state.DetachedPrefetcher, useWarmSnapshot bool) *pendingSRCState {
+ return bc.spawnSRCGoroutine(block, parentRoot, flatDiff, makeWitness, execWitness, allowOwnWitness, detachedPrefetcher, useWarmSnapshot, true)
+}
+
+func (bc *BlockChain) spawnSRCGoroutine(block *types.Block, parentRoot common.Hash, flatDiff *state.FlatDiff, makeWitness bool, execWitness *stateless.Witness, allowOwnWitness bool, detachedPrefetcher *state.DetachedPrefetcher, useWarmSnapshot bool, publishGlobal bool) *pendingSRCState {
+ pending := &pendingSRCState{
+ blockHash: block.Hash(),
+ blockNumber: block.NumberU64(),
+ }
+ if publishGlobal {
+ bc.pendingSRCMu.Lock()
+ bc.pendingSRC = pending
+ bc.pendingSRCMu.Unlock()
+ }
+
+ pending.wg.Add(1)
+ bc.wg.Add(1)
+ go bc.runSRCCompute(pending, block, parentRoot, flatDiff, makeWitness, execWitness, allowOwnWitness, detachedPrefetcher, useWarmSnapshot)
+ return pending
+}
+
+func recordDetachedPrefetchStats(stats state.PrefetcherSnapshotStats, useWarmSnapshot bool) {
+ pipelineImportSRCPrefetchWaitTimer.Update(stats.Drain)
+ pipelineImportSRCPrefetchReportTimer.Update(stats.Report)
+ pipelineImportSRCPrefetchSubfetchers.Update(int64(stats.Fetchers))
+ if !useWarmSnapshot {
+ return
+ }
+ pipelineImportWarmSnapshotCollect.Update(stats.Collect)
+ pipelineImportWarmSnapshotFetchers.Update(int64(stats.LoadedFetchers))
+ pipelineImportWarmSnapshotAccountNodes.Update(int64(stats.AccountNodes))
+ pipelineImportWarmSnapshotStorageNodes.Update(int64(stats.StorageNodes))
+ pipelineImportWarmSnapshotAccountBytes.Update(int64(stats.AccountBytes))
+ pipelineImportWarmSnapshotStorageBytes.Update(int64(stats.StorageBytes))
+ pipelineImportWarmSnapshotNodes.Update(int64(stats.AccountNodes + stats.StorageNodes))
+ pipelineImportWarmSnapshotBytes.Update(int64(stats.AccountBytes + stats.StorageBytes))
+}
+
+func finishDetachedPrefetcher(detachedPrefetcher *state.DetachedPrefetcher, useWarmSnapshot bool) *state.WarmSnapshot {
+ if detachedPrefetcher == nil {
+ return nil
+ }
+ if !useWarmSnapshot {
+ stats := detachedPrefetcher.Stop()
+ recordDetachedPrefetchStats(stats, false)
+ return nil
+ }
+ warmSnapshotInput, stats := detachedPrefetcher.StopAndCollectWarmSnapshot()
+ recordDetachedPrefetchStats(stats, true)
+ if warmSnapshotInput == nil {
+ return nil
+ }
+ buildStart := time.Now()
+ warmSnapshot := warmSnapshotInput.Build()
+ pipelineImportWarmSnapshotBuild.UpdateSince(buildStart)
+ return warmSnapshot
+}
+
+// runSRCCompute is the SRC goroutine body. It opens a StateDB at the
+// committed parent root, replays the FlatDiff, and commits to produce block
+// N's state root. Witness-producing SRC uses a trie-only reader and the
+// journaled FlatDiff replay so proof-path nodes are captured. Witness-off SRC
+// uses state.New plus the direct FlatDiff replay to avoid unnecessary journal
+// overhead. When makeWitness is true, SRC also preloads the FlatDiff read
+// surface so the witness covers proof-path nodes the speculative execution
+// skipped, and encodes + caches the resulting witness. When false, preload
+// and witness encoding are skipped — only deferred root validation runs. All
+// observable side effects (pending.root, pending.err, pending.witness,
+// witness cache) happen here before wg.Done().
+//
+// LINEAR OWNERSHIP INVARIANT for the execution witness W:
+//
+// 1. The main thread writes to W during ProcessBlock:
+// - AddCode via statedb.go (GetCode/GetCodeSize on contract calls)
+// - AddBlockHash via vm/instructions.go (BLOCKHASH opcode)
+// - AddState via statedb.go Finalise/IntermediateRoot reads
+// 2. The trie prefetcher does not write to W — subfetcher.loop only
+// populates trie-local prevalueTracer state. The import path detaches the
+// prefetcher before this goroutine is spawned; this goroutine then stops
+// the detached prefetcher synchronously before converting any warm nodes
+// into a WarmSnapshot. That stop has writers-exited semantics
+// (trie_prefetcher.go: <-sf.term gated on loop's `defer close(sf.term)`),
+// which provides the ordering guarantee should the prefetcher ever gain a
+// witness write path.
+// 3. The main thread hands W to this goroutine via SpawnSRCGoroutine and
+// never touches W again — it moves on to the next block with a fresh
+// witness.
+// 4. This goroutine writes to W during ApplyFlatDiffForCommit +
+// CommitWithUpdate, then encodes via encodeAndCachePendingWitness. The
+// cached bytes are immutable thereafter.
+//
+// The invariant requires:
+// - No AddState / AddCode / AddBlockHash call site reachable from a
+// prefetcher-spawned goroutine.
+// - No terminate(true) (async) call on the SRC handoff path.
+// - No reuse of W on the main thread after SpawnSRCGoroutine.
+func (bc *BlockChain) runSRCCompute(pending *pendingSRCState, block *types.Block, parentRoot common.Hash, flatDiff *state.FlatDiff, makeWitness bool, execWitness *stateless.Witness, allowOwnWitness bool, detachedPrefetcher *state.DetachedPrefetcher, useWarmSnapshot bool) {
+ defer bc.wg.Done()
+ defer pending.wg.Done()
+ pending.markStarted(time.Now())
+ defer func() {
+ pending.markDone(time.Now())
+ }()
+ defer func() {
+ if r := recover(); r != nil {
+ log.Error("Pipelined SRC: panic in SRC goroutine", "block", block.NumberU64(), "err", r)
+ pending.err = fmt.Errorf("SRC goroutine panicked: %v", r)
+ }
+ }()
+ if bc.srcHoldForTesting != nil {
+ bc.srcHoldForTesting(block.NumberU64())
+ }
+
+ // Hard-fail when a caller asked for a witness but did not hand one in.
+ // allowOwnWitness=true is the explicit opt-in for call sites that want
+ // SRC to create its own witness. Without it, the caller's contract is
+ // that the published witness is the same object EVM execution populated,
+ // preserving execution-time entries such as BLOCKHASH headers. BorWitness
+ // encoding intentionally excludes Codes (see core/stateless/encoding.go),
+ // so bytecode entries collected on the in-memory witness are not part of
+ // the canonical Bor witness wire format.
+ if makeWitness && execWitness == nil && !allowOwnWitness {
+ finishDetachedPrefetcher(detachedPrefetcher, false)
+ pending.err = fmt.Errorf(
+ "pipelined SRC witness requested without execution witness: block=%d hash=%s allowOwnWitness=false",
+ block.NumberU64(), block.Hash(),
+ )
+ return
+ }
+
+ warmSnapshot := finishDetachedPrefetcher(detachedPrefetcher, useWarmSnapshot)
+ openStart := time.Now()
+ tmpDB, witness, err := bc.openSRCStateDB(parentRoot, block, makeWitness, execWitness, warmSnapshot)
+ pipelineImportSRCOpenStateDBTimer.UpdateSince(openStart)
+ if err != nil {
+ pending.err = err
+ return
+ }
+ applyStart := time.Now()
+ if makeWitness {
+ tmpDB.ApplyFlatDiffForCommit(flatDiff)
+ } else {
+ tmpDB.ApplyFlatDiffForCommitFast(flatDiff)
+ }
+ pipelineImportSRCApplyFlatDiffTimer.UpdateSince(applyStart)
+
+ // Preload + read-surface histograms only fire when the witness is being
+ // produced — preloadFlatDiffReads exists solely to populate the witness
+ // with proof-path trie nodes, and the histograms describe its work.
+ if makeWitness {
+ recordAndPreloadSRCWitnessReads(tmpDB, flatDiff)
+ }
+
+ deleteEmptyObjects := bc.chainConfig.IsEIP158(block.Number())
+ commitStart := time.Now()
+ root, stateUpdate, err := tmpDB.CommitWithUpdate(block.NumberU64(), deleteEmptyObjects, bc.chainConfig.IsCancun(block.Number()))
+ commitElapsed := time.Since(commitStart)
+ pipelineImportSRCCommitTimer.Update(commitElapsed)
+ stateCommitTimer.Update(commitElapsed)
+ if err != nil {
+ log.Error("Pipelined SRC: CommitWithUpdate failed", "block", block.NumberU64(), "err", err)
+ pending.err = err
+ return
+ }
+ emitSRCStateDBMetrics(tmpDB)
+ if bc.stateSizer != nil {
+ bc.stateSizer.Notify(stateUpdate)
+ }
+ if makeWitness {
+ bc.encodeAndCachePendingWitness(pending, witness, block)
+ }
+ pending.root = root
+}
+
+// recordAndPreloadSRCWitnessReads measures the preload step's wall-time and
+// the size of the FlatDiff read surface, then re-reads that surface through
+// tmpDB so the witness captures proof-path trie nodes the speculative
+// execution skipped. ReadStorage is iterated directly (not via ReadSet)
+// because it also contains read-only slots on mutated accounts — answers
+// "how is storage-read load distributed", which is what shapes any future
+// parallelisation. Fires for both import and miner SRC since runSRCCompute
+// is shared.
+func recordAndPreloadSRCWitnessReads(tmpDB *state.StateDB, flatDiff *state.FlatDiff) {
+ preloadSlots := 0
+ for _, slots := range flatDiff.ReadStorage {
+ preloadSlots += len(slots)
+ pipelineSRCPreloadSlotsPerAccountHistogram.Update(int64(len(slots)))
+ }
+ pipelineSRCPreloadReadAccountsHistogram.Update(int64(len(flatDiff.ReadSet)))
+ pipelineSRCPreloadSlotsHistogram.Update(int64(preloadSlots))
+ pipelineSRCPreloadDestructsHistogram.Update(int64(len(flatDiff.Destructs)))
+ pipelineSRCPreloadNonexistentHistogram.Update(int64(len(flatDiff.NonExistentReads)))
+
+ preloadStart := time.Now()
+ preloadFlatDiffReads(tmpDB, flatDiff)
+ pipelineSRCPreloadTimer.UpdateSince(preloadStart)
+}
+
+// openSRCStateDB opens a StateDB at parentRoot for the pipelined SRC goroutine.
+// Reader choice depends on makeWitness:
+//
+// - makeWitness=true: NewTrieOnly. Every read walks the MPT, which is what
+// lets the witness capture proof-path nodes for FlatDiff overlay accounts
+// whose trie nodes weren't touched during speculative execution. Flat
+// readers would short-circuit the trie and leave the witness incomplete.
+// - makeWitness=false: state.New (multi-reader). Pre-state reads performed
+// by ApplyFlatDiffForCommitFast (original account/storage lookups for
+// existing objects and pure self-destructs) can hit a flat reader (pathdb
+// StateReader in path mode, snapshot in hash mode) instead of the MPT.
+// state.New falls back to the trie reader when no flat reader is
+// installed or StateReader errors, so correctness does not depend on the
+// flat reader being present. Root-consistency between readers at an
+// in-memory committed root is validated by the parity tests.
+// CommitWithUpdate's MPT walk resolves recently rewritten paths through
+// the pathdb node index (one probe per diff-layer node) rather than any
+// SRC-local warm cache.
+//
+// Witness ownership when makeWitness=true:
+//
+// - execWitness != nil: caller hands in the witness already populated by
+// EVM execution (AddCode + AddBlockHash + execution-time AddState). SRC
+// reuses it by attaching it to tmpDB so subsequent AddState calls during
+// ApplyFlatDiffForCommit and CommitWithUpdate land in the same object.
+// - execWitness == nil: only legal for call sites that opted in via
+// allowOwnWitness=true at the SpawnSRCGoroutine call site. SRC creates
+// its own witness, which contains only entries collected by the SRC path.
+// Callers that require execution-time witness entries must pass
+// execWitness != nil (enforced at the top of runSRCCompute).
+//
+// BorWitness serialises Headers and State only. Codes collected on the
+// in-memory witness are not part of the canonical Bor witness wire format.
+//
+// CommitWithUpdate walks the MPT for hashing regardless of reader choice, so
+// the state-root computation cost is unaffected; only the pre-state reads
+// avoid cold trie traversals.
+func (bc *BlockChain) openSRCStateDB(parentRoot common.Hash, block *types.Block, makeWitness bool, execWitness *stateless.Witness, warmSnapshot *state.WarmSnapshot) (*state.StateDB, *stateless.Witness, error) {
+ if !makeWitness {
+ // Witness-off path keeps the multi-reader: flat readers short-circuit
+ // value reads, and commit-time trie node reads are served by the
+ // pathdb node index.
+ tmpDB, err := state.New(parentRoot, bc.statedb)
+ if err != nil {
+ log.Error("Pipelined SRC: failed to open tmpDB", "parentRoot", parentRoot, "err", err)
+ return nil, nil, err
+ }
+ return tmpDB, nil, nil
+ }
+ // Witness-on path uses NewTrieOnly so every read walks the MPT and the
+ // witness captures proof-path nodes. When a warm snapshot is supplied,
+ // install a snapshot-aware reader: trie reads consult the snapshot
+ // (hash-verified) before falling through to pathdb. NewTrieOnly
+ // semantics, prevalueTracer recording, and witness completeness are
+ // unaffected — the snapshot only short-circuits the underlying
+ // NodeReader fetch.
+ tmpDB, err := state.NewTrieOnlyWithSnapshot(parentRoot, bc.statedb, warmSnapshot)
+ if err != nil {
+ log.Error("Pipelined SRC: failed to open tmpDB", "parentRoot", parentRoot, "err", err)
+ return nil, nil, err
+ }
+ witness := execWitness
+ if witness == nil {
+ // Miner / legacy fallback only; runSRCCompute already rejected this
+ // branch for the import path via the allowOwnWitness check.
+ newWitness, witnessErr := stateless.NewWitness(block.Header(), bc)
+ if witnessErr != nil {
+ log.Warn("Pipelined SRC: failed to create witness", "block", block.NumberU64(), "err", witnessErr)
+ return tmpDB, nil, nil
+ }
+ witness = newWitness
+ }
+ tmpDB.SetWitness(witness)
+ return tmpDB, witness, nil
+}
+
+// preloadFlatDiffReads touches every address/slot in the FlatDiff's read
+// surface so the witness sees the proof-path trie nodes even when the
+// speculative execution used the flat overlay. Covers:
+// - ReadSet accounts (+ their ReadStorage slots)
+// - Read-only storage for mutated accounts (ReadStorage)
+// - Pure-destruct accounts (no resurrection)
+// - Non-existent address reads (proof-of-absence)
+func preloadFlatDiffReads(tmpDB *state.StateDB, flatDiff *state.FlatDiff) {
+ for _, addr := range flatDiff.ReadSet {
+ tmpDB.GetBalance(addr)
+ for _, slot := range flatDiff.ReadStorage[addr] {
+ tmpDB.GetState(addr, slot)
+ }
+ }
+ for addr := range flatDiff.Accounts {
+ for _, slot := range flatDiff.ReadStorage[addr] {
+ tmpDB.GetState(addr, slot)
+ }
+ }
+ for addr := range flatDiff.Destructs {
+ if _, resurrected := flatDiff.Accounts[addr]; !resurrected {
+ tmpDB.GetBalance(addr)
+ }
+ }
+ for _, addr := range flatDiff.NonExistentReads {
+ tmpDB.GetBalance(addr)
+ }
+}
+
+// emitSRCStateDBMetrics reports the hash/update/commit timers from the
+// trie-only statedb. These mirror the import-path names in both modes so
+// dashboards work whether pipelining is on or off.
+func emitSRCStateDBMetrics(tmpDB *state.StateDB) {
+ accountHashTimer.Update(tmpDB.AccountHashes)
+ storageHashTimer.Update(tmpDB.StorageHashes)
+ accountUpdateTimer.Update(tmpDB.AccountUpdates)
+ storageUpdateTimer.Update(tmpDB.StorageUpdates)
+ accountCommitTimer.Update(tmpDB.AccountCommits)
+ storageCommitTimer.Update(tmpDB.StorageCommits)
+ snapshotCommitTimer.Update(tmpDB.SnapshotCommits)
+ triedbCommitTimer.Update(tmpDB.TrieDBCommits)
+ witnessCollectionTimer.Update(tmpDB.WitnessCollection)
+}
+
+// encodeAndCachePendingWitness RLP-encodes the witness (complete only after
+// CommitWithUpdate has run) and pushes it into the pending state + cache.
+// For imported blocks the hash is already final; for mined blocks the real
+// hash isn't known until Seal() finalises Extra, so the caller retrieves
+// the bytes via WaitForSRC and writes to DB under the sealed hash in
+// resultLoop.
+func (bc *BlockChain) encodeAndCachePendingWitness(pending *pendingSRCState, witness *stateless.Witness, block *types.Block) {
+ if witness == nil {
+ return
+ }
+ var witBuf bytes.Buffer
+ encodeStart := time.Now()
+ if err := witness.EncodeRLP(&witBuf); err != nil {
+ log.Error("Pipelined SRC: failed to encode witness", "block", block.NumberU64(), "err", err)
+ return
+ }
+ witnessEncodeTimer.UpdateSince(encodeStart)
+ pending.witness = witBuf.Bytes()
+ bc.witnessCache.Add(block.Hash(), pending.witness)
+}
+
+// WaitForSRC blocks until the pending SRC goroutine completes and returns the
+// computed state root and RLP-encoded witness. The witness may be nil if witness
+// creation failed or was not applicable. Returns an error if the goroutine
+// failed or no SRC is pending.
+func (bc *BlockChain) WaitForSRC() (common.Hash, []byte, error) {
+ bc.pendingSRCMu.Lock()
+ pending := bc.pendingSRC
+ bc.pendingSRCMu.Unlock()
+
+ return waitForSRCState(pending)
+}
+
+func waitForSRCState(pending *pendingSRCState) (common.Hash, []byte, error) {
+ if pending == nil {
+ return common.Hash{}, nil, errors.New("no pending SRC goroutine")
+ }
+
+ pending.wg.Wait()
+ if pending.err != nil {
+ return common.Hash{}, nil, pending.err
+ }
+ return pending.root, pending.witness, nil
+}
+
+// flushPendingImportSRC collects the pending import SRC goroutine (if any),
+// verifies the root, writes the block to DB, handles trie GC, and clears
+// the pending state. Called on shutdown, reorg, and when an incoming block
+// doesn't continue from the pending block.
+// flushPendingImportSRC waits for the auto-collection goroutine to finish
+// and clears the pending state. Called on shutdown and when an incoming block
+// doesn't follow the pending one (reorg/gap).
+func (bc *BlockChain) flushPendingImportSRC() error {
+ bc.pendingImportSRCMu.Lock()
+ pending := bc.pendingImportSRC
+ bc.pendingImportSRC = nil
+ bc.pendingImportSRCMu.Unlock()
+
+ if pending == nil {
+ return nil
+ }
+
+ pipelineImportFallbackCounter.Inc(1)
+
+ // Wait for auto-collection to finish (it handles verify, witness, trie GC)
+ <-pending.collectedCh
+ return pending.collectedErr
+}
+
+// collectPendingImportSRC collects the pending import SRC goroutine, writes
+// the previous block, and returns the new committed root. Unlike flush, this
+// does NOT clear pendingImportSRC (the caller replaces it with the new block).
+// collectPendingImportSRC waits for the auto-collection goroutine to finish
+// and returns the committed root. The actual work (verify root, write witness,
+// trie GC) is done by the auto-collection goroutine spawned alongside the SRC.
+func (bc *BlockChain) collectPendingImportSRC() (common.Hash, error) {
+ bc.pendingImportSRCMu.Lock()
+ pending := bc.pendingImportSRC
+ bc.pendingImportSRCMu.Unlock()
+
+ if pending == nil {
+ return common.Hash{}, errors.New("no pending import SRC")
+ }
+
+ // Wait for auto-collection goroutine to finish
+ <-pending.collectedCh
+
+ if pending.collectedErr != nil {
+ return common.Hash{}, pending.collectedErr
+ }
+ return pending.collectedRoot, nil
+}
+
+func (bc *BlockChain) markPendingImportHeadState(block *types.Block) {
+ bc.pendingImportSRCMu.Lock()
+ defer bc.pendingImportSRCMu.Unlock()
+
+ // writeBlockAndSetHeadPipelined exposes the new head before the SRC
+ // goroutine is registered below. Mark it so runtime sync-mode probes don't
+ // misclassify the handoff as a real missing-state condition.
+ bc.pendingImportHeadHash = block.Hash()
+ bc.pendingImportHeadRoot = block.Root()
+ bc.pendingImportHeadStart = time.Now()
+}
+
+func (bc *BlockChain) clearPendingImportHeadState(block *types.Block) {
+ bc.pendingImportSRCMu.Lock()
+ defer bc.pendingImportSRCMu.Unlock()
+
+ if bc.pendingImportHeadHash != block.Hash() || bc.pendingImportHeadRoot != block.Root() {
+ return
+ }
+ // Once pendingImportSRC is visible, the normal pending-SRC check owns the
+ // handoff state. Clear this temporary marker so stale heads don't mask
+ // unrelated missing-state failures.
+ bc.pendingImportHeadHash = common.Hash{}
+ bc.pendingImportHeadRoot = common.Hash{}
+ bc.pendingImportHeadStart = time.Time{}
+}
+
+// handleImportTrieGC performs trie garbage collection after a pipelined import
+// SRC has committed the state. Replicates writeBlockWithState's GC logic.
+func (bc *BlockChain) handleImportTrieGC(root common.Hash, blockNum uint64, procTime time.Duration) {
+ bc.gcproc += procTime
+ if bc.triedb.Scheme() == rawdb.PathScheme {
+ return
+ }
+ if bc.cfg.ArchiveMode {
+ _ = bc.triedb.Commit(root, false)
+ return
+ }
+ bc.triedb.Reference(root, common.Hash{})
+ bc.triegc.Push(root, -int64(blockNum))
+
+ triesInMemory := bc.cfg.GetTriesInMemory()
+ if blockNum <= triesInMemory {
+ return
+ }
+ bc.capTrieIfDirty()
+ chosen := blockNum - triesInMemory
+ bc.maybeFlushChosen(chosen, triesInMemory)
+ bc.dereferenceUpTo(chosen)
+}
+
+// capTrieIfDirty flushes dirty trie nodes to disk when either node memory
+// or preimages exceed their configured limits. Uses IdealBatchSize as a
+// margin so the cap leaves room for further inserts before the next check.
+func (bc *BlockChain) capTrieIfDirty() {
+ _, nodes, imgs := bc.triedb.Size()
+ limit := common.StorageSize(bc.cfg.TrieDirtyLimit) * 1024 * 1024
+ if nodes > limit || imgs > 4*1024*1024 {
+ _ = bc.triedb.Cap(limit - ethdb.IdealBatchSize)
+ }
+}
+
+// maybeFlushChosen commits state at block `chosen` when accumulated
+// processing time has crossed the flush interval. Skips on reorg (chosen
+// header missing); logs a warning when we're overdue vs. the optimum ratio.
+func (bc *BlockChain) maybeFlushChosen(chosen, triesInMemory uint64) {
+ flushInterval := time.Duration(bc.flushInterval.Load())
+ if bc.gcproc <= flushInterval {
+ return
+ }
+ header := bc.GetHeaderByNumber(chosen)
+ if header == nil {
+ log.Warn("Reorg in progress, trie commit postponed", "number", chosen)
+ return
+ }
+ if chosen < bc.lastWrite+triesInMemory && bc.gcproc >= 2*flushInterval {
+ log.Info("State in memory for too long, committing",
+ "time", bc.gcproc, "allowance", flushInterval,
+ "optimum", float64(chosen-bc.lastWrite)/float64(triesInMemory))
+ }
+ _ = bc.triedb.Commit(header.Root, true)
+ bc.lastWrite = chosen
+ bc.gcproc = 0
+}
+
+// dereferenceUpTo drops GC references for every cached trie root at or
+// below `chosen`, freeing the memory held for reorg-safety. Roots above
+// `chosen` are pushed back so we stop at the first still-in-memory entry.
+func (bc *BlockChain) dereferenceUpTo(chosen uint64) {
+ for !bc.triegc.Empty() {
+ r, number := bc.triegc.Pop()
+ if uint64(-number) > chosen {
+ bc.triegc.Push(r, number)
+ return
+ }
+ bc.triedb.Dereference(r)
+ }
+}
+
+// pipelineReaderRoot returns the trie root to open state readers against
+// during pipelined import. The block's parent.Root may not be committed
+// yet (the SRC goroutine for the parent is still running), so we fall back
+// to the last-committed root stored on the PipelineImportOpts. Callers
+// combine this with applyFlatDiffOverlayToAll to see post-execution state.
+func pipelineReaderRoot(parent *types.Header, pipeOpts *PipelineImportOpts) common.Hash {
+ if pipeOpts != nil {
+ return pipeOpts.CommittedParentRoot
+ }
+ return parent.Root
+}
+
+// applyFlatDiffOverlayToAll attaches the pipelined FlatDiff to every
+// statedb so reads see the previous block's post-execution values without
+// waiting for the SRC goroutine to commit the trie. No-op when pipelining
+// is off or the overlay is absent.
+func applyFlatDiffOverlayToAll(pipeOpts *PipelineImportOpts, dbs ...*state.StateDB) {
+ if pipeOpts == nil || pipeOpts.FlatDiff == nil {
+ return
+ }
+ for _, db := range dbs {
+ db.SetFlatDiffRef(pipeOpts.FlatDiff)
+ }
+}
+
+// validateStateForPipeline dispatches to the cheap validator under
+// pipelined import (gas + bloom + receipt root only; the full root match
+// happens later in the SRC goroutine) and to the full validator otherwise.
+// Centralising this keeps ProcessBlock's parallel/serial branches symmetric.
+func validateStateForPipeline(validator Validator, block *types.Block, statedb *state.StateDB, res *ProcessResult, pipeOpts *PipelineImportOpts) error {
+ if pipeOpts != nil {
+ return validator.ValidateStateCheap(block, statedb, res)
+ }
+ return validator.ValidateState(block, statedb, res, false)
+}
+
+// pipelinedImportPersistTimings captures the synchronous post-execution phases
+// that are included in the "Imported new chain segment" elapsed time but are
+// not part of ProcessBlock itself.
+type pipelinedImportPersistTimings struct {
+ witnessCapture time.Duration
+ prefetchDetach time.Duration
+ prefetchCleanup time.Duration
+ commitSnapshot time.Duration
+ collect time.Duration
+ collectTotal time.Duration
+ stateSyncFeed time.Duration
+ reorgCheck time.Duration
+ setFlatDiff time.Duration
+ writeHead time.Duration
+ buildSRCBlock time.Duration
+ spawnSRC time.Duration
+ pendingPublish time.Duration
+ residual time.Duration
+ total time.Duration
+}
+
+func (t pipelinedImportPersistTimings) accounted() time.Duration {
+ return t.witnessCapture +
+ t.commitSnapshot +
+ t.prefetchDetach +
+ t.prefetchCleanup +
+ t.collectTotal +
+ t.stateSyncFeed +
+ t.reorgCheck +
+ t.setFlatDiff +
+ t.writeHead +
+ t.buildSRCBlock +
+ t.spawnSRC +
+ t.pendingPublish
+}
+
+// persistPipelinedImport handles the post-ProcessBlock work for a pipelined
+// imported block: extract FlatDiff, collect any still-pending SRC from the
+// previous block, publish the state-sync feed, write block metadata
+// immediately (so sync protocol sees it), spawn a new SRC goroutine, and
+// start auto-collection. adjustBack=true signals the caller to decrement
+// it.index when returning the error (because the failure belongs to the
+// previously pending block, not the current one).
+func (bc *BlockChain) persistPipelinedImport(block *types.Block, parent *types.Header, statedb *state.StateDB, receipts []*types.Receipt, logs []*types.Log, start time.Time, cheapExec, validation time.Duration, makeWitness bool) (adjustBack bool, err error) {
+ persistStart := time.Now()
+ timings := pipelinedImportPersistTimings{}
+ defer func() {
+ timings.total = time.Since(persistStart)
+ if accounted := timings.accounted(); timings.total > accounted {
+ timings.residual = timings.total - accounted
+ }
+ pipelineImportPostExecTimer.Update(timings.total)
+ pipelineImportPostExecResidualTimer.Update(timings.residual)
+ bc.logSlowPipelinedImport(block, time.Since(start), cheapExec, validation, timings, statedb)
+ }()
+ // Capture the execution witness so SRC can complete it. The trie
+ // prefetcher does not write to this witness — subfetcher.loop only
+ // populates trie-local prevalueTracer state — so the witness can be handed
+ // to SRC independently of the detached prefetcher. See LINEAR OWNERSHIP
+ // INVARIANT at runSRCCompute.
+ var execWitness *stateless.Witness
+ phaseStart := time.Now()
+ if makeWitness {
+ execWitness = statedb.Witness()
+ }
+ timings.witnessCapture = time.Since(phaseStart)
+ pipelineImportWitnessCaptureTimer.Update(timings.witnessCapture)
+
+ phaseStart = time.Now()
+ flatDiff := statedb.CommitSnapshot(bc.chainConfig.IsEIP158(block.Number()))
+ timings.commitSnapshot = time.Since(phaseStart)
+ pipelineImportCommitSnapshotTimer.Update(timings.commitSnapshot)
+
+ // The pipelined path doesn't commit this StateDB; SRC opens its own tmpDB.
+ // Detach the execution prefetcher after CommitSnapshot so Finalise can
+ // still enqueue the dirty-object prefetch work it normally would. The
+ // import thread does not wait here: SRC owns the returned handle and will
+ // synchronously stop/report it before computing the root. If an error
+ // occurs before the handle is handed to SRC, the defer consumes it to avoid
+ // leaking prefetcher goroutines.
+ var detachedPrefetcher *state.DetachedPrefetcher
+ phaseStart = time.Now()
+ detachedPrefetcher = statedb.DetachPrefetcher()
+ timings.prefetchDetach = time.Since(phaseStart)
+ pipelineImportPrefetchDetachTimer.Update(timings.prefetchDetach)
+ defer func() {
+ if detachedPrefetcher != nil {
+ cleanupStart := time.Now()
+ finishDetachedPrefetcher(detachedPrefetcher, false)
+ timings.prefetchCleanup = time.Since(cleanupStart)
+ pipelineImportPrefetchCleanupTimer.Update(timings.prefetchCleanup)
+ }
+ }()
+
+ phaseStart = time.Now()
+ committedRoot, collectElapsed, err := bc.collectPrevImportSRCIfAny(block, parent)
+ timings.collectTotal = time.Since(phaseStart)
+ pipelineImportCollectTotalTimer.Update(timings.collectTotal)
+ timings.collect = collectElapsed
+ if err != nil {
+ return true, err
+ }
+ phaseStart = time.Now()
+ bc.emitStateSyncFeed()
+ timings.stateSyncFeed = time.Since(phaseStart)
+ pipelineImportStateSyncFeedTimer.Update(timings.stateSyncFeed)
+
+ // Verify the block against the whitelisted milestone/checkpoint. Mirrors
+ // the non-pipelined path's per-block check — guards the race where Heimdall
+ // whitelists a milestone AFTER the upfront check at the start of insertChain
+ // but BEFORE this block is written. The block itself is passed as the
+ // current head so the validation treats it as a `past` chain.
+ phaseStart = time.Now()
+ isValid, err := bc.forker.ValidateReorg(block.Header(), []*types.Header{block.Header()})
+ timings.reorgCheck = time.Since(phaseStart)
+ pipelineImportReorgCheckTimer.Update(timings.reorgCheck)
+ if err != nil {
+ return false, err
+ }
+ if !isValid {
+ return false, whitelist.ErrMismatch
+ }
+
+ // Store FlatDiff BEFORE writing metadata. writeBlockAndSetHeadPipelined
+ // emits ChainEvent which triggers subscribers that read state; FlatDiff
+ // must be available so PostExecState works for those reads.
+ phaseStart = time.Now()
+ bc.SetLastFlatDiff(flatDiff, block.NumberU64(), committedRoot, block.Root())
+ timings.setFlatDiff = time.Since(phaseStart)
+ pipelineImportSetFlatDiffTimer.Update(timings.setFlatDiff)
+ // State commit is deferred to the SRC goroutine. Split the write path so
+ // the durable block batch and reorg/status checks complete before SRC
+ // starts, then let SRC overlap the synchronous head/event publication tail.
+ writeHeadStart := time.Now()
+ bc.markPendingImportHeadState(block)
+ status, stateSyncLogs, err := bc.writePipelinedBlockAndResolveStatus(block, receipts, logs, statedb, nil)
+ writePrepareElapsed := time.Since(writeHeadStart)
+ if err != nil {
+ bc.clearPendingImportHeadState(block)
+ timings.writeHead = writePrepareElapsed
+ pipelineImportWriteHeadTimer.Update(timings.writeHead)
+ return false, err
+ }
+
+ phaseStart = time.Now()
+ tmpBlock := types.NewBlockWithHeader(block.Header()).WithBody(*block.Body())
+ timings.buildSRCBlock = time.Since(phaseStart)
+ pipelineImportBuildSRCBlockTimer.Update(timings.buildSRCBlock)
+ // Import passes execWitness from execution and requires SRC to publish
+ // that same witness object. runSRCCompute hard-fails on a nil witness
+ // when allowOwnWitness=false. The detached prefetcher is always passed in
+ // if present; the warm-snapshot flag only controls whether SRC converts
+ // the finished prefetcher into a WarmSnapshot or simply waits/reports and
+ // discards it.
+ phaseStart = time.Now()
+ useWarmSnapshot := makeWitness && bc.cfg.PipelinedSRCWarmSnapshot
+ src := bc.spawnSRCGoroutine(tmpBlock, committedRoot, flatDiff, makeWitness, execWitness, false, detachedPrefetcher, useWarmSnapshot, false)
+ detachedPrefetcher = nil
+ timings.spawnSRC = time.Since(phaseStart)
+ pipelineImportSpawnSRCTimer.Update(timings.spawnSRC)
+
+ publishStart := time.Now()
+ bc.emitPostWriteEvents(block, receipts, logs, stateSyncLogs, status, false)
+ timings.writeHead = writePrepareElapsed + time.Since(publishStart)
+ pipelineImportWriteHeadTimer.Update(timings.writeHead)
+
+ phaseStart = time.Now()
+ newPending := &pendingImportSRCState{
+ block: block,
+ flatDiff: flatDiff,
+ committedRoot: committedRoot,
+ procTime: time.Since(start),
+ blockStart: start,
+ makeWitness: makeWitness,
+ src: src,
+ collectedCh: make(chan struct{}),
+ }
+ bc.pendingImportSRCMu.Lock()
+ bc.pendingImportSRC = newPending
+ bc.pendingImportSRCMu.Unlock()
+ bc.clearPendingImportHeadState(block)
+ bc.wg.Add(1)
+ go bc.runImportAutoCollection(newPending)
+ if bc.cfg.PipelinedImportSRCLogs {
+ log.Info("Pipelined import: spawned SRC",
+ "block", block.NumberU64(), "committedRoot", committedRoot,
+ "txs", len(block.Transactions()))
+ }
+ timings.pendingPublish = time.Since(phaseStart)
+ pipelineImportPendingPublishTimer.Update(timings.pendingPublish)
+ return false, nil
+}
+
+func (bc *BlockChain) logSlowPipelinedImport(block *types.Block, total, cheapExec, validation time.Duration, timings pipelinedImportPersistTimings, statedb *state.StateDB) {
+ if total < slowImportBlockThreshold &&
+ timings.total < slowImportPostExecThreshold &&
+ timings.collect < slowImportCollectThreshold &&
+ timings.prefetchDetach < slowImportSnapshotThreshold &&
+ timings.residual < slowImportResidualThreshold {
+ return
+ }
+ log.Warn("Slow pipelined import phase",
+ "block", block.NumberU64(),
+ "txs", len(block.Transactions()),
+ "mgas", float64(block.GasUsed())/1_000_000,
+ "total", common.PrettyDuration(total),
+ "cheapExec", common.PrettyDuration(cheapExec),
+ "validation", common.PrettyDuration(validation),
+ "postExec", common.PrettyDuration(timings.total),
+ "postExecAccounted", common.PrettyDuration(timings.accounted()),
+ "postExecResidual", common.PrettyDuration(timings.residual),
+ "witnessCapture", common.PrettyDuration(timings.witnessCapture),
+ "prefetchDetach", common.PrettyDuration(timings.prefetchDetach),
+ "prefetchCleanup", common.PrettyDuration(timings.prefetchCleanup),
+ "commitSnapshot", common.PrettyDuration(timings.commitSnapshot),
+ "collect", common.PrettyDuration(timings.collect),
+ "collectTotal", common.PrettyDuration(timings.collectTotal),
+ "stateSyncFeed", common.PrettyDuration(timings.stateSyncFeed),
+ "reorgCheck", common.PrettyDuration(timings.reorgCheck),
+ "setFlatDiff", common.PrettyDuration(timings.setFlatDiff),
+ "writeHead", common.PrettyDuration(timings.writeHead),
+ "buildSRCBlock", common.PrettyDuration(timings.buildSRCBlock),
+ "spawnSRC", common.PrettyDuration(timings.spawnSRC),
+ "pendingPublish", common.PrettyDuration(timings.pendingPublish),
+ "accountReads", common.PrettyDuration(statedb.AccountReads),
+ "storageReads", common.PrettyDuration(statedb.StorageReads),
+ "snapshotAccountReads", common.PrettyDuration(statedb.SnapshotAccountReads),
+ "snapshotStorageReads", common.PrettyDuration(statedb.SnapshotStorageReads),
+ "accountUpdates", common.PrettyDuration(statedb.AccountUpdates),
+ "storageUpdates", common.PrettyDuration(statedb.StorageUpdates),
+ "accountHashes", common.PrettyDuration(statedb.AccountHashes),
+ "storageHashes", common.PrettyDuration(statedb.StorageHashes),
+ "witnessCollection", common.PrettyDuration(statedb.WitnessCollection))
+}
+
+func (bc *BlockChain) logSlowNormalImport(block *types.Block, process, validation, reorgCheck, write, total time.Duration, statedb *state.StateDB) {
+ if total < slowImportBlockThreshold && write < slowImportPostExecThreshold {
+ return
+ }
+ log.Warn("Slow normal import phase",
+ "block", block.NumberU64(),
+ "txs", len(block.Transactions()),
+ "mgas", float64(block.GasUsed())/1_000_000,
+ "total", common.PrettyDuration(total),
+ "process", common.PrettyDuration(process),
+ "validation", common.PrettyDuration(validation),
+ "reorgCheck", common.PrettyDuration(reorgCheck),
+ "write", common.PrettyDuration(write),
+ "accountReads", common.PrettyDuration(statedb.AccountReads),
+ "storageReads", common.PrettyDuration(statedb.StorageReads),
+ "accountUpdates", common.PrettyDuration(statedb.AccountUpdates),
+ "storageUpdates", common.PrettyDuration(statedb.StorageUpdates),
+ "accountHashes", common.PrettyDuration(statedb.AccountHashes),
+ "storageHashes", common.PrettyDuration(statedb.StorageHashes),
+ "accountCommits", common.PrettyDuration(statedb.AccountCommits),
+ "storageCommits", common.PrettyDuration(statedb.StorageCommits),
+ "snapshotCommits", common.PrettyDuration(statedb.SnapshotCommits),
+ "trieDBCommits", common.PrettyDuration(statedb.TrieDBCommits),
+ "witnessCollection", common.PrettyDuration(statedb.WitnessCollection))
+}
+
+// collectPrevImportSRCIfAny blocks on the auto-collection channel of the
+// previous pending SRC (if any) and returns its committed root. If no SRC
+// is pending (first block of the insertChain call), parent.Root is the
+// committed root. Errors propagate as "this block belongs to the previous
+// pending one" — caller returns it.index - 1.
+func (bc *BlockChain) collectPrevImportSRCIfAny(block *types.Block, parent *types.Header) (common.Hash, time.Duration, error) {
+ bc.pendingImportSRCMu.Lock()
+ pending := bc.pendingImportSRC
+ bc.pendingImportSRCMu.Unlock()
+ if pending == nil {
+ return parent.Root, 0, nil
+ }
+ if bc.cfg.PipelinedImportSRCLogs {
+ log.Info("Pipelined import: collecting previous SRC",
+ "block", block.NumberU64(), "pendingBlock", pending.block.NumberU64())
+ }
+ collectStart := time.Now()
+ committedRoot, err := bc.collectPendingImportSRC()
+ elapsed := time.Since(collectStart)
+ pipelineImportCollectTimer.Update(elapsed)
+ // SRC wall-clock is final now (collection joined the goroutine); emit the
+ // next-exec-overlap split using the classification this block's execution set.
+ recordPipelinedImportSRCOverlapSplit(pending.src)
+ return committedRoot, elapsed, err
+}
+
+// emitStateSyncFeed publishes any queued state-sync events under the
+// stateSyncMu read lock. Kept separate from writeBlockAndSetHeadPipelined
+// so the import path can control when subscribers see them (before the
+// FlatDiff is published, so PostExecState overlays work).
+func (bc *BlockChain) emitStateSyncFeed() {
+ bc.stateSyncMu.RLock()
+ defer bc.stateSyncMu.RUnlock()
+ for _, data := range bc.GetStateSync() {
+ bc.stateSyncFeed.Send(StateSyncEvent{Data: data})
+ }
+}
+
+// buildPipelineImportOpts inspects the current pending SRC state and returns
+// the PipelineImportOpts the next ProcessBlock should use. If the pending
+// block is block.Parent, the next block can overlay the FlatDiff (true
+// cross-call overlap). Otherwise the pending state is flushed (reorg/gap)
+// and the block enters the pipeline fresh against parent.Root.
+func (bc *BlockChain) buildPipelineImportOpts(block *types.Block, parent *types.Header) *PipelineImportOpts {
+ bc.pendingImportSRCMu.Lock()
+ pending := bc.pendingImportSRC
+ bc.pendingImportSRCMu.Unlock()
+ var opts *PipelineImportOpts
+ if pending != nil {
+ if block.ParentHash() == pending.block.Hash() {
+ pipelineImportHitCounter.Inc(1)
+ opts = &PipelineImportOpts{
+ CommittedParentRoot: pending.committedRoot,
+ FlatDiff: pending.flatDiff,
+ Mode: pipelineImportModeFlatDiff,
+ PendingBlock: pending.block.NumberU64(),
+ PendingHash: pending.block.Hash(),
+ PendingCollected: pendingImportSRCCollected(pending),
+ pendingSRC: pending.src,
+ }
+ if bc.cfg.PipelinedImportSRCLogs {
+ attrs := []interface{}{"block", block.NumberU64(), "txs", len(block.Transactions())}
+ attrs = append(attrs, pipelineImportLogAttrs(parent, opts)...)
+ log.Info("Pipelined import: started processing block", attrs...)
+ }
+ return opts
+ }
+ pipelineImportMissCounter.Inc(1)
+ if err := bc.flushPendingImportSRC(); err != nil {
+ log.Error("Pipelined import: flush failed on mismatch", "err", err)
+ }
+ }
+ // First block in pipeline — still enter it so the SRC goroutine persists
+ // for the next insertChain call, enabling cross-call overlap.
+ opts = &PipelineImportOpts{
+ CommittedParentRoot: parent.Root,
+ Mode: pipelineImportModeDirect,
+ }
+ if bc.cfg.PipelinedImportSRCLogs {
+ attrs := []interface{}{"block", block.NumberU64(), "txs", len(block.Transactions())}
+ attrs = append(attrs, pipelineImportLogAttrs(parent, opts)...)
+ log.Info("Pipelined import: started processing block", attrs...)
+ }
+ return opts
+}
+
+// runImportAutoCollection waits for a pending import SRC to finish, verifies
+// the computed state root, writes the witness and emits WitnessReadyEvent,
+// then does trie GC. Any failure is captured on p so flushPendingImportSRC/
+// collectPendingImportSRC can surface it synchronously.
+func (bc *BlockChain) runImportAutoCollection(p *pendingImportSRCState) {
+ defer bc.wg.Done()
+ autoCollectStart := time.Now()
+ // Defer order is LIFO: this runs before bc.wg.Done above, matching the
+ // original behaviour where close(p.collectedCh) happens before wg.Done.
+ // The total timer wraps the full goroutine wall time so the main path's
+ // collect-wait can be reconciled against (src + verify + publish + gc).
+ defer func() {
+ pipelineImportAutoCollectTotalTimer.UpdateSince(autoCollectStart)
+ close(p.collectedCh)
+ }()
+ srcStart := time.Now()
+ root, witnessBytes, err := waitForSRCState(p.src)
+ pipelineImportSRCTimer.UpdateSince(srcStart)
+ if err != nil {
+ log.Error("Pipelined import: SRC goroutine failed", "block", p.block.NumberU64(), "err", err)
+ p.collectedErr = err
+ return
+ }
+ verifyStart := time.Now()
+ verifyOk := bc.verifyImportSRCRoot(p, root)
+ pipelineImportAutoCollectVerifyTimer.UpdateSince(verifyStart)
+ if !verifyOk {
+ return
+ }
+ p.collectedRoot = root
+ if bc.cfg.PipelinedImportSRCLogs {
+ log.Info("Pipelined import: SRC verified", "block", p.block.NumberU64(), "root", root)
+ }
+ publishStart := time.Now()
+ bc.publishImportWitness(p, witnessBytes)
+ pipelineImportAutoCollectPublishTimer.UpdateSince(publishStart)
+ if !p.blockStart.IsZero() {
+ witnessReadyEndToEndTimer.UpdateSince(p.blockStart)
+ }
+ gcStart := time.Now()
+ bc.handleImportTrieGC(root, p.block.NumberU64(), p.procTime)
+ pipelineImportAutoCollectGCTimer.UpdateSince(gcStart)
+ pipelineImportBlocksCounter.Inc(1)
+}
+
+// verifyImportSRCRoot compares the SRC-computed root with the imported
+// block's root. On mismatch (should never happen — a mismatch means SRC
+// diverged from the block the peer sent), reverts the chain head to the
+// parent and surfaces the error on p. Returns false on mismatch.
+func (bc *BlockChain) verifyImportSRCRoot(p *pendingImportSRCState, root common.Hash) bool {
+ if root == p.block.Root() {
+ return true
+ }
+ pipelineImportRootMismatchCounter.Inc(1)
+ p.collectedErr = fmt.Errorf("pipelined import: root mismatch (expected: %x got: %x) block: %d",
+ p.block.Root(), root, p.block.NumberU64())
+ log.Error("Pipelined import: root mismatch, reverting chain head",
+ "block", p.block.NumberU64(), "expected", p.block.Root(), "got", root)
+ bc.reportBlock(p.block, nil, p.collectedErr)
+ if parentBlock := bc.GetBlock(p.block.ParentHash(), p.block.NumberU64()-1); parentBlock != nil {
+ // writeHeadBlock requires chainmu. This goroutine runs async of
+ // insertChainWithWitnesses, so we must acquire it explicitly to avoid
+ // racing with a concurrent InsertChain mutating chain head state.
+ // TryLock blocks while the mutex is held but returns false if the
+ // chain is shutting down — skip recovery in that case.
+ if bc.chainmu.TryLock() {
+ bc.writeHeadBlock(parentBlock)
+ bc.chainmu.Unlock()
+ } else {
+ log.Warn("Pipelined import: skipped head revert (chain closing)",
+ "block", p.block.NumberU64())
+ }
+ }
+ return false
+}
+
+// publishImportWitness persists the SRC-computed witness bytes to the
+// witness store and notifies WIT peers via the witness-ready feed.
+func (bc *BlockChain) publishImportWitness(p *pendingImportSRCState, witnessBytes []byte) {
+ if len(witnessBytes) == 0 {
+ return
+ }
+ bc.WriteWitness(p.block.Hash(), witnessBytes)
+ witnessSizeBytesHistogram.Update(int64(len(witnessBytes)))
+ bc.witnessReadyFeed.Send(WitnessReadyEvent{
+ BlockHash: p.block.Hash(),
+ BlockNumber: p.block.NumberU64(),
+ })
+}
+
+// emitPipelinedImportParityMetrics emits the read-side, execution,
+// bor-consensus, and throughput timers under the same metric names the
+// non-pipelined path uses, so dashboards work identically regardless of
+// whether the chain is in pipelined mode. Hash/update/commit/stateCommit
+// timers fire from the SRC goroutine's tmpDB in runSRCCompute.
+func emitPipelinedImportParityMetrics(statedb *state.StateDB, start, pstart time.Time, vtime time.Duration, block *types.Block) {
+ ptimePipelined := time.Since(pstart) - vtime - statedb.BorConsensusTime
+ trieReadPipelined := statedb.SnapshotAccountReads + statedb.AccountReads + statedb.SnapshotStorageReads + statedb.StorageReads
+ accountReadTimer.Update(statedb.AccountReads)
+ storageReadTimer.Update(statedb.StorageReads)
+ snapshotAccountReadTimer.Update(statedb.SnapshotAccountReads)
+ snapshotStorageReadTimer.Update(statedb.SnapshotStorageReads)
+ blockExecutionTimer.Update(ptimePipelined - trieReadPipelined)
+ borConsensusTime.Update(statedb.BorConsensusTime)
+ elapsedPipelined := time.Since(start)
+ blockInsertTimer.Update(elapsedPipelined)
+ pipelineImportTotalTimer.Update(elapsedPipelined)
+ gasUsedPerBlockHistogram.Update(int64(block.GasUsed()))
+ txsPerBlockHistogram.Update(int64(len(block.Transactions())))
+ if elapsedPipelined > 0 {
+ chainMgaspsMeter.Update(time.Duration(float64(block.GasUsed()) * 1000 / float64(elapsedPipelined)))
+ }
+}
+
+// GetLastFlatDiff returns the FlatDiff captured from the most recently committed
+// block. The miner uses this to open a NewWithFlatBase StateDB without waiting
+// for the current SRC goroutine to finish.
+func (bc *BlockChain) GetLastFlatDiff() *state.FlatDiff {
+ bc.lastFlatDiffMu.RLock()
+ defer bc.lastFlatDiffMu.RUnlock()
+ return bc.lastFlatDiff
+}
+
+// SetLastFlatDiff stores the FlatDiff and the block number it belongs to.
+// The block number is used by PostExecState to match the FlatDiff
+// to the correct block (hash matching is unreliable because Root and seal
+// signature are not available when FlatDiff is captured).
+func (bc *BlockChain) SetLastFlatDiff(diff *state.FlatDiff, blockNum uint64, parentRoot common.Hash, blockRoot common.Hash) {
+ bc.lastFlatDiffMu.Lock()
+ bc.lastFlatDiff = diff
+ bc.lastFlatDiffBlockNum = blockNum
+ bc.lastFlatDiffParentRoot = parentRoot
+ bc.lastFlatDiffBlockRoot = blockRoot
+ bc.lastFlatDiffMu.Unlock()
+}
+
+// pipelinedStateCommitWaitCap bounds WaitForPipelinedStateCommit. SRC settles
+// in single-digit milliseconds; the cap only guards a pathological stall, in
+// which case the caller proceeds and fails on the trie open as it would have
+// without the wait.
+const pipelinedStateCommitWaitCap = 5 * time.Second
+
+// WaitForPipelinedStateCommit blocks until the in-flight pipelined import SRC
+// for the block with the given state root (if any) has committed. It is a
+// no-op for any other root. RPC handlers that open tries directly by root
+// (eth_getProof, debug_storageRangeAt) call this so a query at the chain head
+// waits out the brief window between head advancement and state root commit
+// instead of failing transiently. The FlatDiff overlay cannot serve those
+// handlers: a proof is made of trie nodes, which do not exist until SRC
+// builds them.
+func (bc *BlockChain) WaitForPipelinedStateCommit(ctx context.Context, root common.Hash) error {
+ bc.pendingImportSRCMu.Lock()
+ p := bc.pendingImportSRC
+ bc.pendingImportSRCMu.Unlock()
+ if p == nil || p.block.Root() != root {
+ return nil
+ }
+ timer := time.NewTimer(pipelinedStateCommitWaitCap)
+ defer timer.Stop()
+ select {
+ case <-p.collectedCh:
+ return nil
+ case <-timer.C:
+ return nil
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+}
+
+// StateAtWithFlatDiff opens a StateDB at baseRoot with flatDiff as an in-memory
+// overlay, allowing reads to see the post-state of the block that produced
+// flatDiff without waiting for its state root to be committed to the trie DB.
+func (bc *BlockChain) StateAtWithFlatDiff(baseRoot common.Hash, flatDiff *state.FlatDiff) (*state.StateDB, error) {
+ return state.NewWithFlatBase(baseRoot, bc.statedb, flatDiff)
+}
+
// ProcessBlockWithWitnesses processes a block in stateless mode using the provided witnesses.
func (bc *BlockChain) ProcessBlockWithWitnesses(block *types.Block, witness *stateless.Witness) (*state.StateDB, *ProcessResult, error) {
if witness == nil {
@@ -4350,7 +6117,7 @@ func (bc *BlockChain) ProcessBlockWithWitnesses(block *types.Block, witness *sta
} else {
headerReader = bc
}
- if err := stateless.ValidateWitnessPreState(witness, headerReader); err != nil {
+ if err := stateless.ValidateWitnessPreState(witness, headerReader, block.Header()); err != nil {
log.Error("Witness validation failed during stateless processing", "blockNumber", block.Number(), "blockHash", block.Hash(), "err", err)
return nil, nil, fmt.Errorf("witness validation failed: %w", err)
}
diff --git a/core/blockchain_insert.go b/core/blockchain_insert.go
index 3c097b5909..cae4dcfb02 100644
--- a/core/blockchain_insert.go
+++ b/core/blockchain_insert.go
@@ -53,6 +53,12 @@ func (st *insertStats) report(chain []*types.Block, index int, snapDiffItems, sn
for _, block := range chain[(index+1)-st.processed : index+1] {
txs += len(block.Transactions())
}
+ importSegmentBlocksHistogram.Update(int64(st.processed))
+ importSegmentElapsedTimer.Update(elapsed)
+ importSegmentGasUsedHistogram.Update(int64(st.usedGas))
+ if elapsed > 0 {
+ importSegmentMgaspsHistogram.Update(int64(mgasps))
+ }
end := chain[index]
diff --git a/core/blockchain_reader.go b/core/blockchain_reader.go
index bcb9476219..6b7761c9ba 100644
--- a/core/blockchain_reader.go
+++ b/core/blockchain_reader.go
@@ -21,6 +21,8 @@ import (
"fmt"
"math/big"
+ "time"
+
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/consensus"
"github.com/ethereum/go-ethereum/consensus/misc/eip4844"
@@ -158,7 +160,9 @@ func (bc *BlockChain) GetBodyRLP(hash common.Hash) rlp.RawValue {
}
// GetWitness retrieves a witness in RLP encoding from the database by hash,
-// caching it if found.
+// caching it if found. If the witness is not yet available but the pipelined
+// import SRC goroutine is generating it for this block, GetWitness blocks
+// until the SRC completes and the witness is written.
func (bc *BlockChain) GetWitness(hash common.Hash) []byte {
// Short circuit if the witness is already in the cache, retrieve otherwise
if cached, ok := bc.witnessCache.Get(hash); ok {
@@ -166,6 +170,11 @@ func (bc *BlockChain) GetWitness(hash common.Hash) []byte {
}
witness := bc.witnessStore.ReadWitness(hash)
+ if len(witness) == 0 {
+ // Witness not in DB yet — check if the pipelined import SRC goroutine
+ // is currently generating it. If so, wait for it to finish.
+ witness = bc.waitForPipelinedWitness(hash)
+ }
if len(witness) == 0 {
return nil
}
@@ -174,6 +183,100 @@ func (bc *BlockChain) GetWitness(hash common.Hash) []byte {
return witness
}
+// witnessWaitRecencyWindow bounds how far behind the current head a block may
+// be for GetWitness to wait on its in-flight witness. It covers the current
+// import batch (blocks within a batch process milliseconds apart) with slack;
+// witnesses of older blocks are either persisted already or will never appear.
+const witnessWaitRecencyWindow = 64
+
+// waitForPipelinedWitness waits for a witness that is being generated by
+// the pipelined import SRC goroutine. It handles two cases:
+//
+// 1. The requested block IS the current pendingImportSRC — block on its
+// collectedCh until the SRC finishes and the witness is written.
+//
+// 2. The requested block is in the current import batch but hasn't been
+// processed yet (or SRC just completed) — poll the witness cache briefly
+// since the batch processes blocks rapidly (~2ms each).
+//
+// Returns nil if the witness doesn't appear within the timeout.
+//
+// Only recent blocks (within witnessWaitRecencyWindow of the head) are worth
+// waiting for: a witness the pipeline is still producing belongs to a block
+// at or just behind the current head. Anything older either exists in the
+// store already or never will, so falling through to the poll would let a
+// peer tie the handler up for the full timeout per absent hash simply by
+// requesting old block hashes.
+func (bc *BlockChain) waitForPipelinedWitness(hash common.Hash) []byte {
+ if !bc.cfg.EnablePipelinedImportSRC {
+ return nil
+ }
+ if w, ok := bc.waitForPendingSRCWitness(hash); ok {
+ return w
+ }
+ header := bc.GetHeaderByHash(hash)
+ if header == nil {
+ return nil
+ }
+ if head := bc.CurrentBlock(); head == nil || header.Number.Uint64()+witnessWaitRecencyWindow < head.Number.Uint64() {
+ return nil
+ }
+ return bc.pollWitnessCache(hash, 2*time.Second, 10*time.Millisecond)
+}
+
+// waitForPendingSRCWitness returns the witness when hash matches the single
+// in-flight pending SRC block — blocking on collectedCh ensures the witness
+// has been written to cache or store. ok=false means hash is not this block
+// (caller should fall back to polling the cache). When the pending block was
+// imported with makeWitness=false, returns (nil, true) immediately — the
+// witness will never be produced, so neither blocking on collectedCh nor
+// falling through to the 2s cache poll would help. The collectedCh wait is
+// capped so a pathologically stalled SRC goroutine can't pin peer/RPC
+// handlers indefinitely; on timeout the witness is simply reported absent.
+func (bc *BlockChain) waitForPendingSRCWitness(hash common.Hash) ([]byte, bool) {
+ bc.pendingImportSRCMu.Lock()
+ pending := bc.pendingImportSRC
+ bc.pendingImportSRCMu.Unlock()
+ if pending == nil || pending.block.Hash() != hash {
+ return nil, false
+ }
+ if !pending.makeWitness {
+ return nil, true
+ }
+ timer := time.NewTimer(pipelinedStateCommitWaitCap)
+ defer timer.Stop()
+ select {
+ case <-pending.collectedCh:
+ case <-timer.C:
+ return nil, true
+ }
+ if w, ok := bc.witnessCache.Get(hash); ok {
+ return w, true
+ }
+ return bc.witnessStore.ReadWitness(hash), true
+}
+
+// pollWitnessCache waits up to `timeout` for the witness to land in the
+// in-memory cache, checking every `interval`. Used when the block isn't
+// the current pending SRC (may be in an earlier import batch, or SRC just
+// finished and the cache insert is racing with our read).
+func (bc *BlockChain) pollWitnessCache(hash common.Hash, timeout, interval time.Duration) []byte {
+ deadline := time.NewTimer(timeout)
+ defer deadline.Stop()
+ ticker := time.NewTicker(interval)
+ defer ticker.Stop()
+ for {
+ select {
+ case <-ticker.C:
+ if w, ok := bc.witnessCache.Get(hash); ok {
+ return w
+ }
+ case <-deadline.C:
+ return nil
+ }
+ }
+}
+
// GetWitnessUncached retrieves a witness by hash without inserting it into the
// witness cache. Serving peer requests must not evict witnesses that the import
// path depends on, so this path reads through the cache but never populates it.
@@ -184,6 +287,18 @@ func (bc *BlockChain) GetWitnessUncached(hash common.Hash) []byte {
return bc.witnessStore.ReadWitness(hash)
}
+// GetWitnessUncachedWait is GetWitnessUncached plus the bounded wait for an
+// in-flight pipelined SRC witness. Peer-serving paths use it to pick up the
+// tip witness the pipeline is still producing without inserting peer-driven
+// reads into the witness cache (which would let remote requests evict
+// import-critical entries).
+func (bc *BlockChain) GetWitnessUncachedWait(hash common.Hash) []byte {
+ if w := bc.GetWitnessUncached(hash); len(w) > 0 {
+ return w
+ }
+ return bc.waitForPipelinedWitness(hash)
+}
+
// HasWitness checks if a witness is present in the cache or database.
func (bc *BlockChain) HasWitness(hash common.Hash) bool {
if bc.witnessCache.Contains(hash) {
@@ -192,6 +307,13 @@ func (bc *BlockChain) HasWitness(hash common.Hash) bool {
return bc.witnessStore.HasWitness(hash)
}
+// CacheWitness adds a witness to the in-memory cache without writing to the
+// persistent store. Used by pipelined SRC to make witnesses available to the
+// WIT protocol immediately after broadcast, before the async DB write completes.
+func (bc *BlockChain) CacheWitness(hash common.Hash, witness []byte) {
+ bc.witnessCache.Add(hash, witness)
+}
+
// WriteWitness writes the witness to the witness store and updates the cache.
func (bc *BlockChain) WriteWitness(hash common.Hash, witness []byte) {
bc.witnessStore.WriteWitness(hash, witness)
@@ -475,12 +597,68 @@ func (bc *BlockChain) GetTd(hash common.Hash, number uint64) *big.Int {
return bc.hc.GetTd(hash, number)
}
-// HasState checks if state trie is fully present in the database or not.
-func (bc *BlockChain) HasState(hash common.Hash) bool {
- _, err := bc.statedb.OpenTrie(hash)
+// HasState checks if state trie is fully present in the database or being
+// committed by a recent pipelined import SRC handoff.
+func (bc *BlockChain) HasState(root common.Hash) bool {
+ return bc.HasCommittedState(root) || bc.hasRecentPipelinedState(common.Hash{}, root, time.Now(), false)
+}
+
+// HasCommittedState checks if a state trie is fully present in the database.
+func (bc *BlockChain) HasCommittedState(root common.Hash) bool {
+ _, err := bc.statedb.OpenTrie(root)
return err == nil
}
+// HasRecentPipelinedHeadState reports whether hash/root matches the block whose
+// state is currently being handed off to the pipelined SRC commit path.
+func (bc *BlockChain) HasRecentPipelinedHeadState(hash, root common.Hash) bool {
+ return bc.hasRecentPipelinedState(hash, root, time.Now(), true)
+}
+
+func (bc *BlockChain) hasRecentPipelinedState(hash, root common.Hash, now time.Time, requireHash bool) bool {
+ bc.pendingImportSRCMu.Lock()
+ defer bc.pendingImportSRCMu.Unlock()
+
+ // Covers the write-head -> pending-SRC publication gap. This marker is
+ // hash-bound when callers are checking the canonical head, so it can't
+ // hide a different block with the same expected state timing.
+ if !bc.pendingImportHeadStart.IsZero() &&
+ bc.pendingImportHeadRoot == root &&
+ (!requireHash || bc.pendingImportHeadHash == hash) &&
+ withinPipelinedImportStateGrace(bc.pendingImportHeadStart, now) {
+ return true
+ }
+ // Covers the normal in-flight SRC commit window. Collected or stale SRCs
+ // are deliberately not treated as available, so a real missing state still
+ // falls through to the snap-sync recovery path.
+ pending := bc.pendingImportSRC
+ if pending == nil || pending.block == nil || pending.block.Root() != root {
+ return false
+ }
+ if requireHash && pending.block.Hash() != hash {
+ return false
+ }
+ if !withinPipelinedImportStateGrace(pending.blockStart, now) {
+ return false
+ }
+ select {
+ case <-pending.collectedCh:
+ return false
+ default:
+ return true
+ }
+}
+
+func withinPipelinedImportStateGrace(start, now time.Time) bool {
+ if start.IsZero() {
+ return false
+ }
+ // Reject negative ages too; they would indicate a bad clock/test setup, not
+ // a valid in-flight import handoff.
+ age := now.Sub(start)
+ return age >= 0 && age <= pipelinedImportStateAvailabilityGrace
+}
+
// HasBlockAndState checks if a block and associated state trie is fully present
// in the database or not, caching it if present.
func (bc *BlockChain) HasBlockAndState(hash common.Hash, number uint64) bool {
@@ -520,6 +698,24 @@ func (bc *BlockChain) State() (*state.StateDB, error) {
// StateAt returns a new mutable state based on a particular point in time.
func (bc *BlockChain) StateAt(root common.Hash) (*state.StateDB, error) {
+ // Fast path: if this is the latest pipelined import block whose SRC hasn't
+ // committed yet, use FlatDiff overlay. This allows eth_call, eth_estimateGas,
+ // and other state readers to work during the brief window between metadata
+ // write and SRC completion.
+ bc.lastFlatDiffMu.RLock()
+ flatDiff := bc.lastFlatDiff
+ flatDiffBlockRoot := bc.lastFlatDiffBlockRoot
+ flatDiffParentRoot := bc.lastFlatDiffParentRoot
+ bc.lastFlatDiffMu.RUnlock()
+
+ if flatDiff != nil && root == flatDiffBlockRoot {
+ sdb, err := state.NewWithFlatBase(flatDiffParentRoot, bc.statedb, flatDiff)
+ if err != nil {
+ return state.New(root, bc.statedb)
+ }
+ return sdb, nil
+ }
+
return state.New(root, bc.statedb)
}
@@ -529,19 +725,40 @@ func (bc *BlockChain) StateAt(root common.Hash) (*state.StateDB, error) {
// is for actual transaction processing. This enables independent cache hit/miss tracking
// for both phases of block production.
func (bc *BlockChain) StateAtWithReaders(root common.Hash) (*state.StateDB, *state.StateDB, state.ReaderWithStats, state.ReaderWithStats, error) {
- prefetchReader, processReader, err := bc.statedb.ReadersWithCacheStats(root)
+ // If the root matches the latest pipelined import block (whose SRC hasn't
+ // committed yet), open readers at the committed parent root and apply the
+ // FlatDiff overlay. This allows the miner to build pending blocks even when
+ // the chain head's state root is not yet committed to the trie DB.
+ readerRoot := root
+ bc.lastFlatDiffMu.RLock()
+ flatDiff := bc.lastFlatDiff
+ flatDiffBlockRoot := bc.lastFlatDiffBlockRoot
+ flatDiffParentRoot := bc.lastFlatDiffParentRoot
+ bc.lastFlatDiffMu.RUnlock()
+
+ if flatDiff != nil && root == flatDiffBlockRoot {
+ readerRoot = flatDiffParentRoot
+ }
+
+ prefetchReader, processReader, err := bc.statedb.ReadersWithCacheStats(readerRoot)
if err != nil {
return nil, nil, nil, nil, err
}
- statedb, err := state.NewWithReader(root, bc.statedb, processReader)
+ statedb, err := state.NewWithReader(readerRoot, bc.statedb, processReader)
if err != nil {
return nil, nil, nil, nil, err
}
- throwaway, err := state.NewWithReader(root, bc.statedb, prefetchReader)
+ throwaway, err := state.NewWithReader(readerRoot, bc.statedb, prefetchReader)
if err != nil {
return nil, nil, nil, nil, err
}
+ // Apply FlatDiff overlay so the miner sees the latest block's post-state.
+ if flatDiff != nil && root == flatDiffBlockRoot {
+ statedb.SetFlatDiffRef(flatDiff)
+ throwaway.SetFlatDiffRef(flatDiff)
+ }
+
return statedb, throwaway, prefetchReader, processReader, nil
}
@@ -675,6 +892,12 @@ func (bc *BlockChain) SubscribeLogsEvent(ch chan<- []*types.Log) event.Subscript
return bc.scope.Track(bc.logsFeed.Subscribe(ch))
}
+// SubscribeWitnessReadyEvent registers a subscription for witness availability
+// events from the pipelined import SRC goroutine.
+func (bc *BlockChain) SubscribeWitnessReadyEvent(ch chan<- WitnessReadyEvent) event.Subscription {
+ return bc.scope.Track(bc.witnessReadyFeed.Subscribe(ch))
+}
+
// SubscribeBlockProcessingEvent registers a subscription of bool where true means
// block processing has started while false means it has stopped.
func (bc *BlockChain) SubscribeBlockProcessingEvent(ch chan<- bool) event.Subscription {
diff --git a/core/blockchain_test.go b/core/blockchain_test.go
index 0a2cd47a9f..b1dc626ae5 100644
--- a/core/blockchain_test.go
+++ b/core/blockchain_test.go
@@ -27,6 +27,7 @@ import (
"os"
"path"
"reflect"
+ "strings"
"sync"
"sync/atomic"
"testing"
@@ -185,7 +186,7 @@ func testBlockChainImport(chain types.Blocks, blockchain *BlockChain) error {
if err != nil {
return err
}
- receipts, logs, usedGas, statedb, _, err := blockchain.ProcessBlock(block, blockchain.GetBlockByHash(block.ParentHash()).Header(), nil, nil)
+ receipts, logs, usedGas, statedb, _, err := blockchain.ProcessBlock(block, blockchain.GetBlockByHash(block.ParentHash()).Header(), nil, nil, nil)
res := &ProcessResult{
Receipts: receipts,
Logs: logs,
@@ -4236,6 +4237,73 @@ func TestCreateThenDeletePreByzantium(t *testing.T) {
},
})
}
+
+func TestHasRecentPipelinedHeadState(t *testing.T) {
+ block := types.NewBlockWithHeader(&types.Header{
+ Number: big.NewInt(1),
+ Root: common.HexToHash("0x01"),
+ })
+ chain := &BlockChain{}
+
+ require.False(t, chain.HasRecentPipelinedHeadState(block.Hash(), block.Root()))
+
+ chain.pendingImportHeadHash = block.Hash()
+ chain.pendingImportHeadRoot = block.Root()
+ chain.pendingImportHeadStart = time.Now()
+
+ require.True(t, chain.HasRecentPipelinedHeadState(block.Hash(), block.Root()))
+ require.False(t, chain.HasRecentPipelinedHeadState(common.HexToHash("0x02"), block.Root()))
+ require.False(t, chain.HasRecentPipelinedHeadState(block.Hash(), common.HexToHash("0x03")))
+
+ chain.pendingImportHeadStart = time.Now().Add(-pipelinedImportStateAvailabilityGrace - time.Second)
+ require.False(t, chain.HasRecentPipelinedHeadState(block.Hash(), block.Root()))
+}
+
+func TestHasRecentPipelinedHeadStatePendingSRC(t *testing.T) {
+ block := types.NewBlockWithHeader(&types.Header{
+ Number: big.NewInt(1),
+ Root: common.HexToHash("0x04"),
+ })
+ chain := &BlockChain{
+ pendingImportSRC: &pendingImportSRCState{
+ block: block,
+ blockStart: time.Now(),
+ collectedCh: make(chan struct{}),
+ },
+ }
+
+ require.True(t, chain.HasRecentPipelinedHeadState(block.Hash(), block.Root()))
+
+ close(chain.pendingImportSRC.collectedCh)
+ require.False(t, chain.HasRecentPipelinedHeadState(block.Hash(), block.Root()))
+
+ chain.pendingImportSRC = &pendingImportSRCState{
+ block: block,
+ blockStart: time.Now().Add(-pipelinedImportStateAvailabilityGrace - time.Second),
+ collectedCh: make(chan struct{}),
+ }
+ require.False(t, chain.HasRecentPipelinedHeadState(block.Hash(), block.Root()))
+}
+
+func TestHasStateTreatsRecentPipelinedRootAsAvailable(t *testing.T) {
+ _, _, chain, err := newCanonical(ethash.NewFaker(), 0, true, rawdb.HashScheme)
+ require.NoError(t, err)
+ t.Cleanup(chain.Stop)
+
+ block := types.NewBlockWithHeader(&types.Header{
+ Number: big.NewInt(1),
+ Root: common.HexToHash("0x05"),
+ })
+ require.False(t, chain.HasCommittedState(block.Root()))
+
+ chain.pendingImportHeadHash = common.HexToHash("0x06")
+ chain.pendingImportHeadRoot = block.Root()
+ chain.pendingImportHeadStart = time.Now()
+
+ require.True(t, chain.HasState(block.Root()))
+ require.False(t, chain.HasRecentPipelinedHeadState(block.Hash(), block.Root()))
+}
+
func TestCreateThenDeletePostByzantium(t *testing.T) {
t.Parallel()
testCreateThenDelete(t, params.TestChainConfig)
@@ -6549,3 +6617,1691 @@ func TestWriteBlockMetrics(t *testing.T) {
t.Error("stateCommitTimer mean duration should be non-negative")
}
}
+
+// ---------------------------------------------------------------------------
+// Pipelined Import SRC Tests
+// ---------------------------------------------------------------------------
+
+// pipelinedConfig returns a BlockChainConfig with pipelined import SRC enabled.
+func pipelinedConfig(scheme string) *BlockChainConfig {
+ cfg := DefaultConfig().WithStateScheme(scheme)
+ cfg.EnablePipelinedImportSRC = true
+ cfg.PipelinedImportSRCLogs = true
+ return cfg
+}
+
+// pipelinedConfigWithWarmSnapshot returns the standard pipelined config with
+// the warm-snapshot handoff enabled. Used by snapshot-on-vs-off parity tests.
+func pipelinedConfigWithWarmSnapshot(scheme string) *BlockChainConfig {
+ cfg := pipelinedConfig(scheme)
+ cfg.PipelinedSRCWarmSnapshot = true
+ return cfg
+}
+
+// TestPipelinedImportSRC_MultipleBlocks generates 10 blocks with transactions and
+// inserts them into two chains — one with pipelined SRC enabled and one without.
+// The state roots of every canonical block must match between both chains.
+func TestPipelinedImportSRC_MultipleBlocks(t *testing.T) {
+ testPipelinedImportSRC_MultipleBlocks(t, rawdb.HashScheme, pipelinedConfig(rawdb.HashScheme))
+ testPipelinedImportSRC_MultipleBlocks(t, rawdb.PathScheme, pipelinedConfig(rawdb.PathScheme))
+}
+
+func testPipelinedImportSRC_MultipleBlocks(t *testing.T, scheme string, pipeCfg *BlockChainConfig) {
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ // Generate 10 blocks with a simple transfer in each.
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, 10, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ // Chain with pipeline enabled.
+ pipeChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipeCfg)
+ if err != nil {
+ t.Fatalf("failed to create pipeline chain: %v", err)
+ }
+ defer pipeChain.Stop()
+
+ // Reference chain without pipeline.
+ refChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, DefaultConfig().WithStateScheme(scheme))
+ if err != nil {
+ t.Fatalf("failed to create reference chain: %v", err)
+ }
+ defer refChain.Stop()
+
+ if _, err := pipeChain.InsertChain(blocks, false); err != nil {
+ t.Fatalf("pipeline chain: failed to insert blocks: %v", err)
+ }
+ if _, err := refChain.InsertChain(blocks, false); err != nil {
+ t.Fatalf("reference chain: failed to insert blocks: %v", err)
+ }
+
+ // Both chains must agree on head.
+ if pipeChain.CurrentBlock().Number.Uint64() != 10 {
+ t.Fatalf("pipeline chain head = %d, want 10", pipeChain.CurrentBlock().Number.Uint64())
+ }
+ if refChain.CurrentBlock().Number.Uint64() != 10 {
+ t.Fatalf("reference chain head = %d, want 10", refChain.CurrentBlock().Number.Uint64())
+ }
+
+ // All canonical blocks must have matching state roots.
+ for i := uint64(1); i <= 10; i++ {
+ pipeBlock := pipeChain.GetBlockByNumber(i)
+ refBlock := refChain.GetBlockByNumber(i)
+ if pipeBlock == nil || refBlock == nil {
+ t.Fatalf("block %d: missing on pipeline(%v) or reference(%v)", i, pipeBlock == nil, refBlock == nil)
+ }
+ if pipeBlock.Root() != refBlock.Root() {
+ t.Errorf("block %d: state root mismatch pipeline=%s reference=%s", i, pipeBlock.Root(), refBlock.Root())
+ }
+ if pipeBlock.Hash() != refBlock.Hash() {
+ t.Errorf("block %d: block hash mismatch pipeline=%s reference=%s", i, pipeBlock.Hash(), refBlock.Hash())
+ }
+ }
+}
+
+// TestPipelinedImportSRC_SingleBlock inserts a single block with pipeline enabled
+// and verifies correctness of the state.
+func TestPipelinedImportSRC_SingleBlock(t *testing.T) {
+ testPipelinedImportSRC_SingleBlock(t, rawdb.HashScheme)
+ testPipelinedImportSRC_SingleBlock(t, rawdb.PathScheme)
+}
+
+func testPipelinedImportSRC_SingleBlock(t *testing.T, scheme string) {
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, 1, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ chain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(scheme))
+ if err != nil {
+ t.Fatalf("failed to create chain: %v", err)
+ }
+ defer chain.Stop()
+
+ if _, err := chain.InsertChain(blocks, false); err != nil {
+ t.Fatalf("failed to insert block: %v", err)
+ }
+
+ if chain.CurrentBlock().Number.Uint64() != 1 {
+ t.Fatalf("head = %d, want 1", chain.CurrentBlock().Number.Uint64())
+ }
+
+ statedb, err := chain.StateAt(blocks[0].Root())
+ if err != nil {
+ t.Fatalf("StateAt failed: %v", err)
+ }
+
+ // Recipient should have received 1000 wei.
+ bal := statedb.GetBalance(recipient)
+ if bal.IsZero() {
+ t.Error("recipient balance should be non-zero after transfer")
+ }
+}
+
+// TestPipelinedImportSRC_CrossCallPersistence inserts blocks across two separate
+// InsertChain calls with pipelined SRC and verifies that state persists correctly
+// between calls (the pending SRC from the first batch is flushed before the
+// second batch begins).
+func TestPipelinedImportSRC_CrossCallPersistence(t *testing.T) {
+ testPipelinedImportSRC_CrossCallPersistence(t, rawdb.HashScheme)
+ testPipelinedImportSRC_CrossCallPersistence(t, rawdb.PathScheme)
+}
+
+func testPipelinedImportSRC_CrossCallPersistence(t *testing.T, scheme string) {
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, 6, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ // Pipeline chain: split insertion across two calls.
+ pipeChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(scheme))
+ if err != nil {
+ t.Fatalf("failed to create pipeline chain: %v", err)
+ }
+ defer pipeChain.Stop()
+
+ if _, err := pipeChain.InsertChain(blocks[:3], false); err != nil {
+ t.Fatalf("pipeline: first batch insert failed: %v", err)
+ }
+ if _, err := pipeChain.InsertChain(blocks[3:], false); err != nil {
+ t.Fatalf("pipeline: second batch insert failed: %v", err)
+ }
+
+ // Reference chain: single call.
+ refChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, DefaultConfig().WithStateScheme(scheme))
+ if err != nil {
+ t.Fatalf("failed to create reference chain: %v", err)
+ }
+ defer refChain.Stop()
+
+ if _, err := refChain.InsertChain(blocks, false); err != nil {
+ t.Fatalf("reference: insert failed: %v", err)
+ }
+
+ if pipeChain.CurrentBlock().Number.Uint64() != 6 {
+ t.Fatalf("pipeline head = %d, want 6", pipeChain.CurrentBlock().Number.Uint64())
+ }
+
+ for i := uint64(1); i <= 6; i++ {
+ pipeBlock := pipeChain.GetBlockByNumber(i)
+ refBlock := refChain.GetBlockByNumber(i)
+ if pipeBlock == nil || refBlock == nil {
+ t.Fatalf("block %d missing", i)
+ }
+ if pipeBlock.Root() != refBlock.Root() {
+ t.Errorf("block %d: state root mismatch pipeline=%s reference=%s", i, pipeBlock.Root(), refBlock.Root())
+ }
+ }
+}
+
+// TestPipelinedImportSRC_Reorg inserts a main chain and then a longer fork to
+// trigger a reorg. Verifies that the fork becomes canonical and all state roots
+// are valid after the reorg.
+func TestPipelinedImportSRC_Reorg(t *testing.T) {
+ testPipelinedImportSRC_Reorg(t, rawdb.HashScheme)
+ testPipelinedImportSRC_Reorg(t, rawdb.PathScheme)
+}
+
+func testPipelinedImportSRC_Reorg(t *testing.T, scheme string) {
+ var (
+ key1, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ key2, _ = crypto.HexToECDSA("8a1f9a8f95be41cd7ccb6168179afb4504aefe388d1e14474d32c45c72ce7b7a")
+ addr1 = crypto.PubkeyToAddress(key1.PublicKey)
+ addr2 = crypto.PubkeyToAddress(key2.PublicKey)
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ addr1: {Balance: funds},
+ addr2: {Balance: funds},
+ },
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ // Main chain: 5 blocks, transfers from addr1.
+ _, mainBlocks, _ := GenerateChainWithGenesis(gspec, engine, 5, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr1), common.HexToAddress("0x1111"), big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key1,
+ )
+ gen.AddTx(tx)
+ })
+
+ // Fork chain: 7 blocks branching from genesis, using addr2 so it creates
+ // different state. Longer chain so it becomes canonical.
+ _, forkBlocks, _ := GenerateChainWithGenesis(gspec, engine, 7, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr2), common.HexToAddress("0x2222"), big.NewInt(2000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key2,
+ )
+ gen.AddTx(tx)
+ })
+
+ chain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(scheme))
+ if err != nil {
+ t.Fatalf("failed to create chain: %v", err)
+ }
+ defer chain.Stop()
+
+ // Insert main chain.
+ if _, err := chain.InsertChain(mainBlocks, false); err != nil {
+ t.Fatalf("main chain insert failed: %v", err)
+ }
+ if chain.CurrentBlock().Number.Uint64() != 5 {
+ t.Fatalf("after main: head = %d, want 5", chain.CurrentBlock().Number.Uint64())
+ }
+
+ // Insert fork chain — should trigger reorg since it's longer.
+ if _, err := chain.InsertChain(forkBlocks, false); err != nil {
+ t.Fatalf("fork chain insert failed: %v", err)
+ }
+ if chain.CurrentBlock().Number.Uint64() != 7 {
+ t.Fatalf("after fork: head = %d, want 7", chain.CurrentBlock().Number.Uint64())
+ }
+
+ // Verify the fork is now canonical by checking block hashes.
+ for i := uint64(1); i <= 7; i++ {
+ canonical := chain.GetBlockByNumber(i)
+ if canonical == nil {
+ t.Fatalf("missing canonical block %d after reorg", i)
+ }
+ if canonical.Hash() != forkBlocks[i-1].Hash() {
+ t.Errorf("block %d: canonical hash %s != fork hash %s", i, canonical.Hash(), forkBlocks[i-1].Hash())
+ }
+ }
+
+ // Verify state is accessible for the canonical head.
+ statedb, err := chain.StateAt(chain.CurrentBlock().Root)
+ if err != nil {
+ t.Fatalf("StateAt head failed: %v", err)
+ }
+ // addr2 sent 2000 wei per block for 7 blocks => should have less than initial funds.
+ bal := statedb.GetBalance(addr2)
+ if bal.IsZero() {
+ t.Error("addr2 balance should be non-zero")
+ }
+}
+
+// TestPipelinedImportSRC_StateAtDuringPipeline generates blocks that modify
+// account balances and verifies that StateAt returns correct balances for each
+// block's root after pipelined insertion.
+func TestPipelinedImportSRC_StateAtDuringPipeline(t *testing.T) {
+ testPipelinedImportSRC_StateAtDuringPipeline(t, rawdb.HashScheme)
+ testPipelinedImportSRC_StateAtDuringPipeline(t, rawdb.PathScheme)
+}
+
+func testPipelinedImportSRC_StateAtDuringPipeline(t *testing.T, scheme string) {
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ txValue = big.NewInt(10000) // 10000 wei per block
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ numBlocks := 5
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, numBlocks, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, txValue, params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ chain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(scheme))
+ if err != nil {
+ t.Fatalf("failed to create chain: %v", err)
+ }
+ defer chain.Stop()
+
+ if _, err := chain.InsertChain(blocks, false); err != nil {
+ t.Fatalf("failed to insert chain: %v", err)
+ }
+
+ // Verify state at each block root shows monotonically increasing recipient balance.
+ var prevBal *uint256.Int
+ for i := 0; i < numBlocks; i++ {
+ statedb, err := chain.StateAt(blocks[i].Root())
+ if err != nil {
+ t.Fatalf("block %d: StateAt failed: %v", i+1, err)
+ }
+ bal := statedb.GetBalance(recipient)
+ if bal.IsZero() {
+ t.Errorf("block %d: recipient balance is zero, expected non-zero", i+1)
+ }
+ if prevBal != nil && bal.Cmp(prevBal) <= 0 {
+ t.Errorf("block %d: recipient balance %s should be greater than previous %s", i+1, bal, prevBal)
+ }
+ prevBal = bal.Clone()
+ }
+
+ // Final balance should equal txValue * numBlocks.
+ expectedBal := new(big.Int).Mul(txValue, big.NewInt(int64(numBlocks)))
+ finalState, _ := chain.StateAt(blocks[numBlocks-1].Root())
+ got := finalState.GetBalance(recipient).ToBig()
+ if got.Cmp(expectedBal) != 0 {
+ t.Errorf("final recipient balance: got %s, want %s", got, expectedBal)
+ }
+}
+
+// TestPipelinedImportSRC_ValidateStateCheap verifies that blocks inserted with
+// pipelined SRC pass all cheap validation checks (gas used, bloom filter,
+// receipt root). This is implicitly tested by successful insertion, but this
+// test explicitly verifies no errors by comparing against a reference chain.
+func TestPipelinedImportSRC_ValidateStateCheap(t *testing.T) {
+ testPipelinedImportSRC_ValidateStateCheap(t, rawdb.HashScheme)
+ testPipelinedImportSRC_ValidateStateCheap(t, rawdb.PathScheme)
+}
+
+func testPipelinedImportSRC_ValidateStateCheap(t *testing.T, scheme string) {
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, 8, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ // Insert with pipeline — any ValidateStateCheap failure would surface as
+ // an InsertChain error.
+ pipeChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(scheme))
+ if err != nil {
+ t.Fatalf("failed to create pipeline chain: %v", err)
+ }
+ defer pipeChain.Stop()
+
+ n, err := pipeChain.InsertChain(blocks, false)
+ if err != nil {
+ t.Fatalf("pipeline InsertChain failed at block %d: %v", n, err)
+ }
+
+ // Reference chain for comparison.
+ refChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, DefaultConfig().WithStateScheme(scheme))
+ if err != nil {
+ t.Fatalf("failed to create reference chain: %v", err)
+ }
+ defer refChain.Stop()
+
+ if _, err := refChain.InsertChain(blocks, false); err != nil {
+ t.Fatalf("reference InsertChain failed: %v", err)
+ }
+
+ // Verify: every block has matching gas, bloom, receipt root, and state root.
+ for i := uint64(1); i <= 8; i++ {
+ pBlock := pipeChain.GetBlockByNumber(i)
+ rBlock := refChain.GetBlockByNumber(i)
+ if pBlock == nil || rBlock == nil {
+ t.Fatalf("block %d missing", i)
+ }
+ if pBlock.GasUsed() != rBlock.GasUsed() {
+ t.Errorf("block %d: gas used mismatch %d vs %d", i, pBlock.GasUsed(), rBlock.GasUsed())
+ }
+ if pBlock.Bloom() != rBlock.Bloom() {
+ t.Errorf("block %d: bloom filter mismatch", i)
+ }
+ if pBlock.ReceiptHash() != rBlock.ReceiptHash() {
+ t.Errorf("block %d: receipt hash mismatch %s vs %s", i, pBlock.ReceiptHash(), rBlock.ReceiptHash())
+ }
+ if pBlock.Root() != rBlock.Root() {
+ t.Errorf("block %d: state root mismatch %s vs %s", i, pBlock.Root(), rBlock.Root())
+ }
+ }
+}
+
+// TestPipelinedImportMetrics verifies that the pipelined-import metrics and
+// their parity timers actually increment when blocks flow through the
+// pipelined path, and that the mode gauge reflects the enabled config.
+func TestPipelinedImportMetrics(t *testing.T) {
+ metrics.Enable()
+
+ const numBlocks = 5
+
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, numBlocks, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ // Snapshot counters before — metrics registrations are process-global, so
+ // other tests in the same binary may have already moved them.
+ blocksBefore := pipelineImportBlocksCounter.Snapshot().Count()
+ hitBefore := pipelineImportHitCounter.Snapshot().Count()
+ mismatchBefore := pipelineImportRootMismatchCounter.Snapshot().Count()
+ insertBefore := blockInsertTimer.Snapshot().Count()
+ stateCommitBefore := stateCommitTimer.Snapshot().Count()
+ pipelineExecBefore := pipelineImportExecutionTimer.Snapshot().Count()
+ overlapBefore := pipelineImportOverlapExecutionTimer.Snapshot().Count()
+ overlapBlocksBefore := pipelineImportOverlapBlocksCounter.Snapshot().Count()
+ noOverlapBlocksBefore := pipelineImportNoOverlapBlocksCounter.Snapshot().Count()
+ overlapPercentBefore := pipelineImportOverlapExecutionPercent.Snapshot().Count()
+ srcOpenBefore := pipelineImportSRCOpenStateDBTimer.Snapshot().Count()
+ srcApplyBefore := pipelineImportSRCApplyFlatDiffTimer.Snapshot().Count()
+ srcCommitBefore := pipelineImportSRCCommitTimer.Snapshot().Count()
+ execWithOverlapBefore := pipelineImportExecWithOverlapTimer.Snapshot().Count()
+ execNoOverlapBefore := pipelineImportExecNoOverlapTimer.Snapshot().Count()
+ execBucketBefore := pipelineImportExecOverlap0Timer.Snapshot().Count() +
+ pipelineImportExecOverlap1To25Timer.Snapshot().Count() +
+ pipelineImportExecOverlap25To50Timer.Snapshot().Count() +
+ pipelineImportExecOverlap50To75Timer.Snapshot().Count() +
+ pipelineImportExecOverlap75To100Timer.Snapshot().Count()
+ srcWithNextBefore := pipelineImportSRCWithNextExecTimer.Snapshot().Count()
+ srcNoNextBefore := pipelineImportSRCNoNextExecTimer.Snapshot().Count()
+ srcWithNextSumBefore := pipelineImportSRCWithNextExecTimer.Snapshot().Sum()
+ srcNoNextSumBefore := pipelineImportSRCNoNextExecTimer.Snapshot().Sum()
+
+ pipeChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("failed to create pipeline chain: %v", err)
+ }
+ defer pipeChain.Stop()
+
+ if got := pipelineImportEnabledGauge.Snapshot().Value(); got != 1 {
+ t.Errorf("pipelineImportEnabledGauge = %d, want 1 when EnablePipelinedImportSRC=true", got)
+ }
+
+ if _, err := pipeChain.InsertChain(blocks, false); err != nil {
+ t.Fatalf("pipeline InsertChain failed: %v", err)
+ }
+
+ // Drain the trailing pending SRC so per-block counters reflect every block.
+ if err := pipeChain.flushPendingImportSRC(); err != nil {
+ t.Fatalf("flushPendingImportSRC failed: %v", err)
+ }
+
+ blocksDelta := pipelineImportBlocksCounter.Snapshot().Count() - blocksBefore
+ hitDelta := pipelineImportHitCounter.Snapshot().Count() - hitBefore
+ mismatchDelta := pipelineImportRootMismatchCounter.Snapshot().Count() - mismatchBefore
+ insertDelta := blockInsertTimer.Snapshot().Count() - insertBefore
+ stateCommitDelta := stateCommitTimer.Snapshot().Count() - stateCommitBefore
+ pipelineExecDelta := pipelineImportExecutionTimer.Snapshot().Count() - pipelineExecBefore
+ overlapDelta := pipelineImportOverlapExecutionTimer.Snapshot().Count() - overlapBefore
+ overlapBlocksDelta := pipelineImportOverlapBlocksCounter.Snapshot().Count() - overlapBlocksBefore
+ noOverlapBlocksDelta := pipelineImportNoOverlapBlocksCounter.Snapshot().Count() - noOverlapBlocksBefore
+ overlapPercentDelta := pipelineImportOverlapExecutionPercent.Snapshot().Count() - overlapPercentBefore
+ srcOpenDelta := pipelineImportSRCOpenStateDBTimer.Snapshot().Count() - srcOpenBefore
+ srcApplyDelta := pipelineImportSRCApplyFlatDiffTimer.Snapshot().Count() - srcApplyBefore
+ srcCommitDelta := pipelineImportSRCCommitTimer.Snapshot().Count() - srcCommitBefore
+ execWithOverlapDelta := pipelineImportExecWithOverlapTimer.Snapshot().Count() - execWithOverlapBefore
+ execNoOverlapDelta := pipelineImportExecNoOverlapTimer.Snapshot().Count() - execNoOverlapBefore
+ execBucketDelta := pipelineImportExecOverlap0Timer.Snapshot().Count() +
+ pipelineImportExecOverlap1To25Timer.Snapshot().Count() +
+ pipelineImportExecOverlap25To50Timer.Snapshot().Count() +
+ pipelineImportExecOverlap50To75Timer.Snapshot().Count() +
+ pipelineImportExecOverlap75To100Timer.Snapshot().Count() - execBucketBefore
+ srcWithNextDelta := pipelineImportSRCWithNextExecTimer.Snapshot().Count() - srcWithNextBefore
+ srcNoNextDelta := pipelineImportSRCNoNextExecTimer.Snapshot().Count() - srcNoNextBefore
+ srcSplitDurationDelta := pipelineImportSRCWithNextExecTimer.Snapshot().Sum() + pipelineImportSRCNoNextExecTimer.Snapshot().Sum() -
+ srcWithNextSumBefore - srcNoNextSumBefore
+
+ if blocksDelta != numBlocks {
+ t.Errorf("pipelineImportBlocksCounter delta = %d, want %d", blocksDelta, numBlocks)
+ }
+ // First block has no pending predecessor; subsequent blocks should all hit.
+ if hitDelta != numBlocks-1 {
+ t.Errorf("pipelineImportHitCounter delta = %d, want %d", hitDelta, numBlocks-1)
+ }
+ if mismatchDelta != 0 {
+ t.Errorf("pipelineImportRootMismatchCounter delta = %d, want 0 (safety alarm)", mismatchDelta)
+ }
+ if insertDelta != numBlocks {
+ t.Errorf("blockInsertTimer (parity) delta = %d, want %d", insertDelta, numBlocks)
+ }
+ if stateCommitDelta != numBlocks {
+ t.Errorf("stateCommitTimer (parity, from SRC goroutine) delta = %d, want %d", stateCommitDelta, numBlocks)
+ }
+ if pipelineExecDelta != numBlocks {
+ t.Errorf("pipelineImportExecutionTimer delta = %d, want %d", pipelineExecDelta, numBlocks)
+ }
+ if overlapDelta != numBlocks-1 {
+ t.Errorf("pipelineImportOverlapExecutionTimer delta = %d, want %d", overlapDelta, numBlocks-1)
+ }
+ if overlapBlocksDelta+noOverlapBlocksDelta != numBlocks-1 {
+ t.Errorf("overlap/no-overlap block deltas = %d + %d, want %d", overlapBlocksDelta, noOverlapBlocksDelta, numBlocks-1)
+ }
+ if overlapPercentDelta != numBlocks-1 {
+ t.Errorf("pipelineImportOverlapExecutionPercent delta = %d, want %d", overlapPercentDelta, numBlocks-1)
+ }
+ if srcOpenDelta != numBlocks {
+ t.Errorf("pipelineImportSRCOpenStateDBTimer delta = %d, want %d", srcOpenDelta, numBlocks)
+ }
+ if srcApplyDelta != numBlocks {
+ t.Errorf("pipelineImportSRCApplyFlatDiffTimer delta = %d, want %d", srcApplyDelta, numBlocks)
+ }
+ if srcCommitDelta != numBlocks {
+ t.Errorf("pipelineImportSRCCommitTimer delta = %d, want %d", srcCommitDelta, numBlocks)
+ }
+ // Categorical execution split: every classified block lands in exactly one
+ // of with/no overlap, and the binary split must track the overlap counters.
+ if execWithOverlapDelta+execNoOverlapDelta != numBlocks-1 {
+ t.Errorf("execution with/no-overlap deltas = %d + %d, want %d", execWithOverlapDelta, execNoOverlapDelta, numBlocks-1)
+ }
+ if execWithOverlapDelta != overlapBlocksDelta {
+ t.Errorf("execution/with_overlap delta = %d, want %d (overlap blocks)", execWithOverlapDelta, overlapBlocksDelta)
+ }
+ if execNoOverlapDelta != noOverlapBlocksDelta {
+ t.Errorf("execution/no_overlap delta = %d, want %d (no-overlap blocks)", execNoOverlapDelta, noOverlapBlocksDelta)
+ }
+ if execBucketDelta != numBlocks-1 {
+ t.Errorf("execution overlap bucket deltas sum = %d, want %d", execBucketDelta, numBlocks-1)
+ }
+ // SRC split mirrors the execution split from the next-block perspective.
+ if srcWithNextDelta+srcNoNextDelta != numBlocks-1 {
+ t.Errorf("src with/no-next-exec-overlap deltas = %d + %d, want %d", srcWithNextDelta, srcNoNextDelta, numBlocks-1)
+ }
+ if srcWithNextDelta != overlapBlocksDelta {
+ t.Errorf("src/with_next_exec_overlap delta = %d, want %d (overlap blocks)", srcWithNextDelta, overlapBlocksDelta)
+ }
+ if srcWithNextDelta+srcNoNextDelta > 0 && srcSplitDurationDelta <= int64(10*time.Microsecond) {
+ t.Errorf("src overlap split recorded only %s total duration; want real SRC wall-clock, not defer-registration time", time.Duration(srcSplitDurationDelta))
+ }
+}
+
+// TestPipelineImportDisabledGauge verifies the mode gauge reads 0 when the
+// pipeline is not enabled in the chain config.
+func TestPipelineImportDisabledGauge(t *testing.T) {
+ metrics.Enable()
+
+ gspec := &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ engine := ethash.NewFaker()
+
+ chain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, DefaultConfig().WithStateScheme(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("failed to create chain: %v", err)
+ }
+ defer chain.Stop()
+
+ if got := pipelineImportEnabledGauge.Snapshot().Value(); got != 0 {
+ t.Errorf("pipelineImportEnabledGauge = %d, want 0 when EnablePipelinedImportSRC=false", got)
+ }
+}
+
+// TestPipelineFlatDiffHitMeters verifies that the FlatDiff overlay meters
+// increment when consecutive blocks under pipelined import touch accounts/slots
+// mutated by the previous block.
+func TestPipelineFlatDiffHitMeters(t *testing.T) {
+ metrics.Enable()
+
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ // Every block transfers from the same `addr` to `recipient` — both addresses
+ // are in the previous block's FlatDiff, so reads in the next block's
+ // execution should hit the overlay.
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, 3, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ // The FlatDiff meters live in the state package (unexported); look them up
+ // by name in the global registry rather than exposing accessors.
+ flatAcctMeter, ok := metrics.DefaultRegistry.Get("state/flatdiff/account_hits").(*metrics.Meter)
+ if !ok {
+ t.Fatal("state/flatdiff/account_hits meter not registered")
+ }
+ accountHitsBefore := flatAcctMeter.Snapshot().Count()
+
+ pipeChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("failed to create pipeline chain: %v", err)
+ }
+ defer pipeChain.Stop()
+
+ if _, err := pipeChain.InsertChain(blocks, false); err != nil {
+ t.Fatalf("pipeline InsertChain failed: %v", err)
+ }
+ _ = pipeChain.flushPendingImportSRC()
+
+ if flatAcctMeter.Snapshot().Count()-accountHitsBefore == 0 {
+ t.Error("state/flatdiff/account_hits should have non-zero delta after consecutive-block transfers")
+ }
+ // Storage hits depend on the specific SSTORE pattern; pure balance-transfer
+ // blocks may not hit storage slots. We only assert account-side hits here.
+}
+
+// TestPipelinedImportSRC_MakeWitnessFalse verifies that when the pipelined
+// import path is invoked with makeWitness=false (the producewitnesses=false
+// configuration), the SRC goroutine still computes and validates the state
+// root — but skips witness construction, FlatDiff read-surface preload, and
+// witness encoding/caching entirely.
+func TestPipelinedImportSRC_MakeWitnessFalse(t *testing.T) {
+ metrics.Enable()
+
+ const numBlocks = 5
+
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, numBlocks, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ pipeChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("failed to create pipeline chain: %v", err)
+ }
+ defer pipeChain.Stop()
+
+ // Snapshot the metrics that should NOT advance when makeWitness=false.
+ preloadTimerBefore := pipelineSRCPreloadTimer.Snapshot().Count()
+ preloadSlotsBefore := pipelineSRCPreloadSlotsHistogram.Snapshot().Count()
+ preloadAccountsBefore := pipelineSRCPreloadReadAccountsHistogram.Snapshot().Count()
+ // And the one that SHOULD advance — metrics are package-global, so we must
+ // compare deltas rather than absolute counts.
+ stateCommitBefore := stateCommitTimer.Snapshot().Count()
+
+ // makeWitness=false is the default for InsertChain.
+ if _, err := pipeChain.InsertChain(blocks, false); err != nil {
+ t.Fatalf("pipeline InsertChain failed: %v", err)
+ }
+ if err := pipeChain.flushPendingImportSRC(); err != nil {
+ t.Fatalf("flushPendingImportSRC failed: %v", err)
+ }
+
+ // State roots must match the canonical roots from GenerateChainWithGenesis
+ // — root validation runs even with witness off.
+ for i := uint64(1); i <= numBlocks; i++ {
+ pipeBlock := pipeChain.GetBlockByNumber(i)
+ if pipeBlock == nil {
+ t.Fatalf("block %d: missing on pipeline chain", i)
+ }
+ if pipeBlock.Root() != blocks[i-1].Root() {
+ t.Errorf("block %d: state root mismatch pipeline=%s expected=%s", i, pipeBlock.Root(), blocks[i-1].Root())
+ }
+ }
+
+ // No witness should be produced or persisted — neither cache nor store.
+ // HasWitness covers both surfaces; check both explicitly so a future
+ // refactor that splits the write paths still trips the assertion.
+ for i := uint64(1); i <= numBlocks; i++ {
+ hash := pipeChain.GetBlockByNumber(i).Hash()
+ if pipeChain.witnessCache.Contains(hash) {
+ t.Errorf("block %d: witnessCache unexpectedly contains witness when makeWitness=false", i)
+ }
+ if pipeChain.witnessStore.HasWitness(hash) {
+ t.Errorf("block %d: witnessStore unexpectedly contains witness when makeWitness=false", i)
+ }
+ if pipeChain.HasWitness(hash) {
+ t.Errorf("block %d: HasWitness=true when makeWitness=false", i)
+ }
+ }
+
+ // Preload timer + histograms must not have fired — they exist solely to
+ // populate the witness with proof-path nodes.
+ if delta := pipelineSRCPreloadTimer.Snapshot().Count() - preloadTimerBefore; delta != 0 {
+ t.Errorf("pipelineSRCPreloadTimer delta = %d, want 0 when makeWitness=false", delta)
+ }
+ if delta := pipelineSRCPreloadSlotsHistogram.Snapshot().Count() - preloadSlotsBefore; delta != 0 {
+ t.Errorf("pipelineSRCPreloadSlotsHistogram delta = %d, want 0 when makeWitness=false", delta)
+ }
+ if delta := pipelineSRCPreloadReadAccountsHistogram.Snapshot().Count() - preloadAccountsBefore; delta != 0 {
+ t.Errorf("pipelineSRCPreloadReadAccountsHistogram delta = %d, want 0 when makeWitness=false", delta)
+ }
+
+ // stateCommitTimer should still fire once per block — CommitWithUpdate
+ // runs unconditionally, witness or not.
+ if delta := stateCommitTimer.Snapshot().Count() - stateCommitBefore; delta != numBlocks {
+ t.Errorf("stateCommitTimer delta = %d, want %d when makeWitness=false (CommitWithUpdate must still run)", delta, numBlocks)
+ }
+}
+
+// TestPipelinedImportSRC_MakeWitnessTrue verifies that when InsertChain is
+// called with makeWitness=true and the pipeline is enabled, the SRC goroutine
+// produces a witness, caches it, and preload metrics fire as expected.
+func TestPipelinedImportSRC_MakeWitnessTrue(t *testing.T) {
+ metrics.Enable()
+
+ const numBlocks = 3
+
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, numBlocks, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ pipeChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("failed to create pipeline chain: %v", err)
+ }
+ defer pipeChain.Stop()
+
+ preloadTimerBefore := pipelineSRCPreloadTimer.Snapshot().Count()
+ preloadSlotsBefore := pipelineSRCPreloadSlotsHistogram.Snapshot().Count()
+ preloadAccountsBefore := pipelineSRCPreloadReadAccountsHistogram.Snapshot().Count()
+
+ if _, err := pipeChain.InsertChain(blocks, true); err != nil {
+ t.Fatalf("pipeline InsertChain (makeWitness=true) failed: %v", err)
+ }
+ if err := pipeChain.flushPendingImportSRC(); err != nil {
+ t.Fatalf("flushPendingImportSRC failed: %v", err)
+ }
+
+ // Witness should be cached for every imported block.
+ for i := uint64(1); i <= numBlocks; i++ {
+ hash := pipeChain.GetBlockByNumber(i).Hash()
+ if !pipeChain.witnessCache.Contains(hash) {
+ t.Errorf("block %d: witnessCache missing witness when makeWitness=true", i)
+ }
+ }
+
+ // Preload timer + histograms should fire once per block.
+ if delta := pipelineSRCPreloadTimer.Snapshot().Count() - preloadTimerBefore; delta != numBlocks {
+ t.Errorf("pipelineSRCPreloadTimer delta = %d, want %d when makeWitness=true", delta, numBlocks)
+ }
+ if delta := pipelineSRCPreloadSlotsHistogram.Snapshot().Count() - preloadSlotsBefore; delta != numBlocks {
+ t.Errorf("pipelineSRCPreloadSlotsHistogram delta = %d, want %d when makeWitness=true", delta, numBlocks)
+ }
+ if delta := pipelineSRCPreloadReadAccountsHistogram.Snapshot().Count() - preloadAccountsBefore; delta != numBlocks {
+ t.Errorf("pipelineSRCPreloadReadAccountsHistogram delta = %d, want %d when makeWitness=true", delta, numBlocks)
+ }
+}
+
+// TestPipelinedImportSRC_RootParityWitnessOnVsOff is the consensus-critical
+// parity check for mitigation (2.5): the pipelined SRC goroutine uses
+// state.NewTrieOnly when makeWitness=true and state.New (multi-reader) when
+// makeWitness=false. Both reader paths must produce byte-identical state
+// roots when importing the same blocks — otherwise consensus would split
+// between witness-producing and witness-off nodes on the same network.
+//
+// Two import shapes are exercised:
+// - shape A: a single InsertChain(blocks) call — batch behaviour
+// - shape B: two consecutive InsertChain calls — exercises cross-call
+// pending-SRC reuse (the FlatDiff overlay path between batches)
+//
+// Path scheme is used because state.New only differs from state.NewTrieOnly
+// when a flat reader is actually wired (pathdb StateReader); under hash
+// scheme without a snapshot the multi-reader degenerates to trie-only and
+// the test would not detect a real parity bug.
+func TestPipelinedImportSRC_RootParityWitnessOnVsOff(t *testing.T) {
+ const numBlocks = 8
+ const splitAt = 3 // shape B: insert blocks[:splitAt] then blocks[splitAt:]
+
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, numBlocks, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ // Witness=true path: NewTrieOnly reader, single InsertChain batch.
+ witnessOnChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(rawdb.PathScheme))
+ if err != nil {
+ t.Fatalf("witness-on chain: %v", err)
+ }
+ defer witnessOnChain.Stop()
+ if _, err := witnessOnChain.InsertChain(blocks, true); err != nil {
+ t.Fatalf("witness-on InsertChain: %v", err)
+ }
+ if err := witnessOnChain.flushPendingImportSRC(); err != nil {
+ t.Fatalf("witness-on flush: %v", err)
+ }
+
+ // Witness=false path, shape A: single InsertChain batch with state.New reader.
+ witnessOffBatch, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(rawdb.PathScheme))
+ if err != nil {
+ t.Fatalf("witness-off batch chain: %v", err)
+ }
+ defer witnessOffBatch.Stop()
+ if _, err := witnessOffBatch.InsertChain(blocks, false); err != nil {
+ t.Fatalf("witness-off batch InsertChain: %v", err)
+ }
+ if err := witnessOffBatch.flushPendingImportSRC(); err != nil {
+ t.Fatalf("witness-off batch flush: %v", err)
+ }
+
+ // Witness=false path, shape B: split insertion exercises cross-call
+ // pending-SRC reuse — the second InsertChain opens with a pending FlatDiff
+ // from the first batch and must produce the same roots as the batched
+ // path.
+ witnessOffSplit, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(rawdb.PathScheme))
+ if err != nil {
+ t.Fatalf("witness-off split chain: %v", err)
+ }
+ defer witnessOffSplit.Stop()
+ if _, err := witnessOffSplit.InsertChain(blocks[:splitAt], false); err != nil {
+ t.Fatalf("witness-off split first batch: %v", err)
+ }
+ if _, err := witnessOffSplit.InsertChain(blocks[splitAt:], false); err != nil {
+ t.Fatalf("witness-off split second batch: %v", err)
+ }
+ if err := witnessOffSplit.flushPendingImportSRC(); err != nil {
+ t.Fatalf("witness-off split flush: %v", err)
+ }
+
+ // Per-block parity: every chain must agree with the canonical root from
+ // the generator AND with each other.
+ for i := uint64(1); i <= numBlocks; i++ {
+ canonical := blocks[i-1].Root()
+
+ on := witnessOnChain.GetBlockByNumber(i)
+ offBatch := witnessOffBatch.GetBlockByNumber(i)
+ offSplit := witnessOffSplit.GetBlockByNumber(i)
+ if on == nil || offBatch == nil || offSplit == nil {
+ t.Fatalf("block %d: missing on one of the chains (on=%v offBatch=%v offSplit=%v)",
+ i, on == nil, offBatch == nil, offSplit == nil)
+ }
+
+ if on.Root() != canonical {
+ t.Errorf("block %d: witness-on root %s != canonical %s", i, on.Root(), canonical)
+ }
+ if offBatch.Root() != canonical {
+ t.Errorf("block %d: witness-off batch root %s != canonical %s", i, offBatch.Root(), canonical)
+ }
+ if offSplit.Root() != canonical {
+ t.Errorf("block %d: witness-off split root %s != canonical %s", i, offSplit.Root(), canonical)
+ }
+ if on.Root() != offBatch.Root() {
+ t.Errorf("block %d: witness-on vs witness-off-batch root mismatch %s != %s", i, on.Root(), offBatch.Root())
+ }
+ if offBatch.Root() != offSplit.Root() {
+ t.Errorf("block %d: witness-off batch vs split root mismatch %s != %s", i, offBatch.Root(), offSplit.Root())
+ }
+ }
+}
+
+// TestPipelinedImportSRC_WitnessHardFailsWithoutExecWitness verifies that
+// runSRCCompute rejects the configuration where a witness is requested but
+// none is supplied by the caller. The import path always hands the
+// EVM-populated witness through to SRC; only miner/legacy callers may set
+// allowOwnWitness=true to opt into SRC creating its own witness. Spawning
+// the SRC goroutine with makeWitness=true, execWitness=nil, and
+// allowOwnWitness=false must set pending.err.
+func TestPipelinedImportSRC_WitnessHardFailsWithoutExecWitness(t *testing.T) {
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, 1, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ chain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("failed to create chain: %v", err)
+ }
+ defer chain.Stop()
+
+ // First import a block normally so we have a committed parent root and a
+ // FlatDiff to feed the SRC. The import path's makeWitness=false is used so
+ // no witness work runs in the legitimate insertion.
+ if _, err := chain.InsertChain(blocks, false); err != nil {
+ t.Fatalf("InsertChain failed: %v", err)
+ }
+ if err := chain.flushPendingImportSRC(); err != nil {
+ t.Fatalf("flushPendingImportSRC failed: %v", err)
+ }
+
+ // Now manually spawn an SRC goroutine with makeWitness=true,
+ // execWitness=nil, allowOwnWitness=false. Use a dummy FlatDiff to keep
+ // ApplyFlatDiffForCommit from being reached; runSRCCompute should error
+ // out before touching it.
+ parent := chain.GetBlockByNumber(0)
+ target := blocks[0]
+ chain.SpawnSRCGoroutine(target, parent.Root(), &state.FlatDiff{}, true, nil, false, nil, false)
+
+ // Drain via the public wait API: the goroutine sets pending.err and exits.
+ chain.pendingSRCMu.Lock()
+ pending := chain.pendingSRC
+ chain.pendingSRCMu.Unlock()
+ if pending == nil {
+ t.Fatal("expected pendingSRC after SpawnSRCGoroutine")
+ }
+ pending.wg.Wait()
+
+ if pending.err == nil {
+ t.Fatal("expected pending.err to be set when execWitness=nil and allowOwnWitness=false; " +
+ "got nil — the hard-fail check is not enforcing the witness contract")
+ }
+ if !strings.Contains(pending.err.Error(), "without execution witness") {
+ t.Errorf("pending.err = %v, want error mentioning 'without execution witness'", pending.err)
+ }
+}
+
+// TestPipelinedImportSRC_WitnessIncludesBlockHashAncestors verifies that
+// BLOCKHASH opcode access during EVM execution is reflected in the witness
+// published by the pipelined SRC path.
+//
+// The pipelined import path runs EVM execution and SRC commit on different
+// goroutines but must publish a single completed witness. AddBlockHash fires
+// during execution (vm/instructions.go::opBlockhash) on the witness attached
+// to the executing StateDB; the published witness must therefore include
+// those Headers entries. BLOCKHASH ancestor coverage is checked through
+// Headers because BorWitness serialises Headers but not Codes; verifiers
+// source bytecode from local storage.
+//
+// Test setup:
+// - Block 1: regular value transfer, no BLOCKHASH.
+// - Block 2: calls a contract whose bytecode runs BLOCKHASH(0). At block 2
+// this triggers Witness.AddBlockHash(0), which extends Headers to include
+// genesis (parent=block-1 is already in Headers from NewWitness; reaching
+// back to genesis adds the second entry).
+//
+// Generation requires a real chain context for AddTxWithChain to satisfy the
+// EVM's blockhash lookup — chain_makers.go's plain AddTx uses a fake
+// BlockChain with no headers and crashes on GetHashFn. The test builds a
+// separate ctxChain for generation, then imports all blocks into a fresh
+// pipeChain configured with pipelined SRC.
+func TestPipelinedImportSRC_WitnessIncludesBlockHashAncestors(t *testing.T) {
+ // Bytecode: PUSH1 0x00 ; BLOCKHASH ; POP ; STOP
+ // Reads the hash of block 0 (genesis), discards it. Triggers
+ // witness.AddBlockHash(0) inside vm/instructions.go::opBlockhash on
+ // whichever StateDB owns the witness at execution time.
+ contractCode := []byte{0x60, 0x00, 0x40, 0x50, 0x00}
+ contractAddr := common.HexToAddress("0xb1a5cab1ec0de")
+
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ addr: {Balance: funds},
+ contractAddr: {Code: contractCode, Balance: big.NewInt(0)},
+ },
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ // Phase 1: Generate block 1 (no BLOCKHASH yet) using a fresh shared db
+ // that we'll reuse for ctxChain so headers are written exactly once.
+ db := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(db, triedb.HashDefaults)
+ genesisBlock := gspec.MustCommit(db, tdb)
+ prefix, _ := GenerateChain(gspec.Config, genesisBlock, engine, db, 1, func(i int, gen *BlockGen) {})
+
+ // Phase 2: Build ctxChain on the same db so BlockGen.AddTxWithChain has
+ // a real HeaderChain to satisfy the BLOCKHASH lookup in block 2.
+ ctxChain, err := NewBlockChain(db, gspec, engine, DefaultConfig().WithStateScheme(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("ctxChain: %v", err)
+ }
+ if _, err := ctxChain.InsertChain(prefix, false); err != nil {
+ t.Fatalf("ctxChain: insert prefix: %v", err)
+ }
+
+ // Phase 3: Generate block 2 with a tx that calls the contract → BLOCKHASH(0)
+ // fires during EVM execution, extending the execution witness's Headers.
+ block2Slice, _ := GenerateChain(gspec.Config, prefix[0], engine, db, 1, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), contractAddr, big.NewInt(0), 100_000, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTxWithChain(ctxChain, tx)
+ })
+ ctxChain.Stop()
+
+ allBlocks := append(append([]*types.Block{}, prefix...), block2Slice...)
+
+ // Phase 4: Fresh pipeChain, import everything with witness=true
+ pipeChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("pipeChain: %v", err)
+ }
+ defer pipeChain.Stop()
+
+ if _, err := pipeChain.InsertChain(allBlocks, true); err != nil {
+ t.Fatalf("InsertChain (makeWitness=true): %v", err)
+ }
+ if err := pipeChain.flushPendingImportSRC(); err != nil {
+ t.Fatalf("flushPendingImportSRC: %v", err)
+ }
+
+ // Phase 5: Decode the published witness for block 2 and verify Headers
+ // extends past parent, which is what AddBlockHash(0) records during
+ // execution.
+ target := block2Slice[0]
+ encoded := pipeChain.GetWitness(target.Hash())
+ if encoded == nil {
+ t.Fatalf("block 2: witness missing from cache")
+ }
+ var w stateless.Witness
+ if err := rlp.DecodeBytes(encoded, &w); err != nil {
+ t.Fatalf("decode witness: %v", err)
+ }
+
+ // AddBlockHash(0) walks back from parent (block 1) to genesis, so the
+ // published witness's Headers slice should have length 2: [block 1, genesis].
+ if len(w.Headers) < 2 {
+ t.Fatalf("Headers length = %d, want >= 2 (parent + genesis from BLOCKHASH(0)); "+
+ "the published witness must include execution-time AddBlockHash entries",
+ len(w.Headers))
+ }
+
+ parentHeader := prefix[0].Header()
+ if w.Headers[0].Hash() != parentHeader.Hash() {
+ t.Errorf("Headers[0] = %s, want parent (block 1) %s", w.Headers[0].Hash(), parentHeader.Hash())
+ }
+ foundGenesis := false
+ for _, h := range w.Headers {
+ if h.Number.Uint64() == 0 && h.Hash() == genesisBlock.Hash() {
+ foundGenesis = true
+ break
+ }
+ }
+ if !foundGenesis {
+ t.Errorf("Headers does not contain genesis (BLOCKHASH(0) referenced it); Headers=%d entries", len(w.Headers))
+ }
+
+ if err := stateless.ValidateWitnessPreState(&w, pipeChain, target.Header()); err != nil {
+ t.Errorf("ValidateWitnessPreState failed on the published witness: %v", err)
+ }
+}
+
+// TestPipelinedImportSRC_WitnessEncodeDecodeRoundtrip verifies that pipelined
+// witnesses encode-decode cleanly and pass canonical pre-state validation,
+// covering the basic shared-witness contract: the same Witness object that
+// EVM execution populated must round-trip through RLP and validate.
+func TestPipelinedImportSRC_WitnessEncodeDecodeRoundtrip(t *testing.T) {
+ const numBlocks = 3
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, numBlocks, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ pipeChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("failed to create pipeline chain: %v", err)
+ }
+ defer pipeChain.Stop()
+
+ if _, err := pipeChain.InsertChain(blocks, true); err != nil {
+ t.Fatalf("InsertChain (makeWitness=true) failed: %v", err)
+ }
+ if err := pipeChain.flushPendingImportSRC(); err != nil {
+ t.Fatalf("flushPendingImportSRC failed: %v", err)
+ }
+
+ for i := uint64(1); i <= numBlocks; i++ {
+ block := pipeChain.GetBlockByNumber(i)
+ encoded := pipeChain.GetWitness(block.Hash())
+ if encoded == nil {
+ t.Fatalf("block %d: witness missing from cache", i)
+ }
+ var w stateless.Witness
+ if err := rlp.DecodeBytes(encoded, &w); err != nil {
+ t.Fatalf("block %d: decode witness: %v", i, err)
+ }
+ if len(w.Headers) == 0 {
+ t.Errorf("block %d: decoded witness has no Headers", i)
+ }
+ if err := stateless.ValidateWitnessPreState(&w, pipeChain, block.Header()); err != nil {
+ t.Errorf("block %d: ValidateWitnessPreState failed: %v", i, err)
+ }
+ }
+}
+
+// TestPipelinedImportSRC_WarmSnapshotWitnessParity is the consensus-critical
+// parity test for the warm-snapshot handoff. The same chain is imported
+// twice — once with PipelinedSRCWarmSnapshot=false (baseline pathdb-only
+// reader) and once with PipelinedSRCWarmSnapshot=true (snapshot-aware
+// reader). Per-block state roots, decoded witnesses, and the State proof
+// node sets must be identical, AND each published witness must replay
+// statelessly via ProcessBlockWithWitnesses to the same root the chain
+// produced. Any divergence proves the snapshot path silently dropped or
+// substituted proof nodes.
+//
+// Root parity alone is necessary but not sufficient: it proves the commit
+// walk produced the same hash, not that the published witness covers the
+// same proof surface. Stateless replay is the strongest assertion — it
+// reconstructs state from the witness alone (plus the receiver's local
+// codes/headers) and recomputes the post-state root.
+//
+// Runs under both HashScheme and PathScheme; PathScheme is the production
+// target and exercises the pathdb fallthrough path in the snapshot reader.
+func TestPipelinedImportSRC_WarmSnapshotWitnessParity(t *testing.T) {
+ testPipelinedImportSRC_WarmSnapshotWitnessParity(t, rawdb.HashScheme)
+ testPipelinedImportSRC_WarmSnapshotWitnessParity(t, rawdb.PathScheme)
+}
+
+func testPipelinedImportSRC_WarmSnapshotWitnessParity(t *testing.T, scheme string) {
+ t.Run(scheme, func(t *testing.T) {
+ const numBlocks = 8
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, numBlocks, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, big.NewInt(1000), params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ importInto := func(t *testing.T, label string, cfg *BlockChainConfig) *BlockChain {
+ t.Helper()
+ chain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, cfg)
+ if err != nil {
+ t.Fatalf("%s: NewBlockChain: %v", label, err)
+ }
+ t.Cleanup(chain.Stop)
+ if _, err := chain.InsertChain(blocks, true); err != nil {
+ t.Fatalf("%s: InsertChain: %v", label, err)
+ }
+ if err := chain.flushPendingImportSRC(); err != nil {
+ t.Fatalf("%s: flushPendingImportSRC: %v", label, err)
+ }
+ return chain
+ }
+
+ chainOff := importInto(t, "snapshot-off", pipelinedConfig(scheme))
+ chainOn := importInto(t, "snapshot-on", pipelinedConfigWithWarmSnapshot(scheme))
+
+ for i := uint64(1); i <= numBlocks; i++ {
+ blkOff := chainOff.GetBlockByNumber(i)
+ blkOn := chainOn.GetBlockByNumber(i)
+ if blkOff == nil || blkOn == nil {
+ t.Fatalf("block %d: missing on one of the chains (off=%v on=%v)", i, blkOff == nil, blkOn == nil)
+ }
+ if blkOff.Root() != blkOn.Root() {
+ t.Errorf("block %d: state root mismatch off=%s on=%s", i, blkOff.Root(), blkOn.Root())
+ }
+
+ encOff := chainOff.GetWitness(blkOff.Hash())
+ encOn := chainOn.GetWitness(blkOn.Hash())
+ if encOff == nil || encOn == nil {
+ t.Fatalf("block %d: witness missing (off=%v on=%v)", i, encOff == nil, encOn == nil)
+ }
+
+ var wOff, wOn stateless.Witness
+ if err := rlp.DecodeBytes(encOff, &wOff); err != nil {
+ t.Fatalf("block %d: decode off witness: %v", i, err)
+ }
+ if err := rlp.DecodeBytes(encOn, &wOn); err != nil {
+ t.Fatalf("block %d: decode on witness: %v", i, err)
+ }
+
+ if err := stateless.ValidateWitnessPreState(&wOff, chainOff, blkOff.Header()); err != nil {
+ t.Errorf("block %d: off witness pre-state validation: %v", i, err)
+ }
+ if err := stateless.ValidateWitnessPreState(&wOn, chainOn, blkOn.Header()); err != nil {
+ t.Errorf("block %d: on witness pre-state validation: %v", i, err)
+ }
+
+ // State proof-node parity: the snapshot path must produce the
+ // same set of trie proof nodes as the no-snapshot path. The
+ // State map's keys are RLP node blobs; equal sets = equal proof
+ // coverage.
+ if len(wOff.State) != len(wOn.State) {
+ t.Errorf("block %d: State size off=%d on=%d", i, len(wOff.State), len(wOn.State))
+ }
+ for k := range wOff.State {
+ if _, ok := wOn.State[k]; !ok {
+ t.Errorf("block %d: snapshot-on witness missing proof node present in baseline (len=%d)", i, len(k))
+ break
+ }
+ }
+ for k := range wOn.State {
+ if _, ok := wOff.State[k]; !ok {
+ t.Errorf("block %d: snapshot-on witness has proof node not in baseline (len=%d)", i, len(k))
+ break
+ }
+ }
+
+ // Headers parity: BLOCKHASH ancestor inclusion must be identical
+ // across snapshot-on/off. (For this transfer-only chain there are
+ // no BLOCKHASH ops; Headers is just [parent].)
+ if len(wOff.Headers) != len(wOn.Headers) {
+ t.Errorf("block %d: Headers length off=%d on=%d", i, len(wOff.Headers), len(wOn.Headers))
+ }
+
+ // Stateless replay parity: each chain's published witness must
+ // reconstruct state and recompute the post-state root via
+ // ProcessBlockWithWitnesses. ExecuteStateless returns an error
+ // if the recomputed root diverges from block.Root() — that's
+ // exactly the assertion we want. SetHeader installs the
+ // context header (the block being replayed) on the decoded
+ // witness, which RLP decode does not preserve. The witness's
+ // HeaderReader (used to resolve BLOCKHASH ancestor lookups) is
+ // also dropped by RLP decode, but ProcessBlockWithWitnesses
+ // falls back to the BlockChain itself when the witness has no
+ // HeaderReader set — so for in-memory test chains nothing
+ // further needs wiring here.
+ wOff.SetHeader(blkOff.Header())
+ wOn.SetHeader(blkOn.Header())
+ if _, _, err := chainOff.ProcessBlockWithWitnesses(blkOff, &wOff); err != nil {
+ t.Errorf("block %d: stateless replay (off chain witness) failed: %v", i, err)
+ }
+ if _, _, err := chainOn.ProcessBlockWithWitnesses(blkOn, &wOn); err != nil {
+ t.Errorf("block %d: stateless replay (on chain witness) failed: %v", i, err)
+ }
+ }
+ })
+}
+
+// TestPipelinedImportSRC_WarmSnapshotPreservesBlockHashAncestors mirrors
+// TestPipelinedImportSRC_WitnessIncludesBlockHashAncestors but with the
+// warm-snapshot handoff enabled. BLOCKHASH ancestor coverage is collected on
+// the execution witness during EVM execution, before SRC starts; the
+// snapshot path only changes how SRC's trie reads are served, not the
+// witness ownership chain. Therefore Headers must still extend to include
+// the BLOCKHASH-referenced ancestor, and the published witness must
+// statelessly replay.
+//
+// Runs under both HashScheme and PathScheme.
+func TestPipelinedImportSRC_WarmSnapshotPreservesBlockHashAncestors(t *testing.T) {
+ testPipelinedImportSRC_WarmSnapshotPreservesBlockHashAncestors(t, rawdb.HashScheme)
+ testPipelinedImportSRC_WarmSnapshotPreservesBlockHashAncestors(t, rawdb.PathScheme)
+}
+
+func testPipelinedImportSRC_WarmSnapshotPreservesBlockHashAncestors(t *testing.T, scheme string) {
+ t.Run(scheme, func(t *testing.T) {
+ runWarmSnapshotBlockHashTest(t, scheme)
+ })
+}
+
+func runWarmSnapshotBlockHashTest(t *testing.T, scheme string) {
+ contractCode := []byte{0x60, 0x00, 0x40, 0x50, 0x00}
+ contractAddr := common.HexToAddress("0xb1a5cab1ec0de")
+
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ addr: {Balance: funds},
+ contractAddr: {Code: contractCode, Balance: big.NewInt(0)},
+ },
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ db := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(db, triedb.HashDefaults)
+ genesisBlock := gspec.MustCommit(db, tdb)
+ prefix, _ := GenerateChain(gspec.Config, genesisBlock, engine, db, 1, func(i int, gen *BlockGen) {})
+
+ ctxChain, err := NewBlockChain(db, gspec, engine, DefaultConfig().WithStateScheme(rawdb.HashScheme))
+ if err != nil {
+ t.Fatalf("ctxChain: %v", err)
+ }
+ if _, err := ctxChain.InsertChain(prefix, false); err != nil {
+ t.Fatalf("ctxChain insert prefix: %v", err)
+ }
+ block2Slice, _ := GenerateChain(gspec.Config, prefix[0], engine, db, 1, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), contractAddr, big.NewInt(0), 100_000, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTxWithChain(ctxChain, tx)
+ })
+ ctxChain.Stop()
+
+ allBlocks := append(append([]*types.Block{}, prefix...), block2Slice...)
+
+ pipeChain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfigWithWarmSnapshot(scheme))
+ if err != nil {
+ t.Fatalf("pipeChain: %v", err)
+ }
+ defer pipeChain.Stop()
+
+ if _, err := pipeChain.InsertChain(allBlocks, true); err != nil {
+ t.Fatalf("InsertChain (snapshot=true, witness=true): %v", err)
+ }
+ if err := pipeChain.flushPendingImportSRC(); err != nil {
+ t.Fatalf("flushPendingImportSRC: %v", err)
+ }
+
+ target := block2Slice[0]
+ encoded := pipeChain.GetWitness(target.Hash())
+ if encoded == nil {
+ t.Fatalf("block 2: witness missing from cache")
+ }
+ var w stateless.Witness
+ if err := rlp.DecodeBytes(encoded, &w); err != nil {
+ t.Fatalf("decode witness: %v", err)
+ }
+ if len(w.Headers) < 2 {
+ t.Fatalf("Headers length = %d, want >= 2 (parent + genesis from BLOCKHASH(0)) — "+
+ "warm-snapshot path must preserve BLOCKHASH ancestor coverage", len(w.Headers))
+ }
+ parentHeader := prefix[0].Header()
+ if w.Headers[0].Hash() != parentHeader.Hash() {
+ t.Errorf("Headers[0] = %s, want parent %s", w.Headers[0].Hash(), parentHeader.Hash())
+ }
+ foundGenesis := false
+ for _, h := range w.Headers {
+ if h.Number.Uint64() == 0 && h.Hash() == genesisBlock.Hash() {
+ foundGenesis = true
+ break
+ }
+ }
+ if !foundGenesis {
+ t.Errorf("Headers does not contain genesis (BLOCKHASH(0) referenced it); Headers=%d entries", len(w.Headers))
+ }
+ if err := stateless.ValidateWitnessPreState(&w, pipeChain, target.Header()); err != nil {
+ t.Errorf("ValidateWitnessPreState failed on snapshot-on witness: %v", err)
+ }
+
+ // Stateless replay: the published witness must reconstruct state
+ // (including the BLOCKHASH ancestor lookup) and recompute the same
+ // post-state root via ExecuteStateless. This is the consumer-side
+ // check that the snapshot path's witness is actually replayable, not
+ // just structurally well-formed.
+ w.SetHeader(target.Header())
+ if _, _, err := pipeChain.ProcessBlockWithWitnesses(target, &w); err != nil {
+ t.Errorf("stateless replay of snapshot-on BLOCKHASH witness failed: %v", err)
+ }
+}
+
+// TestPipelinedImportSRC_WarmSnapshotStorageTrieParity is the storage-trie
+// counterpart to the witness parity test. The snapshot reader is wrapped at
+// the database.NodeReader layer used by both account and storage tries; this
+// test specifically exercises storage-trie reads (SLOAD) and writes (SSTORE)
+// so the storage-owner branch of newSnapshotNodeDatabase / trieReader.Storage
+// is covered. A single contract is deployed with pre-populated storage; each
+// block's transaction loads a previously-set slot and writes a new one,
+// progressively growing the storage trie. The witness must include the
+// storage proof nodes touched by both the SLOAD and the SSTORE update path.
+//
+// Snapshot-off and snapshot-on import the same chain into independent
+// blockchains; per-block roots, decoded witness State sets, and stateless
+// replay must all agree. Runs under both HashScheme and PathScheme.
+func TestPipelinedImportSRC_WarmSnapshotStorageTrieParity(t *testing.T) {
+ testPipelinedImportSRC_WarmSnapshotStorageTrieParity(t, rawdb.HashScheme)
+ testPipelinedImportSRC_WarmSnapshotStorageTrieParity(t, rawdb.PathScheme)
+}
+
+func testPipelinedImportSRC_WarmSnapshotStorageTrieParity(t *testing.T, scheme string) {
+ t.Run(scheme, func(t *testing.T) {
+ // Contract program:
+ // SLOAD(NUMBER - 1) ; POP — read a previously-set slot, forcing
+ // a storage-trie pre-state read whose
+ // proof nodes must be in the witness
+ // SSTORE(NUMBER, NUMBER) — write a new slot, growing the trie
+ // (the update walks the proof path)
+ contractCode := []byte{
+ byte(vm.PUSH1), 0x01,
+ byte(vm.NUMBER),
+ byte(vm.SUB),
+ byte(vm.SLOAD),
+ byte(vm.POP),
+ byte(vm.NUMBER),
+ byte(vm.NUMBER),
+ byte(vm.SSTORE),
+ byte(vm.STOP),
+ }
+ contractAddr := common.HexToAddress("0x5707a6e500000000000000000000000000000001")
+
+ // Pre-populate slots 0..7 in the contract's storage trie so block 1's
+ // SLOAD(0) hits a real entry rather than an empty slot, ensuring the
+ // pre-state read actually walks storage-trie nodes.
+ preStorage := make(map[common.Hash]common.Hash, 8)
+ for i := 0; i < 8; i++ {
+ preStorage[common.BigToHash(big.NewInt(int64(i)))] = common.BigToHash(big.NewInt(int64(i + 1000)))
+ }
+
+ const numBlocks = 6
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{
+ addr: {Balance: funds},
+ contractAddr: {
+ Code: contractCode,
+ Balance: big.NewInt(0),
+ Storage: preStorage,
+ },
+ },
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, numBlocks, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), contractAddr, big.NewInt(0), 100_000, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+
+ importInto := func(t *testing.T, label string, cfg *BlockChainConfig) *BlockChain {
+ t.Helper()
+ chain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, cfg)
+ if err != nil {
+ t.Fatalf("%s: NewBlockChain: %v", label, err)
+ }
+ t.Cleanup(chain.Stop)
+ if _, err := chain.InsertChain(blocks, true); err != nil {
+ t.Fatalf("%s: InsertChain: %v", label, err)
+ }
+ if err := chain.flushPendingImportSRC(); err != nil {
+ t.Fatalf("%s: flushPendingImportSRC: %v", label, err)
+ }
+ return chain
+ }
+
+ chainOff := importInto(t, "snapshot-off", pipelinedConfig(scheme))
+ chainOn := importInto(t, "snapshot-on", pipelinedConfigWithWarmSnapshot(scheme))
+
+ for i := uint64(1); i <= numBlocks; i++ {
+ blkOff := chainOff.GetBlockByNumber(i)
+ blkOn := chainOn.GetBlockByNumber(i)
+ if blkOff == nil || blkOn == nil {
+ t.Fatalf("block %d: missing on one of the chains (off=%v on=%v)", i, blkOff == nil, blkOn == nil)
+ }
+ if blkOff.Root() != blkOn.Root() {
+ t.Errorf("block %d: state root mismatch off=%s on=%s", i, blkOff.Root(), blkOn.Root())
+ }
+
+ encOff := chainOff.GetWitness(blkOff.Hash())
+ encOn := chainOn.GetWitness(blkOn.Hash())
+ if encOff == nil || encOn == nil {
+ t.Fatalf("block %d: witness missing (off=%v on=%v)", i, encOff == nil, encOn == nil)
+ }
+
+ var wOff, wOn stateless.Witness
+ if err := rlp.DecodeBytes(encOff, &wOff); err != nil {
+ t.Fatalf("block %d: decode off witness: %v", i, err)
+ }
+ if err := rlp.DecodeBytes(encOn, &wOn); err != nil {
+ t.Fatalf("block %d: decode on witness: %v", i, err)
+ }
+
+ if err := stateless.ValidateWitnessPreState(&wOff, chainOff, blkOff.Header()); err != nil {
+ t.Errorf("block %d: off witness pre-state validation: %v", i, err)
+ }
+ if err := stateless.ValidateWitnessPreState(&wOn, chainOn, blkOn.Header()); err != nil {
+ t.Errorf("block %d: on witness pre-state validation: %v", i, err)
+ }
+
+ // State proof-node parity. The snapshot path must produce the same
+ // set of trie proof nodes as the pathdb-only path. For this test
+ // the State map covers both account-trie and storage-trie nodes.
+ if len(wOff.State) != len(wOn.State) {
+ t.Errorf("block %d: State size off=%d on=%d", i, len(wOff.State), len(wOn.State))
+ }
+ for k := range wOff.State {
+ if _, ok := wOn.State[k]; !ok {
+ t.Errorf("block %d: snapshot-on witness missing proof node present in baseline (len=%d)", i, len(k))
+ break
+ }
+ }
+ for k := range wOn.State {
+ if _, ok := wOff.State[k]; !ok {
+ t.Errorf("block %d: snapshot-on witness has proof node not in baseline (len=%d)", i, len(k))
+ break
+ }
+ }
+
+ // Stateless replay parity. ExecuteStateless reconstructs the
+ // pre-state from the witness's State proof nodes (including
+ // storage subtries) and recomputes the post-state root. Failure
+ // here indicates the snapshot path served a storage-trie node
+ // whose blob disagrees with what pathdb would have served.
+ wOff.SetHeader(blkOff.Header())
+ wOn.SetHeader(blkOn.Header())
+ if _, _, err := chainOff.ProcessBlockWithWitnesses(blkOff, &wOff); err != nil {
+ t.Errorf("block %d: stateless replay (off chain witness) failed: %v", i, err)
+ }
+ if _, _, err := chainOn.ProcessBlockWithWitnesses(blkOn, &wOn); err != nil {
+ t.Errorf("block %d: stateless replay (on chain witness) failed: %v", i, err)
+ }
+ }
+ })
+}
diff --git a/core/events.go b/core/events.go
index fadecdedf7..19afd30300 100644
--- a/core/events.go
+++ b/core/events.go
@@ -19,6 +19,7 @@ package core
import (
"time"
+ "github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/stateless"
"github.com/ethereum/go-ethereum/core/types"
)
@@ -49,6 +50,14 @@ type ChainSideEvent struct {
Header *types.Header
}
+// WitnessReadyEvent is posted when a pipelined import SRC goroutine finishes
+// and writes the witness to the database. The handler uses this to announce
+// witness availability to peers via the WIT protocol.
+type WitnessReadyEvent struct {
+ BlockHash common.Hash
+ BlockNumber uint64
+}
+
type ChainHeadEvent struct {
Header *types.Header
}
diff --git a/core/evm.go b/core/evm.go
index a74c098e00..8e17925901 100644
--- a/core/evm.go
+++ b/core/evm.go
@@ -19,6 +19,7 @@ package core
import (
"math/big"
"sync"
+ "sync/atomic"
"github.com/holiman/uint256"
@@ -182,6 +183,65 @@ func GetHashFn(ref *types.Header, chain ChainContext) func(n uint64) common.Hash
}
}
+// SpeculativeGetHashFn returns a GetHashFunc for use during pipelined SRC
+// speculative execution of block N+1, where block N's hash is not yet known
+// (SRC(N) is still computing root_N).
+//
+// It uses three-tier resolution:
+// - Tier 1 (n == pendingBlockN): lazy-resolves by calling srcDone(), which
+// blocks until SRC(N) completes and returns hash(block_N). Cached after
+// first call.
+// - Tier 2 (n == pendingBlockN-1): returns blockN1Header.Hash() directly.
+// Block N-1 is fully committed and in the chain DB.
+// - Tier 3 (n < pendingBlockN-1): delegates to GetHashFn anchored at
+// block N-1. Its cache seeds from blockN1Header.ParentHash = hash(block_{N-2}),
+// so index 0 gives BLOCKHASH(N-2), which is correct.
+//
+// srcDone is called at most once and must return hash(block_N) after SRC(N)
+// completes. It may block.
+func SpeculativeGetHashFn(blockN1Header *types.Header, chain ChainContext, pendingBlockN uint64, srcDone func() common.Hash, blockhashNAccessed *atomic.Bool) func(uint64) common.Hash {
+ blockN1Hash := blockN1Header.Hash()
+ olderFn := GetHashFn(blockN1Header, chain) // blocks N-2 and below
+ resolveN := newPendingBlockNResolver(srcDone, blockhashNAccessed)
+ return func(n uint64) common.Hash {
+ switch {
+ case n >= pendingBlockN+1:
+ return common.Hash{} // future block
+ case n == pendingBlockN:
+ return resolveN()
+ case n == pendingBlockN-1:
+ return blockN1Hash
+ default:
+ return olderFn(n)
+ }
+ }
+}
+
+// newPendingBlockNResolver returns a closure that lazily resolves pending
+// block N's hash via srcDone. On every invocation it flags blockhashNAccessed
+// so the caller knows the speculative block read BLOCKHASH(N) — the resolved
+// hash is pre-seal (no signature in Extra) and will differ from the final
+// on-chain hash, so the speculative execution must be aborted.
+func newPendingBlockNResolver(srcDone func() common.Hash, blockhashNAccessed *atomic.Bool) func() common.Hash {
+ var (
+ resolvedHash common.Hash
+ resolved bool
+ mu sync.Mutex
+ )
+ return func() common.Hash {
+ if blockhashNAccessed != nil {
+ blockhashNAccessed.Store(true)
+ }
+ mu.Lock()
+ defer mu.Unlock()
+ if !resolved {
+ resolvedHash = srcDone()
+ resolved = true
+ }
+ return resolvedHash
+ }
+}
+
// CanTransfer checks whether there are enough funds in the address' account to make a transfer.
// This does not take the necessary gas in to account to make the transfer valid.
func CanTransfer(db vm.StateDB, addr common.Address, amount *uint256.Int) bool {
diff --git a/core/evm_speculative_test.go b/core/evm_speculative_test.go
new file mode 100644
index 0000000000..5e59094ba9
--- /dev/null
+++ b/core/evm_speculative_test.go
@@ -0,0 +1,277 @@
+package core
+
+import (
+ "math/big"
+ "sync"
+ "sync/atomic"
+ "testing"
+ "time"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// mockChainContext implements ChainContext for testing SpeculativeGetHashFn.
+type mockChainContext struct {
+ headers map[uint64]*types.Header
+}
+
+func (m *mockChainContext) Config() *params.ChainConfig {
+ return params.TestChainConfig
+}
+
+func (m *mockChainContext) CurrentHeader() *types.Header {
+ return nil
+}
+
+func (m *mockChainContext) GetHeader(hash common.Hash, number uint64) *types.Header {
+ return m.headers[number]
+}
+
+func (m *mockChainContext) GetHeaderByNumber(number uint64) *types.Header {
+ return m.headers[number]
+}
+
+func (m *mockChainContext) GetHeaderByHash(hash common.Hash) *types.Header {
+ for _, h := range m.headers {
+ if h.Hash() == hash {
+ return h
+ }
+ }
+ return nil
+}
+
+func (m *mockChainContext) GetTd(hash common.Hash, number uint64) *big.Int {
+ return big.NewInt(1)
+}
+
+func (m *mockChainContext) Engine() consensus.Engine {
+ return nil
+}
+
+// buildChain builds a simple chain of headers from 0 to count-1.
+func buildChain(count int) (*mockChainContext, []*types.Header) {
+ headers := make([]*types.Header, count)
+ chain := &mockChainContext{headers: make(map[uint64]*types.Header)}
+
+ for i := 0; i < count; i++ {
+ h := &types.Header{
+ Number: big.NewInt(int64(i)),
+ ParentHash: common.Hash{},
+ Extra: []byte("test"),
+ }
+ if i > 0 {
+ h.ParentHash = headers[i-1].Hash()
+ }
+ headers[i] = h
+ chain.headers[uint64(i)] = h
+ }
+
+ return chain, headers
+}
+
+func TestSpeculativeGetHashFn_Tier1_LazyResolve(t *testing.T) {
+ chain, headers := buildChain(10)
+
+ // Block N=9 is pending (SRC running), block N-1=8 is committed.
+ blockN1Header := headers[8] // block 8
+ pendingBlockN := uint64(9)
+ expectedBlockNHash := common.HexToHash("0xdeadbeef")
+
+ var srcCalled bool
+ srcDone := func() common.Hash {
+ srcCalled = true
+ return expectedBlockNHash
+ }
+
+ fn := SpeculativeGetHashFn(blockN1Header, chain, pendingBlockN, srcDone, nil)
+
+ // Tier 1: BLOCKHASH(9) should lazy-resolve
+ result := fn(9)
+ if result != expectedBlockNHash {
+ t.Errorf("Tier 1: expected %x, got %x", expectedBlockNHash, result)
+ }
+ if !srcCalled {
+ t.Error("Tier 1: srcDone was not called")
+ }
+
+ // Second call should return cached value without calling srcDone again
+ srcCalled = false
+ result = fn(9)
+ if result != expectedBlockNHash {
+ t.Errorf("Tier 1 (cached): expected %x, got %x", expectedBlockNHash, result)
+ }
+}
+
+func TestSpeculativeGetHashFn_Tier1_SetsAbortFlag(t *testing.T) {
+ chain, headers := buildChain(10)
+
+ blockN1Header := headers[8]
+ pendingBlockN := uint64(9)
+ expectedBlockNHash := common.HexToHash("0xdeadbeef")
+ var accessed atomic.Bool
+
+ fn := SpeculativeGetHashFn(blockN1Header, chain, pendingBlockN, func() common.Hash {
+ return expectedBlockNHash
+ }, &accessed)
+
+ result := fn(9)
+ if result != expectedBlockNHash {
+ t.Errorf("Tier 1: expected %x, got %x", expectedBlockNHash, result)
+ }
+ if !accessed.Load() {
+ t.Fatal("Tier 1: BLOCKHASH(N) access did not set abort flag")
+ }
+}
+
+func TestSpeculativeGetHashFn_Tier2_ImmediateParent(t *testing.T) {
+ chain, headers := buildChain(10)
+
+ blockN1Header := headers[8] // block 8
+ pendingBlockN := uint64(9)
+ expectedN1Hash := blockN1Header.Hash()
+
+ srcDone := func() common.Hash {
+ t.Error("srcDone should not be called for Tier 2")
+ return common.Hash{}
+ }
+
+ fn := SpeculativeGetHashFn(blockN1Header, chain, pendingBlockN, srcDone, nil)
+
+ // Tier 2: BLOCKHASH(8) should return block 8's hash immediately
+ result := fn(8)
+ if result != expectedN1Hash {
+ t.Errorf("Tier 2: expected %x, got %x", expectedN1Hash, result)
+ }
+}
+
+func TestSpeculativeGetHashFn_OlderTiersDoNotSetAbortFlag(t *testing.T) {
+ chain, headers := buildChain(10)
+
+ blockN1Header := headers[8]
+ pendingBlockN := uint64(9)
+ var accessed atomic.Bool
+
+ fn := SpeculativeGetHashFn(blockN1Header, chain, pendingBlockN, func() common.Hash {
+ t.Fatal("srcDone should not be called for Tier 2/3")
+ return common.Hash{}
+ }, &accessed)
+
+ _ = fn(8)
+ if accessed.Load() {
+ t.Fatal("Tier 2: BLOCKHASH(N-1) incorrectly set abort flag")
+ }
+
+ _ = fn(7)
+ if accessed.Load() {
+ t.Fatal("Tier 3: BLOCKHASH(N-2) incorrectly set abort flag")
+ }
+}
+
+func TestSpeculativeGetHashFn_Tier3_OlderBlocks(t *testing.T) {
+ chain, headers := buildChain(10)
+
+ blockN1Header := headers[8] // block 8
+ pendingBlockN := uint64(9)
+
+ srcDone := func() common.Hash {
+ t.Error("srcDone should not be called for Tier 3")
+ return common.Hash{}
+ }
+
+ fn := SpeculativeGetHashFn(blockN1Header, chain, pendingBlockN, srcDone, nil)
+
+ // Tier 3: BLOCKHASH(7) should resolve via chain walk from block 8
+ expectedHash7 := headers[7].Hash()
+ result := fn(7)
+ if result != expectedHash7 {
+ t.Errorf("Tier 3 (block 7): expected %x, got %x", expectedHash7, result)
+ }
+
+ // BLOCKHASH(5) — deeper walk
+ expectedHash5 := headers[5].Hash()
+ result = fn(5)
+ if result != expectedHash5 {
+ t.Errorf("Tier 3 (block 5): expected %x, got %x", expectedHash5, result)
+ }
+
+ // BLOCKHASH(0) — genesis
+ expectedHash0 := headers[0].Hash()
+ result = fn(0)
+ if result != expectedHash0 {
+ t.Errorf("Tier 3 (block 0): expected %x, got %x", expectedHash0, result)
+ }
+}
+
+func TestSpeculativeGetHashFn_FutureBlock(t *testing.T) {
+ chain, headers := buildChain(10)
+
+ blockN1Header := headers[8]
+ pendingBlockN := uint64(9)
+
+ srcDone := func() common.Hash {
+ t.Error("srcDone should not be called for future blocks")
+ return common.Hash{}
+ }
+
+ fn := SpeculativeGetHashFn(blockN1Header, chain, pendingBlockN, srcDone, nil)
+
+ // BLOCKHASH(10) — future block, should return zero
+ result := fn(10)
+ if result != (common.Hash{}) {
+ t.Errorf("Future block: expected zero hash, got %x", result)
+ }
+
+ // BLOCKHASH(11) — also future
+ result = fn(11)
+ if result != (common.Hash{}) {
+ t.Errorf("Future block 11: expected zero hash, got %x", result)
+ }
+}
+
+func TestSpeculativeGetHashFn_Tier1_Blocking(t *testing.T) {
+ chain, headers := buildChain(10)
+
+ blockN1Header := headers[8]
+ pendingBlockN := uint64(9)
+ expectedHash := common.HexToHash("0xabcdef")
+
+ var wg sync.WaitGroup
+ wg.Add(1)
+
+ srcDone := func() common.Hash {
+ wg.Wait() // block until released
+ return expectedHash
+ }
+
+ fn := SpeculativeGetHashFn(blockN1Header, chain, pendingBlockN, srcDone, nil)
+
+ // Start BLOCKHASH(9) in a goroutine — it should block
+ resultCh := make(chan common.Hash, 1)
+ go func() {
+ resultCh <- fn(9)
+ }()
+
+ // Verify it hasn't resolved yet
+ select {
+ case <-resultCh:
+ t.Error("BLOCKHASH(9) resolved before srcDone was released")
+ case <-time.After(100 * time.Millisecond):
+ // expected — still blocking
+ }
+
+ // Release srcDone
+ wg.Done()
+
+ // Now it should resolve
+ select {
+ case result := <-resultCh:
+ if result != expectedHash {
+ t.Errorf("Tier 1 blocking: expected %x, got %x", expectedHash, result)
+ }
+ case <-time.After(2 * time.Second):
+ t.Error("BLOCKHASH(9) did not resolve after srcDone was released")
+ }
+}
diff --git a/core/parallel_state_processor.go b/core/parallel_state_processor.go
index 83951a2219..126869a158 100644
--- a/core/parallel_state_processor.go
+++ b/core/parallel_state_processor.go
@@ -778,6 +778,10 @@ type V2ExecutionResult struct {
// behaviour at core/state_processor.go:222.
ExecErrIdx int
ExecErr error
+ // ReadErr is the first database read failure observed by the read-only
+ // base used for V2 execution. The caller must discard the result because
+ // StateDB getters return zero-ish values after recording read errors.
+ ReadErr error
*blockstm.V2ExecutionResult
}
@@ -838,6 +842,10 @@ func ExecuteV2BlockSTM(
}
raw := blockstm.ExecuteV2BlockSTM(ctx, itasks, env, blockCtx.Coinbase, numWorkers, conflictAddrs, settleFn)
+ readErr := env.safeBase.Error()
+ if err := base.Error(); readErr == nil && err != nil {
+ readErr = err
+ }
// V2 worker code reads land in env.safeBase.codeCache (each blob loaded
// once, deduplicated by sync.Map). When witness collection is on, dump
@@ -882,6 +890,7 @@ func ExecuteV2BlockSTM(
PanickedIdx: panickedIdx,
ExecErrIdx: execErrIdx,
ExecErr: execErr,
+ ReadErr: readErr,
V2ExecutionResult: raw,
}
}
@@ -932,19 +941,6 @@ func recoverTaskMessages(tasks []V2Task, chainConfig *params.ChainConfig, blockC
return firstIdx, firstErr
}
-// wireStorageCaches gives SafeBase the prefetcher's trie cache (fast path)
-// and a separate V2-owned overlay for pre-block system-call writes. The
-// overlay can't live in the trie cache: trieReader.Storage's non-atomic
-// Load→read→Store can land after the overlay and clobber it with a zero.
-func wireStorageCaches(base *state.StateDB, sb *state.SafeBase) {
- if sc := base.StorageCache(); sc != nil {
- sb.SharedStorageCache = sc
- }
- overlay := new(sync.Map)
- base.OverlayPendingStorageInto(overlay)
- sb.OverlayStorageCache = overlay
-}
-
// newV2Env builds a v2Env wired up with the shared SafeBase, jumpDest cache,
// and PDB recycle pool.
func newV2Env(base *state.StateDB, store *blockstm.MVStore, bals *blockstm.MVBalanceStore,
@@ -955,7 +951,6 @@ func newV2Env(base *state.StateDB, store *blockstm.MVStore, bals *blockstm.MVBal
poolSize = 2
}
sharedSafeBase := state.NewSafeBase(base, poolSize)
- wireStorageCaches(base, sharedSafeBase)
// Allocate the per-v2Env fallback only when the caller didn't supply a
// shared cache. Production (blockchain.go) sets vmConfig.SharedJumpDestCache
// on the prefetcher-warmed cache, so allocating here would just be dead
@@ -1159,6 +1154,9 @@ func (p *V2StateProcessor) Process(block *types.Block, statedb *state.StateDB, c
if result.ValidationPanic != nil {
return nil, fmt.Errorf("v2: validation panic: %v", result.ValidationPanic)
}
+ if result.ReadErr != nil {
+ return nil, fmt.Errorf("v2: base read: %w", result.ReadErr)
+ }
// Same logic for ApplyMessage consensus-level errors (bad nonce,
// insufficient upfront gas, intrinsic gas underflow, etc.). Serial returns
// the underlying error from state_processor.go:222 and aborts the block;
diff --git a/core/pipelined_window_test.go b/core/pipelined_window_test.go
new file mode 100644
index 0000000000..6f61e21040
--- /dev/null
+++ b/core/pipelined_window_test.go
@@ -0,0 +1,197 @@
+package core
+
+import (
+ "context"
+ "math/big"
+ "sync"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus/ethash"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/ethdb/memorydb"
+ "github.com/ethereum/go-ethereum/params"
+ "github.com/ethereum/go-ethereum/trie"
+)
+
+// TestPipelinedImportSRC_WindowReads pins the state-read semantics that RPC
+// handlers depend on during the pipelined window: the interval where the chain
+// head has advanced to block N but N's state root is not yet committed because
+// the SRC goroutine is still running. The srcHoldForTesting hook keeps that
+// window open deterministically.
+//
+// During the window:
+// - StateAt(N.Root) must serve reads via the FlatDiff overlay (this is what
+// eth_getBalance/eth_call/eth_estimateGas at "latest" resolve through).
+// - state.New(N.Root) — a direct trie open, no overlay — must fail: the
+// window is genuinely open.
+// - trie.NewStateTrie at N.Root must fail: proofs need committed trie
+// nodes the overlay doesn't have — which is why eth_getProof and
+// debug_storageRangeAt gate on WaitForPipelinedStateCommit, verified
+// here to block during the window and release when SRC settles.
+// - The committed parent root must remain fully readable.
+//
+// After release, the same queries must succeed against the committed trie with
+// values identical to those the overlay served.
+func TestPipelinedImportSRC_WindowReads(t *testing.T) {
+ testPipelinedImportSRCWindowReads(t, rawdb.HashScheme)
+ testPipelinedImportSRCWindowReads(t, rawdb.PathScheme)
+}
+
+func testPipelinedImportSRCWindowReads(t *testing.T, scheme string) {
+ var (
+ key, _ = crypto.HexToECDSA("b71c71a67e1177ad4e901695e1b4b9ee17ae16c6668d313eac2f96dbcda3f291")
+ addr = crypto.PubkeyToAddress(key.PublicKey)
+ recipient = common.HexToAddress("0x00000000000000000000000000000000deadbeef")
+ funds = new(big.Int).Mul(big.NewInt(1000), big.NewInt(params.Ether))
+ txValue = big.NewInt(10000)
+ gspec = &Genesis{
+ Config: params.AllEthashProtocolChanges,
+ Alloc: types.GenesisAlloc{addr: {Balance: funds}},
+ BaseFee: big.NewInt(params.InitialBaseFee),
+ }
+ signer = types.LatestSigner(gspec.Config)
+ engine = ethash.NewFaker()
+ )
+
+ numBlocks := 5
+ _, blocks, _ := GenerateChainWithGenesis(gspec, engine, numBlocks, func(i int, gen *BlockGen) {
+ tx, _ := types.SignTx(
+ types.NewTransaction(gen.TxNonce(addr), recipient, txValue, params.TxGas, gen.header.BaseFee, nil),
+ signer, key,
+ )
+ gen.AddTx(tx)
+ })
+ held := blocks[numBlocks-1]
+
+ chain, err := NewBlockChain(rawdb.NewMemoryDatabase(), gspec, engine, pipelinedConfig(scheme))
+ require.NoError(t, err)
+
+ gate := make(chan struct{})
+ var releaseOnce sync.Once
+ release := func() { releaseOnce.Do(func() { close(gate) }) }
+ chain.srcHoldForTesting = func(blockNumber uint64) {
+ if blockNumber == held.NumberU64() {
+ <-gate
+ }
+ }
+ defer func() {
+ release()
+ chain.Stop()
+ }()
+
+ _, err = chain.InsertChain(blocks, false)
+ require.NoError(t, err)
+
+ overlayBalance := assertWindowReads(t, chain, blocks, recipient, addr)
+
+ // WaitForPipelinedStateCommit must block while the window is open (the
+ // eth_getProof / debug_storageRangeAt gate), return once SRC settles, and
+ // be a no-op for roots other than the pending head's.
+ require.NoError(t, chain.WaitForPipelinedStateCommit(context.Background(), blocks[0].Root()))
+ waitDone := make(chan error, 1)
+ go func() {
+ waitDone <- chain.WaitForPipelinedStateCommit(context.Background(), held.Root())
+ }()
+ select {
+ case err := <-waitDone:
+ t.Fatalf("WaitForPipelinedStateCommit returned (%v) while SRC is held", err)
+ case <-time.After(100 * time.Millisecond):
+ }
+
+ release()
+ select {
+ case err := <-waitDone:
+ require.NoError(t, err)
+ case <-time.After(30 * time.Second):
+ t.Fatal("WaitForPipelinedStateCommit did not return after SRC release")
+ }
+ requireRootCommitted(t, chain, held.Root())
+
+ assertSettledReads(t, chain, held, recipient, overlayBalance)
+}
+
+// assertWindowReads verifies read behavior while the SRC window for the last
+// block is held open, and returns the recipient balance the overlay served.
+func assertWindowReads(t *testing.T, chain *BlockChain, blocks []*types.Block, recipient, sender common.Address) *big.Int {
+ t.Helper()
+ held := blocks[len(blocks)-1]
+ parent := blocks[len(blocks)-2]
+
+ require.Equal(t, held.NumberU64(), chain.CurrentBlock().Number.Uint64())
+ require.Equal(t, held.Hash(), chain.CurrentBlock().Hash())
+
+ // The overlay path: what eth_getBalance / eth_call at "latest" resolve
+ // through while the window is open.
+ statedb, err := chain.StateAt(held.Root())
+ require.NoError(t, err, "StateAt(head root) must succeed via FlatDiff overlay during the window")
+ balance := statedb.GetBalance(recipient).ToBig()
+ expected := new(big.Int).Mul(big.NewInt(10000), big.NewInt(int64(len(blocks))))
+ require.Zero(t, balance.Cmp(expected), "overlay recipient balance: got %s, want %s", balance, expected)
+ require.Equal(t, uint64(len(blocks)), statedb.GetNonce(sender))
+
+ // A direct trie open without the overlay must fail — proves the root is
+ // genuinely uncommitted and the window is open.
+ _, err = state.New(held.Root(), chain.statedb)
+ require.Error(t, err, "state.New(head root) must fail while SRC is held")
+
+ // A raw trie open at the head root fails during the window — the reason
+ // eth_getProof / debug_storageRangeAt gate on WaitForPipelinedStateCommit
+ // before opening tries.
+ _, err = trie.NewStateTrie(trie.StateTrieID(held.Root()), chain.triedb)
+ require.Error(t, err, "NewStateTrie(head root) must fail while SRC is held")
+
+ // The committed parent root stays fully readable.
+ parentState, err := chain.StateAt(parent.Root())
+ require.NoError(t, err)
+ require.Equal(t, uint64(len(blocks)-1), parentState.GetNonce(sender))
+
+ return balance
+}
+
+// assertSettledReads verifies that, once SRC has committed the held block's
+// root, direct trie reads succeed and match the values the overlay served.
+func assertSettledReads(t *testing.T, chain *BlockChain, held *types.Block, recipient common.Address, overlayBalance *big.Int) {
+ t.Helper()
+
+ statedb, err := chain.StateAt(held.Root())
+ require.NoError(t, err)
+ require.Zero(t, statedb.GetBalance(recipient).ToBig().Cmp(overlayBalance),
+ "balance served after settle must match the overlay-served value")
+
+ direct, err := state.New(held.Root(), chain.statedb)
+ require.NoError(t, err, "state.New(head root) must succeed after settle")
+ require.Zero(t, direct.GetBalance(recipient).ToBig().Cmp(overlayBalance))
+
+ tr, err := trie.NewStateTrie(trie.StateTrieID(held.Root()), chain.triedb)
+ require.NoError(t, err, "NewStateTrie(head root) must succeed after settle")
+ proofDb := memorydb.New()
+ accountKey := crypto.Keccak256(recipient.Bytes())
+ require.NoError(t, tr.Prove(accountKey, proofDb))
+ val, err := trie.VerifyProof(held.Root(), accountKey, proofDb)
+ require.NoError(t, err, "account proof at settled head root must verify")
+ require.NotEmpty(t, val, "proof must resolve to a non-empty account")
+}
+
+// requireRootCommitted polls until the given root is directly openable
+// (i.e. SRC has committed it), failing the test after a timeout.
+func requireRootCommitted(t *testing.T, chain *BlockChain, root common.Hash) {
+ t.Helper()
+ deadline := time.After(30 * time.Second)
+ for {
+ if _, err := state.New(root, chain.statedb); err == nil {
+ return
+ }
+ select {
+ case <-deadline:
+ t.Fatalf("timed out waiting for SRC to commit root %x", root)
+ case <-time.After(50 * time.Millisecond):
+ }
+ }
+}
diff --git a/core/state/database.go b/core/state/database.go
index 580f3cddbf..b9373266f8 100644
--- a/core/state/database.go
+++ b/core/state/database.go
@@ -200,6 +200,19 @@ func NewDatabaseForTesting() *CachingDB {
return NewDatabase(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil), nil)
}
+// TrieOnlyReader returns a state reader that uses only the trie (MPT), skipping
+// flat/snapshot readers. This ensures all account and storage reads walk the trie,
+// which is required for witness building — the witness captures trie nodes during
+// the walk. Without this, flat readers short-circuit the trie and proof paths are
+// never captured.
+func (db *CachingDB) TrieOnlyReader(stateRoot common.Hash) (Reader, error) {
+ tr, err := newTrieReader(stateRoot, db.triedb, db.pointCache)
+ if err != nil {
+ return nil, err
+ }
+ return newReader(newCachingCodeReader(db.disk, db.codeCache, db.codeSizeCache), tr), nil
+}
+
// Reader returns a state reader associated with the specified state root.
func (db *CachingDB) Reader(stateRoot common.Hash) (Reader, error) {
var readers []StateReader
diff --git a/core/state/metrics.go b/core/state/metrics.go
index dd4b2e9838..0114bcc87c 100644
--- a/core/state/metrics.go
+++ b/core/state/metrics.go
@@ -29,4 +29,18 @@ var (
storageTriesUpdatedMeter = metrics.NewRegisteredMeter("state/update/storagenodes", nil)
accountTrieDeletedMeter = metrics.NewRegisteredMeter("state/delete/accountnodes", nil)
storageTriesDeletedMeter = metrics.NewRegisteredMeter("state/delete/storagenodes", nil)
+
+ // FlatDiff overlay hit meters — fire when a state read is satisfied by the
+ // previous block's FlatDiff instead of falling through to the committed trie.
+ // Non-zero rate confirms the pipelined SRC overlay is active on this statedb
+ // (applies to both block import and speculative build paths).
+ //
+ // These also serve as the build-side cache-visibility substitute under
+ // pipelining: the speculative build path uses NewWithFlatBase, which creates
+ // a plain StateDB without the instrumented prefetch/process readers that
+ // populate chain/*/reads/cache/*. Those meters therefore receive no
+ // build-side contribution when pipelining is enabled. Use the flatdiff
+ // meters here for overlay efficiency signals in pipelined build mode.
+ flatDiffAccountHitsMeter = metrics.NewRegisteredMeter("state/flatdiff/account_hits", nil)
+ flatDiffStorageHitsMeter = metrics.NewRegisteredMeter("state/flatdiff/storage_hits", nil)
)
diff --git a/core/state/reader.go b/core/state/reader.go
index de7d6b3bce..2534c2d6a1 100644
--- a/core/state/reader.go
+++ b/core/state/reader.go
@@ -248,7 +248,13 @@ func (r *flatReader) Storage(addr common.Address, key common.Hash) (common.Hash,
// trieReader is safe for concurrent read.
type trieReader struct {
root common.Hash // State root which uniquely represent a state
- db *triedb.Database // Database for loading trie
+ db *triedb.Database // Database for loading trie (kept for IsVerkle / Disk access)
+
+ // nodeDB is the database used to construct sub-tries (storage tries) on
+ // demand. It defaults to db, but the snapshot-aware constructor wraps db
+ // so storage trie reads consult a WarmSnapshot before falling through to
+ // pathdb. Keeping it as an interface lets the wrapper remain transparent.
+ nodeDB database.NodeDatabase
// Main trie, resolved in constructor. Note either the Merkle-Patricia-tree
// or Verkle-tree is not safe for concurrent read.
@@ -292,6 +298,35 @@ func newTrieReader(root common.Hash, db *triedb.Database, cache *utils.PointCach
return &trieReader{
root: root,
db: db,
+ nodeDB: db,
+ mainTrie: tr,
+ }, nil
+}
+
+// newTrieReaderWithSnapshot mirrors newTrieReader but receives a snapshot-aware
+// NodeDatabase so trie reads can consult a WarmSnapshot before falling through
+// to pathdb/pebble. The snapshot is hash-verified by the supplied NodeDatabase;
+// misses or hash mismatches transparently fall through. The trie itself is
+// unchanged (NewStateTrie sees a wrapped NodeDatabase only) — its
+// resolveAndTrack path and prevalueTracer recording fire identically whether
+// the served node came from the snapshot or pathdb, so witness completeness
+// under NewTrieOnly semantics is preserved.
+//
+// Verkle is not supported by this path: pipelined SRC is MPT-only and the
+// snapshot is constructed from MPT trie nodes. Callers that need verkle
+// readers must use newTrieReader.
+func newTrieReaderWithSnapshot(root common.Hash, db *triedb.Database, nodeDB database.NodeDatabase) (*trieReader, error) {
+ if db.IsVerkle() {
+ return nil, errors.New("warm snapshot reader: verkle scheme is not supported")
+ }
+ tr, err := trie.NewStateTrie(trie.StateTrieID(root), nodeDB)
+ if err != nil {
+ return nil, err
+ }
+ return &trieReader{
+ root: root,
+ db: db,
+ nodeDB: nodeDB,
mainTrie: tr,
}, nil
}
@@ -423,7 +458,7 @@ func (r *trieReader) subTrieConcurrent(addr common.Address) (Trie, error) {
if err != nil {
return nil, err
}
- newTr, err := trie.NewStateTrie(trie.StorageTrieID(r.root, crypto.Keccak256Hash(addr.Bytes()), root), r.db)
+ newTr, err := trie.NewStateTrie(trie.StorageTrieID(r.root, crypto.Keccak256Hash(addr.Bytes()), root), r.nodeDB)
if err != nil {
return nil, err
}
@@ -448,7 +483,7 @@ func (r *trieReader) subTrieLocked(addr common.Address) (Trie, error) {
if err != nil {
return nil, err
}
- tr, err := trie.NewStateTrie(trie.StorageTrieID(r.root, crypto.Keccak256Hash(addr.Bytes()), root), r.db)
+ tr, err := trie.NewStateTrie(trie.StorageTrieID(r.root, crypto.Keccak256Hash(addr.Bytes()), root), r.nodeDB)
if err != nil {
return nil, err
}
diff --git a/core/state/safe_base.go b/core/state/safe_base.go
index 9d3f8e319f..bd11819c14 100644
--- a/core/state/safe_base.go
+++ b/core/state/safe_base.go
@@ -13,10 +13,12 @@ import (
// SafeBase provides thread-safe access to StateDB base reads with a
// shared read-through cache. The base state is immutable for the duration
// of a block, so cached values are valid forever within the block.
+// Overlay semantics such as FlatDiff and pending system-contract writes stay
+// inside StateDB; SafeBase only caches the values returned by StateDB getters.
//
// Architecture:
// - sync.Map caches sit in front of the pool (lock-free reads for cache hits)
-// - Cache misses acquire a pool copy, read from trie, cache result, release
+// - Cache misses acquire a pool copy, read through StateDB, cache result, release
// - All workers share one SafeBase → cache warms from any worker's reads
type SafeBase struct {
pool chan *StateDB
@@ -31,19 +33,11 @@ type SafeBase struct {
hashCache sync.Map // common.Address → common.Hash (code hash)
rootCache sync.Map // common.Address → common.Hash (storage root)
- // SharedStorageCache points to the readerWithCache's storageCache (if set).
- // The prefetcher populates this cache as it warms storage slots.
- // V2 workers check it BEFORE going through the pool, getting instant hits
- // for slots already warmed by the prefetcher.
- SharedStorageCache *sync.Map // storageCacheKey{addr,slot} → common.Hash
-
- // OverlayStorageCache holds pre-block system-call writes (EIP-4788/2935).
- // GetState consults it ahead of SharedStorageCache so the prefetcher's
- // trieReader Load→read→Store cannot clobber the overlay value.
- OverlayStorageCache *sync.Map // stateKey{addr,slot} → common.Hash
-
// readDelay is set from TestReadDelay at creation time.
readDelay time.Duration
+
+ errMu sync.Mutex
+ err error
}
type stateKey struct {
@@ -85,12 +79,51 @@ func (s *SafeBase) acquire() *StateDB {
}
func (s *SafeBase) release(db *StateDB) {
+ if err := db.Error(); err != nil {
+ s.setError(err)
+ if s.pool != nil {
+ // StateDB keeps read errors internally and would keep returning
+ // zero-ish values, so never return a poisoned copy to the pool.
+ replacement := s.DB.Copy()
+ replacement.SkipTimers()
+ s.pool <- replacement
+ }
+ return
+ }
if s.pool == nil {
return
}
s.pool <- db
}
+func (s *SafeBase) setError(err error) {
+ if err == nil {
+ return
+ }
+ s.errMu.Lock()
+ if s.err == nil {
+ s.err = err
+ }
+ s.errMu.Unlock()
+}
+
+// Error returns the first database read failure captured by any pooled base
+// reader. StateDB getters return zero-ish values after recording read errors,
+// so V2 must discard the block execution result when this is non-nil.
+func (s *SafeBase) Error() error {
+ s.errMu.Lock()
+ defer s.errMu.Unlock()
+ return s.err
+}
+
+func (s *SafeBase) cleanRead(db *StateDB) bool {
+ if err := db.Error(); err != nil {
+ s.setError(err)
+ return false
+ }
+ return true
+}
+
func (s *SafeBase) GetBalance(addr common.Address) *uint256.Int {
if v, ok := s.balCache.Load(addr); ok {
bal := v.(uint256.Int)
@@ -100,7 +133,9 @@ func (s *SafeBase) GetBalance(addr common.Address) *uint256.Int {
db := s.acquire()
defer s.release(db)
result := db.GetBalance(addr)
- s.balCache.Store(addr, *result) // store by value
+ if s.cleanRead(db) {
+ s.balCache.Store(addr, *result) // store by value
+ }
return result
}
@@ -112,7 +147,9 @@ func (s *SafeBase) GetNonce(addr common.Address) uint64 {
db := s.acquire()
defer s.release(db)
result := db.GetNonce(addr)
- s.nonceCache.Store(addr, result)
+ if s.cleanRead(db) {
+ s.nonceCache.Store(addr, result)
+ }
return result
}
@@ -121,31 +158,13 @@ func (s *SafeBase) GetState(addr common.Address, key common.Hash) common.Hash {
if v, ok := s.stateCache.Load(sk); ok {
return v.(common.Hash)
}
- // Check the V2-owned overlay first. The prefetcher writes only to
- // SharedStorageCache (its trieReader.storageCache), so it cannot race
- // against this map; the post-system-call value always wins.
- if s.OverlayStorageCache != nil {
- if v, ok := s.OverlayStorageCache.Load(sk); ok {
- result := v.(common.Hash)
- s.stateCache.Store(sk, result)
- return result
- }
- }
- // Check the shared readerWithCache storageCache — populated by the
- // prefetcher running concurrently. This gives V2 workers instant hits
- // for slots the prefetcher has already warmed, bypassing the pool entirely.
- if s.SharedStorageCache != nil {
- if v, ok := s.SharedStorageCache.Load(sk); ok {
- result := v.(common.Hash)
- s.stateCache.Store(sk, result)
- return result
- }
- }
s.simulateReadLatency()
db := s.acquire()
defer s.release(db)
result := db.GetState(addr, key)
- s.stateCache.Store(sk, result)
+ if s.cleanRead(db) {
+ s.stateCache.Store(sk, result)
+ }
return result
}
@@ -162,7 +181,9 @@ func (s *SafeBase) GetCode(addr common.Address) []byte {
db := s.acquire()
defer s.release(db)
result := db.GetCode(addr)
- s.codeCache.Store(addr, result)
+ if s.cleanRead(db) {
+ s.codeCache.Store(addr, result)
+ }
return result
}
@@ -174,7 +195,9 @@ func (s *SafeBase) GetCodeHash(addr common.Address) common.Hash {
db := s.acquire()
defer s.release(db)
result := db.GetCodeHash(addr)
- s.hashCache.Store(addr, result)
+ if s.cleanRead(db) {
+ s.hashCache.Store(addr, result)
+ }
return result
}
@@ -190,7 +213,9 @@ func (s *SafeBase) Exist(addr common.Address) bool {
db := s.acquire()
defer s.release(db)
result := db.Exist(addr)
- s.existCache.Store(addr, result)
+ if s.cleanRead(db) {
+ s.existCache.Store(addr, result)
+ }
return result
}
@@ -217,6 +242,8 @@ func (s *SafeBase) GetStorageRoot(addr common.Address) common.Hash {
db := s.acquire()
defer s.release(db)
result := db.GetStorageRoot(addr)
- s.rootCache.Store(addr, result)
+ if s.cleanRead(db) {
+ s.rootCache.Store(addr, result)
+ }
return result
}
diff --git a/core/state/safe_base_test.go b/core/state/safe_base_test.go
index 4da45cd834..38cc2e4ec5 100644
--- a/core/state/safe_base_test.go
+++ b/core/state/safe_base_test.go
@@ -1,6 +1,7 @@
package state
import (
+ "errors"
"sync"
"testing"
@@ -10,6 +11,7 @@ import (
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/tracing"
"github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/triedb"
)
@@ -148,84 +150,456 @@ func TestSafeBase_GetStorageRoot(t *testing.T) {
}
}
-// TestSafeBase_OverlayWinsOverSharedCache pins the read-priority contract
-// for EIP-4788/EIP-2935 pre-block system-call writes: SafeBase.GetState
-// must return the overlay value even if SharedStorageCache (the prefetcher's
-// trieReader cache) carries a stale zero for the same key. A non-atomic
-// Load → trie-read → Store in trieReader.Storage can land its zero after
-// OverlayPendingStorageInto on a fast-prefetcher / slow-overlay path; the
-// post-system-call value still has to win when SafeBase serves a read.
-func TestSafeBase_OverlayWinsOverSharedCache(t *testing.T) {
+func TestSafeBase_UsesStateDBPendingStorage(t *testing.T) {
addr := common.HexToAddress("0xabcd")
- sb := newTestSafeBase(t, addr)
slot := common.HexToHash("0x42")
- sk := stateKey{addr: addr, slot: slot}
+ want := common.HexToHash("0xbeef")
- // Prefetcher landed first: stale zero in the shared trie cache.
- shared := new(sync.Map)
- shared.Store(sk, common.Hash{})
- sb.SharedStorageCache = shared
+ memdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
+ sdb, err := New(types.EmptyRootHash, NewDatabase(tdb, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ sdb.SetState(addr, slot, want)
- // Overlay landed second: post-system-call value.
- overlay := new(sync.Map)
- beaconRoot := common.HexToHash("0xbeef")
- overlay.Store(sk, beaconRoot)
- sb.OverlayStorageCache = overlay
+ sb := NewSafeBase(sdb, 2)
- if got := sb.GetState(addr, slot); got != beaconRoot {
- t.Fatalf("GetState: got %s, want %s (overlay must beat the shared trie cache)",
- got.Hex(), beaconRoot.Hex())
+ if got := sb.GetState(addr, slot); got != want {
+ t.Fatalf("GetState first call: got %s, want %s", got.Hex(), want.Hex())
+ }
+ if got := sb.GetState(addr, slot); got != want {
+ t.Fatalf("GetState cached call: got %s, want %s", got.Hex(), want.Hex())
}
}
-// TestSafeBase_OverlayResult_CachedInStateCache pins that an overlay hit
-// is cached into the local stateCache for subsequent reads — so once a
-// post-system-call value is observed, the GetState path stays fast and
-// doesn't repeatedly traverse OverlayStorageCache. Kills the `remove
-// call statement` mutation on the stateCache.Store call after an overlay
-// hit: we mutate the overlay between the two reads; the second read still
-// has to return the original value via the local stateCache hit.
-func TestSafeBase_OverlayResult_CachedInStateCache(t *testing.T) {
+func TestStateDB_FlatDiffStorageMasksStaleOriginStorage(t *testing.T) {
addr := common.HexToAddress("0xabcd")
- sb := newTestSafeBase(t, addr)
- slot := common.HexToHash("0x42")
- sk := stateKey{addr: addr, slot: slot}
+ slot := common.HexToHash("0x01")
+ baseValue := common.HexToHash("0xdead")
+ flatValue := common.HexToHash("0xbeef")
+
+ memdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
+ db := NewDatabase(tdb, nil)
+ sdb, err := New(types.EmptyRootHash, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sdb.SetState(addr, slot, baseValue)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ diff := &FlatDiff{
+ Storage: map[common.Address]map[common.Hash]common.Hash{
+ addr: {slot: flatValue},
+ },
+ Destructs: make(map[common.Address]struct{}),
+ }
+ overlayDB, err := NewWithFlatBase(root, db, diff)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if got := overlayDB.GetState(addr, slot); got != flatValue {
+ t.Fatalf("GetState: got %s, want %s (StateDB FlatDiff storage must define the logical base)",
+ got.Hex(), flatValue.Hex())
+ }
+}
+
+func TestStateDB_FlatDiffStorageMasksStaleOriginLoadedBeforeRef(t *testing.T) {
+ addr := common.HexToAddress("0xabcd")
+ slot := common.HexToHash("0x01")
+ baseValue := common.HexToHash("0xdead")
+ flatValue := common.HexToHash("0xbeef")
+
+ memdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
+ db := NewDatabase(tdb, nil)
+ sdb, err := New(types.EmptyRootHash, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sdb.SetState(addr, slot, baseValue)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ overlayDB, err := New(root, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := overlayDB.GetState(addr, slot); got != baseValue {
+ t.Fatalf("preload state: got %s, want %s", got.Hex(), baseValue.Hex())
+ }
+ overlayDB.SetFlatDiffRef(&FlatDiff{
+ Storage: map[common.Address]map[common.Hash]common.Hash{
+ addr: {slot: flatValue},
+ },
+ Destructs: make(map[common.Address]struct{}),
+ })
+
+ if got := overlayDB.GetState(addr, slot); got != flatValue {
+ t.Fatalf("GetState: got %s, want %s (FlatDiff must mask stale originStorage)",
+ got.Hex(), flatValue.Hex())
+ }
+}
+
+func TestStateDB_FlatDiffDestructMasksStaleOriginStorage(t *testing.T) {
+ addr := common.HexToAddress("0xabcd")
+ slot := common.HexToHash("0x01")
+ baseValue := common.HexToHash("0xdead")
- overlay := new(sync.Map)
+ memdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
+ db := NewDatabase(tdb, nil)
+ sdb, err := New(types.EmptyRootHash, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sdb.SetState(addr, slot, baseValue)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ overlayDB, err := New(root, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := overlayDB.GetState(addr, slot); got != baseValue {
+ t.Fatalf("preload state: got %s, want %s", got.Hex(), baseValue.Hex())
+ }
+ overlayDB.SetFlatDiffRef(&FlatDiff{
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: map[common.Address]struct{}{addr: {}},
+ })
+
+ if got := overlayDB.GetState(addr, slot); got != (common.Hash{}) {
+ t.Fatalf("GetState: got %s, want zero (FlatDiff destruct must mask stale originStorage)",
+ got.Hex())
+ }
+}
+
+func TestStateDB_FlatDiffAccountScalarsMaskStaleStateObject(t *testing.T) {
+ addr := common.HexToAddress("0xabcd")
+ baseCode := []byte{0x60, 0x00}
+ flatCode := []byte{0x60, 0x01}
+ flatCodeHash := crypto.Keccak256Hash(flatCode)
+ flatRoot := common.HexToHash("0x1234")
+
+ memdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
+ db := NewDatabase(tdb, nil)
+ sdb, err := New(types.EmptyRootHash, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sdb.SetBalance(addr, uint256.NewInt(1), tracing.BalanceChangeUnspecified)
+ sdb.SetNonce(addr, 1, tracing.NonceChangeUnspecified)
+ sdb.SetCode(addr, baseCode, tracing.CodeChangeUnspecified)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ overlayDB, err := New(root, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Emulate a base object loaded before the FlatDiff reference is attached.
+ if got := overlayDB.GetNonce(addr); got != 1 {
+ t.Fatalf("preload nonce: got %d, want 1", got)
+ }
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 42,
+ Balance: uint256.NewInt(99),
+ Root: flatRoot,
+ CodeHash: flatCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: map[common.Hash][]byte{flatCodeHash: flatCode},
+ }
+ overlayDB.SetFlatDiffRef(diff)
+
+ if got := overlayDB.GetBalance(addr).Uint64(); got != 99 {
+ t.Fatalf("GetBalance: got %d, want FlatDiff balance 99", got)
+ }
+ if got := overlayDB.GetNonce(addr); got != 42 {
+ t.Fatalf("GetNonce: got %d, want FlatDiff nonce 42", got)
+ }
+ if got := overlayDB.GetCode(addr); string(got) != string(flatCode) {
+ t.Fatalf("GetCode: got %x, want FlatDiff code %x", got, flatCode)
+ }
+ if got := overlayDB.GetCodeHash(addr); got != flatCodeHash {
+ t.Fatalf("GetCodeHash: got %s, want %s", got.Hex(), flatCodeHash.Hex())
+ }
+ if !overlayDB.Exist(addr) {
+ t.Fatal("Exist: got false, want true from FlatDiff account")
+ }
+ if got := overlayDB.GetStorageRoot(addr); got != flatRoot {
+ t.Fatalf("GetStorageRoot: got %s, want %s", got.Hex(), flatRoot.Hex())
+ }
+}
+
+func TestStateDB_FlatDiffDestructMasksStaleStateObject(t *testing.T) {
+ addr := common.HexToAddress("0xabcd")
+
+ memdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
+ db := NewDatabase(tdb, nil)
+ sdb, err := New(types.EmptyRootHash, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ sdb.SetBalance(addr, uint256.NewInt(1), tracing.BalanceChangeUnspecified)
+ sdb.SetNonce(addr, 1, tracing.NonceChangeUnspecified)
+ sdb.SetCode(addr, []byte{0x60, 0x00}, tracing.CodeChangeUnspecified)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ overlayDB, err := New(root, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := overlayDB.GetNonce(addr); got != 1 {
+ t.Fatalf("preload nonce: got %d, want 1", got)
+ }
+ overlayDB.SetFlatDiffRef(&FlatDiff{
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: map[common.Address]struct{}{addr: {}},
+ })
+
+ if got := overlayDB.GetBalance(addr).Uint64(); got != 0 {
+ t.Fatalf("GetBalance: got %d, want zero for FlatDiff destruct", got)
+ }
+ if got := overlayDB.GetNonce(addr); got != 0 {
+ t.Fatalf("GetNonce: got %d, want zero for FlatDiff destruct", got)
+ }
+ if got := overlayDB.GetCode(addr); len(got) != 0 {
+ t.Fatalf("GetCode: got %x, want empty for FlatDiff destruct", got)
+ }
+ if got := overlayDB.GetCodeHash(addr); got != (common.Hash{}) {
+ t.Fatalf("GetCodeHash: got %s, want zero for FlatDiff destruct", got.Hex())
+ }
+ if overlayDB.Exist(addr) {
+ t.Fatal("Exist: got true, want false for FlatDiff destruct")
+ }
+ if got := overlayDB.GetStorageRoot(addr); got != (common.Hash{}) {
+ t.Fatalf("GetStorageRoot: got %s, want zero for FlatDiff destruct", got.Hex())
+ }
+}
+
+func TestStateDB_FlatDiffDoesNotMaskCurrentAccountMutation(t *testing.T) {
+ addr := common.HexToAddress("0xabcd")
+
+ memdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
+ db := NewDatabase(tdb, nil)
+ sdb, err := New(types.EmptyRootHash, db)
+ if err != nil {
+ t.Fatal(err)
+ }
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ overlayDB, err := NewWithFlatBase(root, db, &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 1,
+ Balance: uint256.NewInt(1),
+ Root: types.EmptyRootHash,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ overlayDB.SetNonce(addr, 7, tracing.NonceChangeUnspecified)
+ if got := overlayDB.GetNonce(addr); got != 7 {
+ t.Fatalf("GetNonce: got %d, want current mutation 7", got)
+ }
+}
+
+func TestSafeBase_DoesNotCacheStateAfterReadError(t *testing.T) {
+ addr := common.HexToAddress("0xabcd")
+ slot := common.HexToHash("0x01")
want := common.HexToHash("0xbeef")
- overlay.Store(sk, want)
- sb.OverlayStorageCache = overlay
+ reader := newFlakySafeBaseReader()
+ reader.storage = want
+ reader.storageErrs = 1
+ sb := newSafeBaseWithReader(t, reader)
- if got := sb.GetState(addr, slot); got != want {
- t.Fatalf("GetState first call: got %s, want %s", got.Hex(), want.Hex())
+ if got := sb.GetState(addr, slot); got != (common.Hash{}) {
+ t.Fatalf("first GetState: got %s, want zero from failing reader", got.Hex())
+ }
+ if sb.Error() == nil {
+ t.Fatal("SafeBase did not record read error")
}
- // Mutate the overlay underneath — the second read must still return
- // the original value, served from the local stateCache populated on
- // the first call.
- overlay.Store(sk, common.HexToHash("0xfeed"))
if got := sb.GetState(addr, slot); got != want {
- t.Fatalf("GetState second call: got %s, want %s (overlay hit must have populated stateCache)",
+ t.Fatalf("second GetState: got %s, want %s; failed read must not poison cache",
got.Hex(), want.Hex())
}
}
-// TestSafeBase_SharedCacheStillUsedWithoutOverlay pins that the existing
-// fast-path through SharedStorageCache continues to work when the overlay
-// is absent — non-system-call slots warmed by the prefetcher should still
-// serve without a pool acquire.
-func TestSafeBase_SharedCacheStillUsedWithoutOverlay(t *testing.T) {
+func TestSafeBase_DoesNotCacheAccountScalarsAfterReadError(t *testing.T) {
addr := common.HexToAddress("0xabcd")
- sb := newTestSafeBase(t, addr)
- slot := common.HexToHash("0x99")
- sk := stateKey{addr: addr, slot: slot}
+ for _, tt := range []struct {
+ name string
+ read func(*SafeBase) any
+ want any
+ }{
+ {
+ name: "balance",
+ read: func(sb *SafeBase) any { return sb.GetBalance(addr).Uint64() },
+ want: uint64(1000),
+ },
+ {
+ name: "nonce",
+ read: func(sb *SafeBase) any { return sb.GetNonce(addr) },
+ want: uint64(7),
+ },
+ {
+ name: "code hash",
+ read: func(sb *SafeBase) any { return sb.GetCodeHash(addr) },
+ want: common.BytesToHash(crypto.Keccak256([]byte{0x60, 0x00})),
+ },
+ {
+ name: "exist",
+ read: func(sb *SafeBase) any { return sb.Exist(addr) },
+ want: true,
+ },
+ {
+ name: "storage root",
+ read: func(sb *SafeBase) any { return sb.GetStorageRoot(addr) },
+ want: common.HexToHash("0x1234"),
+ },
+ } {
+ t.Run(tt.name, func(t *testing.T) {
+ reader := newFlakySafeBaseReader()
+ reader.accountErrs = 1
+ sb := newSafeBaseWithReader(t, reader)
- shared := new(sync.Map)
- want := common.HexToHash("0xcafe")
- shared.Store(sk, want)
- sb.SharedStorageCache = shared
+ _ = tt.read(sb)
+ if sb.Error() == nil {
+ t.Fatal("SafeBase did not record read error")
+ }
+ if got := tt.read(sb); got != tt.want {
+ t.Fatalf("second read: got %v, want %v; failed read must not poison cache",
+ got, tt.want)
+ }
+ })
+ }
+}
- if got := sb.GetState(addr, slot); got != want {
- t.Fatalf("GetState: got %s, want %s (shared cache hit expected)",
- got.Hex(), want.Hex())
+func TestSafeBase_DoesNotCacheCodeAfterReadError(t *testing.T) {
+ addr := common.HexToAddress("0xabcd")
+ reader := newFlakySafeBaseReader()
+ reader.codeErrs = 1
+ sb := newSafeBaseWithReader(t, reader)
+
+ if got := sb.GetCode(addr); got != nil {
+ t.Fatalf("first GetCode: got %x, want nil from failing reader", got)
+ }
+ if sb.Error() == nil {
+ t.Fatal("SafeBase did not record read error")
+ }
+ if got := sb.GetCode(addr); string(got) != string(reader.code) {
+ t.Fatalf("second GetCode: got %x, want %x; failed read must not poison cache",
+ got, reader.code)
+ }
+}
+
+type flakySafeBaseReader struct {
+ mu sync.Mutex
+
+ accountErrs int
+ storageErrs int
+ codeErrs int
+
+ acct *types.StateAccount
+ storage common.Hash
+ code []byte
+}
+
+func newFlakySafeBaseReader() *flakySafeBaseReader {
+ code := []byte{0x60, 0x00}
+ return &flakySafeBaseReader{
+ acct: &types.StateAccount{
+ Nonce: 7,
+ Balance: uint256.NewInt(1000),
+ Root: common.HexToHash("0x1234"),
+ CodeHash: crypto.Keccak256(code),
+ },
+ code: code,
}
}
+
+func newSafeBaseWithReader(t *testing.T, reader Reader) *SafeBase {
+ t.Helper()
+ memdb := rawdb.NewMemoryDatabase()
+ tdb := triedb.NewDatabase(memdb, triedb.HashDefaults)
+ sdb, err := New(types.EmptyRootHash, NewDatabase(tdb, nil))
+ if err != nil {
+ t.Fatal(err)
+ }
+ sdb.reader = reader
+ return NewSafeBase(sdb, 1)
+}
+
+func (r *flakySafeBaseReader) Account(common.Address) (*types.StateAccount, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.accountErrs > 0 {
+ r.accountErrs--
+ return nil, errSafeBaseRead
+ }
+ return r.acct.Copy(), nil
+}
+
+func (r *flakySafeBaseReader) Storage(common.Address, common.Hash) (common.Hash, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.storageErrs > 0 {
+ r.storageErrs--
+ return common.Hash{}, errSafeBaseRead
+ }
+ return r.storage, nil
+}
+
+func (r *flakySafeBaseReader) Code(common.Address, common.Hash) ([]byte, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.codeErrs > 0 {
+ r.codeErrs--
+ return nil, errSafeBaseRead
+ }
+ return append([]byte(nil), r.code...), nil
+}
+
+func (r *flakySafeBaseReader) CodeSize(common.Address, common.Hash) (int, error) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ if r.codeErrs > 0 {
+ r.codeErrs--
+ return 0, errSafeBaseRead
+ }
+ return len(r.code), nil
+}
+
+var errSafeBaseRead = errors.New("safe base read failed")
diff --git a/core/state/state_object.go b/core/state/state_object.go
index 4c48084531..a6c3e35430 100644
--- a/core/state/state_object.go
+++ b/core/state/state_object.go
@@ -54,6 +54,30 @@ type stateObject struct {
origin *types.StateAccount // Account original data without any change applied, nil means it was not existent
data types.StateAccount // Account data with all mutations applied in the scope of block
+ // prefetchRoot holds the storage root from the committed parent state, used
+ // exclusively for prefetcher interactions during pipelined SRC.
+ //
+ // When an account is loaded from FlatDiff (the previous block's uncommitted
+ // mutations), its origin.Root and data.Root reflect block N's post-state —
+ // but the prefetcher's NodeReader is opened at committedParentRoot (the
+ // grandparent). This creates a (stateRoot, storageRoot) mismatch: the reader
+ // can only resolve trie nodes for the grandparent's storage root, not block
+ // N's. The result is "Unexpected trie node" hash-mismatch errors on every
+ // storage trie root resolution, killing the prefetcher for those accounts.
+ //
+ // prefetchRoot stores the grandparent's storage root — the one consistent
+ // with the prefetcher's reader. It is set only for FlatDiff-sourced accounts;
+ // for accounts loaded from the committed state it stays zero, and
+ // getPrefetchRoot() falls back to data.Root (which is already consistent).
+ //
+ // The committed root is obtained from the flat state reader (in-memory
+ // snapshot), so the cost is effectively zero.
+ prefetchRoot common.Hash
+
+ // fromFlatDiff marks objects materialized from StateDB.flatDiffRef rather
+ // than from the committed trie reader.
+ fromFlatDiff bool
+
// Write caches.
trie Trie // storage trie, which becomes non-nil on first access
code []byte // contract bytecode, which gets set when code is loaded
@@ -122,6 +146,28 @@ func (s *stateObject) touch() {
s.db.journal.touchChange(s.address)
}
+// getPrefetchRoot returns the storage root to use for all prefetcher
+// interactions (prefetch, trie lookup, used). This must be consistent across
+// all calls for a given account so the subfetcher trieID matches.
+//
+// For accounts loaded from FlatDiff (pipelined SRC), the storage root in
+// origin/data reflects block N's post-state, but the prefetcher's NodeReader
+// is at committedParentRoot (the grandparent). Using block N's root would
+// cause a hash mismatch when resolving the storage trie root node. Instead,
+// we return the grandparent's storage root (stored in prefetchRoot), which
+// is consistent with the reader. If the account did not exist at the
+// committed parent root, prefetchRoot is the empty storage root and storage
+// prefetches are skipped.
+//
+// For accounts loaded from the committed state (normal path), prefetchRoot
+// is zero and we fall back to data.Root, which is already consistent.
+func (s *stateObject) getPrefetchRoot() common.Hash {
+ if s.prefetchRoot != (common.Hash{}) {
+ return s.prefetchRoot
+ }
+ return s.data.Root
+}
+
// getTrie returns the associated storage trie. The trie will be opened if it's
// not loaded previously. An error will be returned if trie can't be loaded.
//
@@ -150,11 +196,14 @@ func (s *stateObject) getTrie() (Trie, error) {
func (s *stateObject) getPrefetchedTrie() Trie {
// If there's nothing to meaningfully return, let the user figure it out by
// pulling the trie from disk.
- if (s.data.Root == types.EmptyRootHash && !s.db.db.TrieDB().IsVerkle()) || s.db.prefetcher == nil {
+ root := s.getPrefetchRoot()
+ if (root == types.EmptyRootHash && !s.db.db.TrieDB().IsVerkle()) || s.db.prefetcher == nil {
return nil
}
- // Attempt to retrieve the trie from the prefetcher
- return s.db.prefetcher.trie(s.addrHash, s.data.Root)
+ // Use getPrefetchRoot() so the trieID matches the one used when scheduling
+ // the prefetch. For FlatDiff accounts this is the committed parent's storage
+ // root; for normal accounts it equals data.Root (unchanged behavior).
+ return s.db.prefetcher.trie(s.addrHash, root)
}
// GetState retrieves a value associated with the given storage key.
@@ -184,6 +233,17 @@ func (s *stateObject) GetCommittedState(key common.Hash) common.Hash {
return value
}
+ // Check the FlatDiff reference for storage slots from the parent block.
+ // It must beat originStorage because this object may have been cached from
+ // committedParentRoot before the FlatDiff reference was attached.
+ if value, ok, explicit := s.db.flatDiffRef.storageOverlay(s.address, key); ok {
+ if explicit {
+ flatDiffStorageHitsMeter.Mark(1)
+ }
+ s.originStorage[key] = value
+ return value
+ }
+
if value, cached := s.originStorage[key]; cached {
return value
}
@@ -209,9 +269,14 @@ func (s *stateObject) GetCommittedState(key common.Hash) common.Hash {
s.db.StorageReads += time.Since(start)
// Schedule the resolved storage slots for prefetching if it's enabled.
- if s.db.prefetcher != nil && s.data.Root != types.EmptyRootHash {
- if err = s.db.prefetcher.prefetch(s.addrHash, s.origin.Root, s.address, nil, []common.Hash{key}, true); err != nil {
- log.Error("Failed to prefetch storage slot", "addr", s.address, "key", key, "err", err)
+ // Use getPrefetchRoot() for the storage root so the subfetcher's trieID
+ // is consistent with the prefetcher's NodeReader state root. For FlatDiff
+ // accounts, this is the committed parent's storage root (not block N's).
+ if s.db.prefetcher != nil {
+ if root := s.getPrefetchRoot(); root != types.EmptyRootHash || s.db.db.TrieDB().IsVerkle() {
+ if err = s.db.prefetcher.prefetch(s.addrHash, root, s.address, nil, []common.Hash{key}, true); err != nil {
+ log.Error("Failed to prefetch storage slot", "addr", s.address, "key", key, "err", err)
+ }
}
}
s.originStorage[key] = value
@@ -271,9 +336,12 @@ func (s *stateObject) finalise() {
// byzantium fork) and entry is necessary to modify the value back.
s.pendingStorage[key] = value
}
- if s.db.prefetcher != nil && len(slotsToPrefetch) > 0 && s.data.Root != types.EmptyRootHash {
- if err := s.db.prefetcher.prefetch(s.addrHash, s.data.Root, s.address, nil, slotsToPrefetch, false); err != nil {
- log.Error("Failed to prefetch slots", "addr", s.address, "slots", len(slotsToPrefetch), "err", err)
+ // Use getPrefetchRoot() for consistency with other prefetcher calls.
+ if s.db.prefetcher != nil && len(slotsToPrefetch) > 0 {
+ if root := s.getPrefetchRoot(); root != types.EmptyRootHash || s.db.db.TrieDB().IsVerkle() {
+ if err := s.db.prefetcher.prefetch(s.addrHash, root, s.address, nil, slotsToPrefetch, false); err != nil {
+ log.Error("Failed to prefetch slots", "addr", s.address, "slots", len(slotsToPrefetch), "err", err)
+ }
}
}
@@ -367,8 +435,29 @@ func (s *stateObject) updateTrie() (Trie, error) {
s.db.StorageDeleted.Add(1)
}
+ // Use getPrefetchRoot() so the trieID matches the one used during scheduling.
if s.db.prefetcher != nil {
- s.db.prefetcher.used(s.addrHash, s.data.Root, nil, used)
+ if root := s.getPrefetchRoot(); root != types.EmptyRootHash || s.db.db.TrieDB().IsVerkle() {
+ s.db.prefetcher.used(s.addrHash, root, nil, used)
+ }
+ }
+ // When witness building is enabled without a prefetcher, storage reads
+ // went through the reader (a separate trie with its own PrevalueTracer)
+ // and their intermediate nodes are NOT in obj.trie. Re-read read-only
+ // slots (in originStorage but not in uncommittedStorage) through the
+ // storage trie so that resolveAndTrack captures the intermediate nodes
+ // and obj.trie.Witness() includes them. A failed re-read means the
+ // witness would be incomplete — surface it like the other trie ops so
+ // the commit aborts instead of shipping a broken witness.
+ if s.db.witness != nil && s.db.prefetcher == nil {
+ for key := range s.originStorage {
+ if _, dirty := s.uncommittedStorage[key]; !dirty {
+ if _, err := tr.GetStorage(s.address, key[:]); err != nil {
+ s.db.setError(err)
+ return nil, err
+ }
+ }
+ }
}
s.uncommittedStorage = make(Storage) // empties the commit markers
return tr, nil
@@ -497,6 +586,8 @@ func (s *stateObject) deepCopy(db *StateDB) *stateObject {
addrHash: s.addrHash,
origin: s.origin,
data: s.data,
+ prefetchRoot: s.prefetchRoot,
+ fromFlatDiff: s.fromFlatDiff,
code: s.code,
originStorage: s.originStorage.Copy(),
pendingStorage: s.pendingStorage.Copy(),
diff --git a/core/state/statedb.go b/core/state/statedb.go
index b5d5571f8e..cdea669d6e 100644
--- a/core/state/statedb.go
+++ b/core/state/statedb.go
@@ -152,6 +152,18 @@ type StateDB struct {
witness *stateless.Witness
witnessStats *stateless.WitnessStats
+ // nonExistentReads tracks addresses that were looked up but don't exist
+ // in the state trie. Under pipelined SRC, these are included in the
+ // FlatDiff so the SRC goroutine can walk their trie paths and capture
+ // proof-of-absence nodes for the witness. Without this, stateless
+ // execution fails when it tries to prove these accounts don't exist.
+ nonExistentReads map[common.Address]struct{}
+
+ // flatDiffRef is a read-only reference to the parent block's FlatDiff,
+ // consulted lazily by getStateObject and GetCommittedState before falling
+ // through to the trie reader. Set by NewWithFlatBase; nil otherwise.
+ flatDiffRef *FlatDiff
+
// Measurements gathered during execution for debugging purposes
AccountLoaded int // Number of accounts retrieved from the database during the state transition
@@ -187,6 +199,41 @@ func New(root common.Hash, db Database) (*StateDB, error) {
return NewWithReader(root, db, reader)
}
+// NewTrieOnly creates a new state that uses only the trie reader (no flat/snapshot
+// readers). This forces all account and storage reads to walk the MPT, which is
+// required for witness building — the witness captures trie nodes during the walk.
+// Used by the pipelined SRC goroutine to ensure the witness is complete.
+func NewTrieOnly(root common.Hash, db *CachingDB) (*StateDB, error) {
+ reader, err := db.TrieOnlyReader(root)
+ if err != nil {
+ return nil, err
+ }
+ return NewWithReader(root, db, reader)
+}
+
+// NewTrieOnlyWithSnapshot is the warm-cache variant of NewTrieOnly. Trie reads
+// consult a WarmSnapshot (typically captured from the execution-side trie
+// prefetcher) before falling through to the regular pathdb-backed NodeReader.
+// Hits with matching hash skip diff-layer/disk-layer/pebble work entirely;
+// misses or hash mismatches are served by the underlying reader unchanged.
+// NewTrieOnly semantics are preserved — the trie still walks, prevalueTracer
+// still records, witness is still complete. The snapshot wrapper is installed
+// on the StateDB database itself, not just the initial Reader, so commit-time
+// OpenTrie/OpenStorageTrie calls also use the same warm handoff.
+//
+// A nil snapshot is equivalent to NewTrieOnly.
+func NewTrieOnlyWithSnapshot(root common.Hash, db *CachingDB, snapshot *WarmSnapshot) (*StateDB, error) {
+ if snapshot == nil || snapshot.Len() == 0 {
+ return NewTrieOnly(root, db)
+ }
+ snapshotDB := newSnapshotStateDatabase(db, snapshot)
+ reader, err := snapshotDB.Reader(root)
+ if err != nil {
+ return nil, err
+ }
+ return NewWithReader(root, snapshotDB, reader)
+}
+
// NewWithReader creates a new state for the specified state root. Unlike New,
// this function accepts an additional Reader which is bound to the given root.
func NewWithReader(root common.Hash, db Database, reader Reader) (*StateDB, error) {
@@ -344,18 +391,15 @@ func (s *StateDB) EnableConcurrentReads() {
// StorageCache returns the shared trieReader storage cache (sync.Map) if available.
// This cache is populated by all readers (prefetcher, serial, V2).
-// SafeBase can use it for instant storage lookups.
func (s *StateDB) StorageCache() *sync.Map {
return findStorageCache(s.reader)
}
// OverlayPendingStorageInto walks every live stateObject and writes its
// in-memory pending+dirty storage values into target, keyed by stateKey{addr,
-// slot}. Use it to repair a SharedStorageCache that was populated from raw
-// trie reads BEFORE pre-block writes (EIP-4788/EIP-2935 system contracts,
-// DAO fork, etc.) landed in dirty/pending storage. Without this overlay,
-// SafeBase serves the stale trie value (zero, for previously-unwritten
-// system-contract slots) and downstream V2 workers see no system-call effect.
+// slot}. This is useful for external storage caches that were populated from
+// raw trie reads before pre-block writes (EIP-4788/EIP-2935 system contracts,
+// DAO fork, etc.) landed in dirty/pending storage.
func (s *StateDB) OverlayPendingStorageInto(target *sync.Map) {
if target == nil {
return
@@ -646,6 +690,111 @@ func (s *StateDB) StopPrefetcher() {
}
}
+// DetachedPrefetcher is a trie prefetcher that has been removed from its
+// StateDB and handed to another owner. It is used by pipelined SRC import so
+// the import thread can move on with a fresh StateDB while SRC waits for the
+// previous block's prefetcher to finish.
+//
+// A detached prefetcher must be consumed exactly once via Stop or
+// StopAndCollectWarmSnapshot. Both methods synchronously wait for all
+// subfetcher goroutines to exit before reporting stats.
+type DetachedPrefetcher struct {
+ prefetcher *triePrefetcher
+}
+
+// DetachPrefetcher removes the current prefetcher from the StateDB without
+// stopping it. The caller becomes responsible for eventually calling Stop or
+// StopAndCollectWarmSnapshot on the returned handle.
+func (s *StateDB) DetachPrefetcher() *DetachedPrefetcher {
+ if s.prefetcher == nil {
+ return nil
+ }
+ prefetcher := s.prefetcher
+ s.prefetcher = nil
+ return &DetachedPrefetcher{prefetcher: prefetcher}
+}
+
+// PrefetcherSnapshotStats describes the synchronous phases and warm-node mix
+// observed while stopping and snapshotting a trie prefetcher.
+type PrefetcherSnapshotStats struct {
+ Drain time.Duration
+ Collect time.Duration
+ Report time.Duration
+
+ Fetchers int
+ LoadedFetchers int
+ AccountFetchers int
+ StorageFetchers int
+
+ AccountNodes int
+ StorageNodes int
+ AccountBytes int
+ StorageBytes int
+}
+
+// Stop synchronously drains a detached prefetcher, reports its stats, and
+// discards any warm nodes it loaded. This is the wait-only pipelined SRC mode:
+// it lets the execution-side prefetcher finish warming shared lower-level
+// caches without installing a WarmSnapshot reader.
+func (p *DetachedPrefetcher) Stop() PrefetcherSnapshotStats {
+ var stats PrefetcherSnapshotStats
+ if p == nil || p.prefetcher == nil {
+ return stats
+ }
+ prefetcher := p.prefetcher
+ p.prefetcher = nil
+
+ phaseStart := time.Now()
+ prefetcher.terminate(false)
+ stats.Drain = time.Since(phaseStart)
+ stats.Fetchers = prefetcher.fetcherCount()
+
+ phaseStart = time.Now()
+ prefetcher.report()
+ stats.Report = time.Since(phaseStart)
+ return stats
+}
+
+// StopAndCollectWarmSnapshot synchronously drains a detached prefetcher,
+// captures the trie nodes its subfetchers loaded, reports stats, and returns a
+// quiesced WarmSnapshotInput owned by the caller.
+//
+// This method uses full-drain termination. Execution can continue on the
+// import thread while SRC waits here, so queued prefetch work is allowed to
+// finish and increase the warm surface.
+func (p *DetachedPrefetcher) StopAndCollectWarmSnapshot() (*WarmSnapshotInput, PrefetcherSnapshotStats) {
+ var stats PrefetcherSnapshotStats
+ if p == nil || p.prefetcher == nil {
+ return nil, stats
+ }
+ prefetcher := p.prefetcher
+ p.prefetcher = nil
+
+ phaseStart := time.Now()
+ prefetcher.terminate(false)
+ stats.Drain = time.Since(phaseStart)
+
+ phaseStart = time.Now()
+ tries, snapshotStats := prefetcher.snapshotWarmNodes()
+ stats.Collect = time.Since(phaseStart)
+ stats.Fetchers = snapshotStats.Fetchers
+ stats.LoadedFetchers = snapshotStats.LoadedFetchers
+ stats.AccountFetchers = snapshotStats.AccountFetchers
+ stats.StorageFetchers = snapshotStats.StorageFetchers
+ stats.AccountNodes = snapshotStats.AccountNodes
+ stats.StorageNodes = snapshotStats.StorageNodes
+ stats.AccountBytes = snapshotStats.AccountBytes
+ stats.StorageBytes = snapshotStats.StorageBytes
+
+ phaseStart = time.Now()
+ prefetcher.report()
+ stats.Report = time.Since(phaseStart)
+ if len(tries) == 0 {
+ return nil, stats
+ }
+ return NewWarmSnapshotInput(tries), stats
+}
+
// ResetPrefetcher cleans the prefetcher from a State, commonly used in tempStates to track witness while no impacting block building
// Do also remove mutations previously tracked to just look to the new ones
func (s *StateDB) ResetPrefetcher() {
@@ -1132,6 +1281,20 @@ func (s *StateDB) deleteStateObject(addr common.Address) {
func (s *StateDB) getStateObject(addr common.Address) *stateObject {
return MVRead(s, blockstm.NewAddressKey(addr), nil, func(s *StateDB) *stateObject {
+ // FlatDiff is part of this StateDB's logical base. Let it mask stale
+ // cached objects loaded from committedParentRoot, unless the current
+ // execution has already dirtied the account.
+ if s.flatDiffRef != nil && !s.hasAccountMutation(addr) {
+ if acct, exists, covered := s.flatDiffRef.accountOverlay(addr); covered {
+ if !exists {
+ return nil
+ }
+ if obj := s.stateObjects[addr]; obj != nil && obj.fromFlatDiff {
+ return obj
+ }
+ return s.flatDiffStateObject(addr, acct)
+ }
+ }
// Prefer live objects if any is available
if obj := s.stateObjects[addr]; obj != nil {
return obj
@@ -1164,6 +1327,14 @@ func (s *StateDB) getStateObject(addr common.Address) *stateObject {
}
// Short circuit if the account is not found
if acct == nil {
+ // Track the address so the pipelined SRC goroutine can walk
+ // the trie path and capture proof-of-absence nodes for the
+ // witness. Without this, stateless execution can't verify
+ // non-existent accounts.
+ if s.nonExistentReads == nil {
+ s.nonExistentReads = make(map[common.Address]struct{})
+ }
+ s.nonExistentReads[addr] = struct{}{}
return nil
}
// Insert into the live set
@@ -1178,6 +1349,55 @@ func (s *StateDB) setStateObject(object *stateObject) {
s.stateObjects[object.Address()] = object
}
+// hasAccountMutation reports whether current execution already owns the
+// account value. FlatDiff still defines the parent base, but it must not
+// replace a live object that this block has dirtied.
+func (s *StateDB) hasAccountMutation(addr common.Address) bool {
+ if _, ok := s.journal.dirties[addr]; ok {
+ return true
+ }
+ if _, ok := s.mutations[addr]; ok {
+ return true
+ }
+ return false
+}
+
+func (s *StateDB) flatDiffStateObject(addr common.Address, acct types.StateAccount) *stateObject {
+ flatDiffAccountHitsMeter.Mark(1)
+ acctCopy := acct
+ obj := newObject(s, addr, &acctCopy)
+ obj.fromFlatDiff = true
+ if code, ok := s.flatDiffRef.Code[common.BytesToHash(acctCopy.CodeHash)]; ok {
+ obj.code = code
+ }
+ // Resolve the committed storage root for prefetcher consistency.
+ //
+ // The FlatDiff account's Root is block N's post-state storage root,
+ // but the prefetcher's NodeReader is opened at committedParentRoot
+ // (the grandparent). These are inconsistent — the reader can only
+ // resolve trie nodes for the grandparent's storage root. Without
+ // this, the prefetcher hits "Unexpected trie node" hash mismatches
+ // on every storage trie root resolution for FlatDiff accounts.
+ //
+ // We read the account from the committed state (flat reader, in-
+ // memory snapshot) to get the grandparent's storage root. This is
+ // the root that the prefetcher's reader can actually resolve.
+ if acctCopy.Root != types.EmptyRootHash {
+ if committedAcct, err := s.reader.Account(addr); err == nil && committedAcct != nil {
+ obj.prefetchRoot = committedAcct.Root
+ } else {
+ obj.prefetchRoot = types.EmptyRootHash
+ }
+ // If the account doesn't exist in the committed state (new in
+ // block N), prefetchRoot is set to the empty storage root so the
+ // storage prefetcher skips it; the trie didn't exist at
+ // committedParentRoot and block N's post-state root would be
+ // inconsistent with this reader.
+ }
+ s.setStateObject(obj)
+ return obj
+}
+
// Exporting so that it can be used by simulated backend for test cases
func (s *StateDB) GetOrNewStateObject(addr common.Address) *stateObject {
return s.getOrNewStateObject(addr)
@@ -1288,6 +1508,7 @@ func (s *StateDB) Copy() *StateDB {
transientStorage: s.transientStorage.Copy(),
journal: s.journal.copy(),
}
+ state.flatDiffRef = s.flatDiffRef // read-only, safe to share
if s.trie != nil {
state.trie = mustCopyTrie(s.trie)
}
@@ -1336,6 +1557,10 @@ func (s *StateDB) Copy() *StateDB {
state.mvHashmap = s.mvHashmap
}
+ if len(s.nonExistentReads) > 0 {
+ state.nonExistentReads = maps.Clone(s.nonExistentReads)
+ }
+
return state
}
@@ -1401,6 +1626,36 @@ func (s *StateDB) Finalise(deleteEmptyObjects bool) {
s.clearJournalAndRefund()
}
+// addWitnessNodes adds storage-trie nodes to the block witness and, when
+// per-account stats are being tracked, attributes them to the account.
+func (s *StateDB) addWitnessNodes(nodes map[string][]byte, addrHash common.Hash) {
+ s.witness.AddState(nodes)
+ if s.witnessStats != nil {
+ s.witnessStats.Add(nodes, addrHash)
+ }
+}
+
+// addObjectWitness pulls the object's storage-trie witness into the block
+// witness, preferring the prefetched trie, then the object's own trie. With
+// neither available and no prefetcher running, storage reads went through the
+// reader (separate trie / prevalueTracer), so the intermediate proof-path
+// nodes are missing — open the storage trie and re-read the accessed slots to
+// capture them.
+func (s *StateDB) addObjectWitness(obj *stateObject) {
+ if trie := obj.getPrefetchedTrie(); trie != nil {
+ s.addWitnessNodes(trie.Witness(), obj.addrHash)
+ } else if obj.trie != nil {
+ s.addWitnessNodes(obj.trie.Witness(), obj.addrHash)
+ } else if s.prefetcher == nil {
+ if tr, err := obj.getTrie(); err == nil {
+ for key := range obj.originStorage {
+ tr.GetStorage(obj.address, key[:])
+ }
+ s.addWitnessNodes(tr.Witness(), obj.addrHash)
+ }
+ }
+}
+
// IntermediateRoot computes the current root hash of the state trie.
// It is called in between transactions to get the root hash that
// goes into transaction receipts.
@@ -1480,19 +1735,7 @@ func (s *StateDB) IntermediateRoot(deleteEmptyObjects bool) common.Hash {
if len(obj.originStorage) == 0 {
continue
}
- if trie := obj.getPrefetchedTrie(); trie != nil {
- witness := trie.Witness()
- s.witness.AddState(witness)
- if s.witnessStats != nil {
- s.witnessStats.Add(witness, obj.addrHash)
- }
- } else if obj.trie != nil {
- witness := obj.trie.Witness()
- s.witness.AddState(witness)
- if s.witnessStats != nil {
- s.witnessStats.Add(witness, obj.addrHash)
- }
- }
+ s.addObjectWitness(obj)
}
// Pull in only-read and non-destructed trie witnesses
for _, obj := range s.stateObjects {
@@ -1504,19 +1747,7 @@ func (s *StateDB) IntermediateRoot(deleteEmptyObjects bool) common.Hash {
if len(obj.originStorage) == 0 {
continue
}
- if trie := obj.getPrefetchedTrie(); trie != nil {
- witness := trie.Witness()
- s.witness.AddState(witness)
- if s.witnessStats != nil {
- s.witnessStats.Add(witness, obj.addrHash)
- }
- } else if obj.trie != nil {
- witness := obj.trie.Witness()
- s.witness.AddState(witness)
- if s.witnessStats != nil {
- s.witnessStats.Add(witness, obj.addrHash)
- }
- }
+ s.addObjectWitness(obj)
}
s.WitnessCollection += time.Since(witStart)
}
@@ -1581,6 +1812,26 @@ func (s *StateDB) IntermediateRoot(deleteEmptyObjects bool) common.Hash {
if s.prefetcher != nil {
s.prefetcher.used(common.Hash{}, s.originalRoot, usedAddrs, nil)
}
+ // When there is no prefetcher and witness building is enabled, account
+ // reads went through the reader (a separate trie with its own
+ // prevalueTracer), so s.trie lacks intermediate nodes for read-only
+ // accounts. Walk them through s.trie now to capture proof-path nodes
+ // that will be included in the witness.
+ if s.witness != nil && s.prefetcher == nil && !s.db.TrieDB().IsVerkle() {
+ for _, obj := range s.stateObjects {
+ if _, ok := s.mutations[obj.address]; ok {
+ continue
+ }
+ s.trie.GetAccount(obj.address)
+ }
+ // Walk proof-of-absence paths for non-existent accounts. Even
+ // though these accounts don't exist, the trie traversal captures
+ // intermediate nodes that stateless execution needs to verify
+ // the accounts' non-existence.
+ for addr := range s.nonExistentReads {
+ s.trie.GetAccount(addr)
+ }
+ }
// Track the amount of time wasted on hashing the account trie
if !s.skipTimers {
defer func(start time.Time) { s.AccountHashes += time.Since(start) }(time.Now())
@@ -2020,6 +2271,449 @@ func (s *StateDB) CommitWithUpdate(block uint64, deleteEmptyObjects bool, noStor
return ret.root, ret, nil
}
+// FlatDiff is a flat snapshot of all account and storage mutations from one
+// block's execution. It is extracted cheaply (~1ms) via CommitSnapshot without
+// requiring MPT hashing. A goroutine then applies the FlatDiff to a fresh
+// StateDB to compute the actual state root concurrently with the next block.
+type FlatDiff struct {
+ Accounts map[common.Address]types.StateAccount // post-state of each modified account
+ Storage map[common.Address]map[common.Hash]common.Hash // post-state storage slots
+ Destructs map[common.Address]struct{} // self-destructed accounts
+ Code map[common.Hash][]byte // newly deployed code
+
+ // ReadSet and ReadStorage list accounts and storage slots that were read
+ // (but not mutated) during block execution. The pipelined SRC goroutine loads
+ // these from the root_{N-1} trie so their MPT proof nodes are captured in
+ // the witness for stateless execution.
+ ReadSet []common.Address
+ ReadStorage map[common.Address][]common.Hash
+
+ // NonExistentReads lists addresses that were looked up during execution
+ // but don't exist in the state trie. The SRC goroutine walks these paths
+ // to capture proof-of-absence trie nodes for the witness, enabling
+ // stateless execution to verify these accounts don't exist.
+ NonExistentReads []common.Address
+}
+
+// storageOverlay returns a FlatDiff-covered storage value. A destructed
+// account covers every slot: rewritten slots come from Storage, all other
+// pre-destruction slots read as zero. The third return value is true when
+// the value came from an explicit Storage entry rather than the destruct mask.
+func (diff *FlatDiff) storageOverlay(addr common.Address, key common.Hash) (common.Hash, bool, bool) {
+ if diff == nil {
+ return common.Hash{}, false, false
+ }
+ if slots, ok := diff.Storage[addr]; ok {
+ if value, ok := slots[key]; ok {
+ return value, true, true
+ }
+ }
+ if _, destructed := diff.Destructs[addr]; destructed {
+ return common.Hash{}, true, false
+ }
+ return common.Hash{}, false, false
+}
+
+// accountOverlay returns FlatDiff-covered account data. Accounts wins over
+// Destructs to represent destruct-and-resurrect in the same parent block.
+func (diff *FlatDiff) accountOverlay(addr common.Address) (types.StateAccount, bool, bool) {
+ if diff == nil {
+ return types.StateAccount{}, false, false
+ }
+ if acct, ok := diff.Accounts[addr]; ok {
+ return acct, true, true
+ }
+ if _, destructed := diff.Destructs[addr]; destructed {
+ return types.StateAccount{}, false, true
+ }
+ return types.StateAccount{}, false, false
+}
+
+// TouchAllAddresses performs read-only accesses on dst for every address and
+// storage slot recorded in the FlatDiff. This ensures dst tracks these
+// addresses in its stateObjects so they later appear in dst's own FlatDiff
+// (via CommitSnapshot). Unlike ApplyFlatDiff, it does NOT overwrite any
+// account data — it only forces dst to load the accounts from its own trie.
+func (diff *FlatDiff) TouchAllAddresses(dst *StateDB) {
+ for addr := range diff.Accounts {
+ touchAddressAndStorage(dst, addr, diff.mutatedStorageKeys(addr))
+ }
+ for _, addr := range diff.ReadSet {
+ touchAddressAndStorage(dst, addr, diff.ReadStorage[addr])
+ }
+ for addr := range diff.Destructs {
+ dst.GetBalance(addr)
+ }
+ // Touch non-existent addresses so dst tracks them (via its own
+ // nonExistentReads) and the SRC goroutine can capture their
+ // proof-of-absence trie nodes for the witness.
+ for _, addr := range diff.NonExistentReads {
+ dst.GetBalance(addr)
+ }
+}
+
+// touchAddressAndStorage calls GetBalance on addr and GetCommittedState on
+// each provided slot so the destination statedb tracks the reads (and the
+// background SRC walks those trie nodes for the witness).
+func touchAddressAndStorage(dst *StateDB, addr common.Address, slots []common.Hash) {
+ dst.GetBalance(addr)
+ for _, slot := range slots {
+ dst.GetCommittedState(addr, slot)
+ }
+}
+
+// mutatedStorageKeys returns the keys of diff.Storage[addr] as a slice so
+// TouchAllAddresses can route both mutated and read-only accounts through
+// touchAddressAndStorage without branching on map vs slice.
+func (diff *FlatDiff) mutatedStorageKeys(addr common.Address) []common.Hash {
+ slots, ok := diff.Storage[addr]
+ if !ok {
+ return nil
+ }
+ keys := make([]common.Hash, 0, len(slots))
+ for k := range slots {
+ keys = append(keys, k)
+ }
+ return keys
+}
+
+// CommitSnapshot finalises the StateDB and returns a FlatDiff capturing all
+// mutations without performing any MPT hashing (~1ms). After this call the
+// StateDB should no longer be used by the caller.
+func (s *StateDB) CommitSnapshot(deleteEmptyObjects bool) *FlatDiff {
+ s.Finalise(deleteEmptyObjects)
+
+ diff := &FlatDiff{
+ Accounts: make(map[common.Address]types.StateAccount),
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ ReadStorage: make(map[common.Address][]common.Hash),
+ }
+ for addr := range s.stateObjectsDestruct {
+ diff.Destructs[addr] = struct{}{}
+ }
+ for addr, op := range s.mutations {
+ s.captureMutation(diff, addr, op)
+ }
+ // Read-only accounts: accessed during execution but not mutated. The
+ // pipelined SRC goroutine loads their root_{N-1} trie nodes into the
+ // witness so stateless nodes can execute against root_{N-1}.
+ for addr, obj := range s.stateObjects {
+ s.captureReadOnlyAccount(diff, addr, obj)
+ }
+ // Non-existent account reads: looked-up addresses that don't exist in
+ // the state trie. The SRC goroutine needs these to walk proof-of-absence
+ // paths and capture trie nodes for the witness.
+ for addr := range s.nonExistentReads {
+ s.captureNonExistentRead(diff, addr)
+ }
+ return diff
+}
+
+// captureMutation records a single mutated/destructed account into the
+// FlatDiff. Destructs take both the explicit delete path and the pending
+// Destructs set; live mutations copy account data, dirty code, and both
+// pending and read-only storage so the SRC goroutine can later walk every
+// trie node the block touched.
+func (s *StateDB) captureMutation(diff *FlatDiff, addr common.Address, op *mutation) {
+ if op.isDelete() {
+ diff.Destructs[addr] = struct{}{}
+ return
+ }
+ obj, ok := s.stateObjects[addr]
+ if !ok {
+ return
+ }
+ diff.Accounts[addr] = obj.data
+ if obj.dirtyCode {
+ diff.Code[common.BytesToHash(obj.CodeHash())] = obj.code
+ }
+ captureObjectStorage(diff, addr, obj)
+}
+
+// captureObjectStorage copies pending (post-Finalise) storage mutations and
+// any read-only slots that weren't overwritten. Read-only slots matter
+// because the SRC goroutine needs to load their trie nodes into the witness
+// (e.g., span commits read validator-contract slots they don't write).
+func captureObjectStorage(diff *FlatDiff, addr common.Address, obj *stateObject) {
+ if len(obj.pendingStorage) > 0 {
+ slots := make(map[common.Hash]common.Hash, len(obj.pendingStorage))
+ for k, v := range obj.pendingStorage {
+ slots[k] = v
+ }
+ diff.Storage[addr] = slots
+ }
+ if len(obj.originStorage) == 0 {
+ return
+ }
+ var readSlots []common.Hash
+ for slot := range obj.originStorage {
+ if _, dirty := obj.pendingStorage[slot]; !dirty {
+ readSlots = append(readSlots, slot)
+ }
+ }
+ if len(readSlots) > 0 {
+ diff.ReadStorage[addr] = readSlots
+ }
+}
+
+// captureReadOnlyAccount adds an account to ReadSet (and its originStorage
+// to ReadStorage) if it was accessed but neither mutated nor destructed in
+// this block. Mutated/destructed accounts are already handled by
+// captureMutation.
+func (s *StateDB) captureReadOnlyAccount(diff *FlatDiff, addr common.Address, obj *stateObject) {
+ if _, isMutation := s.mutations[addr]; isMutation {
+ return
+ }
+ if _, isDestruct := s.stateObjectsDestruct[addr]; isDestruct {
+ return
+ }
+ diff.ReadSet = append(diff.ReadSet, addr)
+ if len(obj.originStorage) == 0 {
+ return
+ }
+ slots := make([]common.Hash, 0, len(obj.originStorage))
+ for slot := range obj.originStorage {
+ slots = append(slots, slot)
+ }
+ diff.ReadStorage[addr] = slots
+}
+
+// captureNonExistentRead records proof-of-absence address reads. Skips
+// addresses that ended up existing (e.g., created later in the block) since
+// captureMutation/captureReadOnlyAccount already handled them.
+func (s *StateDB) captureNonExistentRead(diff *FlatDiff, addr common.Address) {
+ if _, isMutation := s.mutations[addr]; isMutation {
+ return
+ }
+ if _, ok := s.stateObjects[addr]; ok {
+ return
+ }
+ diff.NonExistentReads = append(diff.NonExistentReads, addr)
+}
+
+// ApplyFlatDiff installs the previous block's mutations as pre-loaded (but not
+// dirty) state objects, giving the current block immediate read access to the
+// previous block's post-state without waiting for the background goroutine to
+// commit the trie.
+//
+// Accounts are inserted directly into s.stateObjects — bypassing the journal —
+// so Finalise/CommitSnapshot only captures accounts the CURRENT block actually
+// modifies. Without this, every account touched in block N would be re-captured
+// in block N+1's FlatDiff and cascade indefinitely.
+//
+// Newly deployed contract code (dirtyCode in the previous block) is carried
+// in-memory because the background goroutine may not have written it to the
+// key-value store yet.
+func (s *StateDB) ApplyFlatDiff(diff *FlatDiff) {
+ // Register self-destructed accounts so getStateObject returns nil for them,
+ // preventing a stale trie read while the background goroutine's deletion
+ // has not yet been committed.
+ for addr := range diff.Destructs {
+ if _, already := s.stateObjectsDestruct[addr]; !already {
+ s.stateObjectsDestruct[addr] = newObject(s, addr, nil)
+ }
+ }
+ for addr, acct := range diff.Accounts {
+ s.applyFlatAccountOverlay(diff, addr, acct)
+ }
+}
+
+// applyFlatAccountOverlay installs a FlatDiff account into stateObjects as a
+// read-only overlay: no journal entries, no dirty bits. Newly-deployed code
+// is carried in memory because the background goroutine may not have
+// persisted it yet; pre-existing contracts resolve via stateObject.Code().
+// Pending storage from the previous block is loaded as originStorage so
+// CommitSnapshot only re-captures slots that THIS block writes.
+func (s *StateDB) applyFlatAccountOverlay(diff *FlatDiff, addr common.Address, acct types.StateAccount) {
+ acctCopy := acct
+ obj := newObject(s, addr, &acctCopy)
+ if code, ok := diff.Code[common.BytesToHash(acctCopy.CodeHash)]; ok {
+ obj.code = code
+ // dirtyCode intentionally left false: code was deployed in the
+ // previous block, not this one.
+ }
+ if slots, ok := diff.Storage[addr]; ok {
+ for k, v := range slots {
+ obj.originStorage[k] = v
+ }
+ }
+ s.stateObjects[addr] = obj
+}
+
+// ApplyFlatDiffForCommit marks all mutations in diff as dirty via the normal
+// Set* mutation path, so that a subsequent CommitWithUpdate produces the
+// correct state root for the block. Unlike ApplyFlatDiff, which installs
+// accounts as a read-only overlay, this method ensures every change is
+// journalled so Finalise and commit pick them up.
+//
+// Use this only in the background goroutine that computes a block's actual
+// state root; it is not suitable for execution state objects (it would cause
+// mutations to cascade into subsequent FlatDiffs).
+func (s *StateDB) ApplyFlatDiffForCommit(diff *FlatDiff) {
+ // Handle self-destructs. Pure destructs (not resurrected) go through
+ // SelfDestruct, which loads the original from the trie and marks it for
+ // deletion. Resurrected accounts (present in both Destructs and Accounts)
+ // are set up inside applyFlatMutation so the subsequent Set* calls create
+ // a fresh object via getOrNewStateObject.
+ for addr := range diff.Destructs {
+ if _, resurrected := diff.Accounts[addr]; resurrected {
+ continue
+ }
+ s.SelfDestruct(addr)
+ }
+ for addr, acct := range diff.Accounts {
+ s.applyFlatMutation(diff, addr, acct)
+ }
+}
+
+// ApplyFlatDiffForCommitFast marks all mutations in diff as dirty without
+// constructing journal revert entries. It is intended for the witness-off SRC
+// goroutine only: the FlatDiff replay is irrevocable, so Snapshot/RevertToSnapshot
+// support would only add allocation and setter overhead before CommitWithUpdate.
+//
+// Witness-producing SRC still uses ApplyFlatDiffForCommit because the normal
+// setters/read path is the conservative path for collecting every proof node.
+func (s *StateDB) ApplyFlatDiffForCommitFast(diff *FlatDiff) {
+ for addr := range diff.Destructs {
+ if _, resurrected := diff.Accounts[addr]; resurrected {
+ continue
+ }
+ s.applyFlatPureDestructFast(addr)
+ }
+ for addr, acct := range diff.Accounts {
+ s.applyFlatMutationFast(diff, addr, acct)
+ }
+}
+
+// applyFlatMutation commits one FlatDiff account mutation onto the statedb
+// via the journalled Set* path so Finalise / commit pick it up. Handles
+// resurrection by seeding stateObjectsDestruct with the pre-block original
+// (needed by handleDestruction to delete the original storage trie).
+func (s *StateDB) applyFlatMutation(diff *FlatDiff, addr common.Address, acct types.StateAccount) {
+ if _, destructed := diff.Destructs[addr]; destructed {
+ if _, already := s.stateObjectsDestruct[addr]; !already {
+ if prev := s.getStateObject(addr); prev != nil {
+ s.stateObjectsDestruct[addr] = prev
+ }
+ }
+ delete(s.stateObjects, addr)
+ }
+ if code, ok := diff.Code[common.BytesToHash(acct.CodeHash)]; ok {
+ s.SetCode(addr, code, tracing.CodeChangeUnspecified)
+ }
+ // SetState reads the pre-block origin from the storage trie, populating
+ // uncommittedStorage so updateTrie correctly writes or deletes each slot
+ // (including zero-value deletions).
+ if slots, ok := diff.Storage[addr]; ok {
+ for k, v := range slots {
+ s.SetState(addr, k, v)
+ }
+ }
+ // Set* ensures the account appears in journal.dirties so Finalise emits
+ // a markUpdate, even when only storage or code changed.
+ s.SetNonce(addr, acct.Nonce, tracing.NonceChangeUnspecified)
+ s.SetBalance(addr, acct.Balance, tracing.BalanceChangeUnspecified)
+}
+
+func (s *StateDB) applyFlatPureDestructFast(addr common.Address) {
+ obj := s.getStateObject(addr)
+ if obj == nil {
+ return
+ }
+ if !obj.Balance().IsZero() {
+ obj.setBalance(new(uint256.Int))
+ }
+ obj.markSelfdestructed()
+ s.journal.dirty(addr)
+}
+
+// resolveFlatMutationObject returns the state object a flat mutation for addr
+// applies to, recreating the object from scratch when the diff destructed the
+// account (preserving the pre-destruct object for witness collection).
+func (s *StateDB) resolveFlatMutationObject(diff *FlatDiff, addr common.Address) *stateObject {
+ if _, destructed := diff.Destructs[addr]; destructed {
+ if _, already := s.stateObjectsDestruct[addr]; !already {
+ if prev := s.getStateObject(addr); prev != nil {
+ s.stateObjectsDestruct[addr] = prev
+ }
+ }
+ delete(s.stateObjects, addr)
+ delete(s.nonExistentReads, addr)
+ obj := newObject(s, addr, nil)
+ s.setStateObject(obj)
+ return obj
+ }
+ obj := s.getStateObject(addr)
+ if obj == nil {
+ delete(s.nonExistentReads, addr)
+ obj = newObject(s, addr, nil)
+ s.setStateObject(obj)
+ }
+ return obj
+}
+
+func (s *StateDB) applyFlatMutationFast(diff *FlatDiff, addr common.Address, acct types.StateAccount) {
+ obj := s.resolveFlatMutationObject(diff, addr)
+ if obj == nil {
+ return
+ }
+ obj.data.Nonce = acct.Nonce
+ if acct.Balance != nil {
+ obj.data.Balance = new(uint256.Int).Set(acct.Balance)
+ } else {
+ obj.data.Balance = new(uint256.Int)
+ }
+ codeHash := common.BytesToHash(acct.CodeHash)
+ if code, ok := diff.Code[codeHash]; ok {
+ obj.setCode(codeHash, code)
+ }
+ if slots, ok := diff.Storage[addr]; ok {
+ for key, value := range slots {
+ obj.dirtyStorage[key] = value
+ }
+ }
+ s.journal.dirty(addr)
+}
+
+// NewWithFlatBase creates a StateDB at parentCommittedRoot (the last root
+// committed to the trie database) with a FlatDiff overlay so that reads see
+// the post-state of the block that produced flatDiff, without waiting for
+// that block's state root to be computed.
+//
+// This is used during pipelined SRC: while a background goroutine computes
+// root_N from (root_{N-1}, FlatDiff_N), the next block N+1 can already be
+// executed using NewWithFlatBase(root_{N-1}, db, FlatDiff_N).
+func NewWithFlatBase(parentCommittedRoot common.Hash, db Database, flatDiff *FlatDiff) (*StateDB, error) {
+ sdb, err := New(parentCommittedRoot, db)
+ if err != nil {
+ return nil, err
+ }
+ if flatDiff != nil {
+ sdb.flatDiffRef = flatDiff
+ }
+ return sdb, nil
+}
+
+// SetFlatDiffRef sets the read-only FlatDiff reference for lazy lookups.
+func (s *StateDB) SetFlatDiffRef(diff *FlatDiff) {
+ s.flatDiffRef = diff
+}
+
+// WasStorageSlotRead returns true if the given address+slot was accessed
+// (read) during this block's execution. Used by pipelined SRC to detect
+// whether any transaction read the EIP-2935 history storage slot that
+// contains stale data during speculative execution.
+func (s *StateDB) WasStorageSlotRead(addr common.Address, slot common.Hash) bool {
+ obj, exists := s.stateObjects[addr]
+ if !exists {
+ return false
+ }
+ _, accessed := obj.originStorage[slot]
+ return accessed
+}
+
// Prepare handles the preparatory steps for executing a state transition with.
// This method must be invoked before state transition.
//
@@ -2243,7 +2937,7 @@ func (s *StateDB) FinaliseFastWithPrefetch(deleteEmptyObjects bool) {
if obj == nil {
continue
}
- _ = s.prefetcher.prefetch(obj.addrHash, obj.data.Root, as.addr, nil, as.slots, false)
+ _ = s.prefetcher.prefetch(obj.addrHash, as.root, as.addr, nil, as.slots, false)
}
}
s.FinaliseFast(deleteEmptyObjects)
@@ -2251,6 +2945,7 @@ func (s *StateDB) FinaliseFastWithPrefetch(deleteEmptyObjects bool) {
type addrDirtySlots struct {
addr common.Address
+ root common.Hash
slots []common.Hash
}
@@ -2261,14 +2956,18 @@ func (s *StateDB) snapshotDirtyStorageSlots() []addrDirtySlots {
var out []addrDirtySlots
for addr := range s.journal.dirties {
obj, exist := s.stateObjects[addr]
- if !exist || obj.data.Root == types.EmptyRootHash || len(obj.dirtyStorage) == 0 {
+ if !exist || len(obj.dirtyStorage) == 0 {
+ continue
+ }
+ root := obj.getPrefetchRoot()
+ if root == types.EmptyRootHash && !s.db.TrieDB().IsVerkle() {
continue
}
slots := make([]common.Hash, 0, len(obj.dirtyStorage))
for key := range obj.dirtyStorage {
slots = append(slots, key)
}
- out = append(out, addrDirtySlots{addr: addr, slots: slots})
+ out = append(out, addrDirtySlots{addr: addr, root: root, slots: slots})
}
return out
}
@@ -2353,3 +3052,20 @@ func (s *StateDB) AccessEvents() *AccessEvents {
func (s *StateDB) Inner() *StateDB {
return s
}
+
+// PropagateReadsTo touches all addresses and storage slots accessed in s on
+// the destination StateDB. This ensures the destination tracks them in its
+// stateObjects (and later in its FlatDiff ReadSet) so the pipelined SRC
+// goroutine captures their trie proof nodes in the witness.
+//
+// Use this when a temporary copy of the state is used for EVM calls (e.g.,
+// CommitStates → LastStateId) and the accessed addresses must be visible
+// in the original state for witness generation.
+func (s *StateDB) PropagateReadsTo(dst *StateDB) {
+ for addr, obj := range s.stateObjects {
+ dst.GetBalance(addr)
+ for slot := range obj.originStorage {
+ dst.GetState(addr, slot)
+ }
+ }
+}
diff --git a/core/state/statedb_pipeline_mutations_test.go b/core/state/statedb_pipeline_mutations_test.go
new file mode 100644
index 0000000000..c27d36f591
--- /dev/null
+++ b/core/state/statedb_pipeline_mutations_test.go
@@ -0,0 +1,1011 @@
+package state
+
+import (
+ "testing"
+
+ "github.com/holiman/uint256"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+)
+
+// The tests in this file exercise pipelined-SRC-authored code paths in
+// statedb.go at fine enough resolution to kill specific mutation-testing
+// survivors. Each assertion targets a distinct branch, call site, or return
+// path reported by diffguard's T1/T2 mutation pass.
+
+// ---- mutatedStorageKeys ----
+
+func TestMutatedStorageKeys_MissingAddr(t *testing.T) {
+ diff := &FlatDiff{Storage: make(map[common.Address]map[common.Hash]common.Hash)}
+ require.Nil(t, diff.mutatedStorageKeys(common.HexToAddress("0x1234")))
+}
+
+func TestMutatedStorageKeys_PresentAddr(t *testing.T) {
+ addr := common.HexToAddress("0x1234")
+ diff := &FlatDiff{
+ Storage: map[common.Address]map[common.Hash]common.Hash{
+ addr: {
+ common.HexToHash("0xaa"): common.HexToHash("0x01"),
+ common.HexToHash("0xbb"): common.HexToHash("0x02"),
+ },
+ },
+ }
+ got := diff.mutatedStorageKeys(addr)
+ require.Len(t, got, 2)
+ seen := map[common.Hash]bool{}
+ for _, k := range got {
+ seen[k] = true
+ }
+ require.True(t, seen[common.HexToHash("0xaa")])
+ require.True(t, seen[common.HexToHash("0xbb")])
+}
+
+// ---- touchAddressAndStorage / TouchAllAddresses ----
+
+func TestTouchAddressAndStorage_LoadsBalanceWithNoSlots(t *testing.T) {
+ // Kills: removal of dst.GetBalance(addr) when slots slice is empty. Without
+ // that call, a FlatDiff that names an account but no slots would leave dst
+ // untracked entirely.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xtouch1")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(99), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{addr: {}},
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ ReadStorage: make(map[common.Address][]common.Hash),
+ }
+
+ dst, err := New(root, db)
+ require.NoError(t, err)
+ diff.TouchAllAddresses(dst)
+
+ _, loaded := dst.stateObjects[addr]
+ require.True(t, loaded, "TouchAllAddresses must load addr even when the slot list is empty")
+}
+
+func TestTouchAddressAndStorage_LoadsEachSlot(t *testing.T) {
+ // Kills: removal of dst.GetCommittedState(addr, slot) inside the loop.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xtouch2")
+ slot1 := common.HexToHash("0xa1")
+ slot2 := common.HexToHash("0xa2")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(1), 0)
+ sdb.SetState(addr, slot1, common.HexToHash("0xb1"))
+ sdb.SetState(addr, slot2, common.HexToHash("0xb2"))
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{addr: {}},
+ Storage: map[common.Address]map[common.Hash]common.Hash{
+ addr: {slot1: {}, slot2: {}},
+ },
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ ReadStorage: make(map[common.Address][]common.Hash),
+ }
+
+ dst, err := New(root, db)
+ require.NoError(t, err)
+ diff.TouchAllAddresses(dst)
+
+ obj := dst.getStateObject(addr)
+ require.NotNil(t, obj)
+ _, s1 := obj.originStorage[slot1]
+ _, s2 := obj.originStorage[slot2]
+ require.True(t, s1, "slot1 must be tracked in dst.originStorage")
+ require.True(t, s2, "slot2 must be tracked in dst.originStorage")
+}
+
+func TestTouchAllAddresses_ReadSetSlotsLoaded(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xtouch3")
+ slot := common.HexToHash("0xcafe")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(42), 0)
+ sdb.SetState(addr, slot, common.HexToHash("0xbeef"))
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ diff := &FlatDiff{
+ Accounts: make(map[common.Address]types.StateAccount),
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ ReadSet: []common.Address{addr},
+ ReadStorage: map[common.Address][]common.Hash{addr: {slot}},
+ }
+
+ dst, err := New(root, db)
+ require.NoError(t, err)
+ diff.TouchAllAddresses(dst)
+
+ obj := dst.getStateObject(addr)
+ require.NotNil(t, obj)
+ _, loaded := obj.originStorage[slot]
+ require.True(t, loaded, "ReadSet slot must be tracked in dst.originStorage")
+}
+
+func TestTouchAllAddresses_DestructsLoadBalance(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xtouch4")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(5), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ diff := &FlatDiff{
+ Accounts: make(map[common.Address]types.StateAccount),
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: map[common.Address]struct{}{addr: {}},
+ Code: make(map[common.Hash][]byte),
+ }
+
+ dst, err := New(root, db)
+ require.NoError(t, err)
+ diff.TouchAllAddresses(dst)
+
+ _, ok := dst.stateObjects[addr]
+ require.True(t, ok, "destruct entry must cause dst to load addr via GetBalance")
+}
+
+func TestTouchAllAddresses_NonExistentReadsRegistered(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ missing := common.HexToAddress("0xtouch5-missing")
+ diff := &FlatDiff{
+ Accounts: make(map[common.Address]types.StateAccount),
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ NonExistentReads: []common.Address{missing},
+ }
+
+ dst, err := New(root, db)
+ require.NoError(t, err)
+ diff.TouchAllAddresses(dst)
+
+ _, ok := dst.nonExistentReads[missing]
+ require.True(t, ok, "NonExistentReads addr must be tracked via GetBalance")
+}
+
+// ---- captureMutation (via CommitSnapshot) ----
+
+func TestCommitSnapshot_DestructedAccountExcludedFromAccounts(t *testing.T) {
+ // Kills: removal of `if op.isDelete()` early return. Without it, a destructed
+ // account flows into diff.Accounts alongside Destructs.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xcap1")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(100), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+ sdb2.SelfDestruct(addr)
+ diff := sdb2.CommitSnapshot(false)
+
+ require.Contains(t, diff.Destructs, addr)
+ require.NotContains(t, diff.Accounts, addr, "destructed addr must not also appear in Accounts")
+}
+
+func TestCommitSnapshot_DirtyCodeCaptured(t *testing.T) {
+ // Kills: removal of the dirtyCode branch that populates diff.Code.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xcap2")
+ code := []byte{0x60, 0x01, 0x60, 0x00, 0xf3}
+ sdb.CreateAccount(addr)
+ sdb.SetCode(addr, code, 0)
+
+ diff := sdb.CommitSnapshot(false)
+
+ codeHash := common.BytesToHash(crypto.Keccak256(code))
+ got, ok := diff.Code[codeHash]
+ require.True(t, ok, "dirty code must populate diff.Code")
+ require.Equal(t, code, got)
+}
+
+func TestCaptureMutation_OrphanMutationIsSkipped(t *testing.T) {
+ // Kills: removal of the `if !ok { return }` guard at line 2119. Without the
+ // guard, the following `diff.Accounts[addr] = obj.data` dereferences a nil
+ // stateObject and panics.
+ //
+ // Normal execution never produces an orphan mutation (every Set* path that
+ // records a mutation also installs a stateObject), so we construct the
+ // state by hand.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xorphan")
+ sdb.mutations[addr] = &mutation{typ: update}
+
+ require.NotPanics(t, func() { _ = sdb.CommitSnapshot(false) })
+ diff := sdb.CommitSnapshot(false)
+ require.NotContains(t, diff.Accounts, addr,
+ "orphan mutation (no stateObject) must not produce a diff.Accounts entry")
+}
+
+func TestCommitSnapshot_NoCodeWithoutDirtyFlag(t *testing.T) {
+ // Complements the previous test: verifies diff.Code stays empty when no code
+ // is deployed, so the dirtyCode branch is genuinely guarded.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xcap3")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(1), 0)
+
+ diff := sdb.CommitSnapshot(false)
+ require.Empty(t, diff.Code)
+}
+
+// ---- captureObjectStorage ----
+
+func TestCaptureObjectStorage_NoPendingLeavesStorageEmpty(t *testing.T) {
+ // Kills: `len(pendingStorage) > 0` → `>= 0` (always true). Under mutation,
+ // an empty pendingStorage would still add an empty map to diff.Storage[addr].
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xcos1")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(7), 0)
+
+ diff := sdb.CommitSnapshot(false)
+
+ require.Contains(t, diff.Accounts, addr)
+ _, hasStorage := diff.Storage[addr]
+ require.False(t, hasStorage, "addr with no pending writes must not have a Storage entry (even empty)")
+}
+
+func TestCaptureObjectStorage_SplitsPendingAndRead(t *testing.T) {
+ // Kills:
+ // - removal of readSlots append (line 2146)
+ // - removal of `if len(readSlots) > 0 { ... ReadStorage[addr] = readSlots }` (line 2150)
+ // - inversion of the `len(originStorage) == 0` guard (line 2141)
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xcos2")
+ readSlot := common.HexToHash("0xa1")
+ writeSlot := common.HexToHash("0xa2")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(1), 0)
+ sdb.SetState(addr, readSlot, common.HexToHash("0xb1"))
+ sdb.SetState(addr, writeSlot, common.HexToHash("0xb2"))
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+ // Read-only access to readSlot (loads origin only).
+ _ = sdb2.GetState(addr, readSlot)
+ // Write writeSlot so the account is mutated.
+ sdb2.SetState(addr, writeSlot, common.HexToHash("0xb3"))
+
+ diff := sdb2.CommitSnapshot(false)
+
+ slots := diff.Storage[addr]
+ require.Contains(t, slots, writeSlot, "writeSlot must be in Storage")
+ require.NotContains(t, slots, readSlot, "readSlot must NOT be in Storage")
+
+ reads := diff.ReadStorage[addr]
+ require.Contains(t, reads, readSlot, "readSlot must be in ReadStorage")
+ require.NotContains(t, reads, writeSlot, "writeSlot must NOT be in ReadStorage")
+}
+
+// ---- captureReadOnlyAccount ----
+
+func TestCaptureReadOnlyAccount_AddsToReadSet(t *testing.T) {
+ // Kills: removal of the `len(originStorage) == 0` early-return guard
+ // (line 2167). Without it, diff.ReadStorage[addr] would be populated with
+ // an empty slice for read-only accounts that didn't touch any slots.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xro1")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(55), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+ _ = sdb2.GetBalance(addr)
+
+ diff := sdb2.CommitSnapshot(false)
+
+ require.Contains(t, diff.ReadSet, addr)
+ require.NotContains(t, diff.Accounts, addr, "read-only access must not populate Accounts")
+ require.NotContains(t, diff.ReadStorage, addr,
+ "read-only addr without slot accesses must not have a ReadStorage entry")
+}
+
+func TestCaptureReadOnlyAccount_SkipMutated(t *testing.T) {
+ // Kills: removal of `if isMutation { return }` guard (line 2160).
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xro2")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(77), 0)
+
+ diff := sdb.CommitSnapshot(false)
+
+ require.Contains(t, diff.Accounts, addr)
+ require.NotContains(t, diff.ReadSet, addr, "mutated addr must not appear in ReadSet")
+}
+
+func TestCaptureReadOnlyAccount_SkipDestructed(t *testing.T) {
+ // Kills: removal of `if isDestruct { return }` guard (line 2163).
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xro3")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(88), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+ sdb2.SelfDestruct(addr)
+
+ diff := sdb2.CommitSnapshot(false)
+
+ require.Contains(t, diff.Destructs, addr)
+ require.NotContains(t, diff.ReadSet, addr, "destructed addr must not appear in ReadSet")
+}
+
+func TestCaptureReadOnlyAccount_PopulatesReadStorage(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xro4")
+ slot := common.HexToHash("0xdead")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(1), 0)
+ sdb.SetState(addr, slot, common.HexToHash("0xcafe"))
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+ _ = sdb2.GetBalance(addr)
+ _ = sdb2.GetState(addr, slot)
+
+ diff := sdb2.CommitSnapshot(false)
+ require.Contains(t, diff.ReadSet, addr)
+ require.Contains(t, diff.ReadStorage[addr], slot)
+}
+
+// ---- captureNonExistentRead ----
+
+func TestCaptureNonExistentRead_AddsMissingAddr(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ missing := common.HexToAddress("0xnr1")
+ _ = sdb.GetBalance(missing)
+
+ diff := sdb.CommitSnapshot(false)
+ require.Contains(t, diff.NonExistentReads, missing)
+}
+
+func TestCaptureNonExistentRead_SkipMutated(t *testing.T) {
+ // Kills: removal of `if isMutation { return }` guard (line 2181). An address
+ // that was looked up (missing) and then created must not appear in
+ // NonExistentReads.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xnr2")
+
+ _ = sdb.GetBalance(addr)
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(1), 0)
+
+ diff := sdb.CommitSnapshot(false)
+
+ require.Contains(t, diff.Accounts, addr)
+ require.NotContains(t, diff.NonExistentReads, addr, "created addr must not leak into NonExistentReads")
+}
+
+func TestCaptureNonExistentRead_SkipOrphanMutation(t *testing.T) {
+ // Kills: removal of the `if isMutation { return }` guard at line 2181.
+ // Normal flow can't exercise this because: when an addr is in mutations,
+ // it's also in stateObjects, so the 2184 guard catches it anyway — making
+ // 2181 observationally equivalent. We distinguish them with an orphan
+ // mutation: mutations[addr] set but stateObjects[addr] absent. Under the
+ // 2181 mutation, execution falls through to the 2184 check (which also
+ // passes since stateObjects is empty) and appends to NonExistentReads.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xnr_orphan")
+ sdb.mutations[addr] = &mutation{typ: update}
+ if sdb.nonExistentReads == nil {
+ sdb.nonExistentReads = make(map[common.Address]struct{})
+ }
+ sdb.nonExistentReads[addr] = struct{}{}
+
+ diff := sdb.CommitSnapshot(false)
+ require.NotContains(t, diff.NonExistentReads, addr,
+ "orphan mutation in nonExistentReads must be filtered by the isMutation guard")
+}
+
+func TestCaptureNonExistentRead_SkipExistingStateObject(t *testing.T) {
+ // Kills: removal of `if _, ok := stateObjects[addr]; ok { return }` guard
+ // (line 2184). Normally unreachable — force the state by seeding
+ // nonExistentReads directly for an addr that already has a stateObject.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xnr3")
+
+ // Load into stateObjects via GetBalance (account doesn't exist yet, so no
+ // state object is created). Instead, create + finalise to ensure it's in
+ // stateObjects, then manually inject nonExistentReads. Without finalise the
+ // account stays in mutations, which is already covered by the previous test.
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(1), 0)
+ sdb.Finalise(false)
+ // Drop the mutation so captureNonExistentRead's `isMutation` guard is not hit.
+ delete(sdb.mutations, addr)
+ // Inject non-existent read for an addr that now lives in stateObjects.
+ if sdb.nonExistentReads == nil {
+ sdb.nonExistentReads = make(map[common.Address]struct{})
+ }
+ sdb.nonExistentReads[addr] = struct{}{}
+
+ diff := sdb.CommitSnapshot(false)
+
+ require.NotContains(t, diff.NonExistentReads, addr,
+ "addr present in stateObjects must be excluded from NonExistentReads")
+}
+
+// ---- ApplyFlatDiff ----
+
+func TestApplyFlatDiff_DestructPopulatesDestructMap(t *testing.T) {
+ // Kills: removal of `if !already { s.stateObjectsDestruct[addr] = newObject(...) }` (line 2208).
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xafd1")
+ diff := &FlatDiff{
+ Accounts: make(map[common.Address]types.StateAccount),
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: map[common.Address]struct{}{addr: {}},
+ Code: make(map[common.Hash][]byte),
+ }
+ sdb.ApplyFlatDiff(diff)
+ _, ok := sdb.stateObjectsDestruct[addr]
+ require.True(t, ok, "ApplyFlatDiff must register destruct in stateObjectsDestruct")
+}
+
+func TestApplyFlatDiff_PreservesExistingDestructEntry(t *testing.T) {
+ // Kills: inversion of the `!already` guard (line 2208). If the guard flips,
+ // the pre-existing entry would be overwritten by a blank newObject.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xafd2")
+
+ sentinel := newObject(sdb, addr, &types.StateAccount{Nonce: 999})
+ sdb.stateObjectsDestruct[addr] = sentinel
+
+ diff := &FlatDiff{
+ Accounts: make(map[common.Address]types.StateAccount),
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: map[common.Address]struct{}{addr: {}},
+ Code: make(map[common.Hash][]byte),
+ }
+ sdb.ApplyFlatDiff(diff)
+
+ require.Same(t, sentinel, sdb.stateObjectsDestruct[addr],
+ "pre-existing destruct entry must be preserved")
+}
+
+func TestApplyFlatDiff_InstallsAccountInStateObjects(t *testing.T) {
+ // Covers applyFlatAccountOverlay: newObject + stateObjects[addr] = obj.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xafo1")
+ acct := types.StateAccount{
+ Nonce: 7,
+ Balance: uint256.NewInt(123),
+ Root: types.EmptyRootHash,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ }
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{addr: acct},
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ }
+ sdb.ApplyFlatDiff(diff)
+
+ obj, ok := sdb.stateObjects[addr]
+ require.True(t, ok, "overlayed account must be in stateObjects")
+ require.Equal(t, uint64(7), obj.data.Nonce)
+ require.Equal(t, uint256.NewInt(123), obj.data.Balance)
+}
+
+func TestApplyFlatDiff_InstallsCodeOverlay(t *testing.T) {
+ // Covers applyFlatAccountOverlay code branch.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xafo2")
+ code := []byte{0x60, 0x11, 0x60, 0x00, 0xf3}
+ codeHash := common.BytesToHash(crypto.Keccak256(code))
+ acct := types.StateAccount{
+ Nonce: 1,
+ Balance: uint256.NewInt(0),
+ Root: types.EmptyRootHash,
+ CodeHash: codeHash.Bytes(),
+ }
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{addr: acct},
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: map[common.Hash][]byte{codeHash: code},
+ }
+ sdb.ApplyFlatDiff(diff)
+
+ obj, ok := sdb.stateObjects[addr]
+ require.True(t, ok)
+ require.Equal(t, code, obj.code, "FlatDiff code must be carried in obj.code")
+ require.False(t, obj.dirtyCode, "overlayed code must NOT be marked dirty")
+}
+
+func TestApplyFlatDiff_InstallsStorageOverlay(t *testing.T) {
+ // Covers applyFlatAccountOverlay storage branch.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xafo3")
+ slot := common.HexToHash("0xa1")
+ value := common.HexToHash("0xb1")
+ acct := types.StateAccount{
+ Nonce: 1,
+ Balance: uint256.NewInt(0),
+ Root: types.EmptyRootHash,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ }
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{addr: acct},
+ Storage: map[common.Address]map[common.Hash]common.Hash{
+ addr: {slot: value},
+ },
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ }
+ sdb.ApplyFlatDiff(diff)
+
+ obj, ok := sdb.stateObjects[addr]
+ require.True(t, ok)
+ got, loaded := obj.originStorage[slot]
+ require.True(t, loaded, "FlatDiff slot must populate originStorage")
+ require.Equal(t, value, got)
+}
+
+func TestNewWithFlatBase_SuccessInstallsFlatDiffRef(t *testing.T) {
+ // Covers the success path and the `if flatDiff != nil` branch.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ diff := &FlatDiff{
+ Accounts: make(map[common.Address]types.StateAccount),
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ }
+ overlay, err := NewWithFlatBase(root, db, diff)
+ require.NoError(t, err)
+ require.NotNil(t, overlay)
+ require.Same(t, diff, overlay.flatDiffRef, "flatDiffRef must reference the supplied diff")
+}
+
+func TestNewWithFlatBase_NilFlatDiffLeavesRefNil(t *testing.T) {
+ // Covers the `if flatDiff != nil` guard (negative case).
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ overlay, err := NewWithFlatBase(root, db, nil)
+ require.NoError(t, err)
+ require.NotNil(t, overlay)
+ require.Nil(t, overlay.flatDiffRef, "nil FlatDiff must not overwrite flatDiffRef")
+}
+
+func TestApplyFlatMutation_StorageWritesApplied(t *testing.T) {
+ // Covers applyFlatMutation's storage loop (SetState per slot).
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xafm_storage")
+ sdb.CreateAccount(addr)
+ sdb.SetNonce(addr, 1, 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+
+ slot := common.HexToHash("0xabc")
+ value := common.HexToHash("0xdef")
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 1,
+ Balance: uint256.NewInt(0),
+ Root: types.EmptyRootHash,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: map[common.Address]map[common.Hash]common.Hash{
+ addr: {slot: value},
+ },
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ }
+ sdb2.ApplyFlatDiffForCommit(diff)
+
+ require.Equal(t, value, sdb2.GetState(addr, slot), "applyFlatMutation must SetState for each storage slot")
+}
+
+// ---- ApplyFlatDiffForCommit / applyFlatMutation ----
+
+func TestApplyFlatDiffForCommit_PureDestructTriggersSelfDestruct(t *testing.T) {
+ // Kills: removal of `s.SelfDestruct(addr)` call (line 2258).
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xafc1")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(42), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+
+ diff := &FlatDiff{
+ Accounts: make(map[common.Address]types.StateAccount),
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: map[common.Address]struct{}{addr: {}},
+ Code: make(map[common.Hash][]byte),
+ }
+ sdb2.ApplyFlatDiffForCommit(diff)
+
+ obj := sdb2.getStateObject(addr)
+ require.NotNil(t, obj)
+ require.True(t, obj.selfDestructed, "SelfDestruct must have been invoked")
+}
+
+func TestApplyFlatDiffForCommit_ResurrectionSkipsSelfDestruct(t *testing.T) {
+ // Kills:
+ // - removal of `if resurrected { continue }` (line 2255). Without the
+ // skip, SelfDestruct(addr) runs before applyFlatMutation, which zeros
+ // the balance on the pre-block object — the same object that
+ // applyFlatMutation later snapshots into stateObjectsDestruct. Asserting
+ // that the snapshot still carries the ORIGINAL (non-zero) balance
+ // distinguishes the two paths.
+ // - removal of `s.SelfDestruct(addr)` call on the non-resurrected path is
+ // covered by TestApplyFlatDiffForCommit_PureDestructTriggersSelfDestruct.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xafc2")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(5), 0)
+ sdb.SetNonce(addr, 1, 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 100,
+ Balance: uint256.NewInt(0),
+ Root: types.EmptyRootHash,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: map[common.Address]struct{}{addr: {}},
+ Code: make(map[common.Hash][]byte),
+ }
+ sdb2.ApplyFlatDiffForCommit(diff)
+
+ require.Equal(t, uint64(100), sdb2.GetNonce(addr), "resurrected account must adopt new nonce")
+
+ prev, destructed := sdb2.stateObjectsDestruct[addr]
+ require.True(t, destructed, "pre-block object must be recorded in stateObjectsDestruct")
+ require.Equal(t, uint64(1), prev.data.Nonce, "stateObjectsDestruct must snapshot PRE-block nonce")
+ require.Equal(t, uint256.NewInt(5), prev.data.Balance,
+ "stateObjectsDestruct must carry PRE-block balance; if SelfDestruct ran it would be zero")
+ require.False(t, prev.selfDestructed,
+ "prev must NOT be marked self-destructed — that would indicate SelfDestruct was called")
+}
+
+func TestApplyFlatMutation_SetNonceCalled(t *testing.T) {
+ // Kills: removal of `s.SetNonce(...)` (line 2291).
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xafm1")
+ sdb.CreateAccount(addr)
+ sdb.SetNonce(addr, 1, 0)
+ sdb.SetBalance(addr, uint256.NewInt(10), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 50,
+ Balance: uint256.NewInt(10),
+ Root: types.EmptyRootHash,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ }
+ sdb2.ApplyFlatDiffForCommit(diff)
+
+ require.Equal(t, uint64(50), sdb2.GetNonce(addr))
+}
+
+func TestApplyFlatMutation_SetBalanceCalled(t *testing.T) {
+ // Kills: removal of `s.SetBalance(...)` (line 2292).
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xafm2")
+ sdb.CreateAccount(addr)
+ sdb.SetNonce(addr, 1, 0)
+ sdb.SetBalance(addr, uint256.NewInt(10), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 1,
+ Balance: uint256.NewInt(9999),
+ Root: types.EmptyRootHash,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ }
+ sdb2.ApplyFlatDiffForCommit(diff)
+
+ require.Equal(t, uint256.NewInt(9999), sdb2.GetBalance(addr))
+}
+
+func TestApplyFlatMutation_DestructBranchDeletesStateObject(t *testing.T) {
+ // Kills:
+ // - removal of `if destructed` branch entirely (line 2270)
+ // - removal of `if !already` inner guard (line 2271)
+ // - inversion of `prev != nil` check (line 2272)
+ // - removal of `delete(s.stateObjects, addr)` (line 2276)
+ //
+ // We distinguish these by asserting:
+ // - stateObjectsDestruct[addr] contains the PRE-block nonce (7), not the new one.
+ // - the post-commit GetNonce is the NEW value (99), which is only possible if
+ // the old stateObjects entry was deleted so SetNonce created a fresh object.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xafm3")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(500), 0)
+ sdb.SetNonce(addr, 7, 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+ // Pre-load so stateObjects contains the pre-block object.
+ require.Equal(t, uint64(7), sdb2.GetNonce(addr))
+ _, had := sdb2.stateObjects[addr]
+ require.True(t, had)
+
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 99,
+ Balance: uint256.NewInt(1),
+ Root: types.EmptyRootHash,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: map[common.Address]struct{}{addr: {}},
+ Code: make(map[common.Hash][]byte),
+ }
+ sdb2.ApplyFlatDiffForCommit(diff)
+
+ prev, destructed := sdb2.stateObjectsDestruct[addr]
+ require.True(t, destructed, "stateObjectsDestruct must contain addr")
+ require.Equal(t, uint64(7), prev.data.Nonce,
+ "stateObjectsDestruct must hold pre-block nonce; mutating delete(stateObjects) leaves the same pointer here and corrupts this to 99")
+
+ require.Equal(t, uint64(99), sdb2.GetNonce(addr))
+ require.Equal(t, uint256.NewInt(1), sdb2.GetBalance(addr))
+}
+
+func TestApplyFlatMutation_WithCodeCallsSetCode(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xafm4")
+ sdb.CreateAccount(addr)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+
+ code := []byte{0x60, 0x02, 0x60, 0x00, 0xf3}
+ codeHash := common.BytesToHash(crypto.Keccak256(code))
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 1,
+ Balance: uint256.NewInt(0),
+ Root: types.EmptyRootHash,
+ CodeHash: codeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: map[common.Hash][]byte{codeHash: code},
+ }
+ sdb2.ApplyFlatDiffForCommit(diff)
+
+ require.Equal(t, code, sdb2.GetCode(addr))
+ require.Equal(t, codeHash, sdb2.GetCodeHash(addr))
+}
+
+// ---- NewWithFlatBase ----
+
+func TestNewWithFlatBase_PropagatesReaderError(t *testing.T) {
+ // Kills: replace-return-value mutation on `return nil, err` (line 2306).
+ db := NewDatabaseForTesting()
+ // Root is not present in the DB, so the underlying New() returns an error
+ // when opening the trie reader.
+ badRoot := common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
+ sdb, err := NewWithFlatBase(badRoot, db, nil)
+ require.Error(t, err, "bad root must surface the reader error")
+ require.Nil(t, sdb)
+}
+
+// ---- WasStorageSlotRead ----
+
+func TestWasStorageSlotRead_AddrNotLoadedReturnsFalse(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ missing := common.HexToAddress("0xws1")
+ require.False(t, sdb.WasStorageSlotRead(missing, common.HexToHash("0x01")))
+}
+
+func TestWasStorageSlotRead_SlotNotReadReturnsFalse(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xws2")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(1), 0)
+ require.False(t, sdb.WasStorageSlotRead(addr, common.HexToHash("0x01")))
+}
+
+// ---- PropagateReadsTo ----
+
+func TestPropagateReadsTo_LoadsAddrIntoDst(t *testing.T) {
+ // Kills: removal of `dst.GetBalance(addr)` call (line 2494). Assertion
+ // inspects dst.stateObjects directly BEFORE issuing any read that would
+ // incidentally populate it.
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xprop1")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(1), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ src, err := New(root, db)
+ require.NoError(t, err)
+ dst, err := New(root, db)
+ require.NoError(t, err)
+
+ src.GetBalance(addr)
+ src.PropagateReadsTo(dst)
+
+ _, ok := dst.stateObjects[addr]
+ require.True(t, ok, "PropagateReadsTo must populate dst.stateObjects via GetBalance")
+}
+
+func TestPropagateReadsTo_LoadsStorageSlotsIntoDst(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ addr := common.HexToAddress("0xprop2")
+ slot := common.HexToHash("0xcaffe")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(1), 0)
+ sdb.SetState(addr, slot, common.HexToHash("0xbadc0de"))
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ src, err := New(root, db)
+ require.NoError(t, err)
+ dst, err := New(root, db)
+ require.NoError(t, err)
+
+ src.GetBalance(addr)
+ src.GetState(addr, slot)
+ src.PropagateReadsTo(dst)
+
+ dstObj, ok := dst.stateObjects[addr]
+ require.True(t, ok)
+ _, slotLoaded := dstObj.originStorage[slot]
+ require.True(t, slotLoaded, "origin slot must propagate to dst")
+}
diff --git a/core/state/statedb_pipeline_test.go b/core/state/statedb_pipeline_test.go
new file mode 100644
index 0000000000..7fcf7fc9d4
--- /dev/null
+++ b/core/state/statedb_pipeline_test.go
@@ -0,0 +1,854 @@
+package state
+
+import (
+ "testing"
+
+ "github.com/holiman/uint256"
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/rawdb"
+ "github.com/ethereum/go-ethereum/core/stateless"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/triedb"
+ "github.com/ethereum/go-ethereum/triedb/pathdb"
+)
+
+func TestWasStorageSlotRead(t *testing.T) {
+ db := NewDatabase(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil), nil)
+ sdb, _ := New(types.EmptyRootHash, db)
+
+ addr := common.HexToAddress("0x1234")
+ slot := common.HexToHash("0xabcd")
+
+ // Slot not read yet
+ if sdb.WasStorageSlotRead(addr, slot) {
+ t.Error("slot should not be marked as read before any access")
+ }
+
+ // Create an account and read its storage
+ sdb.CreateAccount(addr)
+ sdb.SetNonce(addr, 1, 0)
+ sdb.Finalise(false)
+
+ // Read the slot
+ sdb.GetState(addr, slot)
+
+ // Now it should be marked as read
+ if !sdb.WasStorageSlotRead(addr, slot) {
+ t.Error("slot should be marked as read after GetState")
+ }
+
+ // A different slot should not be marked
+ otherSlot := common.HexToHash("0x5678")
+ if sdb.WasStorageSlotRead(addr, otherSlot) {
+ t.Error("other slot should not be marked as read")
+ }
+
+ // A different address should not be marked
+ otherAddr := common.HexToAddress("0x5678")
+ if sdb.WasStorageSlotRead(otherAddr, slot) {
+ t.Error("other address should not be marked as read")
+ }
+}
+
+func TestFlatDiffOverlay_ReadThrough(t *testing.T) {
+ // Create a base state with an account
+ db := NewDatabase(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil), nil)
+ sdb, _ := New(types.EmptyRootHash, db)
+
+ baseAddr := common.HexToAddress("0xbase")
+ sdb.CreateAccount(baseAddr)
+ sdb.SetNonce(baseAddr, 1, 0)
+ sdb.SetBalance(baseAddr, uint256.NewInt(100), 0)
+ root, _, _ := sdb.CommitWithUpdate(0, false, false)
+
+ // Create a FlatDiff with a new account
+ overlayAddr := common.HexToAddress("0xoverlay")
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ overlayAddr: {
+ Nonce: 42,
+ Balance: uint256.NewInt(200),
+ Root: types.EmptyRootHash,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ ReadStorage: make(map[common.Address][]common.Hash),
+ NonExistentReads: nil,
+ }
+
+ // Create StateDB with FlatDiff overlay
+ overlayDB, err := NewWithFlatBase(root, db, diff)
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // Should see the overlay account
+ if overlayDB.GetNonce(overlayAddr) != 42 {
+ t.Errorf("expected nonce 42 for overlay addr, got %d", overlayDB.GetNonce(overlayAddr))
+ }
+
+ // Should still see the base account
+ if overlayDB.GetNonce(baseAddr) != 1 {
+ t.Errorf("expected nonce 1 for base addr, got %d", overlayDB.GetNonce(baseAddr))
+ }
+}
+
+func TestCommitSnapshot_CapturesWrites(t *testing.T) {
+ db := NewDatabase(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil), nil)
+ sdb, _ := New(types.EmptyRootHash, db)
+
+ addr := common.HexToAddress("0x1234")
+ sdb.CreateAccount(addr)
+ sdb.SetNonce(addr, 10, 0)
+ sdb.SetBalance(addr, uint256.NewInt(500), 0)
+
+ slot := common.HexToHash("0xaaaa")
+ sdb.SetState(addr, slot, common.HexToHash("0xbbbb"))
+
+ diff := sdb.CommitSnapshot(false)
+
+ // Verify account is captured
+ acct, ok := diff.Accounts[addr]
+ if !ok {
+ t.Fatal("account not captured in FlatDiff")
+ }
+ if acct.Nonce != 10 {
+ t.Errorf("expected nonce 10, got %d", acct.Nonce)
+ }
+
+ // Verify storage is captured
+ slots, ok := diff.Storage[addr]
+ if !ok {
+ t.Fatal("storage not captured in FlatDiff")
+ }
+ if slots[slot] != common.HexToHash("0xbbbb") {
+ t.Errorf("expected slot value 0xbbbb, got %x", slots[slot])
+ }
+}
+
+func TestFlatDiffOverlay_DestructedAccountReturnsNil(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xdead01")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(999), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // FlatDiff marks account as destructed but does NOT add it to Accounts.
+ diff := &FlatDiff{
+ Accounts: make(map[common.Address]types.StateAccount),
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: map[common.Address]struct{}{addr: {}},
+ Code: make(map[common.Hash][]byte),
+ }
+
+ overlayDB, err := NewWithFlatBase(root, db, diff)
+ require.NoError(t, err)
+
+ require.False(t, overlayDB.Exist(addr), "destructed account should not exist")
+ require.True(t, overlayDB.GetBalance(addr).IsZero(), "destructed account balance should be zero")
+}
+
+func TestFlatDiffOverlay_DestructAndResurrect(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xdead02")
+ slot := common.HexToHash("0x01")
+ sdb.CreateAccount(addr)
+ sdb.SetNonce(addr, 5, 0)
+ sdb.SetState(addr, slot, common.HexToHash("0xbeef"))
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // FlatDiff has addr in BOTH Destructs and Accounts (destruct + resurrect with new nonce).
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 10,
+ Balance: uint256.NewInt(0),
+ Root: types.EmptyRootHash,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: map[common.Address]struct{}{addr: {}},
+ Code: make(map[common.Hash][]byte),
+ }
+
+ overlayDB, err := NewWithFlatBase(root, db, diff)
+ require.NoError(t, err)
+
+ // The account should be resurrected with the new nonce from FlatDiff.Accounts.
+ require.Equal(t, uint64(10), overlayDB.GetNonce(addr))
+ require.Equal(t, common.Hash{}, overlayDB.GetState(addr, slot),
+ "destruct+resurrect FlatDiff must not expose pre-destruction storage")
+}
+
+func TestTrieOnlyReader_SkipsFlatReaders(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xacc001")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(42), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // Create StateDB via NewTrieOnly — reads go through trie, not flat/snapshot.
+ trieDB, err := NewTrieOnly(root, db)
+ require.NoError(t, err)
+
+ // Verify trie reader returns correct data.
+ require.Equal(t, uint256.NewInt(42), trieDB.GetBalance(addr))
+
+ // Attach a witness and modify the account via a fresh trie-only StateDB.
+ // After IntermediateRoot, the witness should capture trie nodes (non-empty
+ // State map). With flat readers the trie is never walked, so the witness
+ // would remain empty.
+ trieDB2, err := NewTrieOnly(root, db)
+ require.NoError(t, err)
+
+ witness := &stateless.Witness{
+ Headers: []*types.Header{{}},
+ Codes: make(map[string]struct{}),
+ State: make(map[string]struct{}),
+ }
+ trieDB2.SetWitness(witness)
+
+ // Modify the account so that IntermediateRoot walks the trie and collects
+ // witness nodes from the account trie.
+ trieDB2.SetBalance(addr, uint256.NewInt(99), 0)
+ trieDB2.IntermediateRoot(false)
+
+ require.NotEmpty(t, witness.State, "witness should capture trie nodes when using trie-only reader")
+}
+
+func TestNewTrieOnly_ReadsCorrectData(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr1 := common.HexToAddress("0xacc101")
+ addr2 := common.HexToAddress("0xacc102")
+ addr3 := common.HexToAddress("0xacc103")
+
+ sdb.CreateAccount(addr1)
+ sdb.SetBalance(addr1, uint256.NewInt(100), 0)
+ sdb.SetNonce(addr1, 1, 0)
+
+ sdb.CreateAccount(addr2)
+ sdb.SetBalance(addr2, uint256.NewInt(200), 0)
+ sdb.SetNonce(addr2, 5, 0)
+ sdb.SetCode(addr2, []byte{0x60, 0x00, 0x60, 0x00}, 0)
+
+ sdb.CreateAccount(addr3)
+ sdb.SetBalance(addr3, uint256.NewInt(300), 0)
+ slot := common.HexToHash("0xaa01")
+ sdb.SetState(addr3, slot, common.HexToHash("0xbb01"))
+
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // Create via NewTrieOnly and verify all data.
+ trieDB, err := NewTrieOnly(root, db)
+ require.NoError(t, err)
+
+ require.Equal(t, uint256.NewInt(100), trieDB.GetBalance(addr1))
+ require.Equal(t, uint64(1), trieDB.GetNonce(addr1))
+
+ require.Equal(t, uint256.NewInt(200), trieDB.GetBalance(addr2))
+ require.Equal(t, uint64(5), trieDB.GetNonce(addr2))
+ require.Equal(t, crypto.Keccak256Hash([]byte{0x60, 0x00, 0x60, 0x00}), trieDB.GetCodeHash(addr2))
+ require.Equal(t, []byte{0x60, 0x00, 0x60, 0x00}, trieDB.GetCode(addr2))
+
+ require.Equal(t, uint256.NewInt(300), trieDB.GetBalance(addr3))
+ require.Equal(t, common.HexToHash("0xbb01"), trieDB.GetState(addr3, slot))
+}
+
+func TestPropagateReadsTo_AccountsAndStorage(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr1 := common.HexToAddress("0xaa0001")
+ addr2 := common.HexToAddress("0xaa0002")
+ slot1 := common.HexToHash("0xcc0001")
+ slot2 := common.HexToHash("0xcc0002")
+
+ sdb.CreateAccount(addr1)
+ sdb.SetBalance(addr1, uint256.NewInt(111), 0)
+ sdb.SetState(addr1, slot1, common.HexToHash("0xdd0001"))
+ sdb.SetState(addr1, slot2, common.HexToHash("0xdd0002"))
+
+ sdb.CreateAccount(addr2)
+ sdb.SetBalance(addr2, uint256.NewInt(222), 0)
+
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // Create src and dst StateDBs at the same root.
+ src, err := New(root, db)
+ require.NoError(t, err)
+ dst, err := New(root, db)
+ require.NoError(t, err)
+
+ // Read accounts and storage on src.
+ src.GetBalance(addr1)
+ src.GetBalance(addr2)
+ src.GetState(addr1, slot1)
+ src.GetState(addr1, slot2)
+
+ // Propagate reads from src to dst.
+ src.PropagateReadsTo(dst)
+
+ // dst should now have the accounts and storage in its stateObjects
+ // (populated by PropagateReadsTo calling GetBalance/GetState on dst).
+ require.Equal(t, uint256.NewInt(111), dst.GetBalance(addr1))
+ require.Equal(t, uint256.NewInt(222), dst.GetBalance(addr2))
+ require.Equal(t, common.HexToHash("0xdd0001"), dst.GetState(addr1, slot1))
+ require.Equal(t, common.HexToHash("0xdd0002"), dst.GetState(addr1, slot2))
+}
+
+func TestCommitSnapshot_CapturesDestructs(t *testing.T) {
+ db := NewDatabaseForTesting()
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xdestruct01")
+ sdb.CreateAccount(addr)
+ sdb.SetBalance(addr, uint256.NewInt(500), 0)
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // Create a new StateDB at the committed root and self-destruct the account.
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+
+ sdb2.SelfDestruct(addr)
+ diff := sdb2.CommitSnapshot(false)
+
+ _, destructed := diff.Destructs[addr]
+ require.True(t, destructed, "self-destructed account should appear in diff.Destructs")
+}
+
+// TestPrefetchRoot_FlatDiffAccountUsesCommittedRoot verifies that accounts
+// loaded from FlatDiff get their prefetchRoot set to the committed parent's
+// storage root, not the FlatDiff's storage root. This is critical for
+// pipelined SRC: the prefetcher's NodeReader is opened at the committed
+// parent root (grandparent), so it can only resolve trie nodes for that
+// state's storage root. Using FlatDiff's root (block N's post-state) would
+// cause "Unexpected trie node" hash mismatches.
+func TestPrefetchRoot_FlatDiffAccountUsesCommittedRoot(t *testing.T) {
+ db := NewDatabase(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil), nil)
+
+ // --- Set up a committed state with a contract that has storage ---
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xcontract")
+ sdb.CreateAccount(addr)
+ sdb.SetNonce(addr, 1, 0)
+ sdb.SetState(addr, common.HexToHash("0x01"), common.HexToHash("0xaa"))
+ sdb.Finalise(false)
+
+ committedRoot, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // Read back the committed account to get its storage root.
+ committedSDB, err := New(committedRoot, db)
+ require.NoError(t, err)
+ committedObj := committedSDB.getStateObject(addr)
+ require.NotNil(t, committedObj)
+ committedStorageRoot := committedObj.data.Root
+ require.NotEqual(t, types.EmptyRootHash, committedStorageRoot, "committed account should have non-empty storage root")
+
+ // --- Simulate block N: modify the contract's storage and extract FlatDiff ---
+ sdb2, err := New(committedRoot, db)
+ require.NoError(t, err)
+ sdb2.SetState(addr, common.HexToHash("0x02"), common.HexToHash("0xbb")) // new slot
+ sdb2.Finalise(false)
+ diff := sdb2.CommitSnapshot(false)
+
+ // The FlatDiff account has block N's storage root (different from committed).
+ flatDiffAcct, ok := diff.Accounts[addr]
+ require.True(t, ok, "contract should be in FlatDiff")
+ flatDiffStorageRoot := flatDiffAcct.Root
+ // The FlatDiff root is the account's root BEFORE IntermediateRoot (i.e.,
+ // CommitSnapshot doesn't hash — it captures the current data.Root). So it
+ // equals the committed root here. But the key point is that getPrefetchRoot
+ // returns the committed root regardless.
+
+ // --- Create a pipelined StateDB with FlatDiff overlay ---
+ overlayDB, err := NewWithFlatBase(committedRoot, db, diff)
+ require.NoError(t, err)
+
+ // Load the account from FlatDiff
+ obj := overlayDB.getStateObject(addr)
+ require.NotNil(t, obj)
+
+ // Verify origin/data roots come from FlatDiff
+ require.Equal(t, flatDiffStorageRoot, obj.data.Root, "data.Root should be from FlatDiff")
+
+ // Verify prefetchRoot was set to the committed storage root
+ require.Equal(t, committedStorageRoot, obj.prefetchRoot, "prefetchRoot should be the committed parent's storage root")
+
+ // Verify getPrefetchRoot returns the committed root (not data.Root)
+ require.Equal(t, committedStorageRoot, obj.getPrefetchRoot(), "getPrefetchRoot should return the committed storage root")
+}
+
+// TestPrefetchRoot_NormalAccountFallsBackToDataRoot verifies that accounts
+// loaded from the committed state (not FlatDiff) have prefetchRoot=zero,
+// and getPrefetchRoot falls back to data.Root.
+func TestPrefetchRoot_NormalAccountFallsBackToDataRoot(t *testing.T) {
+ db := NewDatabase(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil), nil)
+
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xnormal")
+ sdb.CreateAccount(addr)
+ sdb.SetNonce(addr, 1, 0)
+ sdb.SetState(addr, common.HexToHash("0x01"), common.HexToHash("0xaa"))
+ sdb.Finalise(false)
+
+ root, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // Load the account normally (no FlatDiff)
+ sdb2, err := New(root, db)
+ require.NoError(t, err)
+
+ obj := sdb2.getStateObject(addr)
+ require.NotNil(t, obj)
+
+ // prefetchRoot should be zero (not set for non-FlatDiff accounts)
+ require.Equal(t, common.Hash{}, obj.prefetchRoot, "prefetchRoot should be zero for non-FlatDiff accounts")
+
+ // getPrefetchRoot should fall back to data.Root
+ require.Equal(t, obj.data.Root, obj.getPrefetchRoot(), "getPrefetchRoot should fall back to data.Root")
+}
+
+// TestPrefetchRoot_NewAccountInFlatDiff verifies that an account created in
+// block N (exists in FlatDiff but not in committed state) gets the empty
+// storage root as its prefetch root since there's nothing to prefetch at the
+// committed parent root.
+func TestPrefetchRoot_NewAccountInFlatDiff(t *testing.T) {
+ db := NewDatabase(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil), nil)
+
+ // Commit an empty state
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ committedRoot, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // FlatDiff with a new account that doesn't exist in committed state
+ newAddr := common.HexToAddress("0xnew")
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ newAddr: {
+ Nonce: 1,
+ Balance: uint256.NewInt(100),
+ Root: crypto.Keccak256Hash([]byte("fake-storage-root")), // non-empty root
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ ReadStorage: make(map[common.Address][]common.Hash),
+ NonExistentReads: nil,
+ }
+
+ overlayDB, err := NewWithFlatBase(committedRoot, db, diff)
+ require.NoError(t, err)
+
+ obj := overlayDB.getStateObject(newAddr)
+ require.NotNil(t, obj)
+
+ // Account is new (not in committed state), so storage prefetching must not
+ // use the FlatDiff account's post-state root with the committed-parent
+ // reader. The empty storage root makes those prefetches a no-op.
+ require.Equal(t, types.EmptyRootHash, obj.prefetchRoot, "prefetchRoot should be empty root for new accounts not in committed state")
+
+ // getPrefetchRoot returns the committed-parent-compatible root, not the
+ // FlatDiff post-state storage root.
+ require.Equal(t, types.EmptyRootHash, obj.getPrefetchRoot(), "getPrefetchRoot should return empty root for new accounts")
+}
+
+func TestSnapshotDirtyStorageSlots_UsesCommittedPrefetchRootForFlatDiff(t *testing.T) {
+ db := NewDatabase(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil), nil)
+
+ addr := common.HexToAddress("0xflat")
+ slot := common.HexToHash("0x01")
+
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ sdb.CreateAccount(addr)
+ sdb.SetNonce(addr, 1, 0)
+ sdb.SetState(addr, slot, common.HexToHash("0xaa"))
+ sdb.Finalise(false)
+
+ committedRoot, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ committedDB, err := New(committedRoot, db)
+ require.NoError(t, err)
+ committedObj := committedDB.getStateObject(addr)
+ require.NotNil(t, committedObj)
+ committedStorageRoot := committedObj.data.Root
+ require.NotEqual(t, types.EmptyRootHash, committedStorageRoot)
+
+ postStorageRoot := crypto.Keccak256Hash([]byte("block-n-post-storage-root"))
+ require.NotEqual(t, committedStorageRoot, postStorageRoot)
+
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 1,
+ Balance: uint256.NewInt(0),
+ Root: postStorageRoot,
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ ReadStorage: make(map[common.Address][]common.Hash),
+ }
+
+ overlayDB, err := NewWithFlatBase(committedRoot, db, diff)
+ require.NoError(t, err)
+ overlayDB.SetState(addr, common.HexToHash("0x02"), common.HexToHash("0xbb"))
+
+ slots := overlayDB.snapshotDirtyStorageSlots()
+ require.Len(t, slots, 1)
+ require.Equal(t, addr, slots[0].addr)
+ require.Equal(t, committedStorageRoot, slots[0].root)
+ require.NotEqual(t, postStorageRoot, slots[0].root)
+}
+
+func TestSnapshotDirtyStorageSlots_SkipsNewFlatDiffAccount(t *testing.T) {
+ db := NewDatabase(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil), nil)
+
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ committedRoot, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xnew")
+ diff := &FlatDiff{
+ Accounts: map[common.Address]types.StateAccount{
+ addr: {
+ Nonce: 1,
+ Balance: uint256.NewInt(100),
+ Root: crypto.Keccak256Hash([]byte("block-n-post-storage-root")),
+ CodeHash: types.EmptyCodeHash.Bytes(),
+ },
+ },
+ Storage: make(map[common.Address]map[common.Hash]common.Hash),
+ Destructs: make(map[common.Address]struct{}),
+ Code: make(map[common.Hash][]byte),
+ ReadStorage: make(map[common.Address][]common.Hash),
+ }
+
+ overlayDB, err := NewWithFlatBase(committedRoot, db, diff)
+ require.NoError(t, err)
+ overlayDB.SetState(addr, common.HexToHash("0x01"), common.HexToHash("0xbb"))
+
+ require.Empty(t, overlayDB.snapshotDirtyStorageSlots())
+}
+
+// TestPrefetchRoot_DeepCopyPreserves verifies that stateObject.deepCopy
+// preserves the prefetchRoot field, which is important for StateDB.Copy()
+// used by the block-level prefetcher.
+func TestPrefetchRoot_DeepCopyPreserves(t *testing.T) {
+ db := NewDatabase(triedb.NewDatabase(rawdb.NewMemoryDatabase(), nil), nil)
+
+ sdb, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+
+ addr := common.HexToAddress("0xcopy")
+ sdb.CreateAccount(addr)
+ sdb.SetNonce(addr, 1, 0)
+ sdb.SetState(addr, common.HexToHash("0x01"), common.HexToHash("0xaa"))
+ sdb.Finalise(false)
+
+ committedRoot, _, err := sdb.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // Simulate a FlatDiff account with a different storage root
+ sdb2, err := New(committedRoot, db)
+ require.NoError(t, err)
+ sdb2.SetState(addr, common.HexToHash("0x02"), common.HexToHash("0xbb"))
+ sdb2.Finalise(false)
+ diff := sdb2.CommitSnapshot(false)
+
+ // Create overlay StateDB and load account
+ overlayDB, err := NewWithFlatBase(committedRoot, db, diff)
+ require.NoError(t, err)
+ obj := overlayDB.getStateObject(addr)
+ require.NotNil(t, obj)
+ require.NotEqual(t, common.Hash{}, obj.prefetchRoot)
+
+ // Copy the StateDB and verify prefetchRoot is preserved
+ copiedDB := overlayDB.Copy()
+ copiedObj := copiedDB.getStateObject(addr)
+ require.NotNil(t, copiedObj)
+ require.Equal(t, obj.prefetchRoot, copiedObj.prefetchRoot, "deepCopy should preserve prefetchRoot")
+ require.Equal(t, obj.getPrefetchRoot(), copiedObj.getPrefetchRoot(), "getPrefetchRoot should match after deepCopy")
+}
+
+// TestPipelinedSRC_RootParity_NewVsTrieOnly is a consensus-critical parity
+// check for mitigation (2.5): when the SRC goroutine runs without producing a
+// witness, openSRCStateDB uses state.New (multi-reader, flat-reader-eligible)
+// instead of state.NewTrieOnly. Both reader paths must produce byte-identical
+// state roots from the same FlatDiff applied at the same parent root —
+// otherwise consensus would split between witness-producing and witness-off
+// nodes.
+//
+// The FlatDiff exercises every shape that touches origin reads:
+// - balance/nonce mutation on existing account
+// - storage zero-write (slot deletion) and fresh storage write
+// - pure self-destruct (no resurrection)
+// - self-destruct followed by resurrection with new state
+// - code deploy on a new account
+// - read-only access to an existing account (flatDiff.ReadSet)
+// - non-existent address probe (flatDiff.NonExistentReads)
+//
+// Uses path scheme so state.New actually wires a flat reader (pathdb
+// StateReader), making the trie-only vs multi-reader distinction
+// observable. Under hash scheme without a snapshot the multi-reader
+// degenerates to trie-only and the test would be trivially true.
+func TestPipelinedSRC_RootParity_NewVsTrieOnly(t *testing.T) {
+ disk := rawdb.NewMemoryDatabase()
+ defer disk.Close()
+ tdb := triedb.NewDatabase(disk, &triedb.Config{PathDB: pathdb.Defaults})
+ defer tdb.Close()
+ sdb := NewDatabase(tdb, nil)
+
+ addrMutate := common.HexToAddress("0xa1") // existing → balance/nonce mutation
+ addrZeroSlot := common.HexToAddress("0xa2") // existing storage → zero a slot, write a fresh slot
+ addrPureDest := common.HexToAddress("0xa3") // existing → pure self-destruct
+ addrResurrect := common.HexToAddress("0xa4") // existing → destruct + resurrect with new state
+ addrReadOnly := common.HexToAddress("0xa5") // existing → read-only access only
+ addrCodeNew := common.HexToAddress("0xa6") // new → balance + code deploy
+ addrNonExist := common.HexToAddress("0xa7") // never exists → probed only
+
+ slotZeroed := common.HexToHash("0x01")
+ slotFresh := common.HexToHash("0x02")
+ slotResurrectOld := common.HexToHash("0x03")
+ slotResurrectNew := common.HexToHash("0x04")
+ slotReadOnly := common.HexToHash("0x05")
+
+ // --- Build initial committed state ---
+ initial, err := New(types.EmptyRootHash, sdb)
+ require.NoError(t, err)
+
+ initial.CreateAccount(addrMutate)
+ initial.SetBalance(addrMutate, uint256.NewInt(100), 0)
+ initial.SetNonce(addrMutate, 1, 0)
+
+ initial.CreateAccount(addrZeroSlot)
+ initial.SetBalance(addrZeroSlot, uint256.NewInt(50), 0)
+ initial.SetState(addrZeroSlot, slotZeroed, common.HexToHash("0xbeef"))
+
+ initial.CreateAccount(addrPureDest)
+ initial.SetBalance(addrPureDest, uint256.NewInt(200), 0)
+
+ initial.CreateAccount(addrResurrect)
+ initial.SetBalance(addrResurrect, uint256.NewInt(300), 0)
+ initial.SetNonce(addrResurrect, 5, 0)
+ initial.SetCode(addrResurrect, []byte{0x60, 0x01}, 0)
+ initial.SetState(addrResurrect, slotResurrectOld, common.HexToHash("0xbbbb"))
+
+ initial.CreateAccount(addrReadOnly)
+ initial.SetBalance(addrReadOnly, uint256.NewInt(400), 0)
+ initial.SetState(addrReadOnly, slotReadOnly, common.HexToHash("0xdddd"))
+
+ parentRoot, _, err := initial.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+ require.NoError(t, tdb.Commit(parentRoot, false))
+
+ // Confirm the multi-reader path will actually wire a flat reader at
+ // parentRoot. state.New silently falls back to trie-only if
+ // triedb.StateReader errors, which would let the parity assertion below
+ // pass without exercising the mitigation. Asserting StateReader resolves
+ // makes the test fail loudly if path-mode setup ever regresses.
+ if _, err := tdb.StateReader(parentRoot); err != nil {
+ t.Fatalf("path-scheme StateReader unavailable at parentRoot — "+
+ "multi-reader would silently fall back to trie-only, defeating the parity test: %v", err)
+ }
+
+ // --- Simulate block N execution at parentRoot to produce a FlatDiff ---
+ exec, err := New(parentRoot, sdb)
+ require.NoError(t, err)
+
+ exec.SetBalance(addrMutate, uint256.NewInt(150), 0)
+ exec.SetNonce(addrMutate, 2, 0)
+
+ exec.SetState(addrZeroSlot, slotZeroed, common.Hash{})
+ exec.SetState(addrZeroSlot, slotFresh, common.HexToHash("0x1234"))
+
+ exec.SelfDestruct(addrPureDest)
+
+ // Destruct in one tx, resurrect in the next. Finalise between the two so
+ // the destructed object lands in stateObjectsDestruct before the new
+ // CreateAccount replaces stateObjects[addr]; without this, the new object
+ // overwrites the destruct trail and CommitSnapshot would emit only an
+ // Accounts entry, not the destruct+resurrect shape we want to exercise.
+ exec.SelfDestruct(addrResurrect)
+ exec.Finalise(false)
+ exec.CreateAccount(addrResurrect)
+ exec.SetBalance(addrResurrect, uint256.NewInt(999), 0)
+ exec.SetNonce(addrResurrect, 1, 0)
+ exec.SetCode(addrResurrect, []byte{0x60, 0x02}, 0)
+ exec.SetState(addrResurrect, slotResurrectNew, common.HexToHash("0xffff"))
+
+ exec.CreateAccount(addrCodeNew)
+ exec.SetBalance(addrCodeNew, uint256.NewInt(77), 0)
+ exec.SetCode(addrCodeNew, []byte{0x60, 0x03}, 0)
+
+ exec.GetBalance(addrReadOnly)
+ exec.GetState(addrReadOnly, slotReadOnly)
+
+ exec.GetBalance(addrNonExist)
+
+ flatDiff := exec.CommitSnapshot(false)
+
+ // Sanity: FlatDiff captured every shape we exercise.
+ require.Contains(t, flatDiff.Accounts, addrMutate)
+ require.Contains(t, flatDiff.Accounts, addrZeroSlot)
+ require.Contains(t, flatDiff.Destructs, addrPureDest)
+ require.Contains(t, flatDiff.Destructs, addrResurrect)
+ require.Contains(t, flatDiff.Accounts, addrResurrect)
+ require.Contains(t, flatDiff.Accounts, addrCodeNew)
+ zeroedSlots, ok := flatDiff.Storage[addrZeroSlot]
+ require.True(t, ok)
+ require.Equal(t, common.Hash{}, zeroedSlots[slotZeroed])
+ require.Equal(t, common.HexToHash("0x1234"), zeroedSlots[slotFresh])
+
+ // --- Path A: NewTrieOnly (witness-producing path) ---
+ trieOnlyDB, err := NewTrieOnly(parentRoot, sdb)
+ require.NoError(t, err)
+ trieOnlyDB.ApplyFlatDiffForCommit(flatDiff)
+ rootTrieOnly := trieOnlyDB.IntermediateRoot(false)
+
+ // --- Path B: state.New (witness-off multi-reader path) ---
+ multiDB, err := New(parentRoot, sdb)
+ require.NoError(t, err)
+ multiDB.ApplyFlatDiffForCommit(flatDiff)
+ rootMulti := multiDB.IntermediateRoot(false)
+
+ // --- Path C: state.New + fast witness-off replay path ---
+ fastDB, err := New(parentRoot, sdb)
+ require.NoError(t, err)
+ fastDB.ApplyFlatDiffForCommitFast(flatDiff)
+ rootFast := fastDB.IntermediateRoot(false)
+
+ // --- Parity assertion: byte-identical state roots ---
+ require.Equal(t, rootTrieOnly, rootMulti,
+ "state root must be byte-identical between NewTrieOnly and state.New paths — "+
+ "any divergence is a consensus-splitting bug between witness-producing and witness-off nodes")
+ require.Equal(t, rootTrieOnly, rootFast,
+ "fast FlatDiff replay must produce the same root as the journaled SRC replay")
+
+ // Cross-check against direct execution of the same mutations
+ // on a fresh StateDB at parentRoot (no FlatDiff replay). This catches any
+ // hypothetical bug where ApplyFlatDiffForCommit produces a root that
+ // differs from the original execution.
+ direct, err := New(parentRoot, sdb)
+ require.NoError(t, err)
+ direct.SetBalance(addrMutate, uint256.NewInt(150), 0)
+ direct.SetNonce(addrMutate, 2, 0)
+ direct.SetState(addrZeroSlot, slotZeroed, common.Hash{})
+ direct.SetState(addrZeroSlot, slotFresh, common.HexToHash("0x1234"))
+ direct.SelfDestruct(addrPureDest)
+ direct.SelfDestruct(addrResurrect)
+ // Mirror the exec-path transaction boundary: Finalise after SelfDestruct
+ // so the destructed object is recorded in stateObjectsDestruct before the
+ // resurrection. Without this, the cross-check would diverge from the
+ // FlatDiff path because the FlatDiff captured a destruct+resurrect shape
+ // that only exists when there is a Finalise between the two operations.
+ direct.Finalise(false)
+ direct.CreateAccount(addrResurrect)
+ direct.SetBalance(addrResurrect, uint256.NewInt(999), 0)
+ direct.SetNonce(addrResurrect, 1, 0)
+ direct.SetCode(addrResurrect, []byte{0x60, 0x02}, 0)
+ direct.SetState(addrResurrect, slotResurrectNew, common.HexToHash("0xffff"))
+ direct.CreateAccount(addrCodeNew)
+ direct.SetBalance(addrCodeNew, uint256.NewInt(77), 0)
+ direct.SetCode(addrCodeNew, []byte{0x60, 0x03}, 0)
+ rootDirect := direct.IntermediateRoot(false)
+ require.Equal(t, rootDirect, rootTrieOnly,
+ "FlatDiff replay path must produce the same root as direct execution")
+}
+
+func TestApplyFlatDiffForCommitFast_PreservesParentStorageRootAfterOverlayExecution(t *testing.T) {
+ db := NewDatabaseForTesting()
+
+ addr := common.HexToAddress("0xoverlay-root")
+ slot := common.HexToHash("0x01")
+
+ initial, err := New(types.EmptyRootHash, db)
+ require.NoError(t, err)
+ initial.CreateAccount(addr)
+ initial.SetBalance(addr, uint256.NewInt(1), 0)
+ root0, _, err := initial.CommitWithUpdate(0, false, false)
+ require.NoError(t, err)
+
+ // Block N changes storage. The resulting FlatDiff is used as an overlay
+ // while block N+1 executes before root_N is available.
+ blockNExec, err := New(root0, db)
+ require.NoError(t, err)
+ blockNExec.SetState(addr, slot, common.HexToHash("0xdead"))
+ diffN := blockNExec.CommitSnapshot(false)
+
+ blockNSRC, err := New(root0, db)
+ require.NoError(t, err)
+ blockNSRC.ApplyFlatDiffForCommit(diffN)
+ rootN, _, err := blockNSRC.CommitWithUpdate(1, false, false)
+ require.NoError(t, err)
+
+ // Block N+1 sees block N's storage through the FlatDiff overlay, but its
+ // account data.Root is still rooted at root0 because CommitSnapshot does
+ // not hash storage. A fast replay at rootN must preserve rootN's account
+ // storage root instead of copying this stale metadata root.
+ blockN1Exec, err := NewWithFlatBase(root0, db, diffN)
+ require.NoError(t, err)
+ require.Equal(t, common.HexToHash("0xdead"), blockN1Exec.GetState(addr, slot))
+ blockN1Exec.SetBalance(addr, uint256.NewInt(2), 0)
+ diffN1 := blockN1Exec.CommitSnapshot(false)
+ require.Contains(t, diffN1.Accounts, addr)
+ require.NotContains(t, diffN1.Storage, addr)
+
+ direct, err := New(rootN, db)
+ require.NoError(t, err)
+ direct.SetBalance(addr, uint256.NewInt(2), 0)
+ rootDirect := direct.IntermediateRoot(false)
+
+ slow, err := New(rootN, db)
+ require.NoError(t, err)
+ slow.ApplyFlatDiffForCommit(diffN1)
+ rootSlow := slow.IntermediateRoot(false)
+
+ fast, err := New(rootN, db)
+ require.NoError(t, err)
+ fast.ApplyFlatDiffForCommitFast(diffN1)
+ rootFast := fast.IntermediateRoot(false)
+
+ require.Equal(t, rootDirect, rootSlow)
+ require.Equal(t, rootSlow, rootFast,
+ "fast replay must preserve the parent root's storage trie when FlatDiff account metadata came from an overlay execution")
+}
diff --git a/core/state/trie_prefetcher.go b/core/state/trie_prefetcher.go
index 062b5c5196..0a4653dd4e 100644
--- a/core/state/trie_prefetcher.go
+++ b/core/state/trie_prefetcher.go
@@ -103,9 +103,71 @@ func newTriePrefetcher(db Database, root common.Hash, namespace string, noreads
}
}
+// snapshotWarmNodes collects the trie nodes accumulated by every subfetcher
+// into a list of (owner, path -> blob) maps. It MUST be called only after a
+// synchronous termination has returned — once subfetcher goroutines have exited
+// their loops, their tries are quiescent and trie.Witness() can be read safely.
+// Callers sequence this between a synchronous stop and report so the
+// prefetcher's lifecycle remains intact. The only requirement is that all
+// subfetcher goroutines have exited before collection starts.
+//
+// Returns nil when called on a nil receiver, an already-closed prefetcher
+// without subfetchers, or when no subfetcher loaded any nodes — callers must
+// tolerate a nil result. The stats return describes the subfetcher count and
+// warm-node mix observed while collecting the maps.
+func (p *triePrefetcher) snapshotWarmNodes() ([]TrieWarmNodes, PrefetcherSnapshotStats) {
+ var stats PrefetcherSnapshotStats
+ if p == nil {
+ return nil, stats
+ }
+ p.lock.RLock()
+ defer p.lock.RUnlock()
+
+ stats.Fetchers = len(p.fetchers)
+ if len(p.fetchers) == 0 {
+ return nil, stats
+ }
+ out := make([]TrieWarmNodes, 0, len(p.fetchers))
+ for _, fetcher := range p.fetchers {
+ nodes := fetcher.warmNodes()
+ if len(nodes) == 0 {
+ continue
+ }
+ stats.LoadedFetchers++
+ var bytes int
+ for _, blob := range nodes {
+ bytes += len(blob)
+ }
+ if fetcher.owner == (common.Hash{}) {
+ stats.AccountFetchers++
+ stats.AccountNodes += len(nodes)
+ stats.AccountBytes += bytes
+ } else {
+ stats.StorageFetchers++
+ stats.StorageNodes += len(nodes)
+ stats.StorageBytes += bytes
+ }
+ out = append(out, TrieWarmNodes{Owner: fetcher.owner, Nodes: nodes})
+ }
+ return out, stats
+}
+
+// fetcherCount returns the number of subfetchers currently owned by the
+// prefetcher. It is safe to call after synchronous termination when reporting
+// wait-only detached prefetcher stats.
+func (p *triePrefetcher) fetcherCount() int {
+ if p == nil {
+ return 0
+ }
+ p.lock.RLock()
+ defer p.lock.RUnlock()
+ return len(p.fetchers)
+}
+
// terminate iterates over all the subfetchers and issues a termination request
-// to all of them. Depending on the async parameter, the method will either block
-// until all subfetchers spin down, or return immediately.
+// to all of them. Subfetchers finish queued work before exiting. Depending on
+// the async parameter, the method will either block until all subfetchers spin
+// down, or return immediately.
func (p *triePrefetcher) terminate(async bool) {
p.lock.Lock() // Lock for writing
defer p.lock.Unlock() // Ensure the lock is released after the function
@@ -342,10 +404,18 @@ func (sf *subfetcher) schedule(addrs []common.Address, slots []common.Hash, read
select {
case <-sf.term:
return errTerminated
+ case <-sf.stop:
+ return errTerminated
default:
}
// Append the tasks to the current queue
sf.lock.Lock()
+ select {
+ case <-sf.stop:
+ sf.lock.Unlock()
+ return errTerminated
+ default:
+ }
for _, addr := range addrs {
sf.tasks = append(sf.tasks, &subfetcherTask{read: read, addr: &addr})
}
@@ -394,7 +464,7 @@ func (sf *subfetcher) peek() Trie {
}
// terminate requests the subfetcher to stop accepting new tasks and spin down
-// as soon as everything is loaded. Depending on the async parameter, the method
+// once queued work is drained. Depending on the async parameter, the method
// will either block until all disk loads finish or return immediately.
func (sf *subfetcher) terminate(async bool) {
select {
@@ -408,6 +478,36 @@ func (sf *subfetcher) terminate(async bool) {
<-sf.term
}
+// warmNodes returns the (path -> blob) map of trie nodes this subfetcher has
+// loaded into its trie. It must be called only after the subfetcher's loop has
+// exited — synchronous termination provides that ordering by waiting on
+// <-sf.term. Returns nil if the trie was never opened (openTrie failed) or has
+// not loaded any nodes.
+func (sf *subfetcher) warmNodes() map[string][]byte {
+ if sf == nil || sf.trie == nil {
+ return nil
+ }
+ return sf.trie.Witness()
+}
+
+func (sf *subfetcher) prefetchAccounts(addresses []common.Address) bool {
+ sf.withIO(func() {
+ if err := sf.trie.PrefetchAccount(addresses); err != nil {
+ log.Error("Failed to prefetch accounts", "err", err)
+ }
+ })
+ return true
+}
+
+func (sf *subfetcher) prefetchStorage(slots [][]byte) bool {
+ sf.withIO(func() {
+ if err := sf.trie.PrefetchStorage(sf.addr, slots); err != nil {
+ log.Error("Failed to prefetch storage", "err", err)
+ }
+ })
+ return true
+}
+
// openTrie resolves the target trie from database for prefetching.
func (sf *subfetcher) openTrie() error {
// Open the verkle tree if the sub-fetcher is in verkle mode. Note, there is
@@ -513,26 +613,19 @@ func (sf *subfetcher) loop() {
slots = append(slots, key.Bytes())
}
}
- if len(addresses) != 0 {
- sf.withIO(func() {
- if err := sf.trie.PrefetchAccount(addresses); err != nil {
- log.Error("Failed to prefetch accounts", "err", err)
- }
- })
+ if len(addresses) != 0 && !sf.prefetchAccounts(addresses) {
+ return
}
- if len(slots) != 0 {
- sf.withIO(func() {
- if err := sf.trie.PrefetchStorage(sf.addr, slots); err != nil {
- log.Error("Failed to prefetch storage", "err", err)
- }
- })
+ if len(slots) != 0 && !sf.prefetchStorage(slots) {
+ return
}
case <-sf.stop:
- // Termination is requested, abort if no more tasks are pending. If
- // there are some, exhaust them first.
+ // Termination is requested. Drain queued work before exiting so the
+ // caller can rely on the prefetcher having completed all scheduled
+ // warming by the time synchronous termination returns.
sf.lock.Lock()
- done := sf.tasks == nil
+ done := len(sf.tasks) == 0
sf.lock.Unlock()
if done {
diff --git a/core/state/trie_prefetcher_test.go b/core/state/trie_prefetcher_test.go
index 87332a306d..a421e1a36d 100644
--- a/core/state/trie_prefetcher_test.go
+++ b/core/state/trie_prefetcher_test.go
@@ -71,6 +71,201 @@ func TestUseAfterTerminate(t *testing.T) {
}
}
+func TestDetachedPrefetcherLifecycle(t *testing.T) {
+ db := filledStateDB()
+ db.StartPrefetcher("detach-lifecycle", nil, nil)
+ if db.prefetcher == nil {
+ t.Fatal("expected StartPrefetcher to install a prefetcher")
+ }
+
+ detached := db.DetachPrefetcher()
+ if detached == nil {
+ t.Fatal("expected DetachPrefetcher to return a handle")
+ }
+ if db.prefetcher != nil {
+ t.Fatal("DetachPrefetcher left StateDB.prefetcher installed")
+ }
+ if second := db.DetachPrefetcher(); second != nil {
+ t.Fatal("second DetachPrefetcher returned a handle after prefetcher was detached")
+ }
+
+ stats := detached.Stop()
+ if stats.Fetchers == 0 {
+ t.Fatal("detached Stop reported zero fetchers; expected the account-trie prefetcher")
+ }
+ again := detached.Stop()
+ if again.Fetchers != 0 || again.Drain != 0 || again.Report != 0 {
+ t.Fatalf("second detached Stop returned non-zero stats: %+v", again)
+ }
+}
+
+func TestDetachedPrefetcherNilAndEmpty(t *testing.T) {
+ var nilDetached *DetachedPrefetcher
+ if stats := nilDetached.Stop(); stats.Fetchers != 0 || stats.Drain != 0 || stats.Report != 0 {
+ t.Fatalf("nil Stop returned non-zero stats: %+v", stats)
+ }
+ if input, stats := nilDetached.StopAndCollectWarmSnapshot(); input != nil || stats.Fetchers != 0 || stats.Drain != 0 || stats.Report != 0 {
+ t.Fatalf("nil StopAndCollectWarmSnapshot returned input=%v stats=%+v", input, stats)
+ }
+
+ empty := &DetachedPrefetcher{}
+ if stats := empty.Stop(); stats.Fetchers != 0 || stats.Drain != 0 || stats.Report != 0 {
+ t.Fatalf("empty Stop returned non-zero stats: %+v", stats)
+ }
+ if input, stats := empty.StopAndCollectWarmSnapshot(); input != nil || stats.Fetchers != 0 || stats.Drain != 0 || stats.Report != 0 {
+ t.Fatalf("empty StopAndCollectWarmSnapshot returned input=%v stats=%+v", input, stats)
+ }
+}
+
+func TestSubfetcherDrainTerminateKeepsQueuedTasks(t *testing.T) {
+ db := filledStateDB()
+ slot := common.HexToHash("aaa")
+ sf := &subfetcher{
+ db: db.db,
+ state: db.originalRoot,
+ root: db.originalRoot,
+ wake: make(chan struct{}, 1),
+ stop: make(chan struct{}),
+ term: make(chan struct{}),
+ seenReadAddr: make(map[common.Address]struct{}),
+ seenWriteAddr: make(map[common.Address]struct{}),
+ seenReadSlot: make(map[common.Hash]struct{}),
+ seenWriteSlot: make(map[common.Hash]struct{}),
+ tasks: []*subfetcherTask{{slot: &slot}},
+ }
+ sf.terminate(true)
+
+ if got := len(sf.tasks); got != 1 {
+ t.Fatalf("full-drain terminate changed queued task count to %d, want 1", got)
+ }
+}
+
+func TestTriePrefetcherDrainTerminateCompletesQueuedWork(t *testing.T) {
+ db := NewDatabaseForTesting()
+ tr := newBlockingPrefetchTrie()
+ t.Cleanup(tr.releaseBlockedPrefetch)
+ prefetcher := newTriePrefetcher(&blockingPrefetchDB{
+ Database: db,
+ triedb: db.TrieDB(),
+ trie: tr,
+ }, common.Hash{}, "drain-queued-work", false)
+ addr1 := common.HexToAddress("0x1")
+ addr2 := common.HexToAddress("0x2")
+
+ if err := prefetcher.prefetch(common.Hash{}, common.Hash{}, common.Address{}, []common.Address{addr1}, nil, false); err != nil {
+ t.Fatalf("first prefetch failed: %v", err)
+ }
+ select {
+ case <-tr.started:
+ case <-time.After(time.Second):
+ t.Fatalf("first prefetch did not start")
+ }
+ if err := prefetcher.prefetch(common.Hash{}, common.Hash{}, common.Address{}, []common.Address{addr2}, nil, false); err != nil {
+ t.Fatalf("second prefetch failed: %v", err)
+ }
+
+ done := make(chan struct{})
+ go func() {
+ prefetcher.terminate(false)
+ close(done)
+ }()
+ select {
+ case <-done:
+ t.Fatalf("full-drain terminate returned before in-flight account chunk completed")
+ case <-time.After(20 * time.Millisecond):
+ }
+ tr.releaseBlockedPrefetch()
+ select {
+ case <-done:
+ case <-time.After(time.Second):
+ t.Fatalf("full-drain terminate did not return after queued work completed")
+ }
+
+ if calls, items := tr.accountStats(); calls != 2 || items != 2 {
+ t.Fatalf("executed account prefetch calls/items = %d/%d, want 2/2", calls, items)
+ }
+ if err := prefetcher.prefetch(common.Hash{}, common.Hash{}, common.Address{}, []common.Address{addr2}, nil, false); err != errTerminated {
+ t.Fatalf("prefetch after terminate error = %v, want %v", err, errTerminated)
+ }
+}
+
+type blockingPrefetchDB struct {
+ Database
+ triedb *triedb.Database
+ trie *blockingPrefetchTrie
+}
+
+func (db *blockingPrefetchDB) OpenTrie(common.Hash) (Trie, error) {
+ return db.trie, nil
+}
+
+func (db *blockingPrefetchDB) OpenStorageTrie(common.Hash, common.Address, common.Hash, Trie) (Trie, error) {
+ return db.trie, nil
+}
+
+func (db *blockingPrefetchDB) TrieDB() *triedb.Database {
+ return db.triedb
+}
+
+type blockingPrefetchTrie struct {
+ Trie
+
+ started chan struct{}
+ release chan struct{}
+ once sync.Once
+ releaseOnce sync.Once
+
+ lock sync.Mutex
+ accountCalls int
+ accountItems int
+ storageCalls int
+ storageItems int
+}
+
+func newBlockingPrefetchTrie() *blockingPrefetchTrie {
+ return &blockingPrefetchTrie{
+ started: make(chan struct{}),
+ release: make(chan struct{}),
+ }
+}
+
+func (t *blockingPrefetchTrie) PrefetchAccount(addrs []common.Address) error {
+ t.lock.Lock()
+ t.accountCalls++
+ t.accountItems += len(addrs)
+ first := t.accountCalls == 1
+ t.lock.Unlock()
+ if first {
+ t.once.Do(func() { close(t.started) })
+ <-t.release
+ }
+ return nil
+}
+
+func (t *blockingPrefetchTrie) releaseBlockedPrefetch() {
+ t.releaseOnce.Do(func() { close(t.release) })
+}
+
+func (t *blockingPrefetchTrie) PrefetchStorage(_ common.Address, keys [][]byte) error {
+ t.lock.Lock()
+ t.storageCalls++
+ t.storageItems += len(keys)
+ first := t.storageCalls == 1
+ t.lock.Unlock()
+ if first {
+ t.once.Do(func() { close(t.started) })
+ <-t.release
+ }
+ return nil
+}
+
+func (t *blockingPrefetchTrie) accountStats() (int, int) {
+ t.lock.Lock()
+ defer t.lock.Unlock()
+
+ return t.accountCalls, t.accountItems
+}
+
func TestVerklePrefetcher(t *testing.T) {
disk := rawdb.NewMemoryDatabase()
db := triedb.NewDatabase(disk, triedb.VerkleDefaults)
diff --git a/core/state/v2_method_parity_test.go b/core/state/v2_method_parity_test.go
index e303616554..1e1e7d3907 100644
--- a/core/state/v2_method_parity_test.go
+++ b/core/state/v2_method_parity_test.go
@@ -40,6 +40,7 @@ const (
catLifecycle pdbExemptCategory = "block lifecycle (commit / prefetcher / copy)"
catLowLevel pdbExemptCategory = "low-level / utility"
catDebug pdbExemptCategory = "debug / introspection"
+ catPipelinedSRC pdbExemptCategory = "pipelined SRC import"
)
var pdbExemptMethods = map[string]pdbExemptCategory{
@@ -99,6 +100,19 @@ var pdbExemptMethods = map[string]pdbExemptCategory{
"ResetPrefetcher": catLifecycle,
"Copy": catLifecycle,
+ // Pipelined SRC import — FlatDiff capture/replay, read propagation,
+ // and detached prefetcher handoff are block-level StateDB operations.
+ // ParallelStateDB instances are per-transaction workers; V2 settles
+ // into the underlying StateDB before these methods run.
+ "ApplyFlatDiff": catPipelinedSRC,
+ "ApplyFlatDiffForCommit": catPipelinedSRC,
+ "ApplyFlatDiffForCommitFast": catPipelinedSRC,
+ "CommitSnapshot": catPipelinedSRC,
+ "DetachPrefetcher": catPipelinedSRC,
+ "PropagateReadsTo": catPipelinedSRC,
+ "SetFlatDiffRef": catPipelinedSRC,
+ "WasStorageSlotRead": catPipelinedSRC,
+
// Low-level / utility — not part of the EVM-facing surface.
"Database": catLowLevel,
"Error": catLowLevel,
diff --git a/core/state/warm_snapshot.go b/core/state/warm_snapshot.go
new file mode 100644
index 0000000000..571ecd0f86
--- /dev/null
+++ b/core/state/warm_snapshot.go
@@ -0,0 +1,393 @@
+// Copyright 2026 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library 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.
+//
+// The go-ethereum library 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 the go-ethereum library. If not, see .
+
+package state
+
+import (
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/metrics"
+ "github.com/ethereum/go-ethereum/trie"
+ "github.com/ethereum/go-ethereum/triedb/database"
+)
+
+var (
+ // These meters are intentionally emitted from snapshotNodeReader.Node so
+ // hit/miss attribution includes every trie-node fetch SRC attempts. If this
+ // shows up in CPU profiles, batch these counts per block and emit them once
+ // from the import handoff instead.
+ warmSnapshotAccountHitMeter = metrics.NewRegisteredMeter("chain/imports/pipelined/warm_snapshot/account/hit", nil)
+ warmSnapshotAccountMissMeter = metrics.NewRegisteredMeter("chain/imports/pipelined/warm_snapshot/account/miss", nil)
+ warmSnapshotStorageHitMeter = metrics.NewRegisteredMeter("chain/imports/pipelined/warm_snapshot/storage/hit", nil)
+ warmSnapshotStorageMissMeter = metrics.NewRegisteredMeter("chain/imports/pipelined/warm_snapshot/storage/miss", nil)
+)
+
+// WarmSnapshot is an immutable, hash-verified copy of trie nodes loaded by the
+// execution-side prefetcher. It is constructed in the pipelined SRC goroutine
+// from a quiesced WarmSnapshotInput so SRC's NewTrieOnly reader can
+// short-circuit pathdb/pebble lookups for nodes the main thread already loaded.
+//
+// The snapshot is read-only after construction. Concurrent readers are safe
+// because a populated map is never mutated post-construction; the SRC handoff
+// in persistPipelinedImport provides the happens-before edge.
+type WarmSnapshot struct {
+ // nodes is keyed by (owner, path, hash). Across blocks the same
+ // (owner, path) can resolve to different node hashes as the trie
+ // shape evolves; keying by hash too keeps every distinct warm node
+ // retrievable rather than overwriting earlier entries with later
+ // ones for the same path. The hash check on lookup remains the
+ // authoritative correctness gate; this keying just preserves hits
+ // the prefetcher actually observed.
+ nodes map[warmKey][]byte
+}
+
+// warmKey identifies a trie node by its containing trie's owner (zero for
+// the account trie, account hash for storage tries), its path within the
+// trie, and the node hash itself. The hash field disambiguates entries that
+// share owner+path across different blocks/states.
+// warmKey identifies a trie node by owner, path and content hash. It is a
+// comparable value type built on the stack — a string-keyed variant would
+// allocate on every Lookup, since Go's string(bytes) map-index optimization
+// does not apply to struct-literal keys. MPT paths are at most 64 nibbles;
+// longer paths cannot be produced by the trie and are simply not indexed
+// (Lookup misses fall through to the underlying reader).
+type warmKey struct {
+ owner common.Hash
+ hash common.Hash
+ pathLen uint8
+ path [64]byte // MPT path nibbles
+}
+
+func makeWarmKey(owner common.Hash, path []byte, hash common.Hash) (warmKey, bool) {
+ if len(path) > len(warmKey{}.path) {
+ return warmKey{}, false
+ }
+ key := warmKey{owner: owner, hash: hash, pathLen: uint8(len(path))}
+ copy(key.path[:], path)
+ return key, true
+}
+
+// NewWarmSnapshot constructs a snapshot from per-trie node maps already
+// extracted from a quiesced prefetcher. Each entry in tries supplies a trie
+// owner and a (path -> blob) map (typically the result of trie.Witness()).
+// Blobs are copied; the snapshot does not retain references into the source
+// maps. Empty input produces a non-nil empty snapshot — Lookup on an empty
+// snapshot is a fast miss.
+//
+// Source maps are keyed by path only; this constructor computes each blob's
+// hash and uses (owner, path, hash) as the snapshot key. If the source
+// somehow contains two distinct blobs at the same (owner, path) — possible
+// when the prefetcher loaded the same path at different roots within a
+// single block — both are retained.
+func NewWarmSnapshot(tries []TrieWarmNodes) *WarmSnapshot {
+ total := 0
+ for i := range tries {
+ total += len(tries[i].Nodes)
+ }
+ s := &WarmSnapshot{nodes: make(map[warmKey][]byte, total)}
+ for i := range tries {
+ owner := tries[i].Owner
+ for path, blob := range tries[i].Nodes {
+ if len(blob) == 0 {
+ continue
+ }
+ cp := make([]byte, len(blob))
+ copy(cp, blob)
+ key, ok := makeWarmKey(owner, []byte(path), crypto.Keccak256Hash(cp))
+ if !ok {
+ continue
+ }
+ s.nodes[key] = cp
+ }
+ }
+ return s
+}
+
+// TrieWarmNodes carries one trie's contribution to a WarmSnapshot. The Owner
+// is the trie's identifying hash (zero for the account trie, the account hash
+// for a storage trie). Nodes maps trie-path to RLP-encoded node blob.
+type TrieWarmNodes struct {
+ Owner common.Hash
+ Nodes map[string][]byte
+}
+
+// WarmSnapshotInput is the quiesced handoff from the execution-side
+// prefetcher to SRC. It contains cloned path->blob maps returned by
+// Trie.Witness() after all subfetcher goroutines have exited. The maps are
+// read-only after construction and may be passed to another goroutine.
+//
+// Build constructs the final immutable, hash-indexed WarmSnapshot. Keeping
+// this as a separate step lets the import thread stop and detach the
+// prefetcher quickly while SRC pays the copy/hash/index cost in the background.
+type WarmSnapshotInput struct {
+ tries []TrieWarmNodes
+}
+
+// NewWarmSnapshotInput wraps quiesced trie-node maps for later WarmSnapshot
+// construction. It does not copy blobs or compute hashes; callers must only
+// pass maps that will not be mutated after this point.
+func NewWarmSnapshotInput(tries []TrieWarmNodes) *WarmSnapshotInput {
+ if len(tries) == 0 {
+ return nil
+ }
+ return &WarmSnapshotInput{tries: tries}
+}
+
+// Build constructs the immutable WarmSnapshot from the input. The returned
+// snapshot owns copies of all node blobs and does not alias the input maps.
+func (in *WarmSnapshotInput) Build() *WarmSnapshot {
+ if in == nil || len(in.tries) == 0 {
+ return nil
+ }
+ return NewWarmSnapshot(in.tries)
+}
+
+// Len returns the number of nodes in the snapshot. Useful for tests and
+// metrics; safe to call on a nil snapshot.
+func (s *WarmSnapshot) Len() int {
+ if s == nil {
+ return 0
+ }
+ return len(s.nodes)
+}
+
+// SizeBytes returns the total retained trie-node blob bytes. It intentionally
+// excludes map/key overhead; use it as a stable payload-size signal rather than
+// a precise heap-size estimate.
+func (s *WarmSnapshot) SizeBytes() int {
+ if s == nil {
+ return 0
+ }
+ var size int
+ for _, blob := range s.nodes {
+ size += len(blob)
+ }
+ return size
+}
+
+// Lookup returns the cached trie-node blob for (owner, path, expectedHash) if
+// present. A miss returns (nil, false) and the caller is expected to fall
+// through to the underlying NodeReader.
+//
+// The map is keyed by the (owner, path, hash) triple, so a present entry
+// already has a verified hash by construction (see NewWarmSnapshot); the
+// expectedHash supplied by the caller becomes part of the lookup key
+// itself, which means a request for a different hash at the same
+// (owner, path) is a structural miss — no stored entry can satisfy it
+// regardless of contents. This is the linearisation point that prevents a
+// stale snapshot entry from satisfying a different state's read.
+func (s *WarmSnapshot) Lookup(owner common.Hash, path []byte, expectedHash common.Hash) ([]byte, bool) {
+ if s == nil || len(s.nodes) == 0 {
+ return nil, false
+ }
+ key, ok := makeWarmKey(owner, path, expectedHash)
+ if !ok {
+ return nil, false
+ }
+ blob, ok := s.nodes[key]
+ if !ok {
+ return nil, false
+ }
+ return blob, true
+}
+
+// snapshotStateDatabase wraps CachingDB for a single SRC StateDB so every trie
+// opening path can consult the same WarmSnapshot. The plain snapshot reader
+// wrapper is enough for StateDB.reader reads, but CommitWithUpdate also opens
+// tries through StateDB.db.OpenTrie/OpenStorageTrie. If those methods keep
+// using the unwrapped CachingDB, the commit and witness-collection walks miss
+// the warm handoff entirely.
+//
+// The wrapper preserves NewTrieOnly semantics: account and storage reads still
+// walk MPT tries, and trie resolveAndTrack still records proof nodes. The only
+// change is the NodeDatabase behind those tries: snapshot hits return an
+// already-loaded RLP node blob, while misses fall through to the underlying
+// triedb/pathdb chain.
+type snapshotStateDatabase struct {
+ *CachingDB
+
+ nodeDB database.NodeDatabase
+ snapshot *WarmSnapshot
+}
+
+func newSnapshotStateDatabase(inner *CachingDB, snapshot *WarmSnapshot) *snapshotStateDatabase {
+ return &snapshotStateDatabase{
+ CachingDB: inner,
+ nodeDB: newSnapshotNodeDatabase(inner.triedb, snapshot),
+ snapshot: snapshot,
+ }
+}
+
+// Reader intentionally returns a trie-only snapshot-aware reader, not
+// CachingDB.Reader's multi-reader. This wrapper is meant for short-lived SRC
+// StateDB instances that are discarded after CommitWithUpdate; do not reuse it
+// for long-lived StateDBs that expect flat/snapshot reader semantics after
+// commit-time reader refreshes.
+func (db *snapshotStateDatabase) Reader(stateRoot common.Hash) (Reader, error) {
+ tr, err := newTrieReaderWithSnapshot(stateRoot, db.triedb, db.nodeDB)
+ if err != nil {
+ return nil, err
+ }
+ return newReader(newCachingCodeReader(db.disk, db.codeCache, db.codeSizeCache), tr), nil
+}
+
+func (db *snapshotStateDatabase) OpenTrie(root common.Hash) (Trie, error) {
+ if db.triedb.IsVerkle() {
+ return db.CachingDB.OpenTrie(root)
+ }
+ tr, err := trie.NewStateTrie(trie.StateTrieID(root), db.nodeDB)
+ if err != nil {
+ return nil, err
+ }
+ return tr, nil
+}
+
+func (db *snapshotStateDatabase) OpenStorageTrie(stateRoot common.Hash, address common.Address, root common.Hash, self Trie) (Trie, error) {
+ if db.triedb.IsVerkle() {
+ return self, nil
+ }
+ tr, err := trie.NewStateTrie(trie.StorageTrieID(stateRoot, crypto.Keccak256Hash(address.Bytes()), root), db.nodeDB)
+ if err != nil {
+ return nil, err
+ }
+ return tr, nil
+}
+
+// preimageForwarder is the interface trie.NewStateTrie checks at construction
+// time to decide whether the trie has a backing preimage store (see
+// trie/secure_trie.go's preimageStore type assertion). Mirroring it here
+// lets snapshotNodeDatabase satisfy the same shape so wrapped tries don't
+// silently lose preimage support.
+//
+// We declare it locally rather than importing trie's unexported preimageStore
+// because: (a) trie's interface is unexported, and (b) Go's structural typing
+// makes a type-equivalent local declaration sufficient — the type assertion
+// inside trie.NewStateTrie will succeed against any value that has these
+// three methods.
+type preimageForwarder interface {
+ Preimage(hash common.Hash) []byte
+ InsertPreimage(preimages map[common.Hash][]byte)
+ PreimageEnabled() bool
+}
+
+// snapshotNodeDatabase wraps a database.NodeDatabase so that NodeReaders
+// returned from it consult a WarmSnapshot before falling through to the
+// underlying reader. It is the boundary at which the SRC goroutine's trie
+// reads bypass pathdb/pebble for warm nodes.
+//
+// trie.Reader.Node(path, hash) calls the wrapped NodeReader once per node
+// fetch, supplying owner via its internal field and path/hash from the
+// caller. The wrapper consults the snapshot using exactly that triple. On
+// miss or hash mismatch, the underlying NodeReader is invoked unchanged, so
+// trie.Trie.resolveAndTrack and the trie's prevalueTracer record the served
+// node regardless of whether it came from the snapshot or pathdb. Witness
+// completeness under NewTrieOnly semantics is therefore preserved.
+//
+// snapshotNodeDatabase also forwards preimage methods (Preimage,
+// InsertPreimage, PreimageEnabled) when the inner database supports them.
+// trie.NewStateTrie type-asserts the supplied NodeDatabase to detect a
+// preimage store; without forwarding, wrapped tries would silently lose
+// preimage recording even though the underlying *triedb.Database supports it.
+type snapshotNodeDatabase struct {
+ inner database.NodeDatabase
+ snapshot *WarmSnapshot
+
+ // preimages is the inner database's preimage interface, captured at
+ // construction iff the inner database implements it. Nil when the
+ // underlying database does not record preimages, which preserves the
+ // "preimages disabled" branch in trie.NewStateTrie's type assertion.
+ preimages preimageForwarder
+}
+
+// newSnapshotNodeDatabase wraps inner with the given snapshot. If snapshot is
+// nil or empty, returns inner unchanged so callers can pass through without
+// allocating a wrapper.
+func newSnapshotNodeDatabase(inner database.NodeDatabase, snapshot *WarmSnapshot) database.NodeDatabase {
+ if snapshot == nil || snapshot.Len() == 0 {
+ return inner
+ }
+ wrapped := &snapshotNodeDatabase{inner: inner, snapshot: snapshot}
+ if pi, ok := inner.(preimageForwarder); ok {
+ wrapped.preimages = pi
+ }
+ return wrapped
+}
+
+func (db *snapshotNodeDatabase) NodeReader(stateRoot common.Hash) (database.NodeReader, error) {
+ r, err := db.inner.NodeReader(stateRoot)
+ if err != nil {
+ return nil, err
+ }
+ return &snapshotNodeReader{inner: r, snapshot: db.snapshot}, nil
+}
+
+// Preimage forwards to the inner preimage store. Trie callers reach this via
+// the preimageStore type assertion inside trie.NewStateTrie; if the inner
+// database had no preimage support, this method returns nil (the trie's
+// PreimageEnabled() check below returns false, so the trie won't install us
+// as its preimage store and these methods will not be invoked).
+func (db *snapshotNodeDatabase) Preimage(hash common.Hash) []byte {
+ if db.preimages == nil {
+ return nil
+ }
+ return db.preimages.Preimage(hash)
+}
+
+// InsertPreimage forwards a preimage batch to the inner store. Called by
+// trie.StateTrie.Commit when secKeyCache is non-empty and PreimageEnabled
+// returned true at construction time.
+func (db *snapshotNodeDatabase) InsertPreimage(preimages map[common.Hash][]byte) {
+ if db.preimages == nil {
+ return
+ }
+ db.preimages.InsertPreimage(preimages)
+}
+
+// PreimageEnabled reports whether the underlying database has preimage
+// recording enabled. Returning false makes trie.NewStateTrie skip the
+// preimage-store linkage for the wrapped trie — same outcome as if the
+// caller had passed an unwrapped *triedb.Database with preimages off.
+func (db *snapshotNodeDatabase) PreimageEnabled() bool {
+ if db.preimages == nil {
+ return false
+ }
+ return db.preimages.PreimageEnabled()
+}
+
+// snapshotNodeReader is the per-state-root NodeReader that consults the
+// snapshot first. Hits avoid pathdb diff-layer walks and pebble I/O entirely;
+// misses fall through to the underlying reader without modification.
+type snapshotNodeReader struct {
+ inner database.NodeReader
+ snapshot *WarmSnapshot
+}
+
+func (r *snapshotNodeReader) Node(owner common.Hash, path []byte, hash common.Hash) ([]byte, error) {
+ if blob, ok := r.snapshot.Lookup(owner, path, hash); ok {
+ if owner == (common.Hash{}) {
+ warmSnapshotAccountHitMeter.Mark(1)
+ } else {
+ warmSnapshotStorageHitMeter.Mark(1)
+ }
+ return blob, nil
+ }
+ if owner == (common.Hash{}) {
+ warmSnapshotAccountMissMeter.Mark(1)
+ } else {
+ warmSnapshotStorageMissMeter.Mark(1)
+ }
+ return r.inner.Node(owner, path, hash)
+}
diff --git a/core/state/warm_snapshot_test.go b/core/state/warm_snapshot_test.go
new file mode 100644
index 0000000000..1aad15fec2
--- /dev/null
+++ b/core/state/warm_snapshot_test.go
@@ -0,0 +1,276 @@
+// Copyright 2026 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library 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.
+//
+// The go-ethereum library 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 the go-ethereum library. If not, see .
+
+package state
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/triedb/database"
+)
+
+// stubNodeReader records every call made to it and returns a configured blob
+// per (owner, path) pair. It lets us assert exactly when the snapshot wrapper
+// falls through to the underlying NodeReader.
+type stubNodeReader struct {
+ calls []stubCall
+ nodes map[stubKey]stubNode
+}
+
+type stubCall struct {
+ owner common.Hash
+ path []byte
+ hash common.Hash
+}
+
+type stubKey struct {
+ owner common.Hash
+ path string
+}
+
+type stubNode struct {
+ blob []byte
+ err error
+}
+
+func newStubNodeReader() *stubNodeReader {
+ return &stubNodeReader{nodes: make(map[stubKey]stubNode)}
+}
+
+func (s *stubNodeReader) set(owner common.Hash, path []byte, blob []byte) {
+ s.nodes[stubKey{owner: owner, path: string(path)}] = stubNode{blob: blob}
+}
+
+func (s *stubNodeReader) Node(owner common.Hash, path []byte, hash common.Hash) ([]byte, error) {
+ s.calls = append(s.calls, stubCall{owner: owner, path: append([]byte(nil), path...), hash: hash})
+ n, ok := s.nodes[stubKey{owner: owner, path: string(path)}]
+ if !ok {
+ return nil, errors.New("stub: not found")
+ }
+ if n.err != nil {
+ return nil, n.err
+ }
+ return n.blob, nil
+}
+
+// stubNodeDB satisfies database.NodeDatabase by returning a fixed
+// stubNodeReader regardless of state root. It exists only to drive
+// snapshotNodeDatabase in tests.
+type stubNodeDB struct {
+ reader *stubNodeReader
+}
+
+func (s *stubNodeDB) NodeReader(stateRoot common.Hash) (database.NodeReader, error) {
+ return s.reader, nil
+}
+
+// TestWarmSnapshot_HashMismatchFallsThrough is the consensus-critical safety
+// test for the snapshot reader. The snapshot is keyed by (owner, path, hash);
+// the caller-supplied expectedHash participates in the lookup key, so a
+// request for a different hash at the same (owner, path) is a structural
+// miss and the reader must fall through to the authoritative pathdb-backed
+// reader rather than serve a blob whose hash does not match what the caller
+// expects.
+//
+// Failing this test means a stale snapshot entry could satisfy a current
+// trie read with the wrong blob — a silent state-corruption / consensus
+// risk. The (owner, path, hash) keying is the structural guarantee that
+// prevents that, and the underlying-reader fallthrough is its observable
+// consequence.
+func TestWarmSnapshot_HashMismatchFallsThrough(t *testing.T) {
+ owner := common.HexToHash("0x01")
+ path := []byte{0xab, 0xcd}
+
+ correctBlob := []byte("trie-node-correct")
+ staleBlob := []byte("trie-node-stale-from-old-state")
+
+ correctHash := crypto.Keccak256Hash(correctBlob)
+ staleHash := crypto.Keccak256Hash(staleBlob)
+ require.NotEqual(t, correctHash, staleHash, "test setup: stale and correct blobs must hash differently")
+
+ // Snapshot contains the stale blob keyed by (owner, path).
+ snap := NewWarmSnapshot([]TrieWarmNodes{{
+ Owner: owner,
+ Nodes: map[string][]byte{string(path): staleBlob},
+ }})
+ require.Equal(t, 1, snap.Len())
+
+ // Underlying reader has the correct blob.
+ underlying := newStubNodeReader()
+ underlying.set(owner, path, correctBlob)
+
+ wrappedDB := newSnapshotNodeDatabase(&stubNodeDB{reader: underlying}, snap)
+ reader, err := wrappedDB.NodeReader(common.Hash{}) // root is irrelevant for stub
+ require.NoError(t, err)
+
+ // Caller asks for the CORRECT hash. The snapshot's only entry is keyed
+ // by (owner, path, staleHash), so a lookup with (owner, path,
+ // correctHash) is a structural miss and the wrapper must fall through
+ // to the underlying reader.
+ got, err := reader.Node(owner, path, correctHash)
+ require.NoError(t, err)
+ require.Equal(t, correctBlob, got, "must serve from underlying reader, not the stale snapshot blob")
+ require.Len(t, underlying.calls, 1, "underlying reader must be invoked exactly once on hash-mismatch fallthrough")
+
+ // Sanity: when the caller asks for staleHash, the lookup key matches
+ // the stored entry exactly and the snapshot serves without consulting
+ // the underlying reader. This shows the hash component of the key is
+ // what distinguishes hit from miss at the same (owner, path).
+ got, err = reader.Node(owner, path, staleHash)
+ require.NoError(t, err)
+ require.Equal(t, staleBlob, got, "snapshot must serve when expectedHash matches stored hash")
+ require.Len(t, underlying.calls, 1, "underlying reader must NOT be invoked on a snapshot hit")
+}
+
+// TestWarmSnapshot_NilAndEmpty exercises the no-op paths: a nil snapshot or
+// an empty snapshot must always return a miss and let every read fall through
+// to the underlying reader. Constructing the snapshot wrapper should be free
+// in those cases.
+func TestWarmSnapshot_NilAndEmpty(t *testing.T) {
+ owner := common.HexToHash("0x02")
+ path := []byte{0x10}
+ blob := []byte("real-node")
+ hash := crypto.Keccak256Hash(blob)
+
+ underlying := newStubNodeReader()
+ underlying.set(owner, path, blob)
+
+ innerDB := &stubNodeDB{reader: underlying}
+
+ // Nil snapshot: wrapper short-circuits, returns inner DB unchanged.
+ require.Same(t, innerDB, newSnapshotNodeDatabase(innerDB, nil))
+
+ // Empty snapshot: same short-circuit.
+ empty := NewWarmSnapshot(nil)
+ require.Equal(t, 0, empty.Len())
+ require.Same(t, innerDB, newSnapshotNodeDatabase(innerDB, empty))
+
+ // Direct Lookup on nil snapshot is a miss.
+ var nilSnap *WarmSnapshot
+ _, ok := nilSnap.Lookup(owner, path, hash)
+ require.False(t, ok)
+
+ // Direct Lookup on empty snapshot is a miss.
+ _, ok = empty.Lookup(owner, path, hash)
+ require.False(t, ok)
+}
+
+// TestNewTrieOnlyWithSnapshotInstallsStateDBWrapper verifies that the snapshot
+// handoff is installed on StateDB.db, not only on the initial Reader. Commit
+// paths call StateDB.db.OpenTrie/OpenStorageTrie directly; if db remained the
+// plain CachingDB those calls would bypass WarmSnapshot.
+func TestNewTrieOnlyWithSnapshotInstallsStateDBWrapper(t *testing.T) {
+ cdb := NewDatabaseForTesting()
+ snap := NewWarmSnapshot([]TrieWarmNodes{{
+ Owner: common.Hash{},
+ Nodes: map[string][]byte{"warm": []byte("warm-node")},
+ }})
+ require.Equal(t, 1, snap.Len())
+
+ sdb, err := NewTrieOnlyWithSnapshot(types.EmptyRootHash, cdb, snap)
+ require.NoError(t, err)
+ _, ok := sdb.db.(*snapshotStateDatabase)
+ require.True(t, ok, "StateDB.db must be snapshot-aware so commit-time trie opens use the warm handoff")
+
+ sdb, err = NewTrieOnlyWithSnapshot(types.EmptyRootHash, cdb, nil)
+ require.NoError(t, err)
+ _, ok = sdb.db.(*snapshotStateDatabase)
+ require.False(t, ok, "nil snapshot must preserve the plain trie-only database path")
+}
+
+// TestWarmSnapshot_RetainsDistinctHashesAtSamePath verifies that two warm
+// nodes with the same (owner, path) but different hashes are both retained.
+// Across blocks, root churn means the same trie position can resolve to
+// different node hashes; if the snapshot collapsed entries at the (owner,
+// path) level, an entry with a different hash would silently overwrite an
+// earlier one and the SRC would miss when its expected hash matched the
+// dropped entry. Triple keying by (owner, path, hash) prevents that
+// loss-of-hits scenario.
+func TestWarmSnapshot_RetainsDistinctHashesAtSamePath(t *testing.T) {
+ owner := common.HexToHash("0xa1")
+ path := []byte{0x42}
+
+ blobA := []byte("trie-node-A")
+ blobB := []byte("trie-node-B")
+ hashA := crypto.Keccak256Hash(blobA)
+ hashB := crypto.Keccak256Hash(blobB)
+ require.NotEqual(t, hashA, hashB)
+
+ // Same (owner, path), two different blobs (different hashes).
+ // Source map keyed by path only, so we have to materialise both as
+ // separate TrieWarmNodes entries: each call to NewWarmSnapshot inserts
+ // every (owner, path, hash) it sees — duplicates only collapse if the
+ // triple is identical, not just (owner, path).
+ snap := NewWarmSnapshot([]TrieWarmNodes{
+ {Owner: owner, Nodes: map[string][]byte{string(path): blobA}},
+ {Owner: owner, Nodes: map[string][]byte{string(path): blobB}},
+ })
+ require.Equal(t, 2, snap.Len(), "both entries must be retained when hashes differ at same (owner, path)")
+
+ gotA, ok := snap.Lookup(owner, path, hashA)
+ require.True(t, ok, "lookup with hashA must hit blobA")
+ require.Equal(t, blobA, gotA)
+
+ gotB, ok := snap.Lookup(owner, path, hashB)
+ require.True(t, ok, "lookup with hashB must hit blobB")
+ require.Equal(t, blobB, gotB)
+
+ // A third hash that nobody supplied: structural miss.
+ other := crypto.Keccak256Hash([]byte("never-seen"))
+ _, ok = snap.Lookup(owner, path, other)
+ require.False(t, ok)
+}
+
+// TestWarmSnapshot_OwnerScoped verifies that the (owner, path) keying
+// distinguishes account-trie nodes from storage-trie nodes that may share a
+// path. Without owner scoping a storage-trie lookup could be satisfied by an
+// account-trie node at the same path, which would still pass the hash check
+// only if the blobs collided — but the keying-level isolation is the
+// structural guarantee.
+func TestWarmSnapshot_OwnerScoped(t *testing.T) {
+ accountOwner := common.Hash{}
+ storageOwner := common.HexToHash("0xfeedface")
+ path := []byte{0x07}
+
+ accountBlob := []byte("account-trie-node")
+ storageBlob := []byte("storage-trie-node-different-content")
+
+ snap := NewWarmSnapshot([]TrieWarmNodes{
+ {Owner: accountOwner, Nodes: map[string][]byte{string(path): accountBlob}},
+ {Owner: storageOwner, Nodes: map[string][]byte{string(path): storageBlob}},
+ })
+ require.Equal(t, 2, snap.Len())
+
+ // Account-trie lookup serves the account blob.
+ got, ok := snap.Lookup(accountOwner, path, crypto.Keccak256Hash(accountBlob))
+ require.True(t, ok)
+ require.Equal(t, accountBlob, got)
+
+ // Storage-trie lookup at the same path serves the storage blob.
+ got, ok = snap.Lookup(storageOwner, path, crypto.Keccak256Hash(storageBlob))
+ require.True(t, ok)
+ require.Equal(t, storageBlob, got)
+
+ // Cross-owner lookup with the wrong-owner-but-matching-path is a miss.
+ _, ok = snap.Lookup(storageOwner, path, crypto.Keccak256Hash(accountBlob))
+ require.False(t, ok, "must not serve account blob to storage owner even when that blob's hash matches expectedHash")
+}
diff --git a/core/stateless/encoding.go b/core/stateless/encoding.go
index e955b9c962..a7ffb043c0 100644
--- a/core/stateless/encoding.go
+++ b/core/stateless/encoding.go
@@ -25,8 +25,9 @@ import (
)
// BorWitness is the canonical 3-field RLP encoding used for network
-// transmission in Bor. The State field carries all proof data — both
-// contract bytecodes and MPT state trie nodes — as a flat list of byte slices.
+// transmission in Bor. The State field carries MPT trie proof nodes as a flat
+// list of byte slices. Contract bytecodes are not part of the BorWitness wire
+// format; verifiers read bytecode from local storage via CodeRoutingDB.
type BorWitness struct {
Context *types.Header
Headers []*types.Header
diff --git a/core/stateless/witness.go b/core/stateless/witness.go
index c418b0d129..ca7ccfa398 100644
--- a/core/stateless/witness.go
+++ b/core/stateless/witness.go
@@ -35,8 +35,11 @@ type HeaderReader interface {
GetHeader(hash common.Hash, number uint64) *types.Header
}
-// ValidateWitnessPreState validates that the witness pre-state root matches the parent block's state root.
-func ValidateWitnessPreState(witness *Witness, headerReader HeaderReader) error {
+// ValidateWitnessPreState validates that the witness pre-state root matches
+// the parent block's state root. The expectedBlock header is the block being
+// imported — the witness context must match it (ParentHash and Number) to
+// prevent a malicious peer from substituting a witness for a different block.
+func ValidateWitnessPreState(witness *Witness, headerReader HeaderReader, expectedBlock *types.Header) error {
if witness == nil {
return fmt.Errorf("witness is nil")
}
@@ -52,6 +55,19 @@ func ValidateWitnessPreState(witness *Witness, headerReader HeaderReader) error
return fmt.Errorf("witness context header is nil")
}
+ // Verify the witness is for the expected block — a malicious peer could
+ // craft a witness with a different ParentHash to bypass the pre-state check.
+ if expectedBlock != nil {
+ if contextHeader.ParentHash != expectedBlock.ParentHash {
+ return fmt.Errorf("witness ParentHash mismatch: witness=%x, expected=%x, blockNumber=%d",
+ contextHeader.ParentHash, expectedBlock.ParentHash, expectedBlock.Number.Uint64())
+ }
+ if contextHeader.Number.Uint64() != expectedBlock.Number.Uint64() {
+ return fmt.Errorf("witness block number mismatch: witness=%d, expected=%d",
+ contextHeader.Number.Uint64(), expectedBlock.Number.Uint64())
+ }
+ }
+
// Get the parent block header from the chain.
parentHeader := headerReader.GetHeader(contextHeader.ParentHash, contextHeader.Number.Uint64()-1)
if parentHeader == nil {
@@ -96,9 +112,16 @@ func NewWitness(context *types.Header, chain HeaderReader) (*Witness, error) {
}
headers = append(headers, parent)
}
- // Create the witness with a reconstructed gutted out block
+ // Create the witness with a copy of the context header to prevent
+ // callers from mutating the header after witness creation.
+ // Note: Root and ReceiptHash are NOT zeroed here — they are zeroed at the
+ // point of stateless execution (ProcessBlockWithWitnesses) where they are
+ // recomputed. Zeroing here would break the witness manager's hash matching
+ // (handleBroadcast uses witness.Header().Hash() to look up pending blocks).
+ ctx := types.CopyHeader(context)
+
return &Witness{
- context: context,
+ context: ctx,
Headers: headers,
Codes: make(map[string]struct{}),
State: make(map[string]struct{}),
diff --git a/core/stateless/witness_test.go b/core/stateless/witness_test.go
index 6d662020fd..4d83da46a5 100644
--- a/core/stateless/witness_test.go
+++ b/core/stateless/witness_test.go
@@ -42,7 +42,7 @@ func TestValidateWitnessPreState_Success(t *testing.T) {
}
// Test validation - should succeed.
- err := ValidateWitnessPreState(witness, mockReader)
+ err := ValidateWitnessPreState(witness, mockReader, nil)
if err != nil {
t.Errorf("Expected validation to succeed, but got error: %v", err)
}
@@ -88,7 +88,7 @@ func TestValidateWitnessPreState_StateMismatch(t *testing.T) {
}
// Test validation - should fail.
- err := ValidateWitnessPreState(witness, mockReader)
+ err := ValidateWitnessPreState(witness, mockReader, nil)
if err == nil {
t.Error("Expected validation to fail due to state root mismatch, but it succeeded")
}
@@ -106,7 +106,7 @@ func TestValidateWitnessPreState_EdgeCases(t *testing.T) {
// Test case 1: Nil witness.
t.Run("NilWitness", func(t *testing.T) {
- err := ValidateWitnessPreState(nil, mockReader)
+ err := ValidateWitnessPreState(nil, mockReader, nil)
if err == nil {
t.Error("Expected validation to fail for nil witness")
}
@@ -124,7 +124,7 @@ func TestValidateWitnessPreState_EdgeCases(t *testing.T) {
State: make(map[string]struct{}),
}
- err := ValidateWitnessPreState(witness, mockReader)
+ err := ValidateWitnessPreState(witness, mockReader, nil)
if err == nil {
t.Error("Expected validation to fail for witness with no headers")
}
@@ -147,7 +147,7 @@ func TestValidateWitnessPreState_EdgeCases(t *testing.T) {
State: make(map[string]struct{}),
}
- err := ValidateWitnessPreState(witness, mockReader)
+ err := ValidateWitnessPreState(witness, mockReader, nil)
if err == nil {
t.Error("Expected validation to fail for witness with nil context header")
}
@@ -177,7 +177,7 @@ func TestValidateWitnessPreState_EdgeCases(t *testing.T) {
}
// Don't add parent header to mock reader - it won't be found.
- err := ValidateWitnessPreState(witness, mockReader)
+ err := ValidateWitnessPreState(witness, mockReader, nil)
if err == nil {
t.Error("Expected validation to fail when parent header is not found")
}
@@ -234,7 +234,7 @@ func TestValidateWitnessPreState_MultipleHeaders(t *testing.T) {
}
// Test validation - should succeed (only first header matters for validation).
- err := ValidateWitnessPreState(witness, mockReader)
+ err := ValidateWitnessPreState(witness, mockReader, nil)
if err != nil {
t.Errorf("Expected validation to succeed with multiple headers, but got error: %v", err)
}
@@ -974,3 +974,93 @@ func TestCalculatePageThreshold(t *testing.T) {
}
})
}
+
+// makeValidatePreStateFixture builds a consistent witness + mockReader + context
+// header where the witness pre-state matches the chain's parent block. Callers
+// can mutate the returned expectedBlock to test the anti-malicious-peer guards.
+func makeValidatePreStateFixture() (*Witness, HeaderReader, *types.Header) {
+ parentStateRoot := common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111")
+
+ parentHeader := &types.Header{
+ Number: big.NewInt(99),
+ ParentHash: common.HexToHash("0x0000000000000000000000000000000000000000000000000000000000000000"),
+ Root: parentStateRoot,
+ }
+ parentHash := parentHeader.Hash()
+
+ contextHeader := &types.Header{
+ Number: big.NewInt(100),
+ ParentHash: parentHash,
+ Root: common.HexToHash("0x2222222222222222222222222222222222222222222222222222222222222222"),
+ }
+
+ mockReader := NewMockHeaderReader()
+ mockReader.AddHeader(parentHeader)
+
+ witness := &Witness{
+ context: contextHeader,
+ Headers: []*types.Header{parentHeader},
+ Codes: make(map[string]struct{}),
+ State: make(map[string]struct{}),
+ }
+
+ // expectedBlock mirrors the context header — tests mutate individual fields
+ // (ParentHash / Number) to exercise the ValidateWitnessPreState guards.
+ expectedBlock := &types.Header{
+ Number: big.NewInt(100),
+ ParentHash: parentHash,
+ }
+ return witness, mockReader, expectedBlock
+}
+
+// TestValidateWitnessPreState_ExpectedBlockMatches exercises the non-nil
+// expectedBlock branch with matching ParentHash and Number so the full
+// function runs to completion.
+func TestValidateWitnessPreState_ExpectedBlockMatches(t *testing.T) {
+ witness, reader, expectedBlock := makeValidatePreStateFixture()
+ if err := ValidateWitnessPreState(witness, reader, expectedBlock); err != nil {
+ t.Errorf("expected validation to succeed, got %v", err)
+ }
+}
+
+// TestValidateWitnessPreState_ExpectedBlockParentHashMismatch rejects a witness
+// whose context ParentHash disagrees with the expected block — defends against
+// a malicious peer substituting a witness for a different fork.
+func TestValidateWitnessPreState_ExpectedBlockParentHashMismatch(t *testing.T) {
+ witness, reader, expectedBlock := makeValidatePreStateFixture()
+ expectedBlock.ParentHash = common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
+
+ err := ValidateWitnessPreState(witness, reader, expectedBlock)
+ if err == nil {
+ t.Fatal("expected ParentHash mismatch error, got nil")
+ }
+ if !containsSubstring(err.Error(), "witness ParentHash mismatch") {
+ t.Errorf("expected ParentHash mismatch error, got: %v", err)
+ }
+}
+
+// TestValidateWitnessPreState_ExpectedBlockNumberMismatch rejects a witness
+// whose context Number disagrees with the expected block — defends against a
+// malicious peer substituting a witness for a different block at the same
+// ParentHash (e.g., after a reorg).
+func TestValidateWitnessPreState_ExpectedBlockNumberMismatch(t *testing.T) {
+ witness, reader, expectedBlock := makeValidatePreStateFixture()
+ expectedBlock.Number = big.NewInt(999) // ParentHash still matches
+
+ err := ValidateWitnessPreState(witness, reader, expectedBlock)
+ if err == nil {
+ t.Fatal("expected Number mismatch error, got nil")
+ }
+ if !containsSubstring(err.Error(), "witness block number mismatch") {
+ t.Errorf("expected Number mismatch error, got: %v", err)
+ }
+}
+
+func containsSubstring(s, sub string) bool {
+ for i := 0; i+len(sub) <= len(s); i++ {
+ if s[i:i+len(sub)] == sub {
+ return true
+ }
+ }
+ return false
+}
diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go
index 4211e9169e..3be0b89068 100644
--- a/core/txpool/blobpool/blobpool.go
+++ b/core/txpool/blobpool/blobpool.go
@@ -405,7 +405,7 @@ func (p *BlobPool) Init(gasTip uint64, head *types.Header, reserver txpool.Reser
// Initialize the state with head block, or fallback to empty one in
// case the head state is not available (might occur when node is not
// fully synced).
- state, err := p.chain.StateAt(head.Root)
+ state, err := p.chain.PostExecState(head)
if err != nil {
state, err = p.chain.StateAt(types.EmptyRootHash)
}
@@ -843,7 +843,7 @@ func (p *BlobPool) Reset(oldHead, newHead *types.Header) {
resettimeHist.Update(time.Since(start).Nanoseconds())
}(time.Now())
- statedb, err := p.chain.StateAt(newHead.Root)
+ statedb, err := p.chain.PostExecState(newHead)
if err != nil {
log.Error("Failed to reset blobpool state", "err", err)
return
diff --git a/core/txpool/blobpool/blobpool_test.go b/core/txpool/blobpool/blobpool_test.go
index ae0639d9da..adf86f5510 100644
--- a/core/txpool/blobpool/blobpool_test.go
+++ b/core/txpool/blobpool/blobpool_test.go
@@ -202,6 +202,10 @@ func (bc *testBlockChain) StateAt(common.Hash) (*state.StateDB, error) {
return bc.statedb, nil
}
+func (bc *testBlockChain) PostExecState(header *types.Header) (*state.StateDB, error) {
+ return bc.statedb, nil
+}
+
// reserver is a utility struct to sanity check that accounts are
// properly reserved by the blobpool (no duplicate reserves or unreserves).
type reserver struct {
diff --git a/core/txpool/blobpool/interface.go b/core/txpool/blobpool/interface.go
index 6f296a54bd..abb3c63a9b 100644
--- a/core/txpool/blobpool/interface.go
+++ b/core/txpool/blobpool/interface.go
@@ -41,4 +41,9 @@ type BlockChain interface {
// StateAt returns a state database for a given root hash (generally the head).
StateAt(root common.Hash) (*state.StateDB, error)
+
+ // PostExecState returns a StateDB representing the post-execution
+ // state of the given block header. Under pipelined SRC, uses a non-blocking
+ // FlatDiff overlay when available; otherwise falls back to StateAt.
+ PostExecState(header *types.Header) (*state.StateDB, error)
}
diff --git a/core/txpool/legacypool/legacypool.go b/core/txpool/legacypool/legacypool.go
index ea90e641a0..1182302583 100644
--- a/core/txpool/legacypool/legacypool.go
+++ b/core/txpool/legacypool/legacypool.go
@@ -171,6 +171,11 @@ type BlockChain interface {
// StateAt returns a state database for a given root hash (generally the head).
StateAt(root common.Hash) (*state.StateDB, error)
+
+ // PostExecState returns a StateDB representing the post-execution
+ // state of the given block header. Under pipelined SRC, uses a non-blocking
+ // FlatDiff overlay when available; otherwise falls back to StateAt.
+ PostExecState(header *types.Header) (*state.StateDB, error)
}
// Config are the configuration parameters of the transaction pool.
@@ -402,7 +407,7 @@ func (pool *LegacyPool) Init(gasTip uint64, head *types.Header, reserver txpool.
// Initialize the state with head block, or fallback to empty one in
// case the head state is not available (might occur when node is not
// fully synced).
- statedb, err := pool.chain.StateAt(head.Root)
+ statedb, err := pool.chain.PostExecState(head)
if err != nil {
statedb, err = pool.chain.StateAt(types.EmptyRootHash)
}
@@ -1781,7 +1786,7 @@ func (pool *LegacyPool) reset(oldHead, newHead *types.Header) {
if newHead == nil {
newHead = pool.chain.CurrentBlock() // Special case during testing
}
- statedb, err := pool.chain.StateAt(newHead.Root)
+ statedb, err := pool.chain.PostExecState(newHead)
if err != nil {
log.Error("Failed to reset txpool state", "err", err)
return
@@ -1798,6 +1803,40 @@ func (pool *LegacyPool) reset(oldHead, newHead *types.Header) {
pool.addTxs(reinject, false)
}
+// SetSpeculativeState updates the pool's internal state to reflect a new
+// block that hasn't been written to the chain yet. This is used by pipelined
+// SRC: after block N's transactions are executed but before block N is sealed,
+// the miner calls this to update the txpool so that speculative execution of
+// block N+1 gets correct pending transactions (with block N's nonces/balances).
+//
+// Unlike the full reset() path, this does NOT walk the chain for included/
+// discarded transactions (the block isn't in the chain DB). It only:
+// 1. Updates currentState and pendingNonces from the provided statedb
+// 2. Sets currentHead to the new header
+// 3. Demotes transactions with stale nonces
+// 4. Promotes newly executable transactions
+func (pool *LegacyPool) SetSpeculativeState(newHead *types.Header, statedb *state.StateDB) {
+ pool.mu.Lock()
+
+ pool.currentHead.Store(newHead)
+ pool.currentState = statedb
+ pool.pendingNonces = newNoncer(statedb)
+
+ // Demote transactions that are no longer valid with the new nonces
+ pool.demoteUnexecutables()
+
+ // Promote transactions that are now executable
+ promoted := pool.promoteExecutables(nil)
+ pool.mu.Unlock()
+
+ // Fire events for promoted transactions after releasing the pool lock,
+ // matching the regular promotion flow — a subscriber calling back into
+ // the pool must not deadlock, and Send on a hot lock is contention.
+ if len(promoted) > 0 {
+ pool.txFeed.Send(core.NewTxsEvent{Txs: promoted})
+ }
+}
+
// promoteExecutables moves transactions that have become processable from the
// future queue to the set of pending transactions. During this process, all
// invalidated transactions (low nonce, low balance) are deleted.
diff --git a/core/txpool/legacypool/legacypool_test.go b/core/txpool/legacypool/legacypool_test.go
index 90f2f6c76a..36c804d806 100644
--- a/core/txpool/legacypool/legacypool_test.go
+++ b/core/txpool/legacypool/legacypool_test.go
@@ -117,6 +117,10 @@ func (bc *testBlockChain) StateAt(common.Hash) (*state.StateDB, error) {
return bc.statedb, nil
}
+func (bc *testBlockChain) PostExecState(header *types.Header) (*state.StateDB, error) {
+ return bc.statedb, nil
+}
+
func (bc *testBlockChain) SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription {
return bc.chainHeadFeed.Subscribe(ch)
}
diff --git a/core/txpool/txpool.go b/core/txpool/txpool.go
index 33ffb7e550..2869a3c28d 100644
--- a/core/txpool/txpool.go
+++ b/core/txpool/txpool.go
@@ -56,6 +56,11 @@ type BlockChain interface {
// StateAt returns a state database for a given root hash (generally the head).
StateAt(root common.Hash) (*state.StateDB, error)
+
+ // PostExecState returns a StateDB representing the post-execution
+ // state of the given block header. Under pipelined SRC, uses a non-blocking
+ // FlatDiff overlay when available; otherwise falls back to StateAt.
+ PostExecState(header *types.Header) (*state.StateDB, error)
}
// TxPool is an aggregator for various transaction specific pools, collectively
@@ -88,7 +93,7 @@ func New(gasTip uint64, chain BlockChain, subpools []SubPool) (*TxPool, error) {
// Initialize the state with head block, or fallback to empty one in
// case the head state is not available (might occur when node is not
// fully synced).
- statedb, err := chain.StateAt(head.Root)
+ statedb, err := chain.PostExecState(head)
if err != nil {
statedb, err = chain.StateAt(types.EmptyRootHash)
}
@@ -193,7 +198,7 @@ func (p *TxPool) loop(head *types.Header) {
case resetBusy <- struct{}{}:
// Updates the statedb with the new chain head. The head state may be
// unavailable if the initial state sync has not yet completed.
- if statedb, err := p.chain.StateAt(newHead.Root); err != nil {
+ if statedb, err := p.chain.PostExecState(newHead); err != nil {
log.Error("Failed to reset txpool state", "err", err)
} else {
p.stateLock.Lock()
@@ -553,3 +558,28 @@ func (p *TxPool) Clear() {
subpool.Clear()
}
}
+
+// SpeculativeSetter is implemented by subpools that support speculative
+// state updates for pipelined SRC. This avoids import cycles between txpool
+// and legacypool packages.
+type SpeculativeSetter interface {
+ SetSpeculativeState(newHead *types.Header, statedb *state.StateDB)
+}
+
+// SetSpeculativeState updates the txpool's state to reflect a block that
+// hasn't been written to the chain yet. This is used by pipelined SRC so that
+// speculative execution of block N+1 gets correct pending transactions
+// (reflecting block N's post-execution nonces and balances via FlatDiff overlay).
+func (p *TxPool) SetSpeculativeState(newHead *types.Header, statedb *state.StateDB) {
+ // Update the aggregator's state
+ p.stateLock.Lock()
+ p.state = statedb
+ p.stateLock.Unlock()
+
+ // Update subpools that support speculative state
+ for _, subpool := range p.subpools {
+ if ss, ok := subpool.(SpeculativeSetter); ok {
+ ss.SetSpeculativeState(newHead, statedb)
+ }
+ }
+}
diff --git a/core/types.go b/core/types.go
index 43f1f87897..f9f4b691e3 100644
--- a/core/types.go
+++ b/core/types.go
@@ -35,6 +35,11 @@ type Validator interface {
// ValidateState validates the given statedb and optionally the process result.
ValidateState(block *types.Block, state *state.StateDB, res *ProcessResult, stateless bool) error
+
+ // ValidateStateCheap validates cheap post-state checks (gas, bloom, receipt root,
+ // requests) without computing the expensive IntermediateRoot. Used by the
+ // pipelined import path where IntermediateRoot is deferred to an SRC goroutine.
+ ValidateStateCheap(block *types.Block, state *state.StateDB, res *ProcessResult) error
}
// Prefetcher is an interface for pre-caching transaction signatures and state.
diff --git a/core/types/block.go b/core/types/block.go
index 3226ce8851..27e7838363 100644
--- a/core/types/block.go
+++ b/core/types/block.go
@@ -102,6 +102,10 @@ type Header struct {
// ActualTime is the actual time of the block. It is internally used by the miner.
ActualTime time.Time `json:"-" rlp:"-"`
+ // AbortRecovery marks a miner-local rebuild after speculative execution was
+ // discarded. It is not encoded and is only used for build-time heuristics.
+ AbortRecovery bool `json:"-" rlp:"-"`
+
// BaseFee was added by EIP-1559 and is ignored in legacy headers.
BaseFee *big.Int `json:"baseFeePerGas" rlp:"optional"`
diff --git a/core/v2_blockstm_test.go b/core/v2_blockstm_test.go
index e7a6d37f39..80581b427f 100644
--- a/core/v2_blockstm_test.go
+++ b/core/v2_blockstm_test.go
@@ -236,25 +236,12 @@ func TestV2GasDeterminism(t *testing.T) {
config := params.BorMainnetChainConfig
engine := &benchConsensus{}
- // Pick a block with enough txs
- var bd testBlockData
- for _, b := range blocks {
- if len(b.block.Transactions()) > 50 {
- bd = b
- break
- }
- }
- if bd.block == nil {
- t.Skip("no block with >50 txs")
- }
-
- author := getAuthor(config, bd.witness.Header())
- var expectedGas uint64
- for run := 0; run < 5; run++ {
+ runBlock := func(bd testBlockData) (uint64, error) {
+ author := getAuthor(config, bd.witness.Header())
memdb := bd.witness.MakeHashDB(diskdb)
sdb, err := state.New(bd.witness.Root(), state.NewDatabase(triedb.NewDatabase(memdb, triedb.HashDefaults), nil))
if err != nil {
- t.Fatal(err)
+ return 0, err
}
hc := &benchHeaderChain{config: config, chainDb: memdb,
headerCache: lru.NewCache[common.Hash, *types.Header](256), engine: engine}
@@ -262,13 +249,44 @@ func TestV2GasDeterminism(t *testing.T) {
headerCache: lru.NewCache[common.Hash, *types.Header](256), engine: engine}}
res, err := NewV2StateProcessor(hc, bc, 16).Process(bd.block, sdb, vm.Config{}, &author, context.Background())
+ if err != nil {
+ return 0, err
+ }
+ return res.GasUsed, nil
+ }
+
+ // Pick a block with enough txs and a complete embedded witness fixture.
+ // V2 now surfaces base-read errors instead of continuing with zero-ish
+ // values, so incomplete fixtures are skipped rather than used for gas
+ // determinism assertions.
+ var bd testBlockData
+ var expectedGas uint64
+ for _, b := range blocks {
+ if len(b.block.Transactions()) <= 50 {
+ continue
+ }
+ gas, err := runBlock(b)
+ if err != nil {
+ if strings.Contains(err.Error(), "v2: base read: missing trie node") {
+ continue
+ }
+ t.Fatal(err)
+ }
+ bd = b
+ expectedGas = gas
+ break
+ }
+ if bd.block == nil {
+ t.Skip("no block with >50 txs and complete embedded witness")
+ }
+
+ for run := 1; run < 5; run++ {
+ gas, err := runBlock(bd)
if err != nil {
t.Fatalf("run %d: %v", run, err)
}
- if run == 0 {
- expectedGas = res.GasUsed
- } else if res.GasUsed != expectedGas {
- t.Errorf("run %d: gas %d != expected %d (non-deterministic!)", run, res.GasUsed, expectedGas)
+ if gas != expectedGas {
+ t.Errorf("run %d: gas %d != expected %d (non-deterministic!)", run, gas, expectedGas)
}
}
}
diff --git a/core/v2_pre_exec_system_call_test.go b/core/v2_pre_exec_system_call_test.go
index 709d4f4cd4..e7e92d2d76 100644
--- a/core/v2_pre_exec_system_call_test.go
+++ b/core/v2_pre_exec_system_call_test.go
@@ -22,19 +22,14 @@ import (
// failed every Cancun spec test that exercised EIP-4788's user-facing read
// path of the BeaconRoots system contract.
//
-// Bug: SafeBase.SharedStorageCache (the trie reader's storageCache) is shared
-// across the prefetcher and V2 workers. The prefetcher loads BeaconRoots
-// storage slots from the trie BEFORE applyV2PreExecSystemCalls runs the
-// EIP-4788 system call, so the cache holds the pre-write zeros. The system
-// call then writes 12 (timestamp) and the parent beacon root into stateObject
-// dirty/pending storage — never touching the trie reader, so the cache stays
-// stale. SafeBase.GetState returned the cached zero, BeaconRoots' user-call
-// path checked storage[t%8191] != t and reverted, the spec-test caller saw
-// success=0 and the block's gas/state diverged from the spec.
+// Bug: V2 used to let SafeBase serve storage directly from the trie reader's
+// cache. If that cache was warmed before applyV2PreExecSystemCalls wrote the
+// EIP-4788 timestamp/root pair, workers could read the pre-write zero instead
+// of StateDB's pending storage. BeaconRoots' user-call path then reverted and
+// the block's gas/state diverged from the spec.
//
-// Fix: in newV2Env, after wiring SharedStorageCache, overlay every live
-// stateObject's pending+dirty storage onto the cache so it reflects
-// post-system-call state — see StateDB.OverlayPendingStorageInto.
+// Fix: SafeBase storage misses go through StateDB.GetState, which owns pending
+// writes, FlatDiff overlays, and trie/cache fallback semantics.
func TestV2_PreExecSystemCallVisibleToTx(t *testing.T) {
t.Run("Serial", func(t *testing.T) { runEIP4788Roundtrip(t, false) })
t.Run("V2", func(t *testing.T) { runEIP4788Roundtrip(t, true) })
@@ -106,11 +101,10 @@ func runEIP4788Roundtrip(t *testing.T, useV2 bool) {
t.Fatalf("ApplyMessage: %v", err)
}
} else {
- // Re-create the production V2 setup: trigger StorageCache population
- // (mimicking the prefetcher reading BeaconRoots[12] from trie BEFORE
- // the system call writes to it), then run applyV2PreExecSystemCalls,
- // then ExecuteV2BlockSTM. Without the OverlayPendingStorageInto fix
- // in newV2Env, the worker would see the stale cached zero.
+ // Re-create the production V2 setup: read BeaconRoots[12] before the
+ // system call writes to it, then run applyV2PreExecSystemCalls, then
+ // ExecuteV2BlockSTM. Workers must observe StateDB's post-system-call
+ // pending storage, not the earlier committed-state read.
_ = statedb.GetState(params.BeaconRootsAddress, common.BigToHash(new(big.Int).SetUint64(timestamp%8191)))
body := &types.Body{Transactions: types.Transactions{tx}}
header := &types.Header{
diff --git a/docs/cli/default_config.toml b/docs/cli/default_config.toml
index 5ec5b8c01e..c14e6e9af3 100644
--- a/docs/cli/default_config.toml
+++ b/docs/cli/default_config.toml
@@ -267,3 +267,8 @@ devfakeauthor = false
enable-preconfs = false
enable-private-tx = false
bp-rpc-endpoints = []
+
+[pipeline]
+ enable-import-src = false
+ import-src-logs = false
+ warm-snapshot = true
diff --git a/docs/cli/server.md b/docs/cli/server.md
index 37139cb150..832eed9a3f 100644
--- a/docs/cli/server.md
+++ b/docs/cli/server.md
@@ -330,6 +330,14 @@ The ```bor server``` command runs the Bor client.
- ```v5disc```: Enables the V5 discovery mechanism (default: true)
+### Pipeline Options
+
+- ```pipeline.enable-import-src```: Enable pipelined state root computation during block import: overlap SRC(N) with block N+1 tx execution (default: false)
+
+- ```pipeline.import-src-logs```: Enable verbose logging for pipelined import SRC (default: false)
+
+- ```pipeline.warm-snapshot```: Enable warm-node handoff from the execution-side trie prefetcher to the pipelined SRC when witnesses are produced; no effect when import SRC is disabled or witnesses are off (default: true)
+
### Sealer Options
- ```allow-gas-tip-override```: Allows block producers to override the mining gas tip (default: false)
diff --git a/eth/api_backend.go b/eth/api_backend.go
index 17871fea42..3a8be66735 100644
--- a/eth/api_backend.go
+++ b/eth/api_backend.go
@@ -299,6 +299,10 @@ func (b *EthAPIBackend) StateAndHeaderByNumber(ctx context.Context, number rpc.B
return stateDb, header, nil
}
+func (b *EthAPIBackend) WaitForStateCommit(ctx context.Context, root common.Hash) error {
+ return b.eth.BlockChain().WaitForPipelinedStateCommit(ctx, root)
+}
+
func (b *EthAPIBackend) StateAndHeaderByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*state.StateDB, *types.Header, error) {
if blockNr, ok := blockNrOrHash.Number(); ok {
return b.StateAndHeaderByNumber(ctx, blockNr)
diff --git a/eth/api_debug.go b/eth/api_debug.go
index 7bcf149e07..2c914d0316 100644
--- a/eth/api_debug.go
+++ b/eth/api_debug.go
@@ -227,6 +227,13 @@ func (api *DebugAPI) StorageRangeAt(ctx context.Context, blockNrOrHash rpc.Block
}
defer release()
+ // storageRangeAt opens the storage trie directly at the block's root,
+ // which a pipelined import commits asynchronously — wait out the
+ // in-flight SRC when this block is the pending head.
+ if err := api.eth.blockchain.WaitForPipelinedStateCommit(ctx, block.Root()); err != nil {
+ return StorageRangeResult{}, err
+ }
+
return storageRangeAt(statedb, block.Root(), contractAddress, keyStart, maxResult)
}
@@ -506,7 +513,7 @@ func (api *DebugAPI) ExecutionWitness(bn rpc.BlockNumber) (*stateless.ExtWitness
}
parentBlock := bc.GetBlockByHash(block.ParentHash())
- _, _, _, statedb, _, err := bc.ProcessBlock(parentBlock, block.Header(), nil, nil)
+ _, _, _, statedb, _, err := bc.ProcessBlock(parentBlock, block.Header(), nil, nil, nil)
if err != nil {
return nil, err
}
@@ -527,7 +534,7 @@ func (api *DebugAPI) ExecutionWitnessByHash(hash common.Hash) (*stateless.ExtWit
}
parentBlock := bc.GetBlockByHash(block.ParentHash())
- _, _, _, statedb, _, err := bc.ProcessBlock(parentBlock, block.Header(), nil, nil)
+ _, _, _, statedb, _, err := bc.ProcessBlock(parentBlock, block.Header(), nil, nil, nil)
if err != nil {
return nil, err
}
diff --git a/eth/backend.go b/eth/backend.go
index d6414dd0b1..90d45cddbe 100644
--- a/eth/backend.go
+++ b/eth/backend.go
@@ -74,7 +74,18 @@ import (
gethversion "github.com/ethereum/go-ethereum/version"
)
-var MilestoneWhitelistedDelayTimer = metrics.NewRegisteredTimer("chain/milestone/whitelisteddelay", nil)
+var (
+ MilestoneWhitelistedDelayTimer = metrics.NewRegisteredTimer("chain/milestone/whitelisteddelay", nil)
+ pendingPipelinedMaskedCounter = metrics.NewRegisteredCounter("chain/sync/pending_pipelined_masked_total", nil)
+)
+
+func hasPendingPipelinedHeadState(chain *core.BlockChain, head *types.Header) bool {
+ if !chain.HasRecentPipelinedHeadState(head.Hash(), head.Root) {
+ return false
+ }
+ pendingPipelinedMaskedCounter.Inc(1)
+ return true
+}
const (
// This is the fairness knob for the discovery mixer. When looking for peers, we'll
@@ -307,22 +318,25 @@ func New(stack *node.Node, config *ethconfig.Config) (*Ethereum, error) {
var (
options = &core.BlockChainConfig{
- TrieCleanLimit: config.TrieCleanCache,
- NoPrefetch: config.NoPrefetch,
- TrieDirtyLimit: config.TrieDirtyCache,
- ArchiveMode: config.NoPruning,
- TrieTimeLimit: config.TrieTimeout,
- SnapshotLimit: config.SnapshotCache,
- Preimages: config.Preimages,
- StateHistory: config.StateHistory,
- StateScheme: scheme,
- TriesInMemory: config.TriesInMemory,
- ChainHistoryMode: config.HistoryMode,
- TxLookupLimit: int64(min(config.TransactionHistory, math.MaxInt64)),
- AddressCacheSizes: config.AddressCacheSizes,
- PreloadRateLimit: config.PreloadRateLimit,
- VmConfig: vmCfg,
- Stateless: config.SyncMode == downloader.StatelessSync,
+ TrieCleanLimit: config.TrieCleanCache,
+ NoPrefetch: config.NoPrefetch,
+ TrieDirtyLimit: config.TrieDirtyCache,
+ ArchiveMode: config.NoPruning,
+ TrieTimeLimit: config.TrieTimeout,
+ SnapshotLimit: config.SnapshotCache,
+ Preimages: config.Preimages,
+ StateHistory: config.StateHistory,
+ StateScheme: scheme,
+ TriesInMemory: config.TriesInMemory,
+ ChainHistoryMode: config.HistoryMode,
+ TxLookupLimit: int64(min(config.TransactionHistory, math.MaxInt64)),
+ AddressCacheSizes: config.AddressCacheSizes,
+ PreloadRateLimit: config.PreloadRateLimit,
+ VmConfig: vmCfg,
+ EnablePipelinedImportSRC: config.EnablePipelinedImportSRC,
+ PipelinedImportSRCLogs: config.PipelinedImportSRCLogs,
+ PipelinedSRCWarmSnapshot: config.PipelinedSRCWarmSnapshot,
+ Stateless: config.SyncMode == downloader.StatelessSync,
// Enables file journaling for the trie database. The journal files will be stored
// within the data directory. The corresponding paths will be either:
// - DATADIR/triedb/merkle.journal
@@ -1125,7 +1139,13 @@ func (s *Ethereum) SyncMode() downloader.SyncMode {
// We are in a full sync, but the associated head state is missing. To complete
// the head state, forcefully rerun the snap sync. Note it doesn't mean the
// persistent state is corrupted, just mismatch with the head block.
- if !s.blockchain.HasState(head.Root) && !s.handler.statelessSync.Load() {
+ if !s.blockchain.HasCommittedState(head.Root) && !s.handler.statelessSync.Load() {
+ // Pipelined import can briefly expose a head whose SRC commit is still
+ // in flight. Report full sync during that bounded handoff only; once it
+ // expires, this remains a real missing-state signal.
+ if hasPendingPipelinedHeadState(s.blockchain, head) {
+ return downloader.FullSync
+ }
log.Info("Reenabled snap sync as chain is stateless")
return downloader.SnapSync
}
diff --git a/eth/ethconfig/config.go b/eth/ethconfig/config.go
index c7643976f3..196d01f96f 100644
--- a/eth/ethconfig/config.go
+++ b/eth/ethconfig/config.go
@@ -134,6 +134,17 @@ type Config struct {
NoPruning bool // Whether to disable pruning and flush everything to disk
NoPrefetch bool // Whether to disable prefetching and only load state on demand
+ // Pipelined import SRC: overlap SRC(N) with tx execution of block N+1 during import
+ EnablePipelinedImportSRC bool
+ PipelinedImportSRCLogs bool
+
+ // PipelinedSRCWarmSnapshot enables warm-cache handoff from the
+ // execution-side trie prefetcher to the pipelined SRC goroutine. Trie
+ // reads in SRC consult a hash-verified snapshot before falling through
+ // to pathdb. Targets cold-cache restart/catch-up CPU. NewTrieOnly
+ // semantics, witness completeness, and root determinism are unaffected.
+ PipelinedSRCWarmSnapshot bool
+
// Deprecated: use 'TransactionHistory' instead.
TxLookupLimit uint64 `toml:",omitempty"` // The maximum number of blocks from head whose tx indices are reserved.
diff --git a/eth/filters/api_test.go b/eth/filters/api_test.go
index 5b07fc663a..7278d0a694 100644
--- a/eth/filters/api_test.go
+++ b/eth/filters/api_test.go
@@ -219,3 +219,74 @@ func TestUnmarshalJSONNewFilterArgs(t *testing.T) {
t.Fatalf("expected 0 topics, got %d topics", len(test7.Topics[2]))
}
}
+
+// TestResolveBlockNumForRangeCheck exercises each branch of the sentinel-to-height
+// resolver so that mutations on the sentinel handling and fall-through can be
+// detected by tests rather than only through full FilterAPI integration tests.
+func TestResolveBlockNumForRangeCheck(t *testing.T) {
+ t.Parallel()
+ const head uint64 = 200
+
+ tests := []struct {
+ name string
+ n int64
+ want uint64
+ }{
+ {"concrete_zero", 0, 0},
+ {"concrete_positive", 42, 42},
+ {"concrete_at_head", 200, 200},
+ {"earliest_sentinel", rpc.EarliestBlockNumber.Int64(), 0},
+ {"latest_sentinel", rpc.LatestBlockNumber.Int64(), head},
+ {"pending_sentinel", rpc.PendingBlockNumber.Int64(), head},
+ {"safe_sentinel", rpc.SafeBlockNumber.Int64(), head},
+ {"finalized_sentinel", rpc.FinalizedBlockNumber.Int64(), head},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ if got := resolveBlockNumForRangeCheck(tc.n, head); got != tc.want {
+ t.Errorf("resolveBlockNumForRangeCheck(%d, %d) = %d; want %d", tc.n, head, got, tc.want)
+ }
+ })
+ }
+}
+
+// TestCheckBlockRangeLimit covers the range-limit DoS guard at the unit-function
+// level, including the boundary condition (span == limit) and the `- -> +`
+// operator mutation (a mutant that replaces subtraction with addition would
+// flag ranges whose sum exceeds the limit even though the span is small).
+func TestCheckBlockRangeLimit(t *testing.T) {
+ t.Parallel()
+ const head uint64 = 200
+
+ tests := []struct {
+ name string
+ begin int64
+ end int64
+ limit uint64
+ wantErr bool
+ }{
+ {"limit_zero_disabled", 0, 1000, 0, false},
+ {"span_below_limit", 0, 50, 100, false},
+ {"span_at_limit", 0, 100, 100, false},
+ {"span_above_limit", 0, 101, 100, true},
+ {"end_before_begin_no_false_positive", 50, 40, 100, false},
+ {"sum_exceeds_limit_but_span_small", 50, 55, 10, false},
+ {"earliest_to_head_exceeds_limit", rpc.EarliestBlockNumber.Int64(), rpc.LatestBlockNumber.Int64(), 100, true},
+ {"earliest_to_head_at_limit", rpc.EarliestBlockNumber.Int64(), rpc.LatestBlockNumber.Int64(), 200, false},
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ t.Parallel()
+ err := checkBlockRangeLimit(tc.begin, tc.end, head, tc.limit)
+ if tc.wantErr && err == nil {
+ t.Errorf("checkBlockRangeLimit(%d, %d, %d, %d) = nil; want error", tc.begin, tc.end, head, tc.limit)
+ }
+ if !tc.wantErr && err != nil {
+ t.Errorf("checkBlockRangeLimit(%d, %d, %d, %d) = %v; want nil", tc.begin, tc.end, head, tc.limit, err)
+ }
+ })
+ }
+}
diff --git a/eth/filters/filter_system_test.go b/eth/filters/filter_system_test.go
index 07497c6805..c48bbcbbb3 100644
--- a/eth/filters/filter_system_test.go
+++ b/eth/filters/filter_system_test.go
@@ -589,6 +589,14 @@ func TestInvalidGetRangeLogsRequest(t *testing.T) {
if _, err := api.GetLogs(t.Context(), FilterCriteria{FromBlock: big.NewInt(2), ToBlock: big.NewInt(1)}); err != errInvalidBlockRange {
t.Errorf("Expected Logs for invalid range return error, but got: %v", err)
}
+
+ // GetBorBlockLogs has the same range guard (fromBlock > toBlock → error) and
+ // must reject it independently — the previous branch only covered GetLogs.
+ // This is reached before the backend.CurrentHeader() call, so the empty-DB
+ // fixture above is sufficient.
+ if _, err := api.GetBorBlockLogs(t.Context(), FilterCriteria{FromBlock: big.NewInt(2), ToBlock: big.NewInt(1)}); err != errInvalidBlockRange {
+ t.Errorf("Expected GetBorBlockLogs for invalid range to return errInvalidBlockRange, but got: %v", err)
+ }
}
// TestExceedLogQueryLimit tests getLogs with too many addresses or topics
diff --git a/eth/handler.go b/eth/handler.go
index fc731a7579..1e9a1ce43f 100644
--- a/eth/handler.go
+++ b/eth/handler.go
@@ -164,13 +164,15 @@ type handler struct {
// privateTxGetter to check if a transaction needs to be treated as private or not
privateTxGetter relay.PrivateTxGetter
- eventMux *event.TypeMux
- txsCh chan core.NewTxsEvent
- txsSub event.Subscription
- stuckTxsCh chan core.StuckTxsEvent
- stuckTxsSub event.Subscription
- minedBlockSub *event.TypeMuxSubscription
- blockRange *blockRangeState
+ eventMux *event.TypeMux
+ txsCh chan core.NewTxsEvent
+ txsSub event.Subscription
+ stuckTxsCh chan core.StuckTxsEvent
+ stuckTxsSub event.Subscription
+ minedBlockSub *event.TypeMuxSubscription
+ witnessReadyCh chan core.WitnessReadyEvent
+ witnessReadySub event.Subscription
+ blockRange *blockRangeState
requiredBlocks map[uint64]common.Hash
@@ -620,6 +622,12 @@ func (h *handler) Start(maxPeers int) {
h.minedBlockSub = h.eventMux.Subscribe(core.NewMinedBlockEvent{})
go h.minedBroadcastLoop()
+ // announce witnesses from pipelined import SRC
+ h.witnessReadyCh = make(chan core.WitnessReadyEvent, 10)
+ h.witnessReadySub = h.chain.SubscribeWitnessReadyEvent(h.witnessReadyCh)
+ h.wg.Add(1)
+ go h.witnessReadyBroadcastLoop()
+
h.wg.Add(1)
go h.chainSync.loop()
@@ -641,6 +649,9 @@ func (h *handler) Stop() {
h.stuckTxsSub.Unsubscribe() // quits stuckTxBroadcastLoop
}
h.minedBlockSub.Unsubscribe()
+ if h.witnessReadySub != nil {
+ h.witnessReadySub.Unsubscribe()
+ }
h.blockRange.stop()
// Quit chainSync and txsync64.
@@ -719,8 +730,9 @@ func (h *handler) BroadcastBlock(block *types.Block, witness *stateless.Witness,
return
}
- // Otherwise if the block is indeed in out own chain, announce it
- if h.chain.HasBlock(hash, block.NumberU64()) {
+ // Otherwise, announce the block if it is already written locally or if the
+ // witness is cached and the block is in-flight on the local write path.
+ if h.chain.HasBlock(hash, block.NumberU64()) || h.chain.HasWitness(hash) {
for _, peer := range peers {
peer.AsyncSendNewBlockHash(block)
}
@@ -831,13 +843,76 @@ func (h *handler) minedBroadcastLoop() {
log.Info("[block tracker] Broadcasting mined block", "number", ev.Block.NumberU64(), "hash", ev.Block.Hash(), "blockTime", ev.Block.Time(), "now", now.Unix(), "delay", delay, "delayInMs", delayInMs, "sealToBroadcast", common.PrettyDuration(sealToBcast))
}
loopStart := time.Now()
- h.BroadcastBlock(ev.Block, ev.Witness, true) // First propagate block to peers
- h.BroadcastBlock(ev.Block, ev.Witness, false) // Only then announce to the rest
+ h.BroadcastBlock(ev.Block, ev.Witness, true) // First propagate block to peers
+ // Tracked on h.wg so Stop waits it out (bounded by the 500ms
+ // visibility poll) instead of racing peer shutdown.
+ h.wg.Add(1)
+ go func(block *types.Block, witness *stateless.Witness) {
+ defer h.wg.Done()
+ h.announceMinedBlock(block, witness)
+ }(ev.Block, ev.Witness)
broadcastLoopTimer.Update(time.Since(loopStart))
}
}
}
+// announceMinedBlock announces a locally mined block after it becomes visible
+// through the local chain reader.
+//
+// The pipelined inline path broadcasts before its async DB write completes, so
+// announcing immediately can race with HasBlock() and silently skip the hash
+// announcement to non-propagation peers. Wait briefly for the write to land,
+// then announce. If the block still isn't visible but the witness is cached,
+// fall back to the witness-gated path so stateless peers can still progress.
+func (h *handler) announceMinedBlock(block *types.Block, witness *stateless.Witness) {
+ const (
+ pollInterval = 10 * time.Millisecond
+ maxWait = 500 * time.Millisecond
+ )
+
+ hash := block.Hash()
+ number := block.NumberU64()
+ deadline := time.NewTimer(maxWait)
+ defer deadline.Stop()
+ ticker := time.NewTicker(pollInterval)
+ defer ticker.Stop()
+
+ for {
+ if h.chain.HasBlock(hash, number) {
+ h.BroadcastBlock(block, witness, false)
+ return
+ }
+ select {
+ case <-ticker.C:
+ case <-deadline.C:
+ if h.chain.HasWitness(hash) {
+ h.BroadcastBlock(block, witness, false)
+ } else {
+ log.Debug("Skipping mined block announce before local write became visible", "hash", hash, "number", number)
+ }
+ return
+ }
+ }
+}
+
+// witnessReadyBroadcastLoop announces witness availability from the pipelined
+// import SRC goroutine. Without this, the stateless node would have to poll
+// for witnesses with 10-second retry intervals.
+func (h *handler) witnessReadyBroadcastLoop() {
+ defer h.wg.Done()
+
+ for {
+ select {
+ case ev := <-h.witnessReadyCh:
+ for _, peer := range h.peers.peersWithoutWitness(ev.BlockHash) {
+ peer.Peer.AsyncSendNewWitnessHash(ev.BlockHash, ev.BlockNumber)
+ }
+ case <-h.witnessReadySub.Err():
+ return
+ }
+ }
+}
+
// txBroadcastLoop announces new transactions to connected peers.
func (h *handler) txBroadcastLoop() {
defer h.wg.Done()
diff --git a/eth/handler_eth_test.go b/eth/handler_eth_test.go
index a016d906ac..7745f1e3a5 100644
--- a/eth/handler_eth_test.go
+++ b/eth/handler_eth_test.go
@@ -45,9 +45,10 @@ import (
// testEthHandler is a mock event handler to listen for inbound network requests
// on the `eth` protocol and convert them into a more easily testable form.
type testEthHandler struct {
- blockBroadcasts event.Feed
- txAnnounces event.Feed
- txBroadcasts event.Feed
+ blockBroadcasts event.Feed
+ blockAnnouncements event.Feed
+ txAnnounces event.Feed
+ txBroadcasts event.Feed
}
func (h *testEthHandler) Chain() *core.BlockChain { panic("no backing chain") }
@@ -62,6 +63,11 @@ func (h *testEthHandler) Handle(peer *eth.Peer, packet eth.Packet) error {
h.blockBroadcasts.Send(packet.Block)
return nil
+ case *eth.NewBlockHashesPacket:
+ hashes, _ := packet.Unpack()
+ h.blockAnnouncements.Send(hashes)
+ return nil
+
case *eth.NewPooledTransactionHashesPacket:
h.txAnnounces.Send(packet.Hashes)
return nil
@@ -705,6 +711,70 @@ func testBroadcastBlock(t *testing.T, peers, bcasts int) {
}
}
+func TestMinedBroadcastAnnouncesWithCachedWitnessBeforeWrite(t *testing.T) {
+ t.Parallel()
+
+ source := newTestHandlerWithBlocks(1)
+ defer source.close()
+
+ sinks := make([]*testEthHandler, 2)
+ for i := range sinks {
+ sinks[i] = new(testEthHandler)
+ }
+
+ for i, sink := range sinks {
+ sourcePipe, sinkPipe := p2p.MsgPipe()
+ defer sourcePipe.Close()
+ defer sinkPipe.Close()
+
+ sourcePeer := eth.NewPeer(eth.ETH68, p2p.NewPeerPipe(enode.ID{byte(i)}, "", nil, sourcePipe), sourcePipe, nil)
+ sinkPeer := eth.NewPeer(eth.ETH68, p2p.NewPeerPipe(enode.ID{0}, "", nil, sinkPipe), sinkPipe, nil)
+ defer sourcePeer.Close()
+ defer sinkPeer.Close()
+
+ go source.handler.runEthPeer(sourcePeer, func(peer *eth.Peer) error {
+ return eth.Handle((*ethHandler)(source.handler), peer)
+ })
+ if err := sinkPeer.Handshake(1, source.chain, eth.BlockRangeUpdatePacket{}); err != nil {
+ t.Fatalf("failed to run protocol handshake: %v", err)
+ }
+ go eth.Handle(sink, sinkPeer)
+ }
+
+ announceCh := make(chan []common.Hash, len(sinks))
+ for i := range sinks {
+ sub := sinks[i].blockAnnouncements.Subscribe(announceCh)
+ defer sub.Unsubscribe()
+ }
+
+ parent := source.chain.CurrentBlock()
+ block := types.NewBlockWithHeader(&types.Header{
+ ParentHash: parent.Hash(),
+ Number: new(big.Int).Add(parent.Number, common.Big1),
+ Time: parent.Time + 1,
+ Difficulty: big.NewInt(1),
+ GasLimit: parent.GasLimit,
+ })
+ require.False(t, source.chain.HasBlock(block.Hash(), block.NumberU64()))
+
+ source.chain.CacheWitness(block.Hash(), []byte{0x1})
+
+ time.Sleep(100 * time.Millisecond)
+ source.handler.eventMux.Post(core.NewMinedBlockEvent{Block: block})
+
+ timeout := time.After(2 * time.Second)
+ for {
+ select {
+ case hashes := <-announceCh:
+ if len(hashes) == 1 && hashes[0] == block.Hash() {
+ return
+ }
+ case <-timeout:
+ t.Fatalf("timed out waiting for block hash announcement for block %s", block.Hash())
+ }
+ }
+}
+
// Tests that a propagated malformed block (uncles or transactions don't match
// with the hashes in the header) gets discarded and not broadcast forward.
func TestBroadcastMalformedBlock69(t *testing.T) {
diff --git a/eth/handler_wit.go b/eth/handler_wit.go
index 7bf5e46b1f..076d57727b 100644
--- a/eth/handler_wit.go
+++ b/eth/handler_wit.go
@@ -114,6 +114,48 @@ func (h *witHandler) handleWitnessHashesAnnounce(peer *wit.Peer, hashes []common
// handleGetWitness retrieves witnesses for the requested block hashes and returns them as raw RLP data.
// It now returns the data and error, rather than sending the reply directly.
// The returned data is [][]byte, as rlp.RawValue is essentially []byte.
+// resolveWitnessSizes builds the per-hash size map for a GetWitness request.
+// First checks rawdb for persisted witness sizes. For hashes missing from
+// rawdb but whose block header exists, falls back to GetWitnessUncachedWait
+// (which consults the cache and waits for pipelined SRC without inserting
+// peer-driven reads into the witness cache) so peers get a response for
+// witnesses still being generated by the SRC goroutine.
+//
+// The header existence check is critical: without it, a peer can DoS the
+// handler by requesting sizes for non-existent block hashes, forcing a
+// 2-second wait per hash in the SRC fallback.
+//
+// Returns both the size map and any witnesses fetched during the size
+// lookup so the caller can reuse them instead of re-fetching. Retained
+// prefetch bytes are capped at MaximumResponseSize — nothing beyond that can
+// be served in this response anyway — so a request for many large witnesses
+// can't pin unbounded memory before the caller's per-page budget checks run.
+func (h *witHandler) resolveWitnessSizes(seen map[common.Hash]struct{}) (map[common.Hash]uint64, map[common.Hash][]byte) {
+ sizes := make(map[common.Hash]uint64, len(seen))
+ prefetched := make(map[common.Hash][]byte, len(seen))
+ prefetchedBytes := uint64(0)
+ for hash := range seen {
+ if size := rawdb.ReadWitnessSize(h.Chain().DB(), hash); size != nil {
+ sizes[hash] = *size
+ continue
+ }
+ if h.Chain().GetHeaderByHash(hash) == nil {
+ sizes[hash] = 0
+ continue
+ }
+ if w := h.Chain().GetWitnessUncachedWait(hash); len(w) > 0 {
+ sizes[hash] = uint64(len(w))
+ if prefetchedBytes+uint64(len(w)) <= MaximumResponseSize {
+ prefetched[hash] = w
+ prefetchedBytes += uint64(len(w))
+ }
+ } else {
+ sizes[hash] = 0
+ }
+ }
+ return sizes, prefetched
+}
+
func (h *witHandler) handleGetWitness(peer *wit.Peer, req *wit.GetWitnessPacket) (wit.WitnessPacketResponse, error) {
log.Debug("handleGetWitness processing request", "peer", peer.ID(), "reqID", req.RequestId, "witnessPages", len(req.WitnessPages))
@@ -127,16 +169,7 @@ func (h *witHandler) handleGetWitness(peer *wit.Peer, req *wit.GetWitnessPacket)
seen[witnessPage.Hash] = struct{}{}
}
- // witness sizes query
- witnessSize := make(map[common.Hash]uint64, len(seen))
- for witnessBlockHash := range seen {
- size := rawdb.ReadWitnessSize(h.Chain().DB(), witnessBlockHash)
- if size == nil {
- witnessSize[witnessBlockHash] = 0
- } else {
- witnessSize[witnessBlockHash] = *size
- }
- }
+ witnessSize, prefetchedWitnesses := h.resolveWitnessSizes(seen)
// query witnesses by demand
var response wit.WitnessPacketResponse
@@ -163,9 +196,17 @@ func (h *witHandler) handleGetWitness(peer *wit.Peer, req *wit.GetWitnessPacket)
if totalLoaded+int(size) >= MaximumCachedWitnessOnARequest {
return nil, errors.New("request demands too much memory")
}
- // Read without populating the chain witness cache so peer-serving
- // traffic does not evict witnesses the import path relies on.
- witnessBytes = h.Chain().GetWitnessUncached(witnessPage.Hash)
+ if prefetched, ok := prefetchedWitnesses[witnessPage.Hash]; ok {
+ // Reuse the witness fetched during size resolution: a witness
+ // still being generated by the pipelined SRC has no persisted
+ // size, so resolveWitnessSizes already had to read it — a
+ // second fetch would repeat the pipelined-SRC wait.
+ witnessBytes = prefetched
+ } else {
+ // Read without populating the chain witness cache so peer-serving
+ // traffic does not evict witnesses the import path relies on.
+ witnessBytes = h.Chain().GetWitnessUncached(witnessPage.Hash)
+ }
witnessCache[witnessPage.Hash] = witnessBytes
totalLoaded += len(witnessBytes)
}
diff --git a/eth/peer.go b/eth/peer.go
index 3612db28a8..a2838439ab 100644
--- a/eth/peer.go
+++ b/eth/peer.go
@@ -744,47 +744,56 @@ func (p *ethPeer) doWitnessRequest(
<-witReqSem
return err
}
-
witReqsWg.Add(1)
-
- go func() {
- var witRes *wit.Response
- select {
- case witRes = <-witResCh:
- case <-cancel:
- witReqsWg.Done()
- <-witReqSem
- return
- }
-
- // Unblock the wit dispatcher now that we've received the response.
- // Select with cancel to avoid blocking if Done is unbuffered and
- // the dispatcher has already exited.
- if witRes != nil && witRes.Done != nil {
- select {
- case witRes.Done <- nil:
- case <-cancel:
- witReqsWg.Done()
- <-witReqSem
- return
- }
- }
-
- select {
- case witReqResCh <- &witReqRes{Request: request, Response: witRes}:
- case <-cancel:
- witReqsWg.Done()
- <-witReqSem
- }
- }()
+ go awaitWitnessResponse(request, witResCh, witReqResCh, witReqsWg, witReqSem, cancel)
mapsMu.Lock()
*witReqs = append(*witReqs, witReq)
-
if page >= witTotalRequest[hash] {
witTotalRequest[hash]++
}
mapsMu.Unlock()
-
return nil
}
+
+// awaitWitnessResponse runs in a dedicated goroutine per outstanding witness
+// request. It waits for the peer's response (or cancel), unblocks the wit
+// dispatcher, and forwards the result on witReqResCh. On cancel at any
+// step we release the waitgroup + semaphore so the caller isn't wedged;
+// on successful delivery the consumer of witReqResCh owns that release.
+func awaitWitnessResponse(
+ request []wit.WitnessPageRequest,
+ witResCh <-chan *wit.Response,
+ witReqResCh chan *witReqRes,
+ witReqsWg *sync.WaitGroup,
+ witReqSem chan int,
+ cancel <-chan struct{},
+) {
+ releaseOnCancel := func() {
+ witReqsWg.Done()
+ <-witReqSem
+ }
+ var witRes *wit.Response
+ select {
+ case witRes = <-witResCh:
+ case <-cancel:
+ releaseOnCancel()
+ return
+ }
+ // Unblock the wit dispatcher now that we've received the response.
+ // Select with cancel to avoid blocking if Done is unbuffered and the
+ // dispatcher has already exited.
+ if witRes != nil && witRes.Done != nil {
+ select {
+ case witRes.Done <- nil:
+ case <-cancel:
+ releaseOnCancel()
+ return
+ }
+ }
+ select {
+ case witReqResCh <- &witReqRes{Request: request, Response: witRes}:
+ case <-cancel:
+ releaseOnCancel()
+ }
+}
diff --git a/eth/sync.go b/eth/sync.go
index e86ce4f142..3aadf39e6a 100644
--- a/eth/sync.go
+++ b/eth/sync.go
@@ -307,7 +307,14 @@ func (cs *chainSyncer) modeAndLocalHead() (downloader.SyncMode, *big.Int) {
// We are in a full sync, but the associated head state is missing. To complete
// the head state, forcefully rerun the snap sync. Note it doesn't mean the
// persistent state is corrupted, just mismatch with the head block.
- if !cs.handler.chain.HasState(head.Root) {
+ if !cs.handler.chain.HasCommittedState(head.Root) {
+ // Pipelined import may have already advanced the canonical head while
+ // the matching SRC commit is still in flight. Stay in full sync for
+ // that bounded handoff only; otherwise the snap recovery path below
+ // still handles genuinely missing head state.
+ if hasPendingPipelinedHeadState(cs.handler.chain, head) {
+ return downloader.FullSync, td
+ }
block := cs.handler.chain.CurrentSnapBlock()
td := cs.handler.chain.GetTd(block.Hash(), block.Number.Uint64())
log.Info("Reenabled snap sync as chain is stateless")
diff --git a/internal/cli/server/config.go b/internal/cli/server/config.go
index 99d60680c1..820a379eb7 100644
--- a/internal/cli/server/config.go
+++ b/internal/cli/server/config.go
@@ -175,6 +175,9 @@ type Config struct {
// Relay has transaction relay related settings
Relay *RelayConfig `hcl:"relay,block" toml:"relay,block"`
+
+ // Pipeline has pipelined SRC settings for block import
+ Pipeline *PipelineConfig `hcl:"pipeline,block" toml:"pipeline,block"`
}
type HistoryConfig struct {
@@ -822,6 +825,22 @@ type RelayConfig struct {
BlockProducerRpcEndpoints []string `hcl:"bp-rpc-endpoints,optional" toml:"bp-rpc-endpoints,optional"`
}
+// PipelineConfig has settings for pipelined state root computation during block import.
+type PipelineConfig struct {
+ // EnableImportSRC enables pipelined state root computation during block import:
+ // overlap SRC(N) with tx execution of block N+1
+ EnableImportSRC bool `hcl:"enable-import-src,optional" toml:"enable-import-src,optional"`
+
+ // ImportSRCLogs enables verbose logging for pipelined import SRC
+ ImportSRCLogs bool `hcl:"import-src-logs,optional" toml:"import-src-logs,optional"`
+
+ // WarmSnapshot enables warm-cache handoff from the execution-side trie
+ // prefetcher to the pipelined SRC goroutine when witnesses are produced.
+ // Targets cold-cache restart/catch-up CPU; no effect on correctness or
+ // witness completeness. Witness-off import ignores it.
+ WarmSnapshot bool `hcl:"warm-snapshot,optional" toml:"warm-snapshot,optional"`
+}
+
func DefaultConfig() *Config {
return &Config{
Chain: "mainnet",
@@ -1086,6 +1105,11 @@ func DefaultConfig() *Config {
EnablePrivateTx: false,
BlockProducerRpcEndpoints: []string{},
},
+ Pipeline: &PipelineConfig{
+ EnableImportSRC: false,
+ ImportSRCLogs: false,
+ WarmSnapshot: true,
+ },
}
}
@@ -1174,6 +1198,10 @@ func readConfigFile(path string) (*Config, error) {
TxPool: &TxPoolConfig{},
Cache: &CacheConfig{},
Sealer: &SealerConfig{},
+ // WarmSnapshot defaults on: witness-producing nodes measure faster
+ // with it, and it is inert otherwise. The zero value would silently
+ // put HCL-configured witness nodes on the slower path.
+ Pipeline: &PipelineConfig{WarmSnapshot: true},
}
if err := hclsimple.DecodeFile(path, nil, config); err != nil {
@@ -1568,6 +1596,9 @@ func (c *Config) buildEth(stack *node.Node, accountManager *accounts.Manager) (*
n.TrieDirtyCache = calcPerc(c.Cache.PercGc)
n.NoPrefetch = c.Cache.NoPrefetch
n.Preimages = c.Cache.Preimages
+ n.EnablePipelinedImportSRC = c.Pipeline.EnableImportSRC
+ n.PipelinedImportSRCLogs = c.Pipeline.ImportSRCLogs
+ n.PipelinedSRCWarmSnapshot = c.Pipeline.WarmSnapshot
// Note that even the values set by `history.transactions` will be written in the old flag until it's removed.
n.TransactionHistory = c.Cache.TxLookupLimit
n.TrieTimeout = c.Cache.TrieTimeout
diff --git a/internal/cli/server/flags.go b/internal/cli/server/flags.go
index 07f0c01b50..04dc260c3f 100644
--- a/internal/cli/server/flags.go
+++ b/internal/cli/server/flags.go
@@ -647,6 +647,27 @@ func (c *Command) Flags(config *Config) *flagset.Flagset {
Default: c.cliConfig.Cache.TxLookupLimit,
Group: "Cache",
})
+ f.BoolFlag(&flagset.BoolFlag{
+ Name: "pipeline.enable-import-src",
+ Usage: "Enable pipelined state root computation during block import: overlap SRC(N) with block N+1 tx execution",
+ Value: &c.cliConfig.Pipeline.EnableImportSRC,
+ Default: c.cliConfig.Pipeline.EnableImportSRC,
+ Group: "Pipeline",
+ })
+ f.BoolFlag(&flagset.BoolFlag{
+ Name: "pipeline.import-src-logs",
+ Usage: "Enable verbose logging for pipelined import SRC",
+ Value: &c.cliConfig.Pipeline.ImportSRCLogs,
+ Default: c.cliConfig.Pipeline.ImportSRCLogs,
+ Group: "Pipeline",
+ })
+ f.BoolFlag(&flagset.BoolFlag{
+ Name: "pipeline.warm-snapshot",
+ Usage: "Enable warm-node handoff from the execution-side trie prefetcher to the pipelined SRC when witnesses are produced; no effect when import SRC is disabled or witnesses are off",
+ Value: &c.cliConfig.Pipeline.WarmSnapshot,
+ Default: c.cliConfig.Pipeline.WarmSnapshot,
+ Group: "Pipeline",
+ })
f.IntFlag(&flagset.IntFlag{
Name: "fdlimit",
Usage: "Raise the open file descriptor resource limit (default = system fd limit)",
diff --git a/internal/ethapi/api.go b/internal/ethapi/api.go
index e816d52e19..dafb316234 100644
--- a/internal/ethapi/api.go
+++ b/internal/ethapi/api.go
@@ -480,6 +480,12 @@ func (api *BlockChainAPI) GetProof(ctx context.Context, address common.Address,
if statedb == nil || err != nil {
return nil, err
}
+ // Proofs are built from trie nodes, which a pipelined import commits
+ // asynchronously — wait out the in-flight SRC when the queried root is
+ // the pending head so the trie opens below don't fail transiently.
+ if err := api.b.WaitForStateCommit(ctx, header.Root); err != nil {
+ return nil, err
+ }
codeHash := statedb.GetCodeHash(address)
storageRoot := statedb.GetStorageRoot(address)
diff --git a/internal/ethapi/api_test.go b/internal/ethapi/api_test.go
index 29357c024c..119751bbd8 100644
--- a/internal/ethapi/api_test.go
+++ b/internal/ethapi/api_test.go
@@ -637,6 +637,9 @@ func (b testBackend) StateAndHeaderByNumberOrHash(ctx context.Context, blockNrOr
}
panic("only implemented for number")
}
+func (b testBackend) WaitForStateCommit(ctx context.Context, root common.Hash) error {
+ return nil
+}
func (b testBackend) Pending() (*types.Block, types.Receipts, *state.StateDB) {
block := b.pending
if block == nil {
diff --git a/internal/ethapi/backend.go b/internal/ethapi/backend.go
index 07d326b5df..aec925dec1 100644
--- a/internal/ethapi/backend.go
+++ b/internal/ethapi/backend.go
@@ -91,6 +91,11 @@ type Backend interface {
BlockByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*types.Block, error)
StateAndHeaderByNumber(ctx context.Context, number rpc.BlockNumber) (*state.StateDB, *types.Header, error)
StateAndHeaderByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*state.StateDB, *types.Header, error)
+ // WaitForStateCommit blocks until the state root is committed to the trie
+ // database when it belongs to a pipelined import still in flight; it is a
+ // no-op for any other root. Handlers that open tries directly by root
+ // call this so queries at the chain head don't fail transiently.
+ WaitForStateCommit(ctx context.Context, root common.Hash) error
Pending() (*types.Block, types.Receipts, *state.StateDB)
GetReceipts(ctx context.Context, hash common.Hash) (types.Receipts, error)
GetCanonicalReceipt(tx *types.Transaction, blockHash common.Hash, blockNumber, blockIndex uint64) (*types.Receipt, error)
diff --git a/internal/ethapi/transaction_args_test.go b/internal/ethapi/transaction_args_test.go
index f1baa890e9..62b3380c23 100644
--- a/internal/ethapi/transaction_args_test.go
+++ b/internal/ethapi/transaction_args_test.go
@@ -400,7 +400,8 @@ func (b *backendMock) StateAndHeaderByNumber(ctx context.Context, number rpc.Blo
func (b *backendMock) StateAndHeaderByNumberOrHash(ctx context.Context, blockNrOrHash rpc.BlockNumberOrHash) (*state.StateDB, *types.Header, error) {
return nil, nil, nil
}
-func (b *backendMock) Pending() (*types.Block, types.Receipts, *state.StateDB) { return nil, nil, nil }
+func (b *backendMock) WaitForStateCommit(ctx context.Context, root common.Hash) error { return nil }
+func (b *backendMock) Pending() (*types.Block, types.Receipts, *state.StateDB) { return nil, nil, nil }
func (b *backendMock) GetReceipts(ctx context.Context, hash common.Hash) (types.Receipts, error) {
//nolint:nilnil
return nil, nil
diff --git a/miner/fake_miner.go b/miner/fake_miner.go
index 4db0be668b..383aff2010 100644
--- a/miner/fake_miner.go
+++ b/miner/fake_miner.go
@@ -263,6 +263,10 @@ func (bc *testBlockChainBor) StateAt(common.Hash) (*state.StateDB, error) {
return bc.statedb, nil
}
+func (bc *testBlockChainBor) PostExecState(header *types.Header) (*state.StateDB, error) {
+ return bc.statedb, nil
+}
+
func (bc *testBlockChainBor) SubscribeChainHeadEvent(ch chan<- core.ChainHeadEvent) event.Subscription {
return bc.chainHeadFeed.Subscribe(ch)
}
diff --git a/miner/pipeline.go b/miner/pipeline.go
new file mode 100644
index 0000000000..83660642ae
--- /dev/null
+++ b/miner/pipeline.go
@@ -0,0 +1,1230 @@
+package miner
+
+import (
+ "crypto/sha256"
+ "errors"
+ "fmt"
+ "math/big"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus"
+ "github.com/ethereum/go-ethereum/consensus/bor"
+ "github.com/ethereum/go-ethereum/consensus/misc/eip1559"
+ "github.com/ethereum/go-ethereum/core"
+ "github.com/ethereum/go-ethereum/core/state"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/core/vm"
+ "github.com/ethereum/go-ethereum/log"
+ "github.com/ethereum/go-ethereum/metrics"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// Pipelined SRC metrics
+var (
+ pipelineSpeculativeBlocksCounter = metrics.NewRegisteredCounter("worker/pipelineSpeculativeBlocks", nil)
+ pipelineSpeculativeAbortsCounter = metrics.NewRegisteredCounter("worker/pipelineSpeculativeAborts", nil)
+ pipelineEIP2935AbortsCounter = metrics.NewRegisteredCounter("worker/pipelineEIP2935Aborts", nil)
+ pipelineSRCTimer = metrics.NewRegisteredTimer("worker/pipelineSRCTime", nil)
+ pipelineFlatDiffExtractTimer = metrics.NewRegisteredTimer("worker/pipelineFlatDiffExtractTime", nil)
+ pipelineSpeculativeCommittedCounter = metrics.NewRegisteredCounter("worker/pipelineSpeculativeCommitted", nil) // speculative block broadcast as the real next block — success signal
+ pipelineSRCWaitTimer = metrics.NewRegisteredTimer("worker/pipelineSRCWait", nil) // time blocked on WaitForSRC (ideally near-zero — means SRC finished before the caller arrived)
+ pipelineSealDurationTimer = metrics.NewRegisteredTimer("worker/pipelineSealDuration", nil) // engine.Seal latency in the inline path
+ // Per-cause abort counters — each increments alongside the aggregate pipelineSpeculativeAbortsCounter.
+ pipelineAbortBlockhashCounter = metrics.NewRegisteredCounter("worker/pipelineSpeculativeAborts/blockhash", nil) // BLOCKHASH(N) was read during speculative N+1
+ pipelineAbortSRCFailedCounter = metrics.NewRegisteredCounter("worker/pipelineSpeculativeAborts/src_failed", nil) // WaitForSRC returned an error
+ pipelineAbortFallbackCounter = metrics.NewRegisteredCounter("worker/pipelineSpeculativeAborts/fallback", nil) // fallbackToSequential entered
+ // Announce earliness histogram (ms). Positive = announced before header.Time (PIP-66 working). Negative = announced late.
+ pipelineAnnounceEarlinessMs = metrics.NewRegisteredHistogram("worker/pipelineAnnounceEarlinessMs", nil, metrics.NewExpDecaySample(1028, 0.015))
+ // Mode gauge — currently always 0 because production-side pipelined SRC is
+ // disabled. Keep the metric so existing dashboards can distinguish miner-side
+ // production pipelining from import-side pipelining.
+ pipelineBuildEnabledGauge = metrics.NewRegisteredGauge("worker/pipeline/enabled", nil)
+)
+
+// Production-side pipelined SRC is disabled and no longer exposed as a config
+// option. Keep this local constant so the old production-pipeline logging sites
+// remain easy to re-enable if this path is revisited.
+const productionPipelineLogs = false
+
+const speculativeEmptyRefillLead = 300 * time.Millisecond
+
+// Refill speculative blocks that are still less than 75% full after the first
+// txpool snapshot. This catches the common case where the early snapshot grabs
+// a small trickle of txs, but the load ramps up before the slot boundary.
+const speculativeLowFillRemainingGasDivisor = 4
+
+// speculativeWorkReq is sent to mainLoop's speculative work channel
+// when block N's execution is done and we want to speculatively start N+1.
+type speculativeWorkReq struct {
+ parentHeader *types.Header // block N's header (complete except Root)
+ flatDiff *state.FlatDiff // block N's state mutations
+ parentRoot common.Hash // root_{N-1} (last committed trie root)
+ blockNEnv *environment // block N's execution environment (for assembly later)
+ stateSyncData []*types.StateSyncData // from FinalizeForPipeline
+}
+
+// placeholderParentHash generates a deterministic placeholder hash for use
+// as ParentHash in speculative headers. It must not collide with any real
+// block hash.
+func placeholderParentHash(blockNumber uint64) common.Hash {
+ data := append([]byte("pipelined-src-placeholder:"), new(big.Int).SetUint64(blockNumber).Bytes()...)
+ return sha256.Sum256(data)
+}
+
+// isPipelineEligible checks whether we can use pipelined SRC for block
+// production. Production-side pipelining is intentionally disabled; the
+// import-side pipelined SRC path remains controlled by the chain pipeline
+// config.
+func (w *worker) isPipelineEligible(_ uint64) bool {
+ // Re-enable reference:
+ //
+ // if !w.config.EnablePipelinedSRC {
+ // return false
+ // }
+ // if w.chainConfig.Bor == nil {
+ // return false
+ // }
+ // if len(w.chainConfig.Bor.Sprint) == 0 {
+ // return false
+ // }
+ // if !w.IsRunning() || w.syncing.Load() {
+ // return false
+ // }
+ // // Pre-Rio: the speculative chain reader provides block N's unsigned header.
+ // // When snapshot() walks back and calls ecrecover() on this header, it fails
+ // // because the Extra seal bytes are all zeros (Seal() hasn't run yet).
+ // // This causes speculative Prepare to always fail with "recovery failed",
+ // // making the pipeline useless pre-Rio. Skip it entirely.
+ // nextBlockNumber := currentBlockNumber + 1
+ // if !w.chainConfig.Bor.IsRio(new(big.Int).SetUint64(nextBlockNumber)) {
+ // return false
+ // }
+ // return true
+ return false
+}
+
+// commitPipelined is the pipelined version of commit(). Instead of calling
+// FinalizeAndAssemble (which blocks on IntermediateRoot), it:
+// 1. Calls FinalizeForPipeline (state sync, span commits — no IntermediateRoot)
+// 2. Extracts FlatDiff
+// 3. Sends a speculativeWorkReq to start N+1 execution
+// 4. Returns immediately — the SRC goroutine is spawned by commitSpeculativeWork
+// after confirming the speculative Prepare() succeeds. This avoids a trie DB
+// race between the SRC goroutine and the fallback path's inline commit.
+func (w *worker) commitPipelined(env *environment, start time.Time) error {
+ if !w.IsRunning() {
+ return nil
+ }
+
+ env = env.copy()
+
+ borEngine, ok := w.engine.(*bor.Bor)
+ if !ok {
+ log.Error("Pipelined SRC: engine is not Bor")
+ return nil
+ }
+
+ // Phase 1: Finalize (state sync, span commits) without IntermediateRoot.
+ stateSyncData, err := borEngine.FinalizeForPipeline(w.chain, env.header, env.state, &types.Body{
+ Transactions: env.txs,
+ }, env.receipts)
+ if err != nil {
+ log.Error("Pipelined SRC: FinalizeForPipeline failed", "err", err)
+ return err
+ }
+
+ // Phase 2: Extract FlatDiff, record mode-visible side-effects, build the
+ // speculative work request. The SRC goroutine is NOT spawned here —
+ // commitSpeculativeWork spawns it after confirming Prepare() succeeds.
+ req, ok := w.buildSpeculativeReq(env, stateSyncData)
+ if !ok {
+ return nil
+ }
+
+ // Phase 3: Hand off to mainLoop's speculative path.
+ select {
+ case w.speculativeWorkCh <- req:
+ case <-w.exitCh:
+ }
+ return nil
+}
+
+// buildSpeculativeReq extracts block N's FlatDiff, resolves the committed
+// parent root, and composes the speculativeWorkReq for block N+1.
+// Returns ok=false only when the parent header cannot be located (treated as
+// a soft failure — the caller skips pipelining rather than returning an error,
+// matching the pre-refactor behavior).
+func (w *worker) buildSpeculativeReq(env *environment, stateSyncData []*types.StateSyncData) (*speculativeWorkReq, bool) {
+ flatDiffStart := time.Now()
+ flatDiff := env.state.CommitSnapshot(w.chainConfig.IsEIP158(env.header.Number))
+ pipelineFlatDiffExtractTimer.Update(time.Since(flatDiffStart))
+
+ parent := w.chain.GetHeader(env.header.ParentHash, env.header.Number.Uint64()-1)
+ if parent == nil {
+ log.Error("Pipelined SRC: parent not found", "parentHash", env.header.ParentHash)
+ return nil, false
+ }
+
+ w.chain.SetLastFlatDiff(flatDiff, env.header.Number.Uint64(), parent.Root, common.Hash{})
+ // Counts block N as "entering the pipeline." If Prepare() fails and
+ // fallbackToSequential produces the block inline, this counter is slightly
+ // inflated — block was produced sequentially, not speculatively.
+ pipelineSpeculativeBlocksCounter.Inc(1)
+
+ return &speculativeWorkReq{
+ parentHeader: env.header,
+ flatDiff: flatDiff,
+ parentRoot: parent.Root,
+ blockNEnv: env,
+ stateSyncData: stateSyncData,
+ }, true
+}
+
+// spawnSRCForFinalBlock conditionally spawns the SRC goroutine + publishes the
+// FlatDiff for the last block of a pipeline run (used by sealBlockViaTaskCh).
+func (w *worker) spawnSRCForFinalBlock(finalHeader *types.Header, rootN common.Hash, flatDiff *state.FlatDiff, spawnSRC bool) {
+ if !spawnSRC {
+ return
+ }
+ tmpBlock := types.NewBlockWithHeader(finalHeader)
+ // Miner pipeline always produces witnesses for now. allowOwnWitness=true
+ // explicitly permits SRC to create its own witness when no execution
+ // witness is handed in by the caller. nil detached prefetcher — the
+ // miner-side path does not currently hand execution prefetcher state to
+ // SRC, so SRC falls back to the plain pathdb reader chain.
+ w.chain.SpawnSRCGoroutine(tmpBlock, rootN, flatDiff, true, nil, true, nil, false)
+ w.chain.SetLastFlatDiff(flatDiff, finalHeader.Number.Uint64(), rootN, common.Hash{})
+}
+
+// shouldLateRefillSpeculativeBlock reports whether a speculative block should
+// take one more txpool snapshot shortly before the slot boundary.
+func shouldLateRefillSpeculativeBlock(env *environment) bool {
+ if len(env.txs) == 0 {
+ return true
+ }
+ if env.gasPool == nil {
+ return true
+ }
+
+ // Skip the top-up when the block is already mostly full. Otherwise, give it
+ // one late snapshot to catch txs that arrived after the initial early fill.
+ return env.gasPool.Gas() > env.header.GasLimit/speculativeLowFillRemainingGasDivisor
+}
+
+// fillSpeculativeTransactions snapshots the txpool once immediately, and if
+// the speculative block is still underfilled, gives it one more pass shortly
+// before the slot boundary. This avoids sealing low/empty speculative blocks
+// simply because the initial early snapshot raced ahead of incoming load.
+func (w *worker) fillSpeculativeTransactions(env *environment, interrupt *atomic.Int32) time.Duration {
+ fillStart := time.Now()
+ err := w.fillTransactions(interrupt, env, nil)
+ totalFill := time.Since(fillStart)
+
+ if err != nil || !shouldLateRefillSpeculativeBlock(env) {
+ return totalFill
+ }
+
+ remaining := time.Until(env.header.GetActualTime())
+ if remaining <= speculativeEmptyRefillLead {
+ return totalFill
+ }
+
+ timer := time.NewTimer(remaining - speculativeEmptyRefillLead)
+ defer timer.Stop()
+
+ select {
+ case <-timer.C:
+ case <-w.exitCh:
+ return totalFill
+ }
+
+ refillStart := time.Now()
+ _ = w.fillTransactions(interrupt, env, nil)
+ totalFill += time.Since(refillStart)
+
+ return totalFill
+}
+
+// commitSpeculativeWork handles a speculativeWorkReq: executes block N+1
+// speculatively using the FlatDiff overlay, then waits for SRC(N) to complete,
+// assembles block N, and sends it for sealing. Then it finalizes N+1 and
+// seals it as well.
+//
+// Returns true when mainLoop should requeue normal work after this function
+// returns. This is needed for:
+// - Abort (EIP-2935/BLOCKHASH): the speculative block was discarded, so the
+// block slot must be rebuilt sequentially.
+// - Normal pipeline exit: the last block was sent to sealBlockViaTaskCh, and
+// there is a race where ChainHeadEvent may arrive at newWorkLoop before
+// pendingWorkBlock is cleared, causing the event to be skipped.
+//
+// Returns false when the pipeline fell back to sequential (fallbackToSequential
+// already sealed block N via taskCh → resultLoop → ChainHeadEvent). Retrying
+// work in this case creates a tight loop that keeps restarting Seal() with
+// fresh timestamps, preventing any block from ever being sealed.
+func (w *worker) commitSpeculativeWork(req *speculativeWorkReq) (shouldRetry bool, abortRecovery bool) {
+ // Default: retry commitWork after this function returns. Fallback paths
+ // set shouldRetry = false because they already sealed block N via taskCh
+ // (resultLoop handles it).
+ shouldRetry = true
+ // Ensure pendingWorkBlock is cleared on every exit path.
+ defer w.pendingWorkBlock.Store(0)
+
+ s := newSpecSession(w, req)
+ if !s.setupInitial() {
+ return false, false
+ }
+ defer func() { <-s.initialFillDone }()
+
+ if !s.waitForSRCAndSealBlockN() {
+ return s.exitDuringBlockN, false
+ }
+ <-s.initialFillDone
+
+ for {
+ switch s.runOneIteration() {
+ case iterContinue:
+ continue
+ case iterBreakAbort:
+ abortRecovery = true
+ case iterExitEarly:
+ return false, false
+ }
+ break
+ }
+ if s.prevDBWriteDone != nil {
+ <-s.prevDBWriteDone
+ }
+ return shouldRetry, abortRecovery
+}
+
+// iterResult enumerates how a single pipeline iteration ends.
+type iterResult int
+
+const (
+ iterContinue iterResult = iota // shifted to the next block; keep looping
+ iterBreak // normal exit (error, last block sealed via taskCh)
+ iterBreakAbort // speculative block was discarded (abortRecovery=true)
+ iterExitEarly // w.exitCh fired mid-iteration; caller returns shouldRetry=false
+)
+
+// runOneIteration finalizes the current speculative block, prepares the next
+// one, seals the current block, and shifts state. Each return value tells
+// commitSpeculativeWork how to proceed; see iterResult.
+func (s *specSession) runOneIteration() iterResult {
+ if s.checkCurrentAbort() {
+ return iterBreakAbort
+ }
+ s.drainPrevDBWrite()
+
+ finalSpecHeader, flatDiff, stateSyncData, ok := s.finalizeCurrent()
+ if !ok {
+ return iterBreak
+ }
+ // Last block in pipeline (eligibility failed) → seal via taskCh so
+ // resultLoop emits ChainHeadEvent and normal production resumes.
+ if !s.w.isPipelineEligible(s.nextBlockNumber) || !s.w.IsRunning() {
+ s.w.sealBlockViaTaskCh(s.borEngine, finalSpecHeader, s.specState, s.specEnv.txs,
+ s.specEnv.receipts, stateSyncData, s.rootN, flatDiff, true, s.curBuildStart)
+ return iterBreak
+ }
+
+ next, cont := s.prepareNextIteration(finalSpecHeader, flatDiff, stateSyncData)
+ if !cont {
+ return iterBreak
+ }
+ sealed, exitEarly, ok := s.sealCurrentAndAdvance(finalSpecHeader, stateSyncData, next)
+ if exitEarly {
+ return iterExitEarly
+ }
+ if !ok {
+ return iterBreak
+ }
+ s.shiftToNext(sealed, next)
+ return iterContinue
+}
+
+// specSession holds the rotating per-invocation state of commitSpeculativeWork.
+// It exists so the orchestrator can decompose the 600-line original into
+// focused methods that share state through the receiver — avoiding 15-parameter
+// helper signatures. Fields are mutated through shiftToNext() as each
+// speculative block is sealed.
+type specSession struct {
+ w *worker
+ req *speculativeWorkReq
+ borEngine *bor.Bor
+
+ blockNHeader *types.Header
+ blockNNumber uint64
+ nextBlockNumber uint64
+
+ // Current speculative block state (rotates each iteration).
+ specHeader *types.Header
+ specState *state.StateDB
+ specEnv *environment
+ coinbase common.Address
+ blockhashAccessed *atomic.Bool // set true if speculative block read BLOCKHASH(N)
+ eip2935Abort bool // set by initial-fill goroutine (for first iteration)
+ curBuildStart time.Time // wall clock when this block's fill began
+
+ // Updated as blocks are sealed.
+ realBlockNHash common.Hash
+ rootN common.Hash
+ lastSealedHeader *types.Header
+
+ // Iteration coordination.
+ initialFillDone chan struct{}
+ prevDBWriteDone chan struct{}
+ exitDuringBlockN bool
+}
+
+// specNextIteration bundles everything prepareNextIteration allocates for the
+// next speculative block, so sealCurrentAndAdvance/shiftToNext can consume it
+// without 10-parameter helper signatures.
+type specNextIteration struct {
+ specHeaderNext *types.Header
+ specStateNext *state.StateDB
+ specEnvNext *environment
+ coinbaseNext common.Address
+ blockhashAccessed *atomic.Bool // *atomic.Bool for the next block
+ eip2935AbortPtr *bool // set true by fill goroutine if EIP-2935 slot read
+ nextBuildStart time.Time
+ fillDone chan struct{}
+ fillElapsed *time.Duration // pointer so goroutine writes are visible after <-fillDone
+ srcSpawnTime time.Time
+}
+
+func newSpecSession(w *worker, req *speculativeWorkReq) *specSession {
+ blockNNumber := req.parentHeader.Number.Uint64()
+ return &specSession{
+ w: w,
+ req: req,
+ blockNHeader: req.parentHeader,
+ blockNNumber: blockNNumber,
+ nextBlockNumber: blockNNumber + 1,
+ curBuildStart: time.Now(),
+ }
+}
+
+// setupInitial builds the first speculative environment (N+1), runs Prepare,
+// spawns SRC for block N, and starts the initial fill goroutine. Returns
+// false if Prepare fails or state cannot be opened — in both cases the
+// function has already called fallbackToSequential and the caller should
+// return shouldRetry=false.
+func (s *specSession) setupInitial() bool {
+ log.Debug("Pipelined SRC: starting speculative execution", "speculativeBlock", s.nextBlockNumber, "parent", s.blockNNumber)
+
+ borEngine, ok := s.w.engine.(*bor.Bor)
+ if !ok {
+ log.Error("Pipelined SRC: engine is not Bor")
+ return false
+ }
+ s.borEngine = borEngine
+
+ specReader, specContext, specHeader, coinbase := s.buildInitialSpecHeader()
+ if err := s.w.engine.Prepare(specReader, specHeader, false); err != nil {
+ log.Warn("Pipelined SRC: speculative Prepare failed, falling back", "err", err)
+ s.w.fallbackToSequential(s.req)
+ return false
+ }
+ s.specHeader = specHeader
+ s.coinbase = coinbase
+
+ // Prepare() succeeded — spawn the background SRC goroutine for block N.
+ // Done AFTER Prepare to avoid a trie DB race with fallbackToSequential's
+ // inline FinalizeAndAssemble on the same parent root.
+ tmpBlock := types.NewBlockWithHeader(s.req.parentHeader)
+ // Miner pipeline always produces witnesses for now. allowOwnWitness=true
+ // explicitly permits SRC to create its own witness when no execution
+ // witness is handed in by the caller. nil detached prefetcher — the
+ // miner-side path does not currently hand execution prefetcher state to
+ // SRC, so SRC falls back to the plain pathdb reader chain.
+ s.w.chain.SpawnSRCGoroutine(tmpBlock, s.req.parentRoot, s.req.flatDiff, true, nil, true, nil, false)
+
+ specState, err := s.w.chain.StateAtWithFlatDiff(s.req.parentRoot, s.req.flatDiff)
+ if err != nil {
+ log.Error("Pipelined SRC: failed to open speculative state", "err", err)
+ s.w.chain.WaitForSRC() //nolint:errcheck
+ s.w.fallbackToSequential(s.req)
+ return false
+ }
+ specState.StartPrefetcher("miner-speculative", nil, nil)
+ s.specState = specState
+
+ blockN1Header := s.w.chain.GetHeader(s.blockNHeader.ParentHash, s.blockNNumber-1)
+ if blockN1Header == nil {
+ log.Error("Pipelined SRC: grandparent header not found")
+ s.w.chain.WaitForSRC() //nolint:errcheck
+ s.w.fallbackToSequential(s.req)
+ return false
+ }
+
+ var blockhashAccessed atomic.Bool
+ s.blockhashAccessed = &blockhashAccessed
+ s.specEnv = s.buildSpecEnv(specHeader, specState, coinbase, specContext, blockN1Header, s.blockNNumber, s.newBlockNHashResolver())
+ s.resetTxPoolState(s.blockNHeader, s.req.parentRoot, s.req.flatDiff)
+ s.startInitialFillGoroutine()
+ return true
+}
+
+// buildInitialSpecHeader constructs the header for speculative execution of
+// block N+1 while block N is still being sealed. It intentionally does NOT
+// reuse makeHeader because the inputs diverge fundamentally: the parent is a
+// placeholder hash (block N not sealed yet), the timestamp is deterministic
+// (blockN.Time + bor period — no genParams / user input), the gas limit uses
+// config.GasCeil directly (no dynamic base-fee adjustment), and engine.Prepare
+// is deliberately skipped (it would fail against the placeholder parent). The
+// overlap is limited to coinbase resolution — unified via resolveCoinbase so
+// both headers pick the same address and don't diverge on state root.
+func (s *specSession) buildInitialSpecHeader() (*speculativeChainReader, *speculativeChainContext, *types.Header, common.Address) {
+ placeholder := placeholderParentHash(s.blockNNumber)
+ specReader := newSpeculativeChainReader(s.w.chain, s.blockNHeader, placeholder)
+ specContext := newSpeculativeChainContext(specReader, s.w.engine)
+ coinbase := s.w.resolveCoinbase(s.nextBlockNumber, s.w.etherbase())
+ specHeader := &types.Header{
+ ParentHash: placeholder,
+ Number: new(big.Int).SetUint64(s.nextBlockNumber),
+ GasLimit: core.CalcGasLimit(s.blockNHeader.GasLimit, s.w.config.GasCeil),
+ Time: s.blockNHeader.Time + s.w.chainConfig.Bor.CalculatePeriod(s.nextBlockNumber),
+ Coinbase: coinbase,
+ }
+ if s.w.chainConfig.IsLondon(specHeader.Number) {
+ specHeader.BaseFee = eip1559.CalcBaseFee(s.w.chainConfig, s.blockNHeader)
+ }
+ return specReader, specContext, specHeader, coinbase
+}
+
+// resolveCoinbase matches the importer's NewEVMBlockContext(header, chain, nil)
+// logic: post-Rio uses BorConfig.CalculateCoinbase, otherwise the caller-provided
+// fallback (genParams.coinbase for makeHeader, etherbase for the speculative
+// path). Unifying this ensures the speculative header and the later real header
+// resolve coinbase identically — a mismatch would cause a state root divergence.
+func (w *worker) resolveCoinbase(blockNumber uint64, fallback common.Address) common.Address {
+ var coinbase common.Address
+ if w.chainConfig.Bor != nil && w.chainConfig.Bor.IsRio(new(big.Int).SetUint64(blockNumber)) {
+ coinbase = common.HexToAddress(w.chainConfig.Bor.CalculateCoinbase(blockNumber))
+ }
+ if coinbase == (common.Address{}) {
+ coinbase = fallback
+ }
+ return coinbase
+}
+
+// newBlockNHashResolver returns a lazy resolver for block N's signed hash used
+// by SpeculativeGetHashFn. Block N's hash isn't known until SRC completes
+// because it depends on the state root — if a speculative tx calls BLOCKHASH(N)
+// we wait for SRC, compute the pre-seal hash, and the hashAccessed flag on the
+// outer speculative block triggers a discard (pre-seal hash ≠ final on-chain).
+func (s *specSession) newBlockNHashResolver() func() common.Hash {
+ var (
+ hash common.Hash
+ resolved bool
+ mu sync.Mutex
+ )
+ blockNHeader := s.blockNHeader
+ return func() common.Hash {
+ mu.Lock()
+ defer mu.Unlock()
+ if resolved {
+ return hash
+ }
+ root, _, err := s.w.chain.WaitForSRC()
+ if err != nil {
+ log.Error("Pipelined SRC: SRC failed during BLOCKHASH resolution", "err", err)
+ return common.Hash{}
+ }
+ finalHeader := types.CopyHeader(blockNHeader)
+ finalHeader.Root = root
+ finalHeader.UncleHash = types.CalcUncleHash(nil)
+ hash = finalHeader.Hash()
+ resolved = true
+ return hash
+ }
+}
+
+// buildSpecEnv assembles the *environment used for speculative transaction
+// execution. Used by both the initial setup and each loop iteration's
+// next-block preparation.
+func (s *specSession) buildSpecEnv(header *types.Header, state *state.StateDB, coinbase common.Address, specContext *speculativeChainContext, grandparent *types.Header, grandparentNumber uint64, srcDone func() common.Hash) *environment {
+ specGetHash := core.SpeculativeGetHashFn(grandparent, specContext, grandparentNumber, srcDone, s.blockhashAccessed)
+ evmContext := core.NewEVMBlockContext(header, specContext, &coinbase)
+ evmContext.GetHash = specGetHash
+ env := &environment{
+ signer: types.MakeSigner(s.w.chainConfig, header.Number, header.Time),
+ state: state,
+ size: uint64(header.Size()),
+ coinbase: coinbase,
+ buildInterrupt: newBuildInterruptState(),
+ header: header,
+ evm: vm.NewEVM(evmContext, state, s.w.chainConfig, vm.Config{}),
+ }
+ env.evm.SetInterrupt(env.buildInterrupt.timeoutFlag())
+ env.tcount = 0
+ return env
+}
+
+// resetTxPoolState publishes a fresh speculative state to the txpool so tx
+// selection sees the new block's post-parent view (nonces, balances).
+func (s *specSession) resetTxPoolState(parent *types.Header, parentRoot common.Hash, flatDiff *state.FlatDiff) {
+ specTxPoolState, err := s.w.chain.StateAtWithFlatDiff(parentRoot, flatDiff)
+ if err != nil {
+ log.Error("Pipelined SRC: failed to create txpool speculative state", "err", err)
+ return
+ }
+ s.w.eth.TxPool().SetSpeculativeState(parent, specTxPoolState)
+}
+
+// startInitialFillGoroutine kicks off the speculative tx fill for N+1 and
+// the EIP-2935 abort check. The goroutine closes initialFillDone when done;
+// s.eip2935Abort is only safe to read after <-s.initialFillDone.
+func (s *specSession) startInitialFillGoroutine() {
+ s.initialFillDone = make(chan struct{})
+ go func() {
+ defer close(s.initialFillDone)
+ stop := createInterruptTimer(
+ s.specHeader.Number.Uint64(),
+ s.specHeader.GetActualTime(),
+ s.specEnv.buildInterrupt.timeoutFlag(),
+ s.specEnv.buildInterrupt.flagSetAtPtr(),
+ true,
+ )
+ var interrupt atomic.Int32
+ s.w.fillSpeculativeTransactions(s.specEnv, &interrupt)
+ stop()
+ // Final discard log is emitted in the main loop so each aborted block is logged once.
+ if s.w.chainConfig.IsPrague(s.specHeader.Number) {
+ dangerousSlot := common.BigToHash(new(big.Int).SetUint64(s.blockNNumber % params.HistoryServeWindow))
+ if s.specState.WasStorageSlotRead(params.HistoryStorageAddress, dangerousSlot) {
+ s.eip2935Abort = true
+ pipelineEIP2935AbortsCounter.Inc(1)
+ }
+ }
+ }()
+}
+
+// waitForSRCAndSealBlockN waits for block N's SRC goroutine to complete,
+// assembles block N with the real root, submits it via taskCh, and waits for
+// resultLoop to persist it. Returns false on any failure; sets
+// exitDuringBlockN when the failure was w.exitCh.
+func (s *specSession) waitForSRCAndSealBlockN() bool {
+ srcStart := time.Now()
+ root, witnessN, err := s.w.chain.WaitForSRC()
+ srcWaitN := time.Since(srcStart)
+ pipelineSRCTimer.Update(srcWaitN)
+ pipelineSRCWaitTimer.Update(srcWaitN)
+ if err != nil {
+ log.Error("Pipelined SRC: SRC(N) failed", "block", s.blockNNumber, "err", err)
+ pipelineSpeculativeAbortsCounter.Inc(1)
+ pipelineAbortSRCFailedCounter.Inc(1)
+ return false
+ }
+ finalHeaderN := types.CopyHeader(s.blockNHeader)
+ finalHeaderN.Root = root
+ blockN, receiptsN, err := s.borEngine.AssembleBlock(s.w.chain, finalHeaderN, s.req.blockNEnv.state, &types.Body{
+ Transactions: s.req.blockNEnv.txs,
+ }, s.req.blockNEnv.receipts, root, s.req.stateSyncData)
+ if err != nil {
+ log.Error("Pipelined SRC: AssembleBlock(N) failed", "err", err)
+ return false
+ }
+ // Block N uses the pipelined write path to avoid a double CommitWithUpdate
+ // from the SRC goroutine and writeBlockWithState. Witness from SRC is complete.
+ select {
+ case s.w.taskCh <- &task{receipts: receiptsN, state: s.req.blockNEnv.state, block: blockN, createdAt: time.Now(), pipelined: true, witnessBytes: witnessN}:
+ if productionPipelineLogs {
+ log.Info("Pipelined SRC: block N sent for sealing", "number", blockN.Number(), "txs", len(blockN.Transactions()), "root", root)
+ }
+ case <-s.w.exitCh:
+ s.exitDuringBlockN = true
+ return false
+ }
+ realHash, ok := s.waitForChainHead(blockN.NumberU64())
+ if !ok {
+ return false
+ }
+ s.realBlockNHash = realHash
+ s.rootN = root
+ return true
+}
+
+// waitForChainHead blocks until the chain head reaches blockNum (up to 30s)
+// so we can read the real (signed) block N hash from the canonical chain.
+// resultLoop writes the final header after Seal() modifies Extra, so we
+// can't use blockN.Hash() directly.
+func (s *specSession) waitForChainHead(blockNum uint64) (common.Hash, bool) {
+ waitDeadline := time.After(30 * time.Second)
+ for {
+ if current := s.w.chain.CurrentBlock(); current != nil && current.Number.Uint64() >= blockNum {
+ if current.Number.Uint64() != blockNum {
+ log.Error("Pipelined SRC: chain head mismatch after waiting", "expected", blockNum, "got", current.Number.Uint64())
+ return common.Hash{}, false
+ }
+ return current.Hash(), true
+ }
+ select {
+ case <-time.After(50 * time.Millisecond):
+ case <-waitDeadline:
+ log.Error("Pipelined SRC: timed out waiting for block N to be written", "number", blockNum)
+ return common.Hash{}, false
+ case <-s.w.exitCh:
+ s.exitDuringBlockN = true
+ return common.Hash{}, false
+ }
+ }
+}
+
+// checkCurrentAbort inspects the abort flags set by the current fill goroutine:
+// EIP-2935 history-slot read, or BLOCKHASH(N) read before SRC resolved. Returns
+// true when the speculative block must be discarded (caller sets abortRecovery).
+func (s *specSession) checkCurrentAbort() bool {
+ if s.eip2935Abort {
+ log.Warn("Pipelined SRC: discarding speculative block — EIP-2935 slot accessed", "block", s.nextBlockNumber)
+ pipelineSpeculativeAbortsCounter.Inc(1)
+ return true
+ }
+ if s.blockhashAccessed.Load() {
+ log.Warn("Pipelined SRC: discarding speculative block — BLOCKHASH(N) was accessed",
+ "block", s.nextBlockNumber, "pendingBlockN", s.blockNNumber)
+ pipelineSpeculativeAbortsCounter.Inc(1)
+ pipelineAbortBlockhashCounter.Inc(1)
+ return true
+ }
+ return false
+}
+
+// drainPrevDBWrite waits for the previous iteration's async DB write before
+// FinalizeForPipeline runs. FinalizeForPipeline may read block headers and
+// state sync / span data from the chain DB — if the previous inline-sealed
+// block hasn't persisted, those lookups fail.
+func (s *specSession) drainPrevDBWrite() {
+ if s.prevDBWriteDone != nil {
+ <-s.prevDBWriteDone
+ s.prevDBWriteDone = nil
+ }
+}
+
+// finalizeCurrent runs FinalizeForPipeline on the current speculative block,
+// extracts its FlatDiff, and returns the final header + stateSync data.
+// Returns ok=false if FinalizeForPipeline errors (caller should break).
+func (s *specSession) finalizeCurrent() (*types.Header, *state.FlatDiff, []*types.StateSyncData, bool) {
+ finalSpecHeader := types.CopyHeader(s.specHeader)
+ finalSpecHeader.ParentHash = s.realBlockNHash
+ if s.w.chainConfig.IsPrague(finalSpecHeader.Number) {
+ evmCtx := core.NewEVMBlockContext(finalSpecHeader, s.w.chain, &s.coinbase)
+ vmenv := vm.NewEVM(evmCtx, s.specState, s.w.chainConfig, vm.Config{})
+ core.ProcessParentBlockHash(s.realBlockNHash, vmenv)
+ }
+ stateSyncData, err := s.borEngine.FinalizeForPipeline(s.w.chain, finalSpecHeader, s.specState, &types.Body{
+ Transactions: s.specEnv.txs,
+ }, s.specEnv.receipts)
+ if err != nil {
+ log.Error("Pipelined SRC: FinalizeForPipeline failed", "block", s.nextBlockNumber, "err", err)
+ return nil, nil, nil, false
+ }
+ flatDiff := s.specState.CommitSnapshot(s.w.chainConfig.IsEIP158(finalSpecHeader.Number))
+ return finalSpecHeader, flatDiff, stateSyncData, true
+}
+
+// prepareNextIteration builds the N+2 speculative environment: header,
+// Prepare (may seal current via taskCh on failure), SRC spawn for current
+// block, state open, EVM+srcDone for next, fill goroutine. cont=false means
+// the main loop should break (we already handed off the current block).
+func (s *specSession) prepareNextIteration(finalSpecHeader *types.Header, flatDiff *state.FlatDiff, stateSyncData []*types.StateSyncData) (*specNextIteration, bool) {
+ specHeaderNext, specContextNext, coinbaseNext, ok := s.buildAndPrepareNextHeader(finalSpecHeader, flatDiff, stateSyncData)
+ if !ok {
+ return nil, false
+ }
+ srcSpawnTime := s.spawnSRCForCurrent(finalSpecHeader, flatDiff)
+ specStateNext, specEnvNext, blockhashAccessedNext, ok := s.openNextSpecEnv(finalSpecHeader, flatDiff, stateSyncData, specHeaderNext, specContextNext, coinbaseNext)
+ if !ok {
+ return nil, false
+ }
+ s.resetTxPoolState(finalSpecHeader, s.rootN, flatDiff)
+ fillDone, eip2935AbortPtr, fillElapsedPtr := s.startNextFillGoroutine(specHeaderNext, specEnvNext, specStateNext)
+ return &specNextIteration{
+ specHeaderNext: specHeaderNext,
+ specStateNext: specStateNext,
+ specEnvNext: specEnvNext,
+ coinbaseNext: coinbaseNext,
+ blockhashAccessed: blockhashAccessedNext,
+ eip2935AbortPtr: eip2935AbortPtr,
+ nextBuildStart: time.Now(),
+ fillDone: fillDone,
+ fillElapsed: fillElapsedPtr,
+ srcSpawnTime: srcSpawnTime,
+ }, true
+}
+
+// buildAndPrepareNextHeader constructs the next speculative header (N+2),
+// runs Prepare via the speculative chain reader, and on Prepare failure
+// hands off the CURRENT speculative block via taskCh (spawnSRC=true) before
+// returning ok=false so the caller can break out of the loop.
+func (s *specSession) buildAndPrepareNextHeader(finalSpecHeader *types.Header, flatDiff *state.FlatDiff, stateSyncData []*types.StateSyncData) (*types.Header, *speculativeChainContext, common.Address, bool) {
+ nextNextBlockNumber := s.nextBlockNumber + 1
+ specReaderNext := newSpeculativeChainReader(s.w.chain, finalSpecHeader, placeholderParentHash(s.nextBlockNumber))
+ specContextNext := newSpeculativeChainContext(specReaderNext, s.w.engine)
+ coinbaseNext := s.w.resolveCoinbase(nextNextBlockNumber, s.w.etherbase())
+ specHeaderNext := &types.Header{
+ ParentHash: placeholderParentHash(s.nextBlockNumber),
+ Number: new(big.Int).SetUint64(nextNextBlockNumber),
+ GasLimit: core.CalcGasLimit(finalSpecHeader.GasLimit, s.w.config.GasCeil),
+ Time: finalSpecHeader.Time + s.w.chainConfig.Bor.CalculatePeriod(nextNextBlockNumber),
+ Coinbase: coinbaseNext,
+ }
+ if s.w.chainConfig.IsLondon(specHeaderNext.Number) {
+ specHeaderNext.BaseFee = eip1559.CalcBaseFee(s.w.chainConfig, finalSpecHeader)
+ }
+ if err := s.w.engine.Prepare(specReaderNext, specHeaderNext, false); err != nil {
+ log.Warn("Pipelined SRC: Prepare failed for next block, sealing current", "block", nextNextBlockNumber, "err", err)
+ s.w.sealBlockViaTaskCh(s.borEngine, finalSpecHeader, s.specState, s.specEnv.txs, s.specEnv.receipts, stateSyncData, s.rootN, flatDiff, true, s.curBuildStart)
+ return nil, nil, common.Address{}, false
+ }
+ return specHeaderNext, specContextNext, coinbaseNext, true
+}
+
+// spawnSRCForCurrent starts the SRC goroutine that computes the state root
+// for the current speculative block (now finalized) while the next block's
+// execution runs. Returns the srcSpawnTime used for pipelineSRCTimer.
+func (s *specSession) spawnSRCForCurrent(finalSpecHeader *types.Header, flatDiff *state.FlatDiff) time.Time {
+ srcSpawnTime := time.Now()
+ tmpBlockCur := types.NewBlockWithHeader(finalSpecHeader)
+ // Miner pipeline always produces witnesses for now. allowOwnWitness=true
+ // explicitly permits SRC to create its own witness when no execution
+ // witness is handed in by the caller. nil detached prefetcher — the
+ // miner-side path does not currently hand execution prefetcher state to
+ // SRC, so SRC falls back to the plain pathdb reader chain.
+ s.w.chain.SpawnSRCGoroutine(tmpBlockCur, s.rootN, flatDiff, true, nil, true, nil, false)
+ s.w.chain.SetLastFlatDiff(flatDiff, finalSpecHeader.Number.Uint64(), s.rootN, common.Hash{})
+ if productionPipelineLogs {
+ log.Info("Pipelined SRC: spawned SRC, starting speculative exec", "srcBlock", s.nextBlockNumber, "specExecBlock", s.nextBlockNumber+1)
+ }
+ return srcSpawnTime
+}
+
+// openNextSpecEnv opens the state + environment for the next speculative
+// block (N+2). On failure (state open error or grandparent not found), hands
+// off the CURRENT speculative block via taskCh with spawnSRC=false (SRC for
+// the current block is already in flight from spawnSRCForCurrent).
+func (s *specSession) openNextSpecEnv(finalSpecHeader *types.Header, flatDiff *state.FlatDiff, stateSyncData []*types.StateSyncData, specHeaderNext *types.Header, specContextNext *speculativeChainContext, coinbaseNext common.Address) (*state.StateDB, *environment, *atomic.Bool, bool) {
+ specStateNext, err := s.w.chain.StateAtWithFlatDiff(s.rootN, flatDiff)
+ if err != nil {
+ log.Error("Pipelined SRC: failed to open speculative state for next block", "block", s.nextBlockNumber+1, "err", err)
+ s.w.sealBlockViaTaskCh(s.borEngine, finalSpecHeader, s.specState, s.specEnv.txs, s.specEnv.receipts, stateSyncData, s.rootN, flatDiff, false, s.curBuildStart)
+ return nil, nil, nil, false
+ }
+ specStateNext.StartPrefetcher("miner-speculative", nil, nil)
+
+ grandparent := s.resolveGrandparent()
+ if grandparent == nil {
+ log.Error("Pipelined SRC: grandparent header not found for next block", "number", s.blockNNumber)
+ s.w.sealBlockViaTaskCh(s.borEngine, finalSpecHeader, s.specState, s.specEnv.txs, s.specEnv.receipts, stateSyncData, s.rootN, flatDiff, false, s.curBuildStart)
+ return nil, nil, nil, false
+ }
+
+ blockhashAccessedNext := new(atomic.Bool)
+ specGetHashNext := core.SpeculativeGetHashFn(grandparent, specContextNext, s.nextBlockNumber, s.makeNextHashResolver(finalSpecHeader), blockhashAccessedNext)
+ evmContextNext := core.NewEVMBlockContext(specHeaderNext, specContextNext, &coinbaseNext)
+ evmContextNext.GetHash = specGetHashNext
+
+ specEnvNext := &environment{
+ signer: types.MakeSigner(s.w.chainConfig, specHeaderNext.Number, specHeaderNext.Time),
+ state: specStateNext,
+ size: uint64(specHeaderNext.Size()),
+ coinbase: coinbaseNext,
+ buildInterrupt: newBuildInterruptState(),
+ header: specHeaderNext,
+ evm: vm.NewEVM(evmContextNext, specStateNext, s.w.chainConfig, vm.Config{}),
+ }
+ specEnvNext.evm.SetInterrupt(specEnvNext.buildInterrupt.timeoutFlag())
+ specEnvNext.tcount = 0
+ return specStateNext, specEnvNext, blockhashAccessedNext, true
+}
+
+// resolveGrandparent returns the grandparent header for the next iteration.
+// Prefers lastSealedHeader (the async DB write may not have persisted yet)
+// and falls back to the chain DB.
+func (s *specSession) resolveGrandparent() *types.Header {
+ if s.lastSealedHeader != nil && s.lastSealedHeader.Number.Uint64() == s.blockNNumber {
+ return s.lastSealedHeader
+ }
+ return s.w.chain.GetHeaderByNumber(s.blockNNumber)
+}
+
+// makeNextHashResolver returns a lazy resolver for the current speculative
+// block's signed hash, used by SpeculativeGetHashFn of the NEXT speculative
+// block. Mirrors newBlockNHashResolver but for mid-pipeline iterations.
+func (s *specSession) makeNextHashResolver(finalSpecHeader *types.Header) func() common.Hash {
+ var (
+ hash common.Hash
+ resolved bool
+ mu sync.Mutex
+ )
+ return func() common.Hash {
+ mu.Lock()
+ defer mu.Unlock()
+ if resolved {
+ return hash
+ }
+ rootSpec, _, err := s.w.chain.WaitForSRC()
+ if err != nil {
+ log.Error("Pipelined SRC: SRC failed during BLOCKHASH resolution", "err", err)
+ return common.Hash{}
+ }
+ finalH := types.CopyHeader(finalSpecHeader)
+ finalH.Root = rootSpec
+ finalH.UncleHash = types.CalcUncleHash(nil)
+ hash = finalH.Hash()
+ resolved = true
+ return hash
+ }
+}
+
+// startNextFillGoroutine fills N+2 speculatively in parallel with the current
+// block's seal, and flags EIP-2935 aborts for N+2. Returns the done channel
+// and pointers to the abort/elapsed fields set by the goroutine (only safe to
+// read after <-fillDone).
+func (s *specSession) startNextFillGoroutine(headerNext *types.Header, envNext *environment, stateNext *state.StateDB) (chan struct{}, *bool, *time.Duration) {
+ fillDone := make(chan struct{})
+ var (
+ eip2935Abort bool
+ fillElapsed time.Duration
+ )
+ go func() {
+ defer close(fillDone)
+ stop := createInterruptTimer(
+ headerNext.Number.Uint64(),
+ headerNext.GetActualTime(),
+ envNext.buildInterrupt.timeoutFlag(),
+ envNext.buildInterrupt.flagSetAtPtr(),
+ true,
+ )
+ var interrupt atomic.Int32
+ fillElapsed = s.w.fillSpeculativeTransactions(envNext, &interrupt)
+ stop()
+ if s.w.chainConfig.IsPrague(headerNext.Number) {
+ dangerousSlot := common.BigToHash(new(big.Int).SetUint64(s.nextBlockNumber % params.HistoryServeWindow))
+ if stateNext.WasStorageSlotRead(params.HistoryStorageAddress, dangerousSlot) {
+ eip2935Abort = true
+ pipelineEIP2935AbortsCounter.Inc(1)
+ }
+ }
+ }()
+ return fillDone, &eip2935Abort, &fillElapsed
+}
+
+// sealCurrentAndAdvance waits for SRC of the current speculative block,
+// assembles it, waits for header.Time, inline-seals + broadcasts, and hands
+// back the sealed block. Returns exitEarly=true if w.exitCh fired during the
+// timestamp wait (caller returns false, abortRecovery).
+func (s *specSession) sealCurrentAndAdvance(finalSpecHeader *types.Header, stateSyncData []*types.StateSyncData, next *specNextIteration) (*types.Block, bool, bool) {
+ srcWaitStart := time.Now()
+ rootSpec, witnessSpec, err := s.w.chain.WaitForSRC()
+ srcWaitElapsed := time.Since(srcWaitStart)
+ pipelineSRCTimer.Update(time.Since(next.srcSpawnTime))
+ pipelineSRCWaitTimer.Update(srcWaitElapsed)
+ if err != nil {
+ log.Error("Pipelined SRC: SRC failed", "block", s.nextBlockNumber, "err", err)
+ pipelineSpeculativeAbortsCounter.Inc(1)
+ pipelineAbortSRCFailedCounter.Inc(1)
+ <-next.fillDone
+ return nil, false, false
+ }
+ if productionPipelineLogs {
+ log.Info("Pipelined SRC: SRC completed", "block", s.nextBlockNumber, "srcWait", srcWaitElapsed)
+ }
+ blockSpec, receiptsSpec, err := s.borEngine.AssembleBlock(s.w.chain, finalSpecHeader, s.specState, &types.Body{
+ Transactions: s.specEnv.txs,
+ }, s.specEnv.receipts, rootSpec, stateSyncData)
+ if err != nil {
+ log.Error("Pipelined SRC: AssembleBlock failed", "block", s.nextBlockNumber, "err", err)
+ <-next.fillDone
+ return nil, false, false
+ }
+ // Update pendingWorkBlock BEFORE inline write so that newWorkLoop skips
+ // the ChainHeadEvent for this block. pendingWorkBlock = nextBlockNumber+1
+ // means "working on nextBlockNumber+1, so skip ChainHeadEvent for nextBlockNumber".
+ s.w.pendingWorkBlock.Store(s.nextBlockNumber + 1)
+ if exit := s.waitForParentAnnounceTime(finalSpecHeader, next.fillDone); exit {
+ return nil, true, false
+ }
+ sealedBlock, dbWriteDone, err := s.w.inlineSealAndBroadcast(blockSpec, receiptsSpec, s.specState, witnessSpec, s.curBuildStart)
+ if err != nil {
+ log.Error("Pipelined SRC: inline seal failed", "block", s.nextBlockNumber, "err", err)
+ <-next.fillDone
+ return nil, false, false
+ }
+ <-next.fillDone
+ s.prevDBWriteDone = dbWriteDone
+ pipelineSpeculativeBlocksCounter.Inc(1)
+ if productionPipelineLogs {
+ log.Info("Pipelined SRC: block sealed (inline)", "number", sealedBlock.Number(),
+ "txs", len(sealedBlock.Transactions()), "root", rootSpec, "fillBlock", s.nextBlockNumber+1, "fillElapsed", *next.fillElapsed)
+ }
+ return sealedBlock, false, true
+}
+
+// waitForParentAnnounceTime blocks until the parent slot boundary is reached,
+// draining the fill and previous-DB-write channels on shutdown so goroutines
+// aren't left hanging. Giugliano+ primary producers may announce before the
+// child block timestamp, but not before the parent timestamp.
+func (s *specSession) waitForParentAnnounceTime(finalSpecHeader *types.Header, fillDone chan struct{}) bool {
+ delay := time.Until(s.w.earlyAnnounceTime(finalSpecHeader))
+ if delay <= 0 {
+ return false
+ }
+ select {
+ case <-time.After(delay):
+ return false
+ case <-s.w.exitCh:
+ <-fillDone
+ if s.prevDBWriteDone != nil {
+ <-s.prevDBWriteDone
+ }
+ return true
+ }
+}
+
+// shiftToNext rotates the session's per-iteration state to the block just
+// prepared by prepareNextIteration. Called after a successful inline seal.
+func (s *specSession) shiftToNext(sealed *types.Block, next *specNextIteration) {
+ s.lastSealedHeader = sealed.Header()
+ s.blockNNumber = s.nextBlockNumber
+ s.nextBlockNumber++
+ s.rootN = sealed.Root()
+ s.realBlockNHash = sealed.Hash()
+ s.specHeader = next.specHeaderNext
+ s.specState = next.specStateNext
+ s.specEnv = next.specEnvNext
+ s.coinbase = next.coinbaseNext
+ s.eip2935Abort = *next.eip2935AbortPtr
+ s.blockhashAccessed = next.blockhashAccessed
+ s.curBuildStart = next.nextBuildStart
+}
+
+// fallbackToSequential computes the state root inline and assembles block N
+// without a background SRC goroutine. This avoids trie DB races between
+// background and inline commits.
+func (w *worker) fallbackToSequential(req *speculativeWorkReq) {
+ if productionPipelineLogs {
+ log.Info("Pipelined SRC: falling back to sequential execution")
+ }
+ pipelineSpeculativeAbortsCounter.Inc(1)
+ pipelineAbortFallbackCounter.Inc(1)
+
+ borEngine, ok := w.engine.(*bor.Bor)
+ if !ok {
+ return
+ }
+
+ root := req.blockNEnv.state.IntermediateRoot(w.chainConfig.IsEIP158(req.blockNEnv.header.Number))
+
+ block, receipts, err := borEngine.AssembleBlock(w.chain, req.blockNEnv.header, req.blockNEnv.state, &types.Body{
+ Transactions: req.blockNEnv.txs,
+ }, req.blockNEnv.receipts, root, req.stateSyncData)
+ if err != nil {
+ log.Error("Pipelined SRC: AssembleBlock failed during fallback", "err", err)
+ return
+ }
+
+ select {
+ case w.taskCh <- &task{receipts: receipts, state: req.blockNEnv.state, block: block, createdAt: time.Now()}:
+ if productionPipelineLogs {
+ log.Info("Pipelined SRC: fallback block sealed", "number", block.Number(), "root", root)
+ }
+ case <-w.exitCh:
+ }
+}
+
+// sealBlockViaTaskCh spawns SRC (if needed), waits for the root, assembles the
+// block, and sends it through the normal taskCh → taskLoop → Seal → resultLoop
+// path. Used for the last block in a pipeline run so that resultLoop emits
+// ChainHeadEvent and normal block production resumes immediately.
+func (w *worker) sealBlockViaTaskCh(
+ borEngine *bor.Bor,
+ finalHeader *types.Header,
+ statedb *state.StateDB,
+ txs []*types.Transaction,
+ receipts []*types.Receipt,
+ stateSyncData []*types.StateSyncData,
+ rootN common.Hash,
+ flatDiff *state.FlatDiff,
+ spawnSRC bool, // false if SRC goroutine is already running
+ buildStart time.Time, // wall clock when this block's speculative fill began — for worker/build_to_announce
+) {
+ w.spawnSRCForFinalBlock(finalHeader, rootN, flatDiff, spawnSRC)
+ pipelineSpeculativeBlocksCounter.Inc(1)
+
+ rootSpec, witnessSpec, err := w.chain.WaitForSRC()
+ if err != nil {
+ log.Error("Pipelined SRC: SRC failed", "block", finalHeader.Number, "err", err)
+ return
+ }
+
+ block, blockReceipts, err := borEngine.AssembleBlock(w.chain, finalHeader, statedb, &types.Body{
+ Transactions: txs,
+ }, receipts, rootSpec, stateSyncData)
+ if err != nil {
+ log.Error("Pipelined SRC: AssembleBlock failed", "block", finalHeader.Number, "err", err)
+ return
+ }
+
+ // Speculative Prepare() was called without sleeping. Wait only until the
+ // parent slot boundary, preserving early announcement for primary producers.
+ if delay := time.Until(w.earlyAnnounceTime(finalHeader)); delay > 0 {
+ select {
+ case <-time.After(delay):
+ case <-w.exitCh:
+ return
+ }
+ }
+
+ select {
+ case w.taskCh <- &task{receipts: blockReceipts, state: statedb, block: block, createdAt: time.Now(), productionStart: buildStart, pipelined: true, witnessBytes: witnessSpec}:
+ if productionPipelineLogs {
+ log.Info("Pipelined SRC: block sealed", "number", block.Number(),
+ "txs", len(block.Transactions()), "root", rootSpec)
+ }
+ case <-w.exitCh:
+ }
+}
+
+// earlyAnnounceTime returns the earliest safe local announcement time for a
+// prepared block. Post-Giugliano verification allows primary-producer blocks to
+// arrive before their own timestamp, but not before the parent timestamp.
+func (w *worker) earlyAnnounceTime(header *types.Header) time.Time {
+ if header == nil || header.Number == nil || header.Number.Sign() == 0 {
+ return time.Now()
+ }
+ if borEngine, ok := w.engine.(*bor.Bor); ok {
+ return borEngine.EarliestAnnounceTime(w.chain, header)
+ }
+ if parent := w.chain.GetHeader(header.ParentHash, header.Number.Uint64()-1); parent != nil {
+ return parent.GetActualTime()
+ }
+ return header.GetActualTime()
+}
+
+// inlineSealAndBroadcast seals a pipelined block using a private channel
+// (bypassing taskLoop/resultLoop), broadcasts it to peers immediately, and
+// writes to the chain DB asynchronously. This avoids blocking the pipeline
+// on the DB write — the next iteration can start as soon as the block is sealed.
+//
+// Returns the sealed block and a channel that closes when the async DB write
+// completes. The caller must wait on writeDone before the node can serve the
+// block data from DB, but the pipeline can proceed immediately.
+//
+// Uses emitHeadEvent=false to avoid a deadlock: mainLoop is blocked in
+// commitSpeculativeWork, so chainHeadFeed.Send would eventually block when
+// newWorkLoop's channel fills up.
+func (w *worker) inlineSealAndBroadcast(block *types.Block, receipts []*types.Receipt, statedb *state.StateDB, witnessBytes []byte, buildStart time.Time) (*types.Block, chan struct{}, error) {
+ sealedBlock, err := w.sealViaPrivateChannel(block)
+ if err != nil {
+ return nil, nil, err
+ }
+ hash := sealedBlock.Hash()
+ sealedReceipts, logs := rebindReceiptsToSealedBlock(receipts, sealedBlock)
+
+ log.Info("Successfully sealed new block", "number", sealedBlock.Number(),
+ "sealhash", w.engine.SealHash(sealedBlock.Header()), "hash", hash, "elapsed", "inline")
+
+ // Cache the witness so the WIT protocol can serve it to stateless peers
+ // immediately, without waiting for the async DB write.
+ if len(witnessBytes) > 0 {
+ w.chain.CacheWitness(hash, witnessBytes)
+ }
+
+ w.announceInlineSealedBlock(sealedBlock, buildStart)
+ w.clearPending(sealedBlock.NumberU64())
+
+ // Write to chain DB asynchronously — the pipeline can proceed with the
+ // next iteration using sealedBlock.Hash() directly, without waiting for
+ // the DB write to complete.
+ writeDone := make(chan struct{})
+ go func() {
+ defer close(writeDone)
+ writeStart := time.Now()
+ _, err := w.chain.WriteBlockAndSetHeadPipelined(sealedBlock, sealedReceipts, logs, statedb, false, witnessBytes)
+ writeBlockAndSetHeadTimer.UpdateSince(writeStart)
+ if err != nil {
+ log.Error("Pipelined SRC: async DB write failed", "block", sealedBlock.Number(), "err", err)
+ }
+ }()
+ return sealedBlock, writeDone, nil
+}
+
+// sealViaPrivateChannel runs engine.Seal on a private channel (no contention
+// with the shared resultCh) and waits up to 5s for the sealed block.
+// For primary producers on Bhilai+, delay=0, so the wait is effectively
+// bounded by the Seal signature computation.
+func (w *worker) sealViaPrivateChannel(block *types.Block) (*types.Block, error) {
+ sealCh := make(chan *consensus.NewSealedBlockEvent, 1)
+ stopCh := make(chan struct{})
+ sealStart := time.Now()
+ if err := w.engine.Seal(w.chain, block, nil, sealCh, stopCh); err != nil {
+ return nil, fmt.Errorf("seal failed: %w", err)
+ }
+ select {
+ case ev := <-sealCh:
+ pipelineSealDurationTimer.UpdateSince(sealStart)
+ if ev == nil || ev.Block == nil {
+ return nil, errors.New("nil sealed block from Seal")
+ }
+ return ev.Block, nil
+ case <-time.After(5 * time.Second):
+ close(stopCh)
+ return nil, errors.New("inline seal timed out")
+ case <-w.exitCh:
+ close(stopCh)
+ return nil, errors.New("worker stopped during inline seal")
+ }
+}
+
+// rebindReceiptsToSealedBlock copies receipts with BlockHash/BlockNumber/
+// TransactionIndex pointing at the sealed block, deep-copies logs, and
+// returns the flat logs slice (same behavior as resultLoop's receipt fixup).
+func rebindReceiptsToSealedBlock(receipts []*types.Receipt, sealedBlock *types.Block) ([]*types.Receipt, []*types.Log) {
+ hash := sealedBlock.Hash()
+ sealedReceipts := make([]*types.Receipt, len(receipts))
+ var logs []*types.Log
+ for i, r := range receipts {
+ receipt := new(types.Receipt)
+ sealedReceipts[i] = receipt
+ *receipt = *r
+ receipt.BlockHash = hash
+ receipt.BlockNumber = sealedBlock.Number()
+ receipt.TransactionIndex = uint(i)
+ receipt.Logs = make([]*types.Log, len(r.Logs))
+ for j, l := range r.Logs {
+ logCopy := new(types.Log)
+ receipt.Logs[j] = logCopy
+ *logCopy = *l
+ logCopy.BlockHash = hash
+ }
+ logs = append(logs, receipt.Logs...)
+ }
+ return sealedReceipts, logs
+}
+
+// announceInlineSealedBlock emits the pipelined-sealed block to peers and
+// updates the build-to-announce / earliness / committed / throughput metrics.
+// Broadcast happens BEFORE the async DB write so peers don't wait on disk.
+func (w *worker) announceInlineSealedBlock(sealedBlock *types.Block, buildStart time.Time) {
+ announceAt := time.Now()
+ // Positive when announced before header.GetActualTime (PIP-66 early). Negative when late.
+ earlyMs := sealedBlock.Header().GetActualTime().Sub(announceAt).Milliseconds()
+ pipelineAnnounceEarlinessMs.Update(earlyMs)
+ pipelineSpeculativeCommittedCounter.Inc(1)
+ if !buildStart.IsZero() {
+ workerBuildToAnnounceTimer.UpdateSince(buildStart)
+ }
+ w.mux.Post(core.NewMinedBlockEvent{Block: sealedBlock, SealedAt: announceAt})
+ sealedBlocksCounter.Inc(1)
+ if sealedBlock.Transactions().Len() == 0 {
+ sealedEmptyBlocksCounter.Inc(1)
+ }
+ workerGasUsedPerBlockHistogram.Update(int64(sealedBlock.GasUsed()))
+ workerTxsPerBlockHistogram.Update(int64(sealedBlock.Transactions().Len()))
+}
diff --git a/miner/pipeline_test.go b/miner/pipeline_test.go
new file mode 100644
index 0000000000..7f750df2d1
--- /dev/null
+++ b/miner/pipeline_test.go
@@ -0,0 +1,34 @@
+package miner
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/core"
+ "github.com/ethereum/go-ethereum/core/types"
+)
+
+func TestShouldLateRefillSpeculativeBlock(t *testing.T) {
+ t.Parallel()
+
+ newEnv := func(txs int, gasLimit uint64, remainingGas uint64, withGasPool bool) *environment {
+ env := &environment{
+ header: &types.Header{
+ Number: big.NewInt(1),
+ GasLimit: gasLimit,
+ },
+ txs: make([]*types.Transaction, txs),
+ }
+ if withGasPool {
+ env.gasPool = new(core.GasPool).AddGas(remainingGas)
+ }
+ return env
+ }
+
+ require.True(t, shouldLateRefillSpeculativeBlock(newEnv(0, 1000, 0, false)))
+ require.True(t, shouldLateRefillSpeculativeBlock(newEnv(1, 1000, 600, true)))
+ require.True(t, shouldLateRefillSpeculativeBlock(newEnv(2, 1000, 0, false)))
+ require.False(t, shouldLateRefillSpeculativeBlock(newEnv(1, 1000, 200, true)))
+}
diff --git a/miner/speculative_chain_reader.go b/miner/speculative_chain_reader.go
new file mode 100644
index 0000000000..ef98a03713
--- /dev/null
+++ b/miner/speculative_chain_reader.go
@@ -0,0 +1,122 @@
+package miner
+
+import (
+ "math/big"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// speculativeChainReader wraps a real ChainHeaderReader and intercepts
+// hash-based lookups for a pending block whose hash is not yet known
+// (because its state root is still being computed by the SRC goroutine).
+//
+// During pipelined SRC, block N+1's Prepare() needs to look up block N's
+// header — but block N hasn't been written to the chain DB yet. The wrapper
+// maps a deterministic placeholder hash to block N's provisional header
+// (complete except for Root), allowing Prepare() and snapshot walks to proceed.
+//
+// The snapshot walk (bor.go:686) starts from header.ParentHash. For the
+// speculative header, that's the placeholder hash. The wrapper returns
+// pendingParentHeader for that lookup. Subsequent walk steps use
+// pendingParentHeader.ParentHash (= hash(block_{N-1})), which is in the
+// real chain DB, so the walk continues normally.
+type speculativeChainReader struct {
+ inner consensus.ChainHeaderReader
+ pendingParentHeader *types.Header // block N's header (complete except Root)
+ placeholderHash common.Hash // the placeholder used as block N+1's ParentHash
+}
+
+// newSpeculativeChainReader creates a wrapper that intercepts lookups for
+// the pending parent block.
+//
+// pendingParentHeader must have all fields set except Root. The caller must
+// ensure that pendingParentHeader.ParentHash points to a block that IS in
+// the chain DB (block N-1).
+//
+// placeholderHash is a deterministic sentinel used as ParentHash in the
+// speculative block N+1 header. It must NOT collide with any real block hash.
+func newSpeculativeChainReader(
+ inner consensus.ChainHeaderReader,
+ pendingParentHeader *types.Header,
+ placeholderHash common.Hash,
+) *speculativeChainReader {
+ return &speculativeChainReader{
+ inner: inner,
+ pendingParentHeader: pendingParentHeader,
+ placeholderHash: placeholderHash,
+ }
+}
+
+func (s *speculativeChainReader) Config() *params.ChainConfig {
+ return s.inner.Config()
+}
+
+func (s *speculativeChainReader) CurrentHeader() *types.Header {
+ return s.inner.CurrentHeader()
+}
+
+func (s *speculativeChainReader) GetHeader(hash common.Hash, number uint64) *types.Header {
+ if hash == s.placeholderHash && number == s.pendingParentHeader.Number.Uint64() {
+ return s.pendingParentHeader
+ }
+ return s.inner.GetHeader(hash, number)
+}
+
+func (s *speculativeChainReader) GetHeaderByNumber(number uint64) *types.Header {
+ if number == s.pendingParentHeader.Number.Uint64() {
+ return s.pendingParentHeader
+ }
+ return s.inner.GetHeaderByNumber(number)
+}
+
+func (s *speculativeChainReader) GetHeaderByHash(hash common.Hash) *types.Header {
+ if hash == s.placeholderHash {
+ return s.pendingParentHeader
+ }
+ return s.inner.GetHeaderByHash(hash)
+}
+
+func (s *speculativeChainReader) GetTd(hash common.Hash, number uint64) *big.Int {
+ if hash == s.placeholderHash && number == s.pendingParentHeader.Number.Uint64() {
+ // The pending parent being genesis can't happen in practice (the
+ // speculative path only builds on a produced block), but guard the
+ // subtraction against underflow regardless.
+ parentNumber := s.pendingParentHeader.Number.Uint64()
+ if parentNumber == 0 {
+ return s.inner.GetTd(s.pendingParentHeader.ParentHash, 0)
+ }
+ // Return the parent's TD. This is an approximation — the real TD
+ // would include block N's difficulty, but Bor's Prepare() does not
+ // use TD from GetTd. Seal() uses it for broadcast, but that happens
+ // after the real header is assembled.
+ return s.inner.GetTd(s.pendingParentHeader.ParentHash, parentNumber-1)
+ }
+ return s.inner.GetTd(hash, number)
+}
+
+// speculativeChainContext wraps speculativeChainReader and adds the Engine()
+// method, satisfying core.ChainContext. This is needed because
+// NewEVMBlockContext takes a ChainContext.
+type speculativeChainContext struct {
+ *speculativeChainReader
+ engine consensus.Engine
+}
+
+// newSpeculativeChainContext creates a ChainContext backed by the speculative
+// reader and the given consensus engine.
+func newSpeculativeChainContext(
+ reader *speculativeChainReader,
+ engine consensus.Engine,
+) *speculativeChainContext {
+ return &speculativeChainContext{
+ speculativeChainReader: reader,
+ engine: engine,
+ }
+}
+
+func (s *speculativeChainContext) Engine() consensus.Engine {
+ return s.engine
+}
diff --git a/miner/speculative_chain_reader_test.go b/miner/speculative_chain_reader_test.go
new file mode 100644
index 0000000000..57dae5ba07
--- /dev/null
+++ b/miner/speculative_chain_reader_test.go
@@ -0,0 +1,204 @@
+package miner
+
+import (
+ "math/big"
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/consensus"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/params"
+)
+
+// mockChainHeaderReader implements consensus.ChainHeaderReader for testing.
+type mockChainHeaderReader struct {
+ headers map[common.Hash]*types.Header
+ byNum map[uint64]*types.Header
+}
+
+func newMockChainHeaderReader() *mockChainHeaderReader {
+ return &mockChainHeaderReader{
+ headers: make(map[common.Hash]*types.Header),
+ byNum: make(map[uint64]*types.Header),
+ }
+}
+
+func (m *mockChainHeaderReader) addHeader(h *types.Header) {
+ m.headers[h.Hash()] = h
+ m.byNum[h.Number.Uint64()] = h
+}
+
+func (m *mockChainHeaderReader) Config() *params.ChainConfig { return params.TestChainConfig }
+func (m *mockChainHeaderReader) CurrentHeader() *types.Header { return nil }
+func (m *mockChainHeaderReader) GetTd(common.Hash, uint64) *big.Int { return big.NewInt(1) }
+
+func (m *mockChainHeaderReader) GetHeader(hash common.Hash, number uint64) *types.Header {
+ h, ok := m.headers[hash]
+ if ok && h.Number.Uint64() == number {
+ return h
+ }
+ return nil
+}
+
+func (m *mockChainHeaderReader) GetHeaderByNumber(number uint64) *types.Header {
+ return m.byNum[number]
+}
+
+func (m *mockChainHeaderReader) GetHeaderByHash(hash common.Hash) *types.Header {
+ return m.headers[hash]
+}
+
+func TestSpeculativeChainReader_InterceptsPlaceholder(t *testing.T) {
+ inner := newMockChainHeaderReader()
+
+ // Build a simple chain: block 8 (committed), block 9 (pending)
+ header8 := &types.Header{Number: big.NewInt(8), Extra: []byte("block8")}
+ inner.addHeader(header8)
+
+ // Block 9 is pending — not in the chain DB
+ pendingHeader9 := &types.Header{
+ Number: big.NewInt(9),
+ ParentHash: header8.Hash(),
+ Extra: []byte("block9-pending"),
+ }
+
+ placeholder := common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
+ reader := newSpeculativeChainReader(inner, pendingHeader9, placeholder)
+
+ // GetHeader with placeholder hash and number 9 should return pending header
+ got := reader.GetHeader(placeholder, 9)
+ if got == nil {
+ t.Fatal("GetHeader(placeholder, 9) returned nil")
+ }
+ if got.Number.Uint64() != 9 {
+ t.Errorf("expected block 9, got %d", got.Number.Uint64())
+ }
+ if string(got.Extra) != "block9-pending" {
+ t.Errorf("expected pending header extra, got %s", string(got.Extra))
+ }
+
+ // GetHeaderByHash with placeholder should return pending header
+ got = reader.GetHeaderByHash(placeholder)
+ if got == nil {
+ t.Fatal("GetHeaderByHash(placeholder) returned nil")
+ }
+ if got.Number.Uint64() != 9 {
+ t.Errorf("expected block 9, got %d", got.Number.Uint64())
+ }
+
+ // GetHeaderByNumber(9) should return pending header
+ got = reader.GetHeaderByNumber(9)
+ if got == nil {
+ t.Fatal("GetHeaderByNumber(9) returned nil")
+ }
+ if string(got.Extra) != "block9-pending" {
+ t.Errorf("expected pending header, got %s", string(got.Extra))
+ }
+}
+
+func TestSpeculativeChainReader_DelegatesNonPlaceholder(t *testing.T) {
+ inner := newMockChainHeaderReader()
+
+ header7 := &types.Header{Number: big.NewInt(7), Extra: []byte("block7")}
+ header8 := &types.Header{Number: big.NewInt(8), Extra: []byte("block8")}
+ inner.addHeader(header7)
+ inner.addHeader(header8)
+
+ pendingHeader9 := &types.Header{
+ Number: big.NewInt(9),
+ ParentHash: header8.Hash(),
+ }
+
+ placeholder := common.HexToHash("0xdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef")
+ reader := newSpeculativeChainReader(inner, pendingHeader9, placeholder)
+
+ // Looking up block 8 by its real hash should delegate to inner
+ got := reader.GetHeader(header8.Hash(), 8)
+ if got == nil {
+ t.Fatal("GetHeader(block8Hash, 8) returned nil")
+ }
+ if string(got.Extra) != "block8" {
+ t.Errorf("expected block8 header, got %s", string(got.Extra))
+ }
+
+ // GetHeaderByNumber(7) should delegate
+ got = reader.GetHeaderByNumber(7)
+ if got == nil {
+ t.Fatal("GetHeaderByNumber(7) returned nil")
+ }
+ if string(got.Extra) != "block7" {
+ t.Errorf("expected block7 header, got %s", string(got.Extra))
+ }
+
+ // Unknown hash should return nil
+ got = reader.GetHeader(common.HexToHash("0x1234"), 99)
+ if got != nil {
+ t.Error("expected nil for unknown hash")
+ }
+}
+
+func TestSpeculativeChainReader_WalkThroughPending(t *testing.T) {
+ // Simulate the snapshot walk: start at pending block 9, walk to block 8 (in chain)
+ inner := newMockChainHeaderReader()
+
+ header7 := &types.Header{Number: big.NewInt(7), Extra: []byte("block7")}
+ header8 := &types.Header{Number: big.NewInt(8), ParentHash: header7.Hash(), Extra: []byte("block8")}
+ inner.addHeader(header7)
+ inner.addHeader(header8)
+
+ pendingHeader9 := &types.Header{
+ Number: big.NewInt(9),
+ ParentHash: header8.Hash(),
+ Extra: []byte("block9-pending"),
+ }
+
+ placeholder := common.HexToHash("0xdeadbeef00000000000000000000000000000000000000000000000000000000")
+ reader := newSpeculativeChainReader(inner, pendingHeader9, placeholder)
+
+ // Step 1: look up block 9 via placeholder → returns pending header
+ h9 := reader.GetHeader(placeholder, 9)
+ if h9 == nil {
+ t.Fatal("step 1: pending header not found")
+ }
+
+ // Step 2: walk to block 8 using h9.ParentHash (= header8.Hash(), a real hash)
+ h8 := reader.GetHeader(h9.ParentHash, 8)
+ if h8 == nil {
+ t.Fatal("step 2: block 8 not found via ParentHash walk")
+ }
+ if string(h8.Extra) != "block8" {
+ t.Errorf("step 2: expected block8, got %s", string(h8.Extra))
+ }
+
+ // Step 3: walk to block 7 using h8.ParentHash
+ h7 := reader.GetHeader(h8.ParentHash, 7)
+ if h7 == nil {
+ t.Fatal("step 3: block 7 not found via ParentHash walk")
+ }
+ if string(h7.Extra) != "block7" {
+ t.Errorf("step 3: expected block7, got %s", string(h7.Extra))
+ }
+}
+
+func TestSpeculativeChainReader_Config(t *testing.T) {
+ inner := newMockChainHeaderReader()
+ pendingHeader := &types.Header{Number: big.NewInt(5)}
+ reader := newSpeculativeChainReader(inner, pendingHeader, common.Hash{})
+
+ if reader.Config() != params.TestChainConfig {
+ t.Error("Config() should delegate to inner")
+ }
+}
+
+func TestSpeculativeChainContext_Engine(t *testing.T) {
+ inner := newMockChainHeaderReader()
+ pendingHeader := &types.Header{Number: big.NewInt(5)}
+ reader := newSpeculativeChainReader(inner, pendingHeader, common.Hash{})
+
+ var mockEngine consensus.Engine // nil for testing
+ ctx := newSpeculativeChainContext(reader, mockEngine)
+
+ if ctx.Engine() != mockEngine {
+ t.Error("Engine() should return the provided engine")
+ }
+}
diff --git a/miner/worker.go b/miner/worker.go
index 67fbb3867c..0cb65b4b0a 100644
--- a/miner/worker.go
+++ b/miner/worker.go
@@ -135,11 +135,24 @@ var (
// above stays to preserve existing Grafana dashboards.
txApplyDurationPrefetchedTimer = newRegisteredCustomTimer("worker/txApplyDuration/prefetched", 8192)
txApplyDurationNotPrefetchedTimer = newRegisteredCustomTimer("worker/txApplyDuration/notPrefetched", 8192)
- // finalizeAndAssembleTimer measures time taken to finalize and assemble the block (state root calculation)
+ // finalizeAndAssembleTimer measures time taken to finalize and assemble the block (state root calculation).
+ // NOT emitted when pipelined SRC is enabled: the pipelined path uses
+ // FinalizeForPipeline, which deliberately skips the inline IntermediateRoot
+ // (the root comes from the background SRC goroutine instead). Closest
+ // pipeline equivalents: worker/pipelineSRCTime (total SRC compute) and
+ // worker/pipelineSRCWait (portion of SRC that actually blocked the caller).
finalizeAndAssembleTimer = metrics.NewRegisteredTimer("worker/finalizeAndAssemble", nil)
- // intermediateRootTimer measures time taken to calculate intermediate root
+ // intermediateRootTimer measures time taken to calculate intermediate root.
+ // NOT emitted when pipelined SRC is enabled: there is no inline root calculation
+ // under pipelining — the SRC goroutine computes it in parallel with the next
+ // block's execution. Closest pipeline equivalent: worker/pipelineSRCTime (cost)
+ // or worker/pipelineSRCWait (how much of the cost was hidden by the overlap).
intermediateRootTimer = metrics.NewRegisteredTimer("worker/intermediateRoot", nil)
- // commitTimer measures total time for complete block building (tx execution + finalization + state root)
+ // commitTimer measures total time for complete block building (tx execution + finalization + state root).
+ // NOT emitted when pipelined SRC is enabled: the pipelined model has no
+ // single contiguous "build" interval — speculative fill of N+1 overlaps with
+ // SRC(N), so fabricating a total would be misleading. Closest pipeline signals:
+ // worker/pipelineSRCWait + worker/pipelineSealDuration + worker/pipelineAnnounceEarlinessMs.
commitTimer = metrics.NewRegisteredTimer("worker/commit", nil)
// writeBlockAndSetHeadTimer measures total time for WriteBlockAndSetHead in the seal result loop.
// This covers the entire gap between block sealing and event posting: witness encoding, batch write,
@@ -197,6 +210,15 @@ var (
workerBorConsensusTimer = metrics.NewRegisteredTimer("worker/chain/bor/consensus", nil)
workerBlockExecutionTimer = metrics.NewRegisteredTimer("worker/chain/execution", nil)
workerMgaspsTimer = metrics.NewRegisteredResettingTimer("worker/chain/mgasps", nil)
+ // Throughput histograms — mode-agnostic. For the pipelined path, "per-block build elapsed"
+ // isn't a single contiguous interval, so mgasps is only emitted by the normal path.
+ // gas_used_per_block and txs_per_block are emitted in both modes.
+ workerGasUsedPerBlockHistogram = metrics.NewRegisteredHistogram("worker/chain/gas_used_per_block", nil, metrics.NewExpDecaySample(1028, 0.015))
+ workerTxsPerBlockHistogram = metrics.NewRegisteredHistogram("worker/chain/txs_per_block", nil, metrics.NewExpDecaySample(1028, 0.015))
+ // End-to-end producer timer: wall clock from build begin to NewMinedBlockEvent broadcast.
+ // Fires in both normal (resultLoop → mux.Post) and pipelined (inlineSealAndBroadcast → mux.Post) modes,
+ // giving a directly comparable apples-to-apples A/B signal.
+ workerBuildToAnnounceTimer = metrics.NewRegisteredTimer("worker/build_to_announce", nil)
// Trie commit metrics for block production (populated after WriteBlockAndSetHead → CommitWithUpdate).
workerAccountCommitTimer = metrics.NewRegisteredResettingTimer("worker/chain/account/commits", nil)
@@ -245,6 +267,10 @@ type environment struct {
gasPool *core.GasPool // available gas used to pack transactions
coinbase common.Address
evm *vm.EVM
+ // buildInterrupt owns the timeout signal for this specific block-building
+ // attempt. It must not be shared across overlapping sequential/speculative
+ // builds, otherwise one timer can abort another build.
+ buildInterrupt *buildInterruptState
header *types.Header
txs []*types.Transaction
@@ -253,6 +279,8 @@ type environment struct {
blobs int
mvReadMapList []map[blockstm.Key]blockstm.ReadDescriptor
+ depsBuilder *blockstm.DepsBuilder
+ depsFailed bool
witness *stateless.Witness
// Readers with stats tracking for metrics reporting
@@ -271,6 +299,36 @@ type environment struct {
pendingDuration time.Duration
}
+type buildInterruptState struct {
+ timedOut atomic.Bool
+ flagSetAt atomic.Int64
+}
+
+func newBuildInterruptState() *buildInterruptState {
+ return &buildInterruptState{}
+}
+
+func (s *buildInterruptState) timeoutFlag() *atomic.Bool {
+ if s == nil {
+ return nil
+ }
+ return &s.timedOut
+}
+
+func (s *buildInterruptState) flagSetAtPtr() *atomic.Int64 {
+ if s == nil {
+ return nil
+ }
+ return &s.flagSetAt
+}
+
+func (w *worker) interruptStateForEnv(env *environment) (*atomic.Bool, *atomic.Int64) {
+ if env != nil && env.header != nil && w.isPipelineEligible(env.header.Number.Uint64()) {
+ return env.buildInterrupt.timeoutFlag(), env.buildInterrupt.flagSetAtPtr()
+ }
+ return &w.interruptBlockBuilding, &w.interruptFlagSetAt
+}
+
// copy creates a deep copy of environment.
func (env *environment) copy() *environment {
cpy := &environment{
@@ -279,6 +337,7 @@ func (env *environment) copy() *environment {
tcount: env.tcount,
stateSyncReserve: env.stateSyncReserve,
coinbase: env.coinbase,
+ buildInterrupt: newBuildInterruptState(),
header: types.CopyHeader(env.header),
receipts: copyReceipts(env.receipts),
mvReadMapList: env.mvReadMapList,
@@ -320,8 +379,11 @@ type task struct {
state *state.StateDB
block *types.Block
createdAt time.Time
+ productionStart time.Time // wall clock at build begin — used for worker/build_to_announce (fires from resultLoop at mux.Post)
productionElapsed time.Duration // elapsed from after prepareWork to task submission (excludes sealing wait); used for workerMgaspsTimer and workerBlockExecutionTimer
intermediateRootTime time.Duration // time spent in IntermediateRoot inside FinalizeAndAssemble; subtracted when computing workerBlockExecutionTimer
+ pipelined bool // If true, state was already committed by SRC goroutine — skip CommitWithUpdate in writeBlockWithState
+ witnessBytes []byte // RLP-encoded witness from SRC goroutine (for pipelined blocks)
}
// stateSyncReserveFor returns the block-size budget to hold back for the state-sync
@@ -446,6 +508,10 @@ type worker struct {
// Used to prevent duplicate work.
pendingWorkBlock atomic.Uint64
+ // When set, the next sequential build is recovering a discarded
+ // speculative block and should preserve its original target slot.
+ nextCommitAbortRecovery atomic.Bool
+
snapshotMu sync.RWMutex // The lock used to protect the snapshots below
snapshotBlock *types.Block
snapshotReceipts types.Receipts
@@ -476,9 +542,11 @@ type worker struct {
fullTaskHook func() // Method to call before pushing the full sealing task.
resubmitHook func(time.Duration, time.Duration) // Method to call upon updating resubmitting interval.
- // Interrupt commit to stop block building on time
- interruptCommitFlag bool // Denotes whether interrupt commit is enabled or not
- interruptBlockBuilding atomic.Bool // A toggle to denote whether to stop block building or not
+ // Interrupt commit to stop block building on time. Develop-compatible
+ // sequential builds use the worker-global flag; pipelined builds switch to
+ // per-environment timeout state so overlapping builds cannot interrupt each other.
+ interruptCommitFlag bool
+ interruptBlockBuilding atomic.Bool
interruptFlagSetAt atomic.Int64
mockTxDelay uint // A mock delay for transaction execution, only used in tests
@@ -493,6 +561,9 @@ type worker struct {
noempty atomic.Bool
makeWitness bool
+
+ // Pipelined SRC: speculative work channel for block N+1 execution
+ speculativeWorkCh chan *speculativeWorkReq
}
//nolint:staticcheck
@@ -523,8 +594,13 @@ func newWorker(config *Config, chainConfig *params.ChainConfig, engine consensus
blockTime: config.BlockTime,
slowTxTracker: newSlowTxTopTracker(),
makeWitness: makeWitness,
+ speculativeWorkCh: make(chan *speculativeWorkReq, 1),
}
worker.noempty.Store(true)
+ // Production-side pipelined SRC is intentionally disabled and no longer has
+ // a miner config knob. Keep the gauge at 0; import-side pipelining has its
+ // own chain/imports/pipelined/enabled gauge.
+ pipelineBuildEnabledGauge.Update(0)
// Subscribe for transaction insertion events (whether from network or resurrects)
worker.txsSub = eth.TxPool().SubscribeTransactions(worker.txsCh, true)
// Subscribe events for blockchain
@@ -900,6 +976,57 @@ func (w *worker) newWorkLoop(recommit time.Duration) {
}
}
+// schedulePipelineRetry re-enters block building through the normal newWorkCh
+// path after the pipeline exits or aborts. A short delay lets the latest head
+// become visible first, so the retry builds on the correct parent instead of
+// recursively calling commitWork from inside the speculative-work handler.
+// handleSpeculativeWork runs a pipelined speculative-work request and, when
+// shouldRetry=true, requeues a normal commitWork via the newWorkCh path.
+// Extracted from mainLoop so the main dispatch select stays compact.
+// Requeueing instead of recursing avoids building on a stale parent and is
+// deliberately skipped when commitSpeculativeWork fell back to sequential
+// (fallbackToSequential already sealed block N via taskCh, and retrying would
+// loop-restart Seal() with fresh timestamps).
+func (w *worker) handleSpeculativeWork(req *speculativeWorkReq) {
+ shouldRetry, abortRecovery := w.commitSpeculativeWork(req)
+ if !shouldRetry {
+ return
+ }
+ if abortRecovery {
+ w.nextCommitAbortRecovery.Store(true)
+ }
+ w.schedulePipelineRetry()
+}
+
+func (w *worker) schedulePipelineRetry() {
+ go func() {
+ timer := time.NewTimer(25 * time.Millisecond)
+ defer timer.Stop()
+
+ select {
+ case <-timer.C:
+ case <-w.exitCh:
+ return
+ }
+
+ current := w.chain.CurrentBlock()
+ if current == nil {
+ return
+ }
+
+ target := current.Number.Uint64() + 1
+ if w.pendingWorkBlock.Load() >= target {
+ return
+ }
+ w.pendingWorkBlock.Store(target)
+
+ select {
+ case w.newWorkCh <- &newWorkReq{timestamp: time.Now().Unix()}:
+ case <-w.exitCh:
+ }
+ }()
+}
+
// mainLoop is responsible for generating and submitting sealing work based on
// the received event. It can support two modes: automatically generate task and
// submit it or return task according to given parameters for various proposes.
@@ -929,8 +1056,11 @@ func (w *worker) mainLoop() {
continue
}
+ abortRecovery := w.nextCommitAbortRecovery.Swap(false)
//nolint:contextcheck
- w.commitWork(req.interrupt, req.noempty, req.timestamp)
+ w.commitWork(req.interrupt, req.noempty, req.timestamp, abortRecovery)
+ case req := <-w.speculativeWorkCh:
+ w.handleSpeculativeWork(req)
case req := <-w.getWorkCh:
req.result <- w.generateWork(req.params, false)
@@ -969,16 +1099,23 @@ func (w *worker) mainLoop() {
stopFn := func() {}
if w.interruptCommitFlag {
+ // Production-side pipelining is disabled, so the interrupt
+ // timer should use the regular block-time boundary. If the
+ // production pipeline is re-enabled, this was previously
+ // wired to the miner pipeline enable flag.
+ timeoutInterrupt, timeoutFlagSetAt := w.interruptStateForEnv(w.current)
stopFn = createInterruptTimer(
w.current.header.Number.Uint64(),
w.current.header.GetActualTime(),
- &w.interruptBlockBuilding,
- &w.interruptFlagSetAt,
+ timeoutInterrupt,
+ timeoutFlagSetAt,
+ w.isPipelineEligible(w.current.header.Number.Uint64()),
)
}
- plainTxs := newTransactionsByPriceAndNonce(w.current.signer, txs, w.current.header.BaseFee, &w.interruptBlockBuilding) // Mixed bag of everrything, yolo
- blobTxs := newTransactionsByPriceAndNonce(w.current.signer, nil, w.current.header.BaseFee, &w.interruptBlockBuilding) // Empty bag, don't bother optimising
+ timeoutInterrupt, _ := w.interruptStateForEnv(w.current)
+ plainTxs := newTransactionsByPriceAndNonce(w.current.signer, txs, w.current.header.BaseFee, timeoutInterrupt) // Mixed bag of everrything, yolo
+ blobTxs := newTransactionsByPriceAndNonce(w.current.signer, nil, w.current.header.BaseFee, timeoutInterrupt) // Empty bag, don't bother optimising
tcount := w.current.tcount
@@ -995,7 +1132,7 @@ func (w *worker) mainLoop() {
// submit sealing work here since all empty submission will be rejected
// by clique. Of course the advance sealing(empty submission) is disabled.
if w.chainConfig.Clique != nil && w.chainConfig.Clique.Period == 0 {
- w.commitWork(nil, true, time.Now().Unix())
+ w.commitWork(nil, true, time.Now().Unix(), false)
}
}
@@ -1186,73 +1323,26 @@ func (w *worker) resultLoop() {
witness.SetHeader(block.Header())
}
- // Execution metrics: emitted before write because these values are final after
- // FinalizeAndAssemble and do not depend on write success — matching the import path
- // which emits read/update/hash/execution/bor metrics before writeBlockAndSetHead.
- // Emitting here avoids losing these observations on a rare write failure.
- if metrics.Enabled() {
- workerAccountReadTimer.Update(task.state.AccountReads)
- workerStorageReadTimer.Update(task.state.StorageReads)
- workerSnapshotAccountReadTimer.Update(task.state.SnapshotAccountReads)
- workerSnapshotStorageReadTimer.Update(task.state.SnapshotStorageReads)
- workerAccountUpdateTimer.Update(task.state.AccountUpdates)
- workerStorageUpdateTimer.Update(task.state.StorageUpdates)
- workerAccountHashTimer.Update(task.state.AccountHashes)
- workerStorageHashTimer.Update(task.state.StorageHashes)
- workerBorConsensusTimer.Update(task.state.BorConsensusTime)
- trieRead := task.state.SnapshotAccountReads + task.state.AccountReads +
- task.state.SnapshotStorageReads + task.state.StorageReads
- // productionElapsed covers fillTx + FinalizeAndAssemble; subtract trie reads,
- // Bor consensus time, and IntermediateRoot time to isolate pure EVM execution time.
- // Mirrors the import path formula in blockchain.go (writeBlockAndSetHead),
- // where ptime already excludes vtime (IntermediateRoot) via explicit subtraction.
- // Clamped to zero to avoid negative histogram samples from measurement jitter.
- execTime := task.productionElapsed - trieRead - task.state.BorConsensusTime - task.intermediateRootTime
- if execTime < 0 {
- execTime = 0
- }
- workerBlockExecutionTimer.Update(execTime)
- }
+ emitExecutionMetrics(task)
- // Commit block and state to database.
writeStart := time.Now()
- _, err = w.chain.WriteBlockAndSetHead(block, receipts, logs, task.state, true)
+ _, err = w.writeTaskBlock(task, block, receipts, logs)
writeElapsed := time.Since(writeStart)
writeBlockAndSetHeadTimer.Update(writeElapsed)
-
if err != nil {
log.Error("Failed writing block to chain", "err", err)
- // Error writing block to chain, delete the pending task.
w.pendingMu.Lock()
delete(w.pendingTasks, sealhash)
w.pendingMu.Unlock()
continue
}
- // Commit metrics: emitted only after a successful write because these values are
- // populated by WriteBlockAndSetHead → CommitWithUpdate. Emitting on failure would
- // record zeroes or stale data — matching the import path which also gates commit
- // metrics after a successful writeBlockAndSetHead.
- if metrics.Enabled() {
- workerAccountCommitTimer.Update(task.state.AccountCommits)
- workerStorageCommitTimer.Update(task.state.StorageCommits)
- workerSnapshotCommitTimer.Update(task.state.SnapshotCommits)
- workerTriedbCommitTimer.Update(task.state.TrieDBCommits)
- workerWitnessCollectionTimer.Update(task.state.WitnessCollection)
-
- // MGas/s: denominator includes both production and write time, matching blockchain.go
- // which measures elapsed after writeBlockAndSetHead returns
- // (gas * 1000 / elapsed_nanoseconds stores milli-gas/ns = MGas/s as a Duration value).
- if total := task.productionElapsed + writeElapsed; total > 0 {
- workerMgaspsTimer.Update(time.Duration(float64(block.GasUsed()) * 1000 / float64(total)))
- }
- }
+ emitCommitMetrics(task, block, writeElapsed)
log.Info("Successfully sealed new block", "number", block.Number(), "sealhash", sealhash, "hash", hash,
"elapsed", common.PrettyDuration(time.Since(task.createdAt)))
- // Broadcast the block and announce chain insertion event
- w.mux.Post(core.NewMinedBlockEvent{Block: block, Witness: witness, SealedAt: time.Now()})
+ announceTaskBlock(w.mux, task, block, witness)
sealedBlocksCounter.Inc(1)
@@ -1270,24 +1360,106 @@ func (w *worker) resultLoop() {
}
}
+// emitExecutionMetrics reports the task's pre-write statedb timers + execution
+// time. Matches the import path which emits read/update/hash/execution/bor
+// metrics before writeBlockAndSetHead so observations aren't lost on write
+// failure. No-op when metrics are disabled.
+func emitExecutionMetrics(task *task) {
+ if !metrics.Enabled() {
+ return
+ }
+ workerAccountReadTimer.Update(task.state.AccountReads)
+ workerStorageReadTimer.Update(task.state.StorageReads)
+ workerSnapshotAccountReadTimer.Update(task.state.SnapshotAccountReads)
+ workerSnapshotStorageReadTimer.Update(task.state.SnapshotStorageReads)
+ workerAccountUpdateTimer.Update(task.state.AccountUpdates)
+ workerStorageUpdateTimer.Update(task.state.StorageUpdates)
+ workerAccountHashTimer.Update(task.state.AccountHashes)
+ workerStorageHashTimer.Update(task.state.StorageHashes)
+ workerBorConsensusTimer.Update(task.state.BorConsensusTime)
+ trieRead := task.state.SnapshotAccountReads + task.state.AccountReads +
+ task.state.SnapshotStorageReads + task.state.StorageReads
+ // productionElapsed covers fillTx + FinalizeAndAssemble; subtract trie reads,
+ // Bor consensus, and IntermediateRoot time to isolate pure EVM execution.
+ // Mirrors blockchain.go's ptime = productionElapsed - trieRead (clamped
+ // to zero for measurement jitter).
+ execTime := task.productionElapsed - trieRead - task.state.BorConsensusTime - task.intermediateRootTime
+ if execTime < 0 {
+ execTime = 0
+ }
+ workerBlockExecutionTimer.Update(execTime)
+}
+
+// writeTaskBlock commits the sealed block + state to disk. Pipelined tasks go
+// through WriteBlockAndSetHeadPipelined so the SRC goroutine's earlier
+// CommitWithUpdate isn't duplicated; normal tasks go through the standard
+// path. Returns the write status for parity with the original inline call.
+func (w *worker) writeTaskBlock(task *task, block *types.Block, receipts []*types.Receipt, logs []*types.Log) (core.WriteStatus, error) {
+ if task.pipelined {
+ return w.chain.WriteBlockAndSetHeadPipelined(block, receipts, logs, task.state, true, task.witnessBytes)
+ }
+ return w.chain.WriteBlockAndSetHead(block, receipts, logs, task.state, true)
+}
+
+// emitCommitMetrics reports the task's post-write statedb timers, mgas/s,
+// and per-block throughput histograms. Must run only after a successful
+// write — the commit fields are populated by CommitWithUpdate inside
+// WriteBlockAndSetHead. No-op when metrics are disabled.
+func emitCommitMetrics(task *task, block *types.Block, writeElapsed time.Duration) {
+ if !metrics.Enabled() {
+ return
+ }
+ workerAccountCommitTimer.Update(task.state.AccountCommits)
+ workerStorageCommitTimer.Update(task.state.StorageCommits)
+ workerSnapshotCommitTimer.Update(task.state.SnapshotCommits)
+ workerTriedbCommitTimer.Update(task.state.TrieDBCommits)
+ workerWitnessCollectionTimer.Update(task.state.WitnessCollection)
+ // MGas/s: denominator is production + write (matches blockchain.go's
+ // elapsed, measured after writeBlockAndSetHead returns). Duration stores
+ // milli-gas/ns = MGas/s.
+ if total := task.productionElapsed + writeElapsed; total > 0 {
+ workerMgaspsTimer.Update(time.Duration(float64(block.GasUsed()) * 1000 / float64(total)))
+ }
+ workerGasUsedPerBlockHistogram.Update(int64(block.GasUsed()))
+ workerTxsPerBlockHistogram.Update(int64(block.Transactions().Len()))
+}
+
+// announceTaskBlock broadcasts the sealed block to peers and updates the
+// build-to-announce + PIP-66 earliness + committed metrics for pipelined
+// tasks sealed via taskCh (last-of-pipeline, eligibility-fail, or fallback).
+// inlineSealAndBroadcast emits the same signals on the inline path.
+func announceTaskBlock(mux *event.TypeMux, task *task, block *types.Block, witness *stateless.Witness) {
+ announceAt := time.Now()
+ if !task.productionStart.IsZero() {
+ workerBuildToAnnounceTimer.UpdateSince(task.productionStart)
+ }
+ if task.pipelined {
+ earlyMs := block.Header().GetActualTime().Sub(announceAt).Milliseconds()
+ pipelineAnnounceEarlinessMs.Update(earlyMs)
+ pipelineSpeculativeCommittedCounter.Inc(1)
+ }
+ mux.Post(core.NewMinedBlockEvent{Block: block, Witness: witness, SealedAt: announceAt})
+}
+
+// resolveStateFor returns the caller-supplied statedb if any (from commitWork's
+// dual-reader path), otherwise opens one at the parent's root. Kept as a helper
+// so makeEnv itself fits within the function-size budget.
+func (w *worker) resolveStateFor(header *types.Header, genParams *generateParams) (*state.StateDB, error) {
+ if genParams.statedb != nil {
+ return genParams.statedb, nil
+ }
+ parent := w.chain.GetHeader(header.ParentHash, header.Number.Uint64()-1)
+ if parent == nil {
+ return nil, fmt.Errorf("parent block not found")
+ }
+ return w.chain.StateAt(parent.Root)
+}
+
// makeEnv creates a new environment for the sealing block.
func (w *worker) makeEnv(header *types.Header, coinbase common.Address, witness bool, genParams *generateParams) (*environment, error) {
- var state *state.StateDB
-
- // If statedb is not provided (e.g., from getSealingBlock path), create it
- if genParams.statedb == nil {
- parent := w.chain.GetHeader(header.ParentHash, header.Number.Uint64()-1)
- if parent == nil {
- return nil, fmt.Errorf("parent block not found")
- }
- var err error
- state, err = w.chain.StateAt(parent.Root)
- if err != nil {
- return nil, err
- }
- } else {
- // Use the provided statedb (from commitWork with dual readers)
- state = genParams.statedb
+ state, err := w.resolveStateFor(header, genParams)
+ if err != nil {
+ return nil, err
}
if witness {
@@ -1307,6 +1479,7 @@ func (w *worker) makeEnv(header *types.Header, coinbase common.Address, witness
state: state,
size: uint64(header.Size()),
coinbase: coinbase,
+ buildInterrupt: newBuildInterruptState(),
header: header,
witness: state.Witness(),
evm: vm.NewEVM(core.NewEVMBlockContext(header, w.chain, &coinbase), state, w.chainConfig, w.vmConfig()),
@@ -1314,7 +1487,8 @@ func (w *worker) makeEnv(header *types.Header, coinbase common.Address, witness
processReader: genParams.processReader,
prefetchedTxHashes: genParams.prefetchedTxHashes,
}
- env.evm.SetInterrupt(&w.interruptBlockBuilding)
+ timeoutInterrupt, _ := w.interruptStateForEnv(env)
+ env.evm.SetInterrupt(timeoutInterrupt)
env.stateSyncReserve = stateSyncReserveFor(w.chainConfig, header.Number)
// Keep track of transactions which return errors so they can be removed
@@ -1375,41 +1549,13 @@ func (w *worker) commitTransactions(env *environment, plainTxs, blobTxs *transac
var coalescedLogs []*types.Log
- var deps map[int]map[int]bool
-
- var depsBuilder *blockstm.DepsBuilder
- var chDeps chan blockstm.TxReadWriteSet
-
- var depsWg sync.WaitGroup
- var once sync.Once
-
EnableMVHashMap := w.chainConfig.IsCancun(env.header.Number)
- // create and add empty mvHashMap in statedb
- if EnableMVHashMap && w.IsRunning() {
- depsBuilder = blockstm.NewDepsBuilder()
- chDeps = make(chan blockstm.TxReadWriteSet)
-
- // Make sure we safely close the channel in case of interrupt
- defer once.Do(func() {
- close(chDeps)
- })
-
- depsWg.Add(1)
-
- go func(chDeps chan blockstm.TxReadWriteSet) {
- for t := range chDeps {
- if err := depsBuilder.AddTransaction(t.Index, t.ReadList, t.WriteList); err != nil {
- // Non-sequential index indicates a systematic bug, not a transient error.
- // Drain the channel so the sender never blocks, then stop processing.
- log.Error("Failed to build tx dependency metadata, dropping DAG hint", "tx", t.Index, "err", err)
- for range chDeps {
- }
- break
- }
- }
- depsWg.Done()
- }(chDeps)
+ // Speculative blocks can be filled in more than one pass. Keep a single DAG
+ // builder on the environment so dependency indices continue from the first
+ // pass instead of restarting from zero on a refill.
+ if EnableMVHashMap && w.IsRunning() && env.depsBuilder == nil && !env.depsFailed {
+ env.depsBuilder = blockstm.NewDepsBuilder()
}
var lastTxHash common.Hash
@@ -1437,13 +1583,19 @@ mainloop:
}
// Check for the flag to interrupt block building on timeout.
- if w.interruptBlockBuilding.Load() {
+ // The worker-global interrupt is only a manual/test override; the real
+ // timeout state is owned by this build attempt.
+ if w.interruptBlockBuilding.Load() || (env.buildInterrupt != nil && env.buildInterrupt.timedOut.Load()) {
txCommitInterruptCounter.Inc(1)
logCtx := []interface{}{
"number", env.header.Number.Uint64(),
"headerTime", common.PrettyTime(time.Unix(int64(env.header.Time), 0)),
}
- if flagSetAt := w.interruptFlagSetAt.Load(); flagSetAt > 0 {
+ flagSetAt := w.interruptFlagSetAt.Load()
+ if flagSetAt == 0 && env.buildInterrupt != nil {
+ flagSetAt = env.buildInterrupt.flagSetAt.Load()
+ }
+ if flagSetAt > 0 {
flagSetTime := time.Unix(0, flagSetAt)
logCtx = append(logCtx, "flagSetAt", common.PrettyTime(flagSetTime))
logCtx = append(logCtx, "flagToAbortDelay", common.PrettyDuration(time.Since(flagSetTime)))
@@ -1632,25 +1784,14 @@ mainloop:
return errors.New("transaction count exceeds dependency list length")
}
- temp := blockstm.TxReadWriteSet{
- Index: env.tcount - 1,
- ReadList: env.state.MVReadList(),
- WriteList: env.state.MVFullWriteList(),
- }
-
- // Send with timeout to prevent deadlock
- select {
- case chDeps <- temp:
- // Successfully sent
- case <-time.After(1 * time.Second):
- // Timeout after 1 second - channel is blocked
- log.Error("Transaction dependency channel blocked, aborting block building",
- "txIndex", env.tcount-1,
- "blockNumber", env.header.Number.Uint64())
- once.Do(func() {
- close(chDeps)
- })
- return errors.New("dependency channel timeout")
+ if !env.depsFailed {
+ if env.depsBuilder == nil {
+ env.depsBuilder = blockstm.NewDepsBuilder()
+ }
+ if err := env.depsBuilder.AddTransaction(env.tcount-1, env.state.MVReadList(), env.state.MVFullWriteList()); err != nil {
+ log.Error("Failed to build tx dependency metadata, dropping DAG hint", "tx", env.tcount-1, "err", err)
+ env.depsFailed = true
+ }
}
}
@@ -1672,7 +1813,11 @@ mainloop:
case errors.Is(err, vm.ErrInterrupt):
// Timeout interrupt surfaced from EVM execution for this tx.
if !hasTxInterruptDelay {
- if flagSetAt := w.interruptFlagSetAt.Load(); flagSetAt > 0 {
+ flagSetAt := w.interruptFlagSetAt.Load()
+ if flagSetAt == 0 && env.buildInterrupt != nil {
+ flagSetAt = env.buildInterrupt.flagSetAt.Load()
+ }
+ if flagSetAt > 0 {
flagToTxInterruptDelay = time.Since(time.Unix(0, flagSetAt))
hasTxInterruptDelay = true
}
@@ -1693,73 +1838,10 @@ mainloop:
}
}
- // nolint:nestif
if EnableMVHashMap && w.IsRunning() {
- once.Do(func() {
- close(chDeps)
- })
- depsWg.Wait()
-
- deps = depsBuilder.GetDeps()
- if deps == nil {
- log.Warn("Failed to build tx dependency DAG, skipping metadata", "number", env.header.Number)
- }
-
- var blockExtraData types.BlockExtraData
-
- tempVanity := env.header.Extra[:types.ExtraVanityLength]
- tempSeal := env.header.Extra[len(env.header.Extra)-types.ExtraSealLength:]
-
- // Always decode header extra data before overwriting TxDependency.
- if err := rlp.DecodeBytes(env.header.Extra[types.ExtraVanityLength:len(env.header.Extra)-types.ExtraSealLength], &blockExtraData); err != nil {
- log.Error("error while decoding block extra data", "err", err)
- return err
- }
-
- // deps is nil when DepsBuilder errored, and non-nil empty when no transactions were added.
- if deps != nil && len(env.mvReadMapList) > 0 {
- tempDeps := make([][]uint64, len(env.mvReadMapList))
-
- for j := range deps[0] {
- tempDeps[0] = append(tempDeps[0], uint64(j))
- }
-
- delayFlag := true
-
- for i := 1; i <= len(env.mvReadMapList)-1; i++ {
- reads := env.mvReadMapList[i]
-
- // Coinbase and burn-contract balance reads create an implicit ordering not captured by the DAG.
- _, ok1 := reads[blockstm.NewSubpathKey(env.coinbase, state.BalancePath)]
- _, ok2 := reads[blockstm.NewSubpathKey(common.HexToAddress(w.chainConfig.Bor.CalculateBurntContract(env.header.Number.Uint64())), state.BalancePath)]
- if ok1 || ok2 {
- delayFlag = false
- break
- }
-
- for j := range deps[i] {
- tempDeps[i] = append(tempDeps[i], uint64(j))
- }
- }
-
- if delayFlag {
- blockExtraData.TxDependency = tempDeps
- } else {
- blockExtraData.TxDependency = nil
- }
- } else {
- blockExtraData.TxDependency = nil
- }
-
- blockExtraDataBytes, err := rlp.EncodeToBytes(blockExtraData)
- if err != nil {
- log.Error("error while encoding block extra data: %v", err)
+ if err := w.updateTxDependencyMetadata(env); err != nil {
return err
}
-
- env.header.Extra = []byte{}
- env.header.Extra = append(tempVanity, blockExtraDataBytes...)
- env.header.Extra = append(env.header.Extra, tempSeal...)
}
if !w.IsRunning() && len(coalescedLogs) > 0 {
@@ -1781,12 +1863,75 @@ mainloop:
return nil
}
+func (w *worker) updateTxDependencyMetadata(env *environment) error {
+ var deps map[int]map[int]bool
+ if env.depsBuilder != nil && !env.depsFailed {
+ deps = env.depsBuilder.GetDeps()
+ }
+ if deps == nil && len(env.mvReadMapList) > 0 {
+ log.Warn("Failed to build tx dependency DAG, skipping metadata", "number", env.header.Number)
+ }
+
+ var blockExtraData types.BlockExtraData
+ tempVanity := env.header.Extra[:types.ExtraVanityLength]
+ tempSeal := env.header.Extra[len(env.header.Extra)-types.ExtraSealLength:]
+
+ // Always decode header extra data before overwriting TxDependency.
+ if err := rlp.DecodeBytes(env.header.Extra[types.ExtraVanityLength:len(env.header.Extra)-types.ExtraSealLength], &blockExtraData); err != nil {
+ log.Error("error while decoding block extra data", "err", err)
+ return err
+ }
+
+ blockExtraData.TxDependency = w.buildTxDependencyArray(env, deps)
+
+ blockExtraDataBytes, err := rlp.EncodeToBytes(blockExtraData)
+ if err != nil {
+ log.Error("error while encoding block extra data: %v", err)
+ return err
+ }
+
+ env.header.Extra = []byte{}
+ env.header.Extra = append(tempVanity, blockExtraDataBytes...)
+ env.header.Extra = append(env.header.Extra, tempSeal...)
+ return nil
+}
+
+// buildTxDependencyArray projects the DepsBuilder output into the block's
+// TxDependency encoding. Returns nil when deps are unavailable or when any
+// transaction reads coinbase/burn-contract balance (implicit ordering the
+// DAG doesn't capture — signalled by delayFlag=false in the original code).
+func (w *worker) buildTxDependencyArray(env *environment, deps map[int]map[int]bool) [][]uint64 {
+ // deps is nil when DepsBuilder errored; non-nil empty when no txs were added.
+ if deps == nil || len(env.mvReadMapList) == 0 {
+ return nil
+ }
+ tempDeps := make([][]uint64, len(env.mvReadMapList))
+ for j := range deps[0] {
+ tempDeps[0] = append(tempDeps[0], uint64(j))
+ }
+ burntContract := common.HexToAddress(w.chainConfig.Bor.CalculateBurntContract(env.header.Number.Uint64()))
+ for i := 1; i <= len(env.mvReadMapList)-1; i++ {
+ reads := env.mvReadMapList[i]
+ // Coinbase and burn-contract balance reads create an implicit ordering not captured by the DAG.
+ _, ok1 := reads[blockstm.NewSubpathKey(env.coinbase, state.BalancePath)]
+ _, ok2 := reads[blockstm.NewSubpathKey(burntContract, state.BalancePath)]
+ if ok1 || ok2 {
+ return nil
+ }
+ for j := range deps[i] {
+ tempDeps[i] = append(tempDeps[i], uint64(j))
+ }
+ }
+ return tempDeps
+}
+
// generateParams wraps various of settings for generating sealing task.
type generateParams struct {
timestamp uint64 // The timestamp for sealing task
forceTime bool // Flag whether the given timestamp is immutable or not
parentHash common.Hash // Parent block hash, empty means the latest chain head
coinbase common.Address // The fee recipient address for including transaction
+ abortRecovery bool // Flag that this build is rebuilding a discarded speculative block
random common.Hash // The randomness generated by beacon chain, empty before the merge
withdrawals types.Withdrawals // List of withdrawals to include in block.
beaconRoot *common.Hash // The beacon root (cancun field).
@@ -1828,18 +1973,8 @@ func (w *worker) makeHeader(genParams *generateParams, waitOnPrepare bool) (*typ
timestamp = parent.Time + 1
}
- var coinbase common.Address
newBlockNumber := new(big.Int).Add(parent.Number, common.Big1)
- if w.chainConfig.Bor != nil && w.chainConfig.Bor.IsRio(newBlockNumber) {
- coinbase = common.HexToAddress(w.chainConfig.Bor.CalculateCoinbase(newBlockNumber.Uint64()))
-
- // In case of coinbase is not set post Rio, use the default coinbase
- if coinbase == (common.Address{}) {
- coinbase = genParams.coinbase
- }
- } else {
- coinbase = genParams.coinbase
- }
+ coinbase := w.resolveCoinbase(newBlockNumber.Uint64(), genParams.coinbase)
// Calculate desired gas limit (may be dynamically adjusted based on base fee)
desiredGasLimit := w.calculateDesiredGasLimit(parent)
@@ -1852,6 +1987,7 @@ func (w *worker) makeHeader(genParams *generateParams, waitOnPrepare bool) (*typ
Time: timestamp,
Coinbase: coinbase,
}
+ header.AbortRecovery = genParams.abortRecovery
// Set the extra field.
if len(w.extra) != 0 {
header.Extra = w.extra
@@ -2091,6 +2227,10 @@ func scanOverflow(
//
//nolint:gocognit
func (w *worker) fillTransactions(interrupt *atomic.Int32, env *environment, genParams *generateParams) error {
+ if w.interruptBlockBuilding.Load() {
+ return nil
+ }
+
w.mu.RLock()
prio := w.prio
w.mu.RUnlock()
@@ -2098,9 +2238,10 @@ func (w *worker) fillTransactions(interrupt *atomic.Int32, env *environment, gen
pendingStart := time.Now()
filter := w.buildDefaultFilter(env.header.BaseFee, env.header.Number)
+ timeoutInterrupt, _ := w.interruptStateForEnv(env)
filter.BlobTxs = false
- pendingPlainTxs := w.eth.TxPool().Pending(filter, &w.interruptBlockBuilding)
+ pendingPlainTxs := w.eth.TxPool().Pending(filter, timeoutInterrupt)
filter.BlobTxs = true
if w.chainConfig.IsOsaka(env.header.Number) {
@@ -2108,7 +2249,7 @@ func (w *worker) fillTransactions(interrupt *atomic.Int32, env *environment, gen
} else {
filter.BlobVersion = types.BlobSidecarVersion0
}
- pendingBlobTxs := w.eth.TxPool().Pending(filter, &w.interruptBlockBuilding)
+ pendingBlobTxs := w.eth.TxPool().Pending(filter, timeoutInterrupt)
env.pendingDuration = time.Since(pendingStart)
pendingTimer.Update(env.pendingDuration)
@@ -2150,8 +2291,8 @@ func (w *worker) fillTransactions(interrupt *atomic.Int32, env *environment, gen
// Fill the block with all available pending transactions.
if len(prioPlainTxs) > 0 || len(prioBlobTxs) > 0 {
- plainTxs := newTransactionsByPriceAndNonce(env.signer, prioPlainTxs, env.header.BaseFee, &w.interruptBlockBuilding)
- blobTxs := newTransactionsByPriceAndNonce(env.signer, prioBlobTxs, env.header.BaseFee, &w.interruptBlockBuilding)
+ plainTxs := newTransactionsByPriceAndNonce(env.signer, prioPlainTxs, env.header.BaseFee, timeoutInterrupt)
+ blobTxs := newTransactionsByPriceAndNonce(env.signer, prioBlobTxs, env.header.BaseFee, timeoutInterrupt)
sendPlan(builderPlanCh, genParams, plainTxs, remainingGas())
if err := w.commitTransactions(env, plainTxs, blobTxs, interrupt, builderGasFreedCh); err != nil {
return err
@@ -2159,8 +2300,8 @@ func (w *worker) fillTransactions(interrupt *atomic.Int32, env *environment, gen
}
if len(normalPlainTxs) > 0 || len(normalBlobTxs) > 0 {
heapInitTime := time.Now()
- plainTxs := newTransactionsByPriceAndNonce(env.signer, normalPlainTxs, env.header.BaseFee, &w.interruptBlockBuilding)
- blobTxs := newTransactionsByPriceAndNonce(env.signer, normalBlobTxs, env.header.BaseFee, &w.interruptBlockBuilding)
+ plainTxs := newTransactionsByPriceAndNonce(env.signer, normalPlainTxs, env.header.BaseFee, timeoutInterrupt)
+ blobTxs := newTransactionsByPriceAndNonce(env.signer, normalBlobTxs, env.header.BaseFee, timeoutInterrupt)
txHeapInitTimer.Update(time.Since(heapInitTime))
sendPlan(builderPlanCh, genParams, plainTxs, remainingGas())
if err := w.commitTransactions(env, plainTxs, blobTxs, interrupt, builderGasFreedCh); err != nil {
@@ -2239,9 +2380,13 @@ func (w *worker) generateWork(params *generateParams, witness bool) *newPayloadR
// commitWork generates several new sealing tasks based on the parent block
// and submit them to the sealer.
-func (w *worker) commitWork(interrupt *atomic.Int32, noempty bool, timestamp int64) {
- // Must be declared before any early return so pendingWorkBlock is
- // always cleared — otherwise the veblop fallback would short-circuit.
+func (w *worker) commitWork(interrupt *atomic.Int32, noempty bool, timestamp int64, abortRecovery bool) {
+ // Clear pendingWorkBlock on every exit, including early returns, so the
+ // veblop fallback doesn't short-circuit on a stale signal. When production
+ // pipelining is re-enabled this needs to become pipeline-aware: a pipelined
+ // build advances pendingWorkBlock past head+1 to mark N+1 in flight, and an
+ // unconditional reset here would wipe that signal (see git history:
+ // clearPendingWorkOnExit).
defer func() {
w.pendingWorkBlock.Store(0)
}()
@@ -2250,7 +2395,6 @@ func (w *worker) commitWork(interrupt *atomic.Int32, noempty bool, timestamp int
if w.syncing.Load() {
return
}
-
buildStart := time.Now()
// Set the coinbase if the worker is running or it's required
@@ -2263,10 +2407,7 @@ func (w *worker) commitWork(interrupt *atomic.Int32, noempty bool, timestamp int
}
}
- // Find the parent block for sealing task
parent := w.chain.CurrentBlock()
-
- // Retrieve the parent state to execute on top, with separate readers for stats tracking.
state, throwaway, prefetchReader, processReader, err := w.chain.StateAtWithReaders(parent.Root)
if err != nil {
return
@@ -2275,6 +2416,7 @@ func (w *worker) commitWork(interrupt *atomic.Int32, noempty bool, timestamp int
genParams := generateParams{
timestamp: uint64(timestamp),
coinbase: coinbase,
+ abortRecovery: abortRecovery,
parentHash: parent.Hash(),
statedb: state,
prefetchReader: prefetchReader,
@@ -2284,34 +2426,53 @@ func (w *worker) commitWork(interrupt *atomic.Int32, noempty bool, timestamp int
}
var interruptPrefetch atomic.Bool
+ w.maybeStartPrefetch(parent, throwaway, &genParams, &interruptPrefetch)
+ w.buildAndCommitBlock(interrupt, noempty, &genParams, &interruptPrefetch)
+}
+
+// maybeStartPrefetch launches the tx-prefetch goroutine when enabled AND
+// the next block is Giugliano-activated. Giugliano gating prevents pre-fork
+// blocks from triggering speculative prefetch, which can read storage slots
+// the current block's EVM hasn't touched yet and cause cache thrash.
+func (w *worker) maybeStartPrefetch(parent *types.Header, throwaway *state.StateDB, genParams *generateParams, interruptPrefetch *atomic.Bool) {
newBlockNumber := new(big.Int).Add(parent.Number, common.Big1)
- if w.config.EnablePrefetch && w.chainConfig.Bor != nil && w.chainConfig.Bor.IsGiugliano(newBlockNumber) {
- // Only allocate the builder-mode signal when a prefetcher will consume it.
- // Downstream (buildAndCommitBlock, fillTransactions, commitTransactions) gate all
- // planning work on `builderStarted != nil`, so leaving it nil means zero overhead
- // when prefetch is disabled.
- genParams.builderStarted = new(atomic.Bool)
- genParams.builderPrefetchedTxHashes = &sync.Map{}
- w.prefetchWg.Add(1)
- go func() {
- defer w.prefetchWg.Done()
- defer func() {
- if r := recover(); r != nil {
- log.Error("Prefetch goroutine panicked", "err", r, "stack", string(debug.Stack()))
- prefetchPanicMeter.Mark(1)
- }
- }()
- w.runPrefetcher(parent, throwaway, &genParams, &interruptPrefetch)
- // Goroutine exits naturally after prefetch completes.
- // Go's GC keeps throwaway StateDB alive while this goroutine references it.
- // When the goroutine exits, the reference is released and GC can collect it.
- }()
+ if !w.config.EnablePrefetch || w.chainConfig.Bor == nil || !w.chainConfig.Bor.IsGiugliano(newBlockNumber) {
+ return
}
-
- w.buildAndCommitBlock(interrupt, noempty, &genParams, &interruptPrefetch)
+ // Only allocate the builder-mode signal when a prefetcher will consume it.
+ // Downstream paths gate planning work on builderStarted != nil, so leaving
+ // it nil means zero overhead when prefetch is disabled.
+ genParams.builderStarted = new(atomic.Bool)
+ genParams.builderPrefetchedTxHashes = &sync.Map{}
+ w.prefetchWg.Add(1)
+ go func() {
+ defer w.prefetchWg.Done()
+ defer func() {
+ if r := recover(); r != nil {
+ log.Error("Prefetch goroutine panicked", "err", r, "stack", string(debug.Stack()))
+ prefetchPanicMeter.Mark(1)
+ }
+ }()
+ // Go's GC keeps throwaway alive while this goroutine references it;
+ // released when the goroutine exits after prefetch completes.
+ w.runPrefetcher(parent, throwaway, genParams, interruptPrefetch)
+ }()
}
// buildAndCommitBlock prepares work, fills transactions, and commits the block for sealing.
+// submitForSealing dispatches the built block to either the pipelined path
+// (overlap SRC for N with N+1's execution) or the sequential path. The
+// pendingWorkBlock bump is what de-duplicates ChainHeadEvent-triggered
+// commitWork in newWorkLoop when the pipeline is already handling N+1.
+func (w *worker) submitForSealing(work *environment, start time.Time, genParams *generateParams) {
+ if w.isPipelineEligible(work.header.Number.Uint64()) {
+ w.pendingWorkBlock.Store(work.header.Number.Uint64() + 1)
+ _ = w.commitPipelined(work, start)
+ return
+ }
+ _ = w.commit(work.copy(), w.fullTaskHook, true, start, genParams)
+}
+
func (w *worker) buildAndCommitBlock(interrupt *atomic.Int32, noempty bool, genParams *generateParams, interruptPrefetch *atomic.Bool) {
// Must be the first defer so the prefetcher goroutine is signaled to exit
// on every return path — including the early return below when prepareWork
@@ -2327,7 +2488,8 @@ func (w *worker) buildAndCommitBlock(interrupt *atomic.Int32, noempty bool, genP
prepareWorkDuration := time.Since(prepareWorkStart)
prepareWorkTimer.Update(prepareWorkDuration)
- // Starts accounting time after prepareWork, since it includes the wait we have on Prepare phase of Bor
+ // Starts accounting time after prepareWork. Slot timing is handled in Seal
+ // for sequential paths and explicitly in the pipeline path.
start := time.Now()
// Create the builder plan channel before signalling builder mode so the prefetcher goroutine
@@ -2367,11 +2529,16 @@ func (w *worker) buildAndCommitBlock(interrupt *atomic.Int32, noempty bool, genP
if !noempty && w.interruptCommitFlag {
// Start the timer for block building
+ // Production-side pipelining is disabled, so the interrupt timer should
+ // use the regular block-time boundary. If the production pipeline is
+ // re-enabled, this was previously wired to the miner pipeline enable flag.
+ timeoutInterrupt, timeoutFlagSetAt := w.interruptStateForEnv(work)
stopFn = createInterruptTimer(
work.header.Number.Uint64(),
work.header.GetActualTime(),
- &w.interruptBlockBuilding,
- &w.interruptFlagSetAt,
+ timeoutInterrupt,
+ timeoutFlagSetAt,
+ w.isPipelineEligible(work.header.Number.Uint64()),
)
}
@@ -2435,8 +2602,7 @@ func (w *worker) buildAndCommitBlock(interrupt *atomic.Int32, noempty bool, genP
work.discard()
return
}
- // Submit the generated block for consensus sealing.
- _ = w.commit(work.copy(), w.fullTaskHook, true, start, genParams)
+ w.submitForSealing(work, start, genParams)
// Swap out the old work with the new one, terminating any leftover
// prefetcher processes in the mean time and starting a new one.
@@ -2825,13 +2991,17 @@ func collectPlanBatch(
}
}
-// createInterruptTimer creates and starts a timer based on the header's timestamp for block building
-// and toggles the flag when the timer expires.
-func createInterruptTimer(number uint64, actualTimestamp time.Time, interruptBlockBuilding *atomic.Bool, interruptFlagSetAt *atomic.Int64) func() {
+// createInterruptTimer creates and starts a timer based on the header's timestamp.
+func createInterruptTimer(number uint64, actualTimestamp time.Time, interruptBlockBuilding *atomic.Bool, interruptFlagSetAt *atomic.Int64, pipelinedSRC bool) func() {
+ if interruptBlockBuilding == nil {
+ return func() {}
+ }
+
delay := time.Until(actualTimestamp)
- // Reduce the timeout to give some buffer for state root computation
- if delay > 1*time.Second {
+ // Reserve buffer for state root computation unless pipelined SRC is enabled,
+ // in which case SRC runs in the background and fillTransactions gets the full block time.
+ if !pipelinedSRC && delay > 1*time.Second {
delay -= interruptBuffer
}
@@ -2839,7 +3009,9 @@ func createInterruptTimer(number uint64, actualTimestamp time.Time, interruptBlo
// Reset the flag when timer starts for building a new block.
interruptBlockBuilding.Store(false)
- interruptFlagSetAt.Store(0)
+ if interruptFlagSetAt != nil {
+ interruptFlagSetAt.Store(0)
+ }
go func() {
// Wait for timeout
@@ -2848,11 +3020,10 @@ func createInterruptTimer(number uint64, actualTimestamp time.Time, interruptBlo
// Toggle the flag to indicate commit transactions loop and EVM interpreter loop
// to stop block building.
if interruptCtx.Err() != context.Canceled {
- interruptFlagSetAt.Store(time.Now().UnixNano())
- }
- interruptBlockBuilding.Store(true)
-
- if interruptCtx.Err() != context.Canceled {
+ if interruptFlagSetAt != nil {
+ interruptFlagSetAt.Store(time.Now().UnixNano())
+ }
+ interruptBlockBuilding.Store(true)
cancel()
}
}()
@@ -2952,7 +3123,7 @@ func (w *worker) commit(env *environment, interval func(), update bool, start ti
}
select {
- case w.taskCh <- &task{receipts: env.receipts, state: env.state, block: block, createdAt: time.Now(), productionElapsed: time.Since(firstNonZeroTime(productionStartFrom(genParams), start)), intermediateRootTime: commitTime}:
+ case w.taskCh <- &task{receipts: env.receipts, state: env.state, block: block, createdAt: time.Now(), productionStart: firstNonZeroTime(productionStartFrom(genParams), start), productionElapsed: time.Since(firstNonZeroTime(productionStartFrom(genParams), start)), intermediateRootTime: commitTime}:
fees := totalFees(block, env.receipts)
feesInEther := new(big.Float).Quo(new(big.Float).SetInt(fees), big.NewFloat(params.Ether))
log.Info("Commit new sealing work",
diff --git a/miner/worker_test.go b/miner/worker_test.go
index 8c88ba5b35..435fcea359 100644
--- a/miner/worker_test.go
+++ b/miner/worker_test.go
@@ -53,6 +53,7 @@ import (
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/metrics"
"github.com/ethereum/go-ethereum/params"
+ "github.com/ethereum/go-ethereum/rlp"
"github.com/ethereum/go-ethereum/tests/bor/mocks"
"github.com/ethereum/go-ethereum/trie"
"github.com/ethereum/go-ethereum/triedb"
@@ -1131,6 +1132,37 @@ func TestCommitInterruptPending(t *testing.T) {
w.stop()
}
+func TestCreateInterruptTimer_IsolatedPerBuild(t *testing.T) {
+ t.Parallel()
+
+ first := newBuildInterruptState()
+ second := newBuildInterruptState()
+
+ stopFirst := createInterruptTimer(1, time.Now().Add(25*time.Millisecond), first.timeoutFlag(), first.flagSetAtPtr(), true)
+ defer stopFirst()
+ stopSecond := createInterruptTimer(2, time.Now().Add(500*time.Millisecond), second.timeoutFlag(), second.flagSetAtPtr(), true)
+ defer stopSecond()
+
+ require.Eventually(t, func() bool {
+ return first.timedOut.Load()
+ }, time.Second, 10*time.Millisecond)
+ require.NotZero(t, first.flagSetAt.Load())
+ require.False(t, second.timedOut.Load(), "one build's timeout must not trip another build")
+ require.Zero(t, second.flagSetAt.Load())
+}
+
+func TestCreateInterruptTimer_CancelDoesNotTripInterrupt(t *testing.T) {
+ t.Parallel()
+
+ state := newBuildInterruptState()
+ stop := createInterruptTimer(1, time.Now().Add(500*time.Millisecond), state.timeoutFlag(), state.flagSetAtPtr(), true)
+ stop()
+
+ time.Sleep(50 * time.Millisecond)
+ require.False(t, state.timedOut.Load(), "canceling a build timer must not look like a timeout")
+ require.Zero(t, state.flagSetAt.Load())
+}
+
// TestBenchmarkPending is a simple benchmark test to measure the performance of transaction pool. It inserts
// large number of transactions into the pool and captures the time taken for `pending` to return the list
// of pending transactions. The purpose is just to compare the performance on different branches.
@@ -1657,7 +1689,7 @@ func TestCommitWorkLeaksPendingWorkBlockWhenSyncing(t *testing.T) {
w.pendingWorkBlock.Store(42)
// Call commitWork directly to drive the in-sync early-return path.
- w.commitWork(nil, false, time.Now().Unix())
+ w.commitWork(nil, false, time.Now().Unix(), false)
got := w.pendingWorkBlock.Load()
if got != 0 {
@@ -3162,6 +3194,30 @@ func TestWriteBlockAndSetHeadTimer(t *testing.T) {
}
}
+// TestPipelineBuildGaugeAlwaysDisabled verifies that production-side pipelined
+// SRC is not exposed as a miner config option and stays disabled.
+func TestPipelineBuildGaugeAlwaysDisabled(t *testing.T) {
+ metrics.Enable()
+
+ var (
+ engine consensus.Engine
+ chainConfig = params.BorUnittestChainConfig
+ db = rawdb.NewMemoryDatabase()
+ ctrl *gomock.Controller
+ )
+ engine, ctrl = getFakeBorFromConfig(t, chainConfig)
+ defer engine.Close()
+ defer ctrl.Finish()
+
+ cfg := DefaultTestConfig()
+ w, _, _ := newTestWorker(t, cfg, chainConfig, engine, db, false, 0)
+ defer w.close()
+
+ if got := pipelineBuildEnabledGauge.Snapshot().Value(); got != 0 {
+ t.Errorf("pipelineBuildEnabledGauge = %d, want 0 when production pipelining is disabled", got)
+ }
+}
+
// TestDelayFlagOffByOne verifies that the delayFlag check inspects each transaction's
// own read set rather than its predecessor's.
func TestDelayFlagOffByOne(t *testing.T) {
@@ -3209,6 +3265,54 @@ func TestDelayFlagOffByOne(t *testing.T) {
require.False(t, fixedDelayFlag(), "fix: last tx detected, DAG hint suppressed")
}
+func TestTxDependencyMetadataPersistsAcrossSpeculativeRefillPasses(t *testing.T) {
+ t.Parallel()
+
+ chainConfig := params.BorUnittestChainConfig
+ engine, ctrl := getFakeBorFromConfig(t, chainConfig)
+ defer engine.Close()
+ defer ctrl.Finish()
+
+ w, _, _ := newTestWorker(t, DefaultTestConfig(), chainConfig, engine, rawdb.NewMemoryDatabase(), false, 0)
+ defer w.close()
+ w.running.Store(true)
+
+ extraDataBytes, err := rlp.EncodeToBytes(types.BlockExtraData{})
+ require.NoError(t, err)
+
+ headerExtra := append(make([]byte, types.ExtraVanityLength), extraDataBytes...)
+ headerExtra = append(headerExtra, make([]byte, types.ExtraSealLength)...)
+
+ env := &environment{
+ header: &types.Header{
+ Number: big.NewInt(1),
+ Extra: headerExtra,
+ },
+ coinbase: testBankAddress,
+ depsBuilder: blockstm.NewDepsBuilder(),
+ }
+
+ key := blockstm.NewSubpathKey(testUserAddress, state.BalancePath)
+
+ env.mvReadMapList = append(env.mvReadMapList, map[blockstm.Key]blockstm.ReadDescriptor{})
+ require.NoError(t, env.depsBuilder.AddTransaction(0, nil, []blockstm.WriteDescriptor{{Path: key}}))
+ require.NoError(t, w.updateTxDependencyMetadata(env))
+
+ var blockExtraData types.BlockExtraData
+ require.NoError(t, rlp.DecodeBytes(env.header.Extra[types.ExtraVanityLength:len(env.header.Extra)-types.ExtraSealLength], &blockExtraData))
+ require.Len(t, blockExtraData.TxDependency, 1)
+ require.Empty(t, blockExtraData.TxDependency[0])
+
+ env.mvReadMapList = append(env.mvReadMapList, map[blockstm.Key]blockstm.ReadDescriptor{
+ key: {Path: key},
+ })
+ require.NoError(t, env.depsBuilder.AddTransaction(1, []blockstm.ReadDescriptor{{Path: key}}, nil))
+ require.NoError(t, w.updateTxDependencyMetadata(env))
+
+ require.NoError(t, rlp.DecodeBytes(env.header.Extra[types.ExtraVanityLength:len(env.header.Extra)-types.ExtraSealLength], &blockExtraData))
+ require.Equal(t, [][]uint64{{}, {0}}, blockExtraData.TxDependency)
+}
+
// TestPrefetchFromPool_BuilderModeSwitch verifies that when builderStarted is signaled
// the prefetch goroutine transitions from speculative idle mode to a single targeted builder
// pass and then exits cleanly.
diff --git a/tests/bor/bor_test.go b/tests/bor/bor_test.go
index 57a2a101c8..c58eb7f97c 100644
--- a/tests/bor/bor_test.go
+++ b/tests/bor/bor_test.go
@@ -3196,6 +3196,670 @@ func getMockedSpannerWithSpanRotation(t *testing.T, validator1, validator2 commo
return spanner
}
+// TestBlockProduction_Basic verifies that a single miner can produce multiple
+// consecutive blocks correctly.
+func TestBlockProduction_Basic(t *testing.T) {
+ t.Parallel()
+ log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelInfo, true)))
+ fdlimit.Raise(2048)
+
+ faucets := make([]*ecdsa.PrivateKey, 128)
+ for i := 0; i < len(faucets); i++ {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 16)
+ genesis.Config.Bor.Period = map[string]uint64{"0": 2}
+ genesis.Config.Bor.Sprint = map[string]uint64{"0": 16}
+ genesis.Config.Bor.RioBlock = big.NewInt(0) // Enable Rio so snapshot uses spanByBlockNumber (no ecrecover needed)
+
+ // Start a single miner.
+ stack, ethBackend, err := InitMiner(genesis, keys[0], true)
+ require.NoError(t, err)
+ defer stack.Close()
+
+ for stack.Server().NodeInfo().Ports.Listener == 0 {
+ time.Sleep(250 * time.Millisecond)
+ }
+
+ // Start mining
+ err = ethBackend.StartMining()
+ require.NoError(t, err)
+
+ // Wait for the miner to produce at least 10 blocks
+ targetBlock := uint64(10)
+ deadline := time.After(60 * time.Second)
+ for {
+ select {
+ case <-deadline:
+ currentNum := ethBackend.BlockChain().CurrentBlock().Number.Uint64()
+ t.Fatalf("Timed out waiting for block %d, current block: %d", targetBlock, currentNum)
+ default:
+ time.Sleep(500 * time.Millisecond)
+ if ethBackend.BlockChain().CurrentBlock().Number.Uint64() >= targetBlock {
+ goto done
+ }
+ }
+ }
+done:
+
+ chain := ethBackend.BlockChain()
+ currentNum := chain.CurrentBlock().Number.Uint64()
+ t.Logf("Miner produced %d blocks", currentNum)
+
+ // Verify chain integrity: each block's parent hash matches the previous block's hash
+ for i := uint64(1); i <= currentNum; i++ {
+ block := chain.GetBlockByNumber(i)
+ require.NotNil(t, block, "block %d not found", i)
+
+ if i > 0 {
+ parent := chain.GetBlockByNumber(i - 1)
+ require.NotNil(t, parent, "parent block %d not found", i-1)
+ require.Equal(t, parent.Hash(), block.ParentHash(),
+ "block %d ParentHash mismatch: expected %x, got %x", i, parent.Hash(), block.ParentHash())
+ }
+
+ // Verify state root is valid (can open state at this root)
+ _, err := chain.StateAt(block.Root())
+ require.NoError(t, err, "cannot open state at block %d root %x", i, block.Root())
+ }
+}
+
+// TestBlockProduction_WithTransactions verifies that the miner correctly
+// includes transactions in blocks.
+func TestBlockProduction_WithTransactions(t *testing.T) {
+ t.Parallel()
+ log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelInfo, true)))
+ fdlimit.Raise(2048)
+
+ faucets := make([]*ecdsa.PrivateKey, 128)
+ for i := 0; i < len(faucets); i++ {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 16)
+ genesis.Config.Bor.Period = map[string]uint64{"0": 2}
+ genesis.Config.Bor.Sprint = map[string]uint64{"0": 16}
+ genesis.Config.Bor.RioBlock = big.NewInt(0)
+
+ stack, ethBackend, err := InitMiner(genesis, keys[0], true)
+ require.NoError(t, err)
+ defer stack.Close()
+
+ for stack.Server().NodeInfo().Ports.Listener == 0 {
+ time.Sleep(250 * time.Millisecond)
+ }
+
+ err = ethBackend.StartMining()
+ require.NoError(t, err)
+
+ // Wait for a few blocks first
+ for ethBackend.BlockChain().CurrentBlock().Number.Uint64() < 2 {
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ // Submit transactions
+ txpool := ethBackend.TxPool()
+ senderKey := pkey1
+ recipientAddr := crypto.PubkeyToAddress(pkey2.PublicKey)
+ signer := types.LatestSignerForChainID(genesis.Config.ChainID)
+
+ nonce := txpool.Nonce(crypto.PubkeyToAddress(senderKey.PublicKey))
+ txCount := 10
+
+ for i := 0; i < txCount; i++ {
+ tx := types.NewTransaction(
+ nonce+uint64(i),
+ recipientAddr,
+ big.NewInt(1000),
+ 21000,
+ big.NewInt(30000000000),
+ nil,
+ )
+ signedTx, err := types.SignTx(tx, signer, senderKey)
+ require.NoError(t, err)
+ errs := txpool.Add([]*types.Transaction{signedTx}, true)
+ require.Nil(t, errs[0], "failed to add tx %d", i)
+ }
+
+ // Wait for transactions to be included in blocks
+ deadline := time.After(60 * time.Second)
+ for {
+ select {
+ case <-deadline:
+ t.Fatal("Timed out waiting for transactions to be included")
+ default:
+ time.Sleep(500 * time.Millisecond)
+ // Check if all transactions have been mined
+ currentNonce := txpool.Nonce(crypto.PubkeyToAddress(senderKey.PublicKey))
+ if currentNonce >= nonce+uint64(txCount) {
+ goto txsDone
+ }
+ }
+ }
+txsDone:
+
+ chain := ethBackend.BlockChain()
+ currentNum := chain.CurrentBlock().Number.Uint64()
+ t.Logf("All %d transactions included by block %d", txCount, currentNum)
+
+ // Give block insertion time to settle before scanning the chain.
+ time.Sleep(2 * time.Second)
+ currentNum = chain.CurrentBlock().Number.Uint64()
+
+ // Verify we can find the transactions in the blocks
+ totalTxs := 0
+ for i := uint64(1); i <= currentNum; i++ {
+ block := chain.GetBlockByNumber(i)
+ if block != nil {
+ totalTxs += len(block.Transactions())
+ }
+ }
+ require.GreaterOrEqual(t, totalTxs, txCount,
+ "expected at least %d transactions across all blocks, got %d", txCount, totalTxs)
+}
+
+// TestPipelinedImportSRC_BasicImport verifies that a non-mining node with
+// pipelined import SRC enabled correctly syncs blocks from a block-producing
+// peer. The importer computes state roots in the background (overlapping
+// SRC(N) with tx execution of N+1) and should arrive at the same chain state
+// as the BP.
+func TestPipelinedImportSRC_BasicImport(t *testing.T) {
+ t.Parallel()
+ log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelInfo, true)))
+ fdlimit.Raise(2048)
+
+ faucets := make([]*ecdsa.PrivateKey, 128)
+ for i := 0; i < len(faucets); i++ {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 16)
+ genesis.Config.Bor.Period = map[string]uint64{"0": 2}
+ genesis.Config.Bor.Sprint = map[string]uint64{"0": 16}
+ genesis.Config.Bor.RioBlock = big.NewInt(0)
+
+ // Start a normal BP (no pipeline on mining side)
+ bpStack, bpBackend, err := InitMiner(genesis, keys[0], true)
+ require.NoError(t, err)
+ defer bpStack.Close()
+
+ for bpStack.Server().NodeInfo().Ports.Listener == 0 {
+ time.Sleep(250 * time.Millisecond)
+ }
+
+ // Start a non-mining importer with pipelined import SRC
+ importerStack, importerBackend, err := InitImporterWithPipelinedSRC(genesis, keys[1], true)
+ require.NoError(t, err)
+ defer importerStack.Close()
+
+ for importerStack.Server().NodeInfo().Ports.Listener == 0 {
+ time.Sleep(250 * time.Millisecond)
+ }
+
+ // Connect the two peers
+ connectAndWaitForPeers(t, importerStack, bpStack)
+
+ // Start mining on the BP
+ err = bpBackend.StartMining()
+ require.NoError(t, err)
+
+ // Wait for the BP to produce at least 20 blocks
+ targetBlock := uint64(20)
+ deadline := time.After(120 * time.Second)
+ for {
+ select {
+ case <-deadline:
+ bpNum := bpBackend.BlockChain().CurrentBlock().Number.Uint64()
+ t.Fatalf("Timed out waiting for BP to reach block %d, current: %d", targetBlock, bpNum)
+ default:
+ time.Sleep(500 * time.Millisecond)
+ if bpBackend.BlockChain().CurrentBlock().Number.Uint64() >= targetBlock {
+ goto bpDone
+ }
+ }
+ }
+bpDone:
+
+ bpNum := bpBackend.BlockChain().CurrentBlock().Number.Uint64()
+ t.Logf("BP produced %d blocks, waiting for importer to sync", bpNum)
+
+ // Wait for the importer to sync up to the target
+ deadline = time.After(120 * time.Second)
+ for {
+ select {
+ case <-deadline:
+ importerNum := importerBackend.BlockChain().CurrentBlock().Number.Uint64()
+ t.Fatalf("Timed out waiting for importer to reach block %d, current: %d", targetBlock, importerNum)
+ default:
+ time.Sleep(500 * time.Millisecond)
+ if importerBackend.BlockChain().CurrentBlock().Number.Uint64() >= targetBlock {
+ goto importerDone
+ }
+ }
+ }
+importerDone:
+
+ importerNum := importerBackend.BlockChain().CurrentBlock().Number.Uint64()
+ t.Logf("Importer synced to block %d", importerNum)
+
+ // Allow async DB writes to flush
+ time.Sleep(2 * time.Second)
+
+ // Use the minimum of both chains for comparison
+ bpNum = bpBackend.BlockChain().CurrentBlock().Number.Uint64()
+ importerNum = importerBackend.BlockChain().CurrentBlock().Number.Uint64()
+ compareUpTo := bpNum
+ if importerNum < compareUpTo {
+ compareUpTo = importerNum
+ }
+
+ bpChain := bpBackend.BlockChain()
+ importerChain := importerBackend.BlockChain()
+
+ for i := uint64(1); i <= compareUpTo; i++ {
+ bpBlock := bpChain.GetBlockByNumber(i)
+ require.NotNil(t, bpBlock, "BP missing block %d", i)
+
+ importerBlock := importerChain.GetBlockByNumber(i)
+ require.NotNil(t, importerBlock, "importer missing block %d", i)
+
+ // Block hashes must match
+ require.Equal(t, bpBlock.Hash(), importerBlock.Hash(),
+ "block %d hash mismatch: BP=%x importer=%x", i, bpBlock.Hash(), importerBlock.Hash())
+
+ // State roots must match
+ require.Equal(t, bpBlock.Root(), importerBlock.Root(),
+ "block %d state root mismatch: BP=%x importer=%x", i, bpBlock.Root(), importerBlock.Root())
+
+ // Verify the importer can open state at each block's root
+ _, err := importerChain.StateAt(importerBlock.Root())
+ require.NoError(t, err, "importer cannot open state at block %d root %x", i, importerBlock.Root())
+ }
+
+ t.Logf("Verified %d blocks: hashes, state roots, and state accessibility all match", compareUpTo)
+}
+
+// TestPipelinedImportSRC_WithTransactions verifies that a non-mining node with
+// pipelined import SRC correctly imports blocks containing transactions. It
+// checks that transaction receipts exist and that account balances match
+// between the BP and the importer.
+func TestPipelinedImportSRC_WithTransactions(t *testing.T) {
+ t.Parallel()
+ log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelInfo, true)))
+ fdlimit.Raise(2048)
+
+ faucets := make([]*ecdsa.PrivateKey, 128)
+ for i := 0; i < len(faucets); i++ {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 16)
+ genesis.Config.Bor.Period = map[string]uint64{"0": 2}
+ genesis.Config.Bor.Sprint = map[string]uint64{"0": 16}
+ genesis.Config.Bor.RioBlock = big.NewInt(0)
+
+ // Start BP without pipeline
+ bpStack, bpBackend, err := InitMiner(genesis, keys[0], true)
+ require.NoError(t, err)
+ defer bpStack.Close()
+
+ for bpStack.Server().NodeInfo().Ports.Listener == 0 {
+ time.Sleep(250 * time.Millisecond)
+ }
+
+ // Start importer with pipelined import SRC
+ importerStack, importerBackend, err := InitImporterWithPipelinedSRC(genesis, keys[1], true)
+ require.NoError(t, err)
+ defer importerStack.Close()
+
+ for importerStack.Server().NodeInfo().Ports.Listener == 0 {
+ time.Sleep(250 * time.Millisecond)
+ }
+
+ // Connect peers
+ connectAndWaitForPeers(t, importerStack, bpStack)
+
+ // Start mining
+ err = bpBackend.StartMining()
+ require.NoError(t, err)
+
+ // Wait for a few blocks before submitting transactions
+ for bpBackend.BlockChain().CurrentBlock().Number.Uint64() < 2 {
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ // Submit ETH transfer transactions to the BP
+ txpool := bpBackend.TxPool()
+ senderKey := pkey1
+ senderAddr := crypto.PubkeyToAddress(senderKey.PublicKey)
+ recipientAddr := crypto.PubkeyToAddress(pkey2.PublicKey)
+ signer := types.LatestSignerForChainID(genesis.Config.ChainID)
+
+ nonce := txpool.Nonce(senderAddr)
+ txCount := 10
+ transferAmount := big.NewInt(1000)
+
+ for i := 0; i < txCount; i++ {
+ tx := types.NewTransaction(
+ nonce+uint64(i),
+ recipientAddr,
+ transferAmount,
+ 21000,
+ big.NewInt(30000000000),
+ nil,
+ )
+ signedTx, err := types.SignTx(tx, signer, senderKey)
+ require.NoError(t, err)
+ errs := txpool.Add([]*types.Transaction{signedTx}, true)
+ require.Nil(t, errs[0], "failed to add tx %d", i)
+ }
+
+ // Wait for all transactions to be mined on the BP
+ deadline := time.After(120 * time.Second)
+ for {
+ select {
+ case <-deadline:
+ t.Fatal("Timed out waiting for transactions to be mined on BP")
+ default:
+ time.Sleep(500 * time.Millisecond)
+ if txpool.Nonce(senderAddr) >= nonce+uint64(txCount) {
+ goto txsMined
+ }
+ }
+ }
+txsMined:
+
+ bpNum := bpBackend.BlockChain().CurrentBlock().Number.Uint64()
+ t.Logf("All %d transactions mined on BP by block %d", txCount, bpNum)
+
+ // Wait for the importer to sync past the block containing the last tx
+ targetBlock := bpNum
+ deadline = time.After(120 * time.Second)
+ for {
+ select {
+ case <-deadline:
+ importerNum := importerBackend.BlockChain().CurrentBlock().Number.Uint64()
+ t.Fatalf("Timed out waiting for importer to reach block %d, current: %d", targetBlock, importerNum)
+ default:
+ time.Sleep(500 * time.Millisecond)
+ if importerBackend.BlockChain().CurrentBlock().Number.Uint64() >= targetBlock {
+ goto importerSynced
+ }
+ }
+ }
+importerSynced:
+
+ // Allow async DB writes to flush
+ time.Sleep(2 * time.Second)
+
+ importerNum := importerBackend.BlockChain().CurrentBlock().Number.Uint64()
+ t.Logf("Importer synced to block %d", importerNum)
+
+ bpChain := bpBackend.BlockChain()
+ importerChain := importerBackend.BlockChain()
+
+ // Re-read current block numbers after the flush delay
+ bpNum = bpChain.CurrentBlock().Number.Uint64()
+ importerNum = importerChain.CurrentBlock().Number.Uint64()
+ compareUpTo := bpNum
+ if importerNum < compareUpTo {
+ compareUpTo = importerNum
+ }
+
+ // Verify blocks, state roots, and transaction counts match
+ totalBpTxs := 0
+ totalImporterTxs := 0
+ for i := uint64(1); i <= compareUpTo; i++ {
+ bpBlock := bpChain.GetBlockByNumber(i)
+ require.NotNil(t, bpBlock, "BP missing block %d", i)
+
+ importerBlock := importerChain.GetBlockByNumber(i)
+ require.NotNil(t, importerBlock, "importer missing block %d", i)
+
+ require.Equal(t, bpBlock.Hash(), importerBlock.Hash(),
+ "block %d hash mismatch", i)
+ require.Equal(t, bpBlock.Root(), importerBlock.Root(),
+ "block %d state root mismatch", i)
+
+ // Transaction counts must match per block
+ require.Equal(t, len(bpBlock.Transactions()), len(importerBlock.Transactions()),
+ "block %d tx count mismatch: BP=%d importer=%d", i,
+ len(bpBlock.Transactions()), len(importerBlock.Transactions()))
+
+ totalBpTxs += len(bpBlock.Transactions())
+ totalImporterTxs += len(importerBlock.Transactions())
+
+ // Verify receipts exist on the importer for each transaction
+ for j, tx := range importerBlock.Transactions() {
+ receipt, _, _, _ := rawdb.ReadReceipt(importerBackend.ChainDb(), tx.Hash(), importerChain.Config())
+ require.NotNil(t, receipt, "importer missing receipt for tx %d in block %d (hash=%x)", j, i, tx.Hash())
+ }
+ }
+
+ require.GreaterOrEqual(t, totalBpTxs, txCount,
+ "expected at least %d transactions across BP blocks, got %d", txCount, totalBpTxs)
+ require.Equal(t, totalBpTxs, totalImporterTxs,
+ "total tx count mismatch: BP=%d importer=%d", totalBpTxs, totalImporterTxs)
+
+ // Verify account balances match between BP and importer at the latest
+ // common block — this confirms the pipelined SRC produced correct state.
+ bpState, err := bpChain.StateAt(bpChain.GetBlockByNumber(compareUpTo).Root())
+ require.NoError(t, err, "cannot open BP state at block %d", compareUpTo)
+
+ importerState, err := importerChain.StateAt(importerChain.GetBlockByNumber(compareUpTo).Root())
+ require.NoError(t, err, "cannot open importer state at block %d", compareUpTo)
+
+ bpRecipientBal := bpState.GetBalance(recipientAddr)
+ importerRecipientBal := importerState.GetBalance(recipientAddr)
+ require.Equal(t, bpRecipientBal.String(), importerRecipientBal.String(),
+ "recipient balance mismatch at block %d: BP=%s importer=%s",
+ compareUpTo, bpRecipientBal, importerRecipientBal)
+
+ bpSenderBal := bpState.GetBalance(senderAddr)
+ importerSenderBal := importerState.GetBalance(senderAddr)
+ require.Equal(t, bpSenderBal.String(), importerSenderBal.String(),
+ "sender balance mismatch at block %d: BP=%s importer=%s",
+ compareUpTo, bpSenderBal, importerSenderBal)
+
+ t.Logf("Verified %d blocks with %d total transactions, balances match", compareUpTo, totalImporterTxs)
+}
+
+// TestPipelinedImportSRC_SelfDestruct verifies that a contract which
+// self-destructs in its constructor is correctly handled by the FlatDiff
+// overlay during pipelined import. Without the Destructs check in
+// getStateObject, the importer would fall through to the trie reader and
+// see stale pre-destruct state from the committed parent root.
+//
+// Post-Cancun (EIP-6780), SELFDESTRUCT only fully destroys an account when
+// called in the same transaction that created the contract, so the test uses
+// a constructor that immediately self-destructs.
+func TestPipelinedImportSRC_SelfDestruct(t *testing.T) {
+ t.Parallel()
+ log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelInfo, true)))
+ fdlimit.Raise(2048)
+
+ faucets := make([]*ecdsa.PrivateKey, 128)
+ for i := 0; i < len(faucets); i++ {
+ faucets[i], _ = crypto.GenerateKey()
+ }
+
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 16)
+ genesis.Config.Bor.Period = map[string]uint64{"0": 2}
+ genesis.Config.Bor.Sprint = map[string]uint64{"0": 16}
+ genesis.Config.Bor.RioBlock = big.NewInt(0)
+
+ // Start a normal BP (no pipeline on mining side)
+ bpStack, bpBackend, err := InitMiner(genesis, keys[0], true)
+ require.NoError(t, err)
+ defer bpStack.Close()
+
+ for bpStack.Server().NodeInfo().Ports.Listener == 0 {
+ time.Sleep(250 * time.Millisecond)
+ }
+
+ // Start importer with pipelined import SRC
+ importerStack, importerBackend, err := InitImporterWithPipelinedSRC(genesis, keys[1], true)
+ require.NoError(t, err)
+ defer importerStack.Close()
+
+ for importerStack.Server().NodeInfo().Ports.Listener == 0 {
+ time.Sleep(250 * time.Millisecond)
+ }
+
+ // Connect peers
+ connectAndWaitForPeers(t, importerStack, bpStack)
+
+ // Start mining
+ err = bpBackend.StartMining()
+ require.NoError(t, err)
+
+ // Wait for a few blocks so we're past cancunBlock=3
+ for bpBackend.BlockChain().CurrentBlock().Number.Uint64() < 5 {
+ time.Sleep(500 * time.Millisecond)
+ }
+
+ // Deploy a contract whose constructor immediately self-destructs,
+ // sending its value back to CALLER.
+ // Init code: CALLER (0x33) SELFDESTRUCT (0xFF) = 0x33FF
+ selfDestructInitCode := []byte{byte(vm.CALLER), byte(vm.SELFDESTRUCT)}
+ deployValue := big.NewInt(1_000_000_000_000_000_000) // 1 ETH
+
+ txpool := bpBackend.TxPool()
+ senderKey := pkey1
+ senderAddr := crypto.PubkeyToAddress(senderKey.PublicKey)
+ signer := types.LatestSignerForChainID(genesis.Config.ChainID)
+
+ nonce := txpool.Nonce(senderAddr)
+
+ // Predict the contract address
+ contractAddr := crypto.CreateAddress(senderAddr, nonce)
+ t.Logf("Deploying self-destruct contract at predicted address %s with nonce %d", contractAddr.Hex(), nonce)
+
+ // Record sender balance before deployment
+ bpChain := bpBackend.BlockChain()
+ preState, err := bpChain.StateAt(bpChain.CurrentBlock().Root)
+ require.NoError(t, err)
+ senderBalBefore := preState.GetBalance(senderAddr)
+ t.Logf("Sender balance before deploy: %s", senderBalBefore.String())
+
+ // Create the deployment tx with value
+ deployTx, err := types.SignTx(
+ types.NewContractCreation(nonce, deployValue, 100_000, big.NewInt(30_000_000_000), selfDestructInitCode),
+ signer, senderKey,
+ )
+ require.NoError(t, err)
+
+ errs := txpool.Add([]*types.Transaction{deployTx}, true)
+ require.Nil(t, errs[0], "failed to add deploy tx")
+
+ // Also send a normal transfer in the NEXT block to force pipeline overlap.
+ // This ensures block N+1 uses the FlatDiff from block N (which has the destruct).
+ nonce++
+ recipientAddr := crypto.PubkeyToAddress(pkey2.PublicKey)
+ transferTx, err := types.SignTx(
+ types.NewTransaction(nonce, recipientAddr, big.NewInt(1000), 21000, big.NewInt(30_000_000_000), nil),
+ signer, senderKey,
+ )
+ require.NoError(t, err)
+ errs = txpool.Add([]*types.Transaction{transferTx}, true)
+ require.Nil(t, errs[0], "failed to add transfer tx")
+
+ // Wait for both txs to be mined
+ deadline := time.After(120 * time.Second)
+ for {
+ select {
+ case <-deadline:
+ t.Fatal("Timed out waiting for transactions to be mined on BP")
+ default:
+ time.Sleep(500 * time.Millisecond)
+ if txpool.Nonce(senderAddr) >= nonce+1 {
+ goto txsMined
+ }
+ }
+ }
+txsMined:
+
+ bpNum := bpBackend.BlockChain().CurrentBlock().Number.Uint64()
+ t.Logf("Transactions mined on BP by block %d", bpNum)
+
+ // Wait for importer to sync
+ targetBlock := bpNum
+ deadline = time.After(120 * time.Second)
+ for {
+ select {
+ case <-deadline:
+ importerNum := importerBackend.BlockChain().CurrentBlock().Number.Uint64()
+ t.Fatalf("Timed out waiting for importer to reach block %d, current: %d", targetBlock, importerNum)
+ default:
+ time.Sleep(500 * time.Millisecond)
+ if importerBackend.BlockChain().CurrentBlock().Number.Uint64() >= targetBlock {
+ goto importerSynced
+ }
+ }
+ }
+importerSynced:
+
+ // Allow async DB writes to flush
+ time.Sleep(2 * time.Second)
+
+ importerChain := importerBackend.BlockChain()
+ importerNum := importerChain.CurrentBlock().Number.Uint64()
+ t.Logf("Importer synced to block %d", importerNum)
+
+ // Re-read BP chain head
+ bpNum = bpChain.CurrentBlock().Number.Uint64()
+ compareUpTo := bpNum
+ if importerNum < compareUpTo {
+ compareUpTo = importerNum
+ }
+
+ // Verify block hashes and state roots match
+ for i := uint64(1); i <= compareUpTo; i++ {
+ bpBlock := bpChain.GetBlockByNumber(i)
+ require.NotNil(t, bpBlock, "BP missing block %d", i)
+
+ importerBlock := importerChain.GetBlockByNumber(i)
+ require.NotNil(t, importerBlock, "importer missing block %d", i)
+
+ require.Equal(t, bpBlock.Hash(), importerBlock.Hash(),
+ "block %d hash mismatch", i)
+ require.Equal(t, bpBlock.Root(), importerBlock.Root(),
+ "block %d state root mismatch", i)
+ }
+
+ // Verify the self-destructed contract is gone on BOTH chains
+ bpState, err := bpChain.StateAt(bpChain.GetBlockByNumber(compareUpTo).Root())
+ require.NoError(t, err)
+ importerState, err := importerChain.StateAt(importerChain.GetBlockByNumber(compareUpTo).Root())
+ require.NoError(t, err)
+
+ // Contract should have zero balance (ETH sent back to sender via SELFDESTRUCT)
+ bpContractBal := bpState.GetBalance(contractAddr)
+ importerContractBal := importerState.GetBalance(contractAddr)
+ require.True(t, bpContractBal.IsZero(), "BP: contract should have zero balance, got %s", bpContractBal)
+ require.True(t, importerContractBal.IsZero(), "importer: contract should have zero balance, got %s", importerContractBal)
+
+ // Contract should have no code
+ bpCode := bpState.GetCode(contractAddr)
+ importerCode := importerState.GetCode(contractAddr)
+ require.Empty(t, bpCode, "BP: contract should have no code")
+ require.Empty(t, importerCode, "importer: contract should have no code")
+
+ // Contract nonce should be zero (fully destroyed)
+ require.Equal(t, uint64(0), bpState.GetNonce(contractAddr), "BP: contract nonce should be 0")
+ require.Equal(t, uint64(0), importerState.GetNonce(contractAddr), "importer: contract nonce should be 0")
+
+ // Sender balances must match between BP and importer
+ bpSenderBal := bpState.GetBalance(senderAddr)
+ importerSenderBal := importerState.GetBalance(senderAddr)
+ require.Equal(t, bpSenderBal.String(), importerSenderBal.String(),
+ "sender balance mismatch: BP=%s importer=%s", bpSenderBal, importerSenderBal)
+
+ t.Logf("Verified: contract %s fully destroyed, sender balances match (BP=%s, importer=%s)",
+ contractAddr.Hex(), bpSenderBal, importerSenderBal)
+}
+
// TestProducerRecoversAfterMiningRestart is an integration-level regression
// test for the family of "elected but silent" producer stalls
// (post-mortem INC-37 for the 2026-05-07 Amoy chain halt).
diff --git a/tests/bor/helper.go b/tests/bor/helper.go
index 3ce8c4737f..43f91e70c3 100644
--- a/tests/bor/helper.go
+++ b/tests/bor/helper.go
@@ -40,6 +40,7 @@ import (
"github.com/ethereum/go-ethereum/eth"
"github.com/ethereum/go-ethereum/eth/downloader"
"github.com/ethereum/go-ethereum/eth/ethconfig"
+ "github.com/ethereum/go-ethereum/eth/tracers"
"github.com/ethereum/go-ethereum/ethdb"
"github.com/ethereum/go-ethereum/miner"
"github.com/ethereum/go-ethereum/node"
@@ -760,3 +761,119 @@ func InitMinerWithOptions(genesis *core.Genesis, privKey *ecdsa.PrivateKey, with
return stack, ethBackend, err
}
+
+// InitImporterWithPipelinedSRC creates a non-mining node with pipelined import
+// SRC enabled. The node will import blocks from peers using the pipelined state
+// root computation path. A validator key is still needed for the keystore (used
+// for P2P identity / account manager) but the node does NOT start mining.
+func InitImporterWithPipelinedSRC(genesis *core.Genesis, privKey *ecdsa.PrivateKey, withoutHeimdall bool) (*node.Node, *eth.Ethereum, error) {
+ stack, err := newPipelineTestNode("InitImporter-")
+ if err != nil {
+ return nil, nil, err
+ }
+ ethBackend, err := eth.New(stack, ðconfig.Config{
+ Genesis: genesis,
+ NetworkId: genesis.Config.ChainID.Uint64(),
+ SyncMode: downloader.FullSync,
+ DatabaseCache: 256,
+ DatabaseHandles: 256,
+ TxPool: legacypool.DefaultConfig,
+ GPO: ethconfig.Defaults.GPO,
+ Miner: miner.Config{
+ Etherbase: crypto.PubkeyToAddress(privKey.PublicKey),
+ GasCeil: genesis.GasLimit * 11 / 10,
+ GasPrice: big.NewInt(1),
+ Recommit: time.Second,
+ },
+ WithoutHeimdall: withoutHeimdall,
+ EnablePipelinedImportSRC: true,
+ PipelinedImportSRCLogs: true,
+ })
+ if err != nil {
+ return nil, nil, err
+ }
+ if err := importValidatorKey(stack, ethBackend, privKey); err != nil {
+ return nil, nil, err
+ }
+ // The debug/tracing namespace is registered by the CLI server layer in
+ // production, not by eth.New — register it here so RPC tests can exercise
+ // debug_* methods against the pipelined importer.
+ stack.RegisterAPIs(tracers.APIs(ethBackend.APIBackend))
+ return stack, ethBackend, stack.Start()
+}
+
+// newPipelineTestNode creates a headless node.Node in a fresh temp datadir
+// with P2P discovery disabled. Shared between the miner and importer test
+// setups since their node-level configuration is identical.
+func newPipelineTestNode(dirPrefix string) (*node.Node, error) {
+ datadir, err := os.MkdirTemp("", dirPrefix+uuid.New().String())
+ if err != nil {
+ return nil, err
+ }
+ return node.New(&node.Config{
+ Name: "geth",
+ Version: params.Version,
+ DataDir: datadir,
+ P2P: p2p.Config{
+ ListenAddr: "0.0.0.0:0",
+ NoDiscovery: true,
+ MaxPeers: 25,
+ },
+ UseLightweightKDF: true,
+ })
+}
+
+// importValidatorKey imports the validator's ECDSA key into the node's
+// keystore, unlocks the imported account, and registers the keystore with
+// the eth account manager so mining / signing paths can use it.
+func importValidatorKey(stack *node.Node, ethBackend *eth.Ethereum, privKey *ecdsa.PrivateKey) error {
+ kStore := keystore.NewKeyStore(stack.KeyStoreDir(), keystore.StandardScryptN, keystore.StandardScryptP)
+ if _, err := kStore.ImportECDSA(privKey, ""); err != nil {
+ return err
+ }
+ if err := kStore.Unlock(kStore.Accounts()[0], ""); err != nil {
+ return err
+ }
+ ethBackend.AccountManager().AddBackend(kStore)
+ return nil
+}
+
+// connectAndWaitForPeers statically peers the two nodes and blocks until the
+// connection is live on both servers. Two failure modes make a naive AddPeer
+// unreliable on slow hosts:
+//
+// - Self() publishes its TCP port asynchronously after the listener starts,
+// so an early AddPeer can capture a port-0 enode. The dial scheduler
+// dedupes static nodes by ID (p2p/dial.go addStaticCh handling), so later
+// re-adds never update the bad record — the dialer keeps dialing a dead
+// address forever. Wait for both enodes to carry a real port before the
+// first AddPeer.
+//
+// - A transiently failed dial (e.g. a loaded CI runner) parks the target in
+// the scheduler's history for dialHistoryExpiration (35s), so a 60s
+// deadline covers barely one retry. Use a deadline long enough for
+// several history windows.
+func connectAndWaitForPeers(t *testing.T, a, b *node.Node) {
+ t.Helper()
+ deadline := time.After(120 * time.Second)
+ for a.Server().Self().TCP() == 0 || b.Server().Self().TCP() == 0 {
+ select {
+ case <-deadline:
+ t.Fatalf("nodes failed to publish listener ports: a=%d b=%d",
+ a.Server().Self().TCP(), b.Server().Self().TCP())
+ default:
+ time.Sleep(50 * time.Millisecond)
+ }
+ }
+ a.Server().AddPeer(b.Server().Self())
+ b.Server().AddPeer(a.Server().Self())
+ for a.Server().PeerCount() == 0 || b.Server().PeerCount() == 0 {
+ select {
+ case <-deadline:
+ t.Fatalf("nodes failed to peer within deadline: peers a=%d b=%d",
+ a.Server().PeerCount(), b.Server().PeerCount())
+ default:
+ time.Sleep(250 * time.Millisecond)
+ }
+ }
+}
diff --git a/tests/bor/pipelined_rpc_test.go b/tests/bor/pipelined_rpc_test.go
new file mode 100644
index 0000000000..4a17adf1f4
--- /dev/null
+++ b/tests/bor/pipelined_rpc_test.go
@@ -0,0 +1,305 @@
+//go:build integration
+
+package bor
+
+import (
+ "context"
+ "crypto/ecdsa"
+ "encoding/json"
+ "math/big"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/require"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/common/fdlimit"
+ "github.com/ethereum/go-ethereum/common/hexutil"
+ "github.com/ethereum/go-ethereum/core/txpool"
+ "github.com/ethereum/go-ethereum/core/types"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/eth"
+ "github.com/ethereum/go-ethereum/ethdb/memorydb"
+ "github.com/ethereum/go-ethereum/log"
+ "github.com/ethereum/go-ethereum/rpc"
+ "github.com/ethereum/go-ethereum/trie"
+)
+
+// rpcCall is one named RPC invocation of the read battery.
+type rpcCall struct {
+ name string
+ method string
+ params []interface{}
+}
+
+// pipelinedRPCTarget bundles what the battery needs to build calls.
+type pipelinedRPCTarget struct {
+ client *rpc.Client
+ sender common.Address
+ recipient common.Address
+}
+
+// TestPipelinedImportSRC_RPCDuringImport verifies that the read-only RPC
+// surface stays correct on a node importing with pipelined SRC enabled:
+//
+// 1. While the importer is syncing, a battery of state-reading RPC methods
+// issued at pinned recent heights and at "latest" must never error —
+// value reads resolve through StateAt's FlatDiff overlay when the queried
+// block is still inside the SRC window, and eth_getProof (which needs
+// committed trie nodes) waits out the window via WaitForStateCommit.
+// 2. Values served during the sync (possibly from the overlay) must be
+// identical to values served after everything settles.
+// 3. After settle, every height's responses must match a non-pipelined
+// block producer's responses exactly, and account proofs must verify
+// cryptographically against the block state root.
+func TestPipelinedImportSRC_RPCDuringImport(t *testing.T) {
+ t.Parallel()
+ log.SetDefault(log.NewLogger(log.NewTerminalHandlerWithLevel(os.Stderr, log.LevelInfo, true)))
+ _, err := fdlimit.Raise(2048)
+ require.NoError(t, err, "raising fd limit")
+
+ faucets := make([]*ecdsa.PrivateKey, 128)
+ for i := 0; i < len(faucets); i++ {
+ faucets[i], err = crypto.GenerateKey()
+ require.NoError(t, err, "generating faucet key %d", i)
+ }
+
+ genesis := InitGenesis(t, faucets, "./testdata/genesis_2val.json", 16)
+ genesis.Config.Bor.Period = map[string]uint64{"0": 2}
+ genesis.Config.Bor.Sprint = map[string]uint64{"0": 16}
+ genesis.Config.Bor.RioBlock = big.NewInt(0)
+
+ bpStack, bpBackend, err := InitMiner(genesis, keys[0], true)
+ require.NoError(t, err)
+ defer bpStack.Close()
+
+ importerStack, importerBackend, err := InitImporterWithPipelinedSRC(genesis, keys[1], true)
+ require.NoError(t, err)
+ defer importerStack.Close()
+
+ // No listener-wait loops needed: connectAndWaitForPeers waits (with a
+ // deadline) for both nodes to publish real listener ports before peering.
+ connectAndWaitForPeers(t, importerStack, bpStack)
+ require.NoError(t, bpBackend.StartMining())
+
+ imp := &pipelinedRPCTarget{
+ client: importerStack.Attach(),
+ sender: crypto.PubkeyToAddress(pkey1.PublicKey),
+ recipient: crypto.PubkeyToAddress(pkey2.PublicKey),
+ }
+ defer imp.client.Close()
+ bp := &pipelinedRPCTarget{client: bpStack.Attach(), sender: imp.sender, recipient: imp.recipient}
+ defer bp.client.Close()
+
+ // Continuous transfer stream so imported blocks mutate state.
+ txStream := newTransferStream(t, bpBackend, pkey1, imp.recipient)
+
+ const targetBlock = uint64(24)
+ recorded := make(map[uint64]map[string]json.RawMessage)
+
+ deadline := time.After(240 * time.Second)
+ for importerBackend.BlockChain().CurrentBlock().Number.Uint64() < targetBlock {
+ select {
+ case <-deadline:
+ t.Fatalf("timed out syncing to block %d, importer at %d",
+ targetBlock, importerBackend.BlockChain().CurrentBlock().Number.Uint64())
+ default:
+ }
+ txStream.sendBatch(2)
+
+ if h := importerBackend.BlockChain().CurrentBlock().Number.Uint64(); h >= 1 {
+ if _, seen := recorded[h]; !seen {
+ recorded[h] = runStrictBattery(t, imp, pinnedCalls(imp, hexutil.Uint64(h)))
+ }
+ runStrictBattery(t, imp, pinnedCalls(imp, "latest"))
+ }
+ time.Sleep(200 * time.Millisecond)
+ }
+
+ // Let the last block's SRC settle and async writes flush.
+ time.Sleep(3 * time.Second)
+
+ verifyRecordedBatteriesStable(t, imp, recorded)
+
+ receipt, err := callRPC(imp.client, "eth_getTransactionReceipt", txStream.firstTxHash)
+ require.NoError(t, err)
+ require.NotEqual(t, "null", string(receipt), "receipt for first streamed tx must exist on importer")
+
+ compareUpTo := importerBackend.BlockChain().CurrentBlock().Number.Uint64()
+ if bpNum := bpBackend.BlockChain().CurrentBlock().Number.Uint64(); bpNum < compareUpTo {
+ compareUpTo = bpNum
+ }
+ assertRPCParity(t, imp, bp, compareUpTo)
+ t.Logf("RPC battery: %d pinned heights recorded, parity verified up to block %d",
+ len(recorded), compareUpTo)
+}
+
+// pinnedCalls builds the strict battery: state-reading RPC methods that must
+// never fail at the given height, SRC window or not.
+func pinnedCalls(target *pipelinedRPCTarget, blockArg interface{}) []rpcCall {
+ callArgs := map[string]interface{}{
+ "from": target.sender,
+ "to": target.recipient,
+ "value": "0x1",
+ }
+ return []rpcCall{
+ {"getBalance-sender", "eth_getBalance", []interface{}{target.sender, blockArg}},
+ {"getBalance-recipient", "eth_getBalance", []interface{}{target.recipient, blockArg}},
+ {"getTransactionCount", "eth_getTransactionCount", []interface{}{target.sender, blockArg}},
+ {"getCode", "eth_getCode", []interface{}{target.recipient, blockArg}},
+ {"getStorageAt", "eth_getStorageAt", []interface{}{target.recipient, "0x0", blockArg}},
+ {"call", "eth_call", []interface{}{callArgs, blockArg}},
+ {"estimateGas", "eth_estimateGas", []interface{}{callArgs, blockArg}},
+ {"getBlockByNumber", "eth_getBlockByNumber", []interface{}{blockArg, true}},
+ {"getBlockReceipts", "eth_getBlockReceipts", []interface{}{blockArg}},
+ {"getLogs", "eth_getLogs", []interface{}{map[string]interface{}{"fromBlock": blockArg, "toBlock": blockArg}}},
+ {"traceBlockByNumber", "debug_traceBlockByNumber", []interface{}{blockArg, nil}},
+ {"getProof", "eth_getProof", []interface{}{target.sender, []string{}, blockArg}},
+ }
+}
+
+func runStrictBattery(t *testing.T, target *pipelinedRPCTarget, calls []rpcCall) map[string]json.RawMessage {
+ t.Helper()
+ results := make(map[string]json.RawMessage, len(calls))
+ for _, c := range calls {
+ res, err := callRPC(target.client, c.method, c.params...)
+ require.NoError(t, err, "%s (%s) must not fail", c.name, c.method)
+ results[c.name] = res
+ }
+ return results
+}
+
+// verifyRecordedBatteriesStable re-runs every pinned battery after full settle
+// and requires responses identical to what was served during the sync — a
+// value served from the FlatDiff overlay inside the SRC window must match the
+// committed trie exactly.
+func verifyRecordedBatteriesStable(t *testing.T, target *pipelinedRPCTarget, recorded map[uint64]map[string]json.RawMessage) {
+ t.Helper()
+ for h, want := range recorded {
+ got := runStrictBattery(t, target, pinnedCalls(target, hexutil.Uint64(h)))
+ for name, wantRes := range want {
+ require.JSONEq(t, string(wantRes), string(got[name]),
+ "%s at height %d changed between sync-time and settled read", name, h)
+ }
+ }
+}
+
+// assertRPCParity compares the settled importer's responses against the
+// non-pipelined BP for every height, and cryptographically verifies the
+// importer's account proofs against each block's state root.
+func assertRPCParity(t *testing.T, imp, bp *pipelinedRPCTarget, upTo uint64) {
+ t.Helper()
+ parityMethods := []rpcCall{
+ {"getBalance-sender", "eth_getBalance", nil},
+ {"getBalance-recipient", "eth_getBalance", nil},
+ {"getTransactionCount", "eth_getTransactionCount", nil},
+ }
+ for h := uint64(1); h <= upTo; h++ {
+ blockArg := hexutil.Uint64(h)
+ for _, m := range parityMethods {
+ addr := imp.sender
+ if m.name == "getBalance-recipient" {
+ addr = imp.recipient
+ }
+ impRes, err := callRPC(imp.client, m.method, addr, blockArg)
+ require.NoError(t, err)
+ bpRes, err := callRPC(bp.client, m.method, addr, blockArg)
+ require.NoError(t, err)
+ require.JSONEq(t, string(bpRes), string(impRes), "%s parity at height %d", m.name, h)
+ }
+ impBlock, err := callRPC(imp.client, "eth_getBlockByNumber", blockArg, true)
+ require.NoError(t, err)
+ bpBlock, err := callRPC(bp.client, "eth_getBlockByNumber", blockArg, true)
+ require.NoError(t, err)
+ require.JSONEq(t, string(bpBlock), string(impBlock), "block %d JSON parity", h)
+
+ impReceipts, err := callRPC(imp.client, "eth_getBlockReceipts", blockArg)
+ require.NoError(t, err)
+ bpReceipts, err := callRPC(bp.client, "eth_getBlockReceipts", blockArg)
+ require.NoError(t, err)
+ require.JSONEq(t, string(bpReceipts), string(impReceipts), "block %d receipts parity", h)
+
+ verifyAccountProofParity(t, imp, bp, blockArg, impBlock)
+ }
+}
+
+// verifyAccountProofParity checks getProof equality between the two nodes and
+// verifies the importer's account proof against the block's state root.
+func verifyAccountProofParity(t *testing.T, imp, bp *pipelinedRPCTarget, blockArg hexutil.Uint64, blockJSON json.RawMessage) {
+ t.Helper()
+ impProof, err := callRPC(imp.client, "eth_getProof", imp.sender, []string{}, blockArg)
+ require.NoError(t, err, "eth_getProof on importer at height %d after settle", blockArg)
+ bpProof, err := callRPC(bp.client, "eth_getProof", bp.sender, []string{}, blockArg)
+ require.NoError(t, err)
+ require.JSONEq(t, string(bpProof), string(impProof), "getProof parity at height %d", blockArg)
+
+ var header struct {
+ StateRoot common.Hash `json:"stateRoot"`
+ }
+ require.NoError(t, json.Unmarshal(blockJSON, &header))
+ var proof struct {
+ AccountProof []hexutil.Bytes `json:"accountProof"`
+ }
+ require.NoError(t, json.Unmarshal(impProof, &proof))
+
+ proofDb := memorydb.New()
+ for _, node := range proof.AccountProof {
+ require.NoError(t, proofDb.Put(crypto.Keccak256(node), node))
+ }
+ val, err := trie.VerifyProof(header.StateRoot, crypto.Keccak256(imp.sender.Bytes()), proofDb)
+ require.NoError(t, err, "account proof at height %d must verify against state root %x", blockArg, header.StateRoot)
+ require.NotEmpty(t, val, "account proof at height %d must resolve to a non-empty account", blockArg)
+}
+
+func callRPC(client *rpc.Client, method string, params ...interface{}) (json.RawMessage, error) {
+ ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ var res json.RawMessage
+ err := client.CallContext(ctx, &res, method, params...)
+ return res, err
+}
+
+// transferStream submits plain transfers to the BP's txpool so imported
+// blocks carry state mutations.
+type transferStream struct {
+ t *testing.T
+ pool *txpool.TxPool
+ signer types.Signer
+ key *ecdsa.PrivateKey
+ to common.Address
+ nonce uint64
+ sent int
+ firstTxHash common.Hash
+}
+
+const transferStreamMaxTxs = 40
+
+func newTransferStream(t *testing.T, bpBackend *eth.Ethereum, key *ecdsa.PrivateKey, to common.Address) *transferStream {
+ sender := crypto.PubkeyToAddress(key.PublicKey)
+ pool := bpBackend.TxPool()
+ return &transferStream{
+ t: t,
+ pool: pool,
+ signer: types.LatestSignerForChainID(bpBackend.BlockChain().Config().ChainID),
+ key: key,
+ to: to,
+ nonce: pool.Nonce(sender),
+ }
+}
+
+func (s *transferStream) sendBatch(n int) {
+ s.t.Helper()
+ for i := 0; i < n && s.sent < transferStreamMaxTxs; i++ {
+ tx := types.NewTransaction(s.nonce, s.to, big.NewInt(1000), 21000, big.NewInt(30000000000), nil)
+ signedTx, err := types.SignTx(tx, s.signer, s.key)
+ require.NoError(s.t, err)
+ errs := s.pool.Add([]*types.Transaction{signedTx}, true)
+ require.NoError(s.t, errs[0], "failed to add streamed tx %d", s.sent)
+ if s.sent == 0 {
+ s.firstTxHash = signedTx.Hash()
+ }
+ s.nonce++
+ s.sent++
+ }
+}
diff --git a/triedb/pathdb/layertree.go b/triedb/pathdb/layertree.go
index ec45257db5..64b27f58ae 100644
--- a/triedb/pathdb/layertree.go
+++ b/triedb/pathdb/layertree.go
@@ -43,6 +43,7 @@ type layerTree struct {
// This map includes all the existing diff layers and the disk layer.
descendants map[common.Hash]map[common.Hash]struct{}
lookup *lookup
+ nodeIndex *nodeLookup
lock sync.RWMutex
}
@@ -74,6 +75,7 @@ func (tree *layerTree) init(head layer) {
}
tree.base = current.(*diskLayer) // panic if it's not a disk layer
tree.lookup = newLookup(head, tree.isDescendant)
+ tree.nodeIndex = newNodeLookup(head)
}
// get retrieves a layer belonging to the given state root.
@@ -168,6 +170,9 @@ func (tree *layerTree) add(root common.Hash, parentRoot common.Hash, block uint6
// Link the given layer into the state mutation history
tree.lookup.addLayer(l)
+
+ // Link the given layer's trie nodes into the node index
+ tree.nodeIndex.addLayer(l)
return nil
}
@@ -202,6 +207,7 @@ func (tree *layerTree) cap(root common.Hash, layers int) error {
// with no descendants.
tree.descendants = make(map[common.Hash]map[common.Hash]struct{})
tree.lookup = newLookup(base, tree.isDescendant)
+ tree.nodeIndex = newNodeLookup(base)
return nil
}
// Dive until we run out of layers or reach the persistent database
@@ -276,6 +282,7 @@ func (tree *layerTree) cap(root common.Hash, layers int) error {
return
}
tree.lookup.removeLayer(diff)
+ tree.nodeIndex.removeLayer(diff)
}
var remove func(root common.Hash)
remove = func(root common.Hash) {
@@ -295,6 +302,17 @@ func (tree *layerTree) cap(root common.Hash, layers int) error {
return nil
}
+// node looks up a trie node in the diff-layer node index. When found is
+// false, definitive reports whether the miss guarantees the node is absent
+// from every live diff layer, letting the caller read the disk layer
+// directly instead of walking the layer chain.
+func (tree *layerTree) node(owner common.Hash, path []byte, hash common.Hash) (blob []byte, found bool, definitive bool) {
+ tree.lock.RLock()
+ defer tree.lock.RUnlock()
+
+ return tree.nodeIndex.get(owner, path, hash)
+}
+
// bottom returns the bottom-most disk layer in this tree.
func (tree *layerTree) bottom() *diskLayer {
tree.lock.RLock()
diff --git a/triedb/pathdb/lookup_nodes.go b/triedb/pathdb/lookup_nodes.go
new file mode 100644
index 0000000000..c5326c90de
--- /dev/null
+++ b/triedb/pathdb/lookup_nodes.go
@@ -0,0 +1,200 @@
+// Copyright 2026 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library 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.
+//
+// The go-ethereum library 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 the go-ethereum library. If not, see .
+
+package pathdb
+
+import (
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/metrics"
+ "github.com/ethereum/go-ethereum/trie/trienode"
+)
+
+// nodeLookupMaxPath bounds the path length representable in a nodeLookupKey.
+// MPT paths are at most 64 nibbles (32-byte keys); a longer path can never be
+// produced by the trie, but if one ever appeared the index would flag itself
+// non-definitive rather than give wrong miss guarantees.
+const nodeLookupMaxPath = 64
+
+var (
+ nodeIndexHitMeter = metrics.NewRegisteredMeter("pathdb/nodeindex/hit", nil)
+ nodeIndexMissMeter = metrics.NewRegisteredMeter("pathdb/nodeindex/miss", nil)
+ nodeIndexCountGauge = metrics.NewRegisteredGauge("pathdb/nodeindex/count", nil)
+ nodeIndexBytesGauge = metrics.NewRegisteredGauge("pathdb/nodeindex/bytes", nil)
+)
+
+// nodeLookupKey identifies a trie node by owner, path and content hash. It is
+// a comparable value type built on the stack, so index probes do not allocate.
+type nodeLookupKey struct {
+ owner common.Hash
+ hash common.Hash
+ pathLen uint8
+ path [nodeLookupMaxPath]byte
+}
+
+func makeNodeLookupKey(owner common.Hash, path []byte, hash common.Hash) (nodeLookupKey, bool) {
+ if len(path) > nodeLookupMaxPath {
+ return nodeLookupKey{}, false
+ }
+ key := nodeLookupKey{owner: owner, hash: hash, pathLen: uint8(len(path))}
+ copy(key.path[:], path)
+ return key, true
+}
+
+// makeNodeLookupKeyString is the string-path variant used on the mutation
+// side, where paths arrive as map keys.
+func makeNodeLookupKeyString(owner common.Hash, path string, hash common.Hash) (nodeLookupKey, bool) {
+ if len(path) > nodeLookupMaxPath {
+ return nodeLookupKey{}, false
+ }
+ key := nodeLookupKey{owner: owner, hash: hash, pathLen: uint8(len(path))}
+ copy(key.path[:], path)
+ return key, true
+}
+
+// nodeLookupEntry pairs a node blob with a reference count: the same
+// (owner, path, hash) triple can be supplied by several live layers (sibling
+// forks, or a path rewritten to identical content), and the entry must
+// survive until the last of them is unlinked.
+type nodeLookupEntry struct {
+ blob []byte
+ refs uint32
+}
+
+// nodeLookup is the trie-node counterpart of lookup. It maps every live trie
+// node held by the diff layers, keyed by (owner, path, hash), to its blob.
+//
+// A node request always carries the expected hash — parent trie nodes embed
+// their child hashes — which makes the triple content-addressed: a hit is
+// correct regardless of which layer or fork supplied the entry, and a miss
+// guarantees that no live diff layer holds the requested node, so readers can
+// go straight to the disk layer instead of walking the layer chain probing
+// every diff layer's maps.
+//
+// Deletion markers (empty blobs) are not indexed: a deleted node is never
+// referenced by post-deletion parents, so no reader at a newer root asks for
+// it, and readers at older roots want the pre-deletion version, which either
+// lives in an older diff layer (indexed) or on disk (the miss path).
+//
+// Mutations happen under the layerTree lock, mirroring lookup.
+type nodeLookup struct {
+ nodes map[nodeLookupKey]nodeLookupEntry
+ bytes int64
+
+ // overflow is set if a node with an unrepresentable path was ever seen;
+ // from then on misses are reported as non-definitive and readers use the
+ // legacy layer walk. Cannot happen with well-formed MPT paths.
+ overflow bool
+}
+
+// newNodeLookup indexes every diff layer reachable from head. Order does not
+// matter: entries are content-addressed and reference-counted.
+func newNodeLookup(head layer) *nodeLookup {
+ l := &nodeLookup{nodes: make(map[nodeLookupKey]nodeLookupEntry)}
+ for current := head; current != nil; current = current.parentLayer() {
+ if diff, ok := current.(*diffLayer); ok {
+ l.addLayer(diff)
+ }
+ }
+ return l
+}
+
+// get returns the blob for the requested node if any live diff layer holds
+// it. When found is false, definitive reports whether that miss is a
+// guarantee (the node is absent from every live diff layer) or whether the
+// caller must fall back to the layer walk.
+func (l *nodeLookup) get(owner common.Hash, path []byte, hash common.Hash) (blob []byte, found bool, definitive bool) {
+ key, ok := makeNodeLookupKey(owner, path, hash)
+ if !ok {
+ return nil, false, false
+ }
+ entry, ok := l.nodes[key]
+ if !ok {
+ return nil, false, !l.overflow
+ }
+ return entry.blob, true, true
+}
+
+// addLayer indexes all live nodes of the given diff layer.
+func (l *nodeLookup) addLayer(dl *diffLayer) {
+ for path, n := range dl.nodes.accountNodes {
+ l.insert(common.Hash{}, path, n)
+ }
+ for owner, subset := range dl.nodes.storageNodes {
+ for path, n := range subset {
+ l.insert(owner, path, n)
+ }
+ }
+ l.report()
+}
+
+// removeLayer unlinks all live nodes of the given diff layer, dropping
+// entries whose last reference is gone.
+func (l *nodeLookup) removeLayer(dl *diffLayer) {
+ for path, n := range dl.nodes.accountNodes {
+ l.remove(common.Hash{}, path, n)
+ }
+ for owner, subset := range dl.nodes.storageNodes {
+ for path, n := range subset {
+ l.remove(owner, path, n)
+ }
+ }
+ l.report()
+}
+
+func (l *nodeLookup) insert(owner common.Hash, path string, n *trienode.Node) {
+ if n == nil || len(n.Blob) == 0 {
+ return // deletion marker
+ }
+ key, ok := makeNodeLookupKeyString(owner, path, n.Hash)
+ if !ok {
+ l.overflow = true
+ return
+ }
+ entry, exists := l.nodes[key]
+ if exists {
+ entry.refs++
+ l.nodes[key] = entry
+ return
+ }
+ l.nodes[key] = nodeLookupEntry{blob: n.Blob, refs: 1}
+ l.bytes += int64(len(n.Blob))
+}
+
+func (l *nodeLookup) remove(owner common.Hash, path string, n *trienode.Node) {
+ if n == nil || len(n.Blob) == 0 {
+ return
+ }
+ key, ok := makeNodeLookupKeyString(owner, path, n.Hash)
+ if !ok {
+ return
+ }
+ entry, exists := l.nodes[key]
+ if !exists {
+ return
+ }
+ if entry.refs > 1 {
+ entry.refs--
+ l.nodes[key] = entry
+ return
+ }
+ l.bytes -= int64(len(entry.blob))
+ delete(l.nodes, key)
+}
+
+func (l *nodeLookup) report() {
+ nodeIndexCountGauge.Update(int64(len(l.nodes)))
+ nodeIndexBytesGauge.Update(l.bytes)
+}
diff --git a/triedb/pathdb/lookup_nodes_test.go b/triedb/pathdb/lookup_nodes_test.go
new file mode 100644
index 0000000000..bc15264568
--- /dev/null
+++ b/triedb/pathdb/lookup_nodes_test.go
@@ -0,0 +1,124 @@
+// Copyright 2026 The go-ethereum Authors
+// This file is part of the go-ethereum library.
+//
+// The go-ethereum library 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.
+//
+// The go-ethereum library 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 the go-ethereum library. If not, see .
+
+package pathdb
+
+import (
+ "testing"
+
+ "github.com/ethereum/go-ethereum/common"
+ "github.com/ethereum/go-ethereum/crypto"
+ "github.com/ethereum/go-ethereum/trie/trienode"
+)
+
+func nodeLookupTestLayer(nodes map[common.Hash]map[string]*trienode.Node) *diffLayer {
+ return newDiffLayer(emptyLayer(), common.Hash{}, 0, 0, NewNodeSetWithOrigin(nodes, nil), NewStateSetWithOrigin(nil, nil, nil, nil, false))
+}
+
+func testNode(blob []byte) *trienode.Node {
+ return trienode.New(crypto.Keccak256Hash(blob), blob)
+}
+
+func TestNodeLookup_AddGetRemove(t *testing.T) {
+ owner := common.HexToHash("0xaa")
+ acctNode := testNode([]byte("account-node"))
+ storNode := testNode([]byte("storage-node"))
+
+ layer := nodeLookupTestLayer(map[common.Hash]map[string]*trienode.Node{
+ {}: {"\x01\x02": acctNode},
+ owner: {"\x03": storNode},
+ })
+ l := &nodeLookup{nodes: make(map[nodeLookupKey]nodeLookupEntry)}
+ l.addLayer(layer)
+
+ blob, found, definitive := l.get(common.Hash{}, []byte{0x01, 0x02}, acctNode.Hash)
+ if !found || !definitive || string(blob) != "account-node" {
+ t.Fatalf("account node not served: found=%v definitive=%v blob=%q", found, definitive, blob)
+ }
+ blob, found, _ = l.get(owner, []byte{0x03}, storNode.Hash)
+ if !found || string(blob) != "storage-node" {
+ t.Fatalf("storage node not served: found=%v blob=%q", found, blob)
+ }
+ // Wrong hash at a live path is a definitive miss.
+ _, found, definitive = l.get(owner, []byte{0x03}, common.HexToHash("0xdead"))
+ if found || !definitive {
+ t.Fatalf("wrong-hash lookup: found=%v definitive=%v, want miss+definitive", found, definitive)
+ }
+
+ l.removeLayer(layer)
+ if len(l.nodes) != 0 || l.bytes != 0 {
+ t.Fatalf("index not empty after removal: entries=%d bytes=%d", len(l.nodes), l.bytes)
+ }
+}
+
+func TestNodeLookup_RefcountAcrossForks(t *testing.T) {
+ shared := testNode([]byte("shared-fork-node"))
+ mk := func() *diffLayer {
+ return nodeLookupTestLayer(map[common.Hash]map[string]*trienode.Node{
+ {}: {"\x07": shared},
+ })
+ }
+ forkA, forkB := mk(), mk()
+
+ l := &nodeLookup{nodes: make(map[nodeLookupKey]nodeLookupEntry)}
+ l.addLayer(forkA)
+ l.addLayer(forkB)
+
+ // Removing one fork must not drop the entry the sibling still owns.
+ l.removeLayer(forkA)
+ _, found, _ := l.get(common.Hash{}, []byte{0x07}, shared.Hash)
+ if !found {
+ t.Fatal("shared entry dropped while a sibling fork still holds it")
+ }
+ l.removeLayer(forkB)
+ if _, found, _ := l.get(common.Hash{}, []byte{0x07}, shared.Hash); found {
+ t.Fatal("entry survived removal of every owning layer")
+ }
+}
+
+func TestNodeLookup_DeletionMarkersSkipped(t *testing.T) {
+ deleted := trienode.NewDeleted()
+ layer := nodeLookupTestLayer(map[common.Hash]map[string]*trienode.Node{
+ {}: {"\x09": deleted},
+ })
+ l := &nodeLookup{nodes: make(map[nodeLookupKey]nodeLookupEntry)}
+ l.addLayer(layer)
+ if len(l.nodes) != 0 {
+ t.Fatalf("deletion marker indexed: entries=%d", len(l.nodes))
+ }
+ // And removal of the same layer is a no-op rather than a corruption.
+ l.removeLayer(layer)
+ if l.bytes != 0 {
+ t.Fatalf("byte accounting corrupted by deletion markers: %d", l.bytes)
+ }
+}
+
+func TestNodeLookup_OverflowDisablesDefinitiveMiss(t *testing.T) {
+ longPath := string(make([]byte, nodeLookupMaxPath+1))
+ layer := nodeLookupTestLayer(map[common.Hash]map[string]*trienode.Node{
+ {}: {longPath: testNode([]byte("oversized"))},
+ })
+ l := &nodeLookup{nodes: make(map[nodeLookupKey]nodeLookupEntry)}
+ l.addLayer(layer)
+
+ _, found, definitive := l.get(common.Hash{}, []byte{0x01}, common.HexToHash("0x01"))
+ if found {
+ t.Fatal("unexpected hit")
+ }
+ if definitive {
+ t.Fatal("miss reported definitive after an unindexable path was seen")
+ }
+}
diff --git a/triedb/pathdb/reader.go b/triedb/pathdb/reader.go
index 70ca558721..8b726e075b 100644
--- a/triedb/pathdb/reader.go
+++ b/triedb/pathdb/reader.go
@@ -64,9 +64,65 @@ type reader struct {
// node info. Don't modify the returned byte slice since it's not deep-copied
// and still be referenced by database.
func (r *reader) Node(owner common.Hash, path []byte, hash common.Hash) ([]byte, error) {
+ // Fast path: consult the layer tree's node index. A hit is
+ // content-verified (the hash is part of the index key); a definitive
+ // miss guarantees no live diff layer holds the node, so it can only be
+ // in the disk layer — skip the layer-chain walk entirely.
+ if !r.noHashCheck && hash != (common.Hash{}) {
+ if blob, found, definitive := r.db.tree.node(owner, path, hash); found {
+ nodeIndexHitMeter.Mark(1)
+ return blob, nil
+ } else if definitive {
+ nodeIndexMissMeter.Mark(1)
+ if blob, ok := r.diskNode(owner, path, hash); ok {
+ return blob, nil
+ }
+ // Irregularity (stale disk layer mid-read, hash mismatch) —
+ // resolve through the legacy walk below, which owns fallback
+ // and error reporting.
+ }
+ }
+ return r.nodeWalk(owner, path, hash)
+}
+
+// diskNode serves a definitive node-index miss straight from the disk layer.
+// ok=false on any irregularity so the caller retries via the legacy walk.
+func (r *reader) diskNode(owner common.Hash, path []byte, hash common.Hash) ([]byte, bool) {
+ blob, got, loc, err := r.db.tree.bottom().node(owner, path, 0)
+ if err != nil {
+ return nil, false
+ }
+ if got == hash {
+ return blob, true
+ }
+ // Evict a potentially poisoned clean-cache entry before the legacy walk
+ // retries (see the mismatch commentary in nodeWalk).
+ if loc.loc == locCleanCache {
+ nodeCleanFalseMeter.Mark(1)
+ evictCachedNode(r.db.tree.bottom(), owner, path)
+ }
+ return nil, false
+}
+
+// nodeWalk resolves a trie node by walking the reader's layer chain. It is
+// the pre-index resolution path, kept as the authority for noHashCheck
+// readers, non-definitive index misses, and all irregular cases (stale
+// layers, hash mismatches), owning the fallback and error-reporting logic.
+func (r *reader) nodeWalk(owner common.Hash, path []byte, hash common.Hash) ([]byte, error) {
blob, got, loc, err := r.layer.node(owner, path, 0)
if err != nil {
- return nil, err
+ // If the diff layer chain walks into a stale disk layer (marked stale
+ // by concurrent cap()/persist() during pipelined SRC), fall back to
+ // the current base disk layer — same strategy as accountFallback and
+ // storageFallback.
+ if errors.Is(err, errSnapshotStale) {
+ blob, got, loc, err = r.nodeFallback(owner, path)
+ if err != nil {
+ return nil, err
+ }
+ } else {
+ return nil, err
+ }
}
if r.noHashCheck || got == hash {
return blob, nil
@@ -123,6 +179,24 @@ func evictCachedNode(l layer, owner common.Hash, path []byte) {
}
}
+// nodeFallback retrieves a trie node when the normal diff layer walk fails
+// due to concurrent layer flattening (cap).
+//
+// During pipelined SRC, the background SRC goroutine's CommitWithUpdate can
+// trigger cap() which flattens bottom diff layers into a new disk layer,
+// marking the old disk layer as stale. Concurrently, the prefetcher's trie
+// walk may reach this stale disk layer and get errSnapshotStale.
+//
+// Unlike accountFallback/storageFallback — whose primary attempt is the
+// lookup-index path, making an entry-layer retry a genuinely different
+// second strategy — nodeWalk's primary attempt already IS the entry-layer
+// walk, so retrying r.layer.node would deterministically hit the same stale
+// disk layer. Go straight to tree.bottom(), the current base disk layer,
+// which holds the flattened data and is guaranteed non-stale.
+func (r *reader) nodeFallback(owner common.Hash, path []byte) ([]byte, common.Hash, *nodeLoc, error) {
+ return r.db.tree.bottom().node(owner, path, 0)
+}
+
// AccountRLP directly retrieves the account associated with a particular hash.
// An error will be returned if the read operation exits abnormally. Specifically,
// if the layer is already stale.
@@ -133,6 +207,9 @@ func evictCachedNode(l layer, owner common.Hash, path []byte) {
func (r *reader) AccountRLP(hash common.Hash) ([]byte, error) {
l, err := r.db.tree.lookupAccount(hash, r.state)
if err != nil {
+ if errors.Is(err, errSnapshotStale) {
+ return r.accountFallback(hash)
+ }
return nil, err
}
// If the located layer is stale, fall back to the slow path to retrieve
@@ -145,7 +222,21 @@ func (r *reader) AccountRLP(hash common.Hash) ([]byte, error) {
// not affect the result unless the entry point layer is also stale.
blob, err := l.account(hash, 0)
if errors.Is(err, errSnapshotStale) {
- return r.layer.account(hash, 0)
+ return r.accountFallback(hash)
+ }
+ return blob, err
+}
+
+// accountFallback retrieves account data when the normal lookup path fails
+// due to concurrent layer flattening (cap). It tries the reader's entry-point
+// layer first (which is still in memory), then falls back to the current base
+// disk layer. The base fallback is needed because persist() creates intermediate
+// disk layers that are marked stale during recursive flattening — only the
+// final base layer is guaranteed non-stale.
+func (r *reader) accountFallback(hash common.Hash) ([]byte, error) {
+ blob, err := r.layer.account(hash, 0)
+ if errors.Is(err, errSnapshotStale) {
+ return r.db.tree.bottom().account(hash, 0)
}
return blob, err
}
@@ -182,6 +273,9 @@ func (r *reader) Account(hash common.Hash) (*types.SlimAccount, error) {
func (r *reader) Storage(accountHash, storageHash common.Hash) ([]byte, error) {
l, err := r.db.tree.lookupStorage(accountHash, storageHash, r.state)
if err != nil {
+ if errors.Is(err, errSnapshotStale) {
+ return r.storageFallback(accountHash, storageHash)
+ }
return nil, err
}
// If the located layer is stale, fall back to the slow path to retrieve
@@ -194,7 +288,16 @@ func (r *reader) Storage(accountHash, storageHash common.Hash) ([]byte, error) {
// not affect the result unless the entry point layer is also stale.
blob, err := l.storage(accountHash, storageHash, 0)
if errors.Is(err, errSnapshotStale) {
- return r.layer.storage(accountHash, storageHash, 0)
+ return r.storageFallback(accountHash, storageHash)
+ }
+ return blob, err
+}
+
+// storageFallback is the storage counterpart of accountFallback.
+func (r *reader) storageFallback(accountHash, storageHash common.Hash) ([]byte, error) {
+ blob, err := r.layer.storage(accountHash, storageHash, 0)
+ if errors.Is(err, errSnapshotStale) {
+ return r.db.tree.bottom().storage(accountHash, storageHash, 0)
}
return blob, err
}