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 }