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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/utils/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -1140,6 +1140,10 @@ var (
Usage: "EXPERIMENTAL: enables concurrent trie for commitment",
Value: false,
}
UseForkchoiceFinalityFlag = cli.BoolFlag{
Name: "experimental.use-forkchoice-finality",
Usage: "Skip changeset generation for blocks finalized by forkchoice (e.g., Polygon milestones). Reduces overhead but requires chaindata reset if finality is reverted.",
}
GDBMeFlag = cli.BoolFlag{
Name: "gdbme",
Usage: "restart erigon under gdb for debug purposes",
Expand Down
2 changes: 1 addition & 1 deletion db/rawdb/rawtemporaldb/accessors_commitment.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ func CanUnwindToBlockNum(tx kv.TemporalTx) (uint64, error) {
return 0, err
}
if minUnwindale == math.MaxUint64 { // no unwindable block found
log.Warn("no unwindable block found from changesets, falling back to latest with commitment")
log.Debug("no unwindable block found from changesets, falling back to latest with commitment")
return commitmentdb.LatestBlockNumWithCommitment(tx)
}
if minUnwindale > 0 {
Expand Down
1 change: 1 addition & 0 deletions eth/ethconfig/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,7 @@ type Sync struct {

ChaosMonkey bool
AlwaysGenerateChangesets bool
UseForkchoiceFinality bool
MaxReorgDepth uint64
KeepExecutionProofs bool
PersistReceiptsCacheV2 bool
Expand Down
6 changes: 6 additions & 0 deletions execution/eth1/forkchoice.go
Original file line number Diff line number Diff line change
Expand Up @@ -486,6 +486,12 @@ func (e *EthereumExecutionModule) updateForkChoice(ctx context.Context, original
}
}

// Write finalized hash BEFORE execution so shouldGenerateChangeSets() can read it
// during execution stage. This enables the UseForkchoiceFinality optimization.
if finalizedHash != (common.Hash{}) {
rawdb.WriteForkchoiceFinalized(tx, finalizedHash)
}

firstCycle := false
loopIter := 0
for {
Expand Down
48 changes: 42 additions & 6 deletions execution/stagedsync/exec3.go
Original file line number Diff line number Diff line change
Expand Up @@ -467,9 +467,14 @@ func ExecV3(ctx context.Context,
lastFrozenTxNum = uint64((lastFrozenStep+1)*kv.Step(doms.StepSize())) - 1
}

var finalizedBlockNum uint64
if cfg.syncCfg.UseForkchoiceFinality {
finalizedBlockNum = getFinalizedBlockNum(applyTx)
}

Loop:
for ; blockNum <= maxBlockNum; blockNum++ {
shouldGenerateChangesets := shouldGenerateChangeSets(cfg, blockNum, maxBlockNum, initialCycle)
shouldGenerateChangesets := shouldGenerateChangeSets(cfg, finalizedBlockNum, blockNum, maxBlockNum, initialCycle)
changeSet := &changeset2.StateChangeSet{}
if shouldGenerateChangesets && blockNum > 0 {
executor.domains().SetChangesetAccumulator(changeSet)
Expand Down Expand Up @@ -762,9 +767,15 @@ Loop:

timeStart := time.Now()

// allow greedy prune on non-chain-tip
// allow greedy prune on non-chain-tip or when not generating changesets
pruneTimeout := 250 * time.Millisecond
if initialCycle {
if initialCycle || !shouldGenerateChangesets {
// When not generating changesets (finalized blocks), we can afford longer pruning
// since we're generating less data overall
if !initialCycle {
logger.Debug(fmt.Sprintf("[%s] aggressive pruning via forkchoice finality", execStage.LogPrefix()),
"block", blockNum, "finalizedBlock", finalizedBlockNum)
}
pruneTimeout = 10 * time.Hour

if err = executor.tx().(kv.TemporalRwTx).GreedyPruneHistory(ctx, kv.CommitmentDomain); err != nil {
Expand All @@ -787,7 +798,9 @@ Loop:
errExhausted = &ErrLoopExhausted{From: startBlockNum, To: blockNum, Reason: "block batch is full"}
break Loop
}
if !initialCycle && canPrune {
// Skip pruning break if not generating changesets (finalized blocks via UseForkchoiceFinality)
// This allows larger batches similar to initialCycle behavior
if !initialCycle && canPrune && shouldGenerateChangesets {
errExhausted = &ErrLoopExhausted{From: startBlockNum, To: blockNum, Reason: "block batch can be pruned"}
break Loop
}
Expand Down Expand Up @@ -1021,7 +1034,21 @@ func blockWithSenders(ctx context.Context, db kv.RoDB, tx kv.Tx, blockReader ser
return b, err
}

func shouldGenerateChangeSets(cfg ExecuteBlockCfg, blockNum, maxBlockNum uint64, initialCycle bool) bool {
// getFinalizedBlockNum returns the finalized block number from forkchoice state.
// Returns 0 if no finalized block is set or if the hash cannot be resolved to a block number.
func getFinalizedBlockNum(tx kv.Getter) uint64 {
finalizedHash := rawdb.ReadForkchoiceFinalized(tx)
if finalizedHash == (common.Hash{}) {
return 0
}
finalizedNum := rawdb.ReadHeaderNumber(tx, finalizedHash)
if finalizedNum == nil {
return 0
}
return *finalizedNum
}

func shouldGenerateChangeSets(cfg ExecuteBlockCfg, finalizedBlockNum, blockNum, maxBlockNum uint64, initialCycle bool) bool {
if cfg.syncCfg.AlwaysGenerateChangesets {
return true
}
Expand All @@ -1031,6 +1058,15 @@ func shouldGenerateChangeSets(cfg ExecuteBlockCfg, blockNum, maxBlockNum uint64,
if initialCycle {
return false
}
// once past the initial cycle, make sure to generate changesets for the last blocks that fall in the reorg window

// Use forkchoice finalized block if enabled and available (e.g., Polygon milestones).
// Blocks at or before the finalized block don't need changesets since they cannot be reorged.
// WARNING: If finality is later reverted (e.g., faulty milestone purged by hard fork),
// the node will require a chaindata reset to recover.
if cfg.syncCfg.UseForkchoiceFinality && finalizedBlockNum > 0 && blockNum <= finalizedBlockNum {
return false
}

// Fallback: generate changesets for blocks in the reorg window (last MaxReorgDepth blocks)
return blockNum+cfg.syncCfg.MaxReorgDepth >= maxBlockNum
}
112 changes: 112 additions & 0 deletions execution/stagedsync/exec3_changeset_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package stagedsync

import (
"testing"

"github.com/stretchr/testify/assert"

"github.com/erigontech/erigon/eth/ethconfig"
"github.com/erigontech/erigon/turbo/services"
)

// mockBlockReader implements services.FullBlockReader with only FrozenBlocks() used.
type mockBlockReader struct {
services.FullBlockReader
frozenBlocks uint64
}

func (m *mockBlockReader) FrozenBlocks() uint64 {
return m.frozenBlocks
}

func TestShouldGenerateChangeSets(t *testing.T) {
tests := []struct {
name string
alwaysGenerate bool
frozenBlocks uint64
initialCycle bool
useFinality bool
finalizedBlockNum uint64
maxReorgDepth uint64
blockNum uint64
maxBlockNum uint64
want bool
}{
{
name: "AlwaysGenerateChangesets overrides everything",
alwaysGenerate: true,
frozenBlocks: 1000,
blockNum: 500, // below frozen
maxBlockNum: 2000,
want: true,
},
{
name: "block below frozen returns false",
frozenBlocks: 1000,
blockNum: 999,
maxBlockNum: 2000,
want: false,
},
{
name: "initialCycle returns false",
initialCycle: true,
blockNum: 1500,
maxBlockNum: 2000,
want: false,
},
{
name: "UseForkchoiceFinality: block below finalized returns false",
useFinality: true,
finalizedBlockNum: 1000,
blockNum: 900,
maxBlockNum: 2000,
want: false,
},
{
name: "UseForkchoiceFinality: block equal to finalized returns false",
useFinality: true,
finalizedBlockNum: 1000,
blockNum: 1000,
maxBlockNum: 2000,
want: false,
},
{
name: "UseForkchoiceFinality: block above finalized falls through to MaxReorgDepth",
useFinality: true,
finalizedBlockNum: 1000,
maxReorgDepth: 100,
blockNum: 1001,
maxBlockNum: 2000,
want: false, // 1001 + 100 = 1101 < 2000
},
{
name: "MaxReorgDepth: block in reorg window returns true",
maxReorgDepth: 100,
blockNum: 1950,
maxBlockNum: 2000,
want: true, // 1950 + 100 = 2050 >= 2000
},
{
name: "MaxReorgDepth: block outside reorg window returns false",
maxReorgDepth: 100,
blockNum: 1800,
maxBlockNum: 2000,
want: false, // 1800 + 100 = 1900 < 2000
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cfg := ExecuteBlockCfg{
blockReader: &mockBlockReader{frozenBlocks: tt.frozenBlocks},
syncCfg: ethconfig.Sync{
AlwaysGenerateChangesets: tt.alwaysGenerate,
UseForkchoiceFinality: tt.useFinality,
MaxReorgDepth: tt.maxReorgDepth,
},
}
got := shouldGenerateChangeSets(cfg, tt.finalizedBlockNum, tt.blockNum, tt.maxBlockNum, tt.initialCycle)
assert.Equal(t, tt.want, got, "shouldGenerateChangeSets(%d, %d, %d)", tt.blockNum, tt.maxBlockNum, tt.initialCycle)
})
}
}
6 changes: 5 additions & 1 deletion execution/stagedsync/exec3_parallel.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,11 @@ func (te *txExecutor) getHeader(ctx context.Context, hash common.Hash, number ui
}

func (te *txExecutor) shouldGenerateChangeSets() bool {
return shouldGenerateChangeSets(te.cfg, te.inputBlockNum.Load(), te.maxBlockNum, te.initialCycle)
var finalizedBlockNum uint64
if te.cfg.syncCfg.UseForkchoiceFinality {
finalizedBlockNum = getFinalizedBlockNum(te.applyTx)
}
return shouldGenerateChangeSets(te.cfg, finalizedBlockNum, te.inputBlockNum.Load(), te.maxBlockNum, te.initialCycle)
}

type parallelExecutor struct {
Expand Down
77 changes: 75 additions & 2 deletions polygon/sync/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,15 +143,32 @@ type Sync struct {
wiggleCalculator wiggleCalculator
engineAPISwitcher EngineAPISwitcher
blockRequestsCache *lru.ARCCache[common.Hash, struct{}]

// lastFinalizedBlockNum tracks the end block of the last validated finality
// waypoint (milestone or checkpoint). This is used to set the finalized block
// in forkchoice updates, enabling the execution stage to skip changeset
// generation for finalized blocks.
lastFinalizedBlockNum uint64
}

func (s *Sync) commitExecution(ctx context.Context, newTip *types.Header, finalizedHeader *types.Header) error {
if err := s.store.Flush(ctx); err != nil {
return err
}

blockNum := newTip.Number.Uint64()
// After flush, improve finalized header if possible.
// If newTip is at or before the last known milestone, it IS finalized.
tipNum := newTip.Number.Uint64()
if s.lastFinalizedBlockNum > 0 && tipNum <= s.lastFinalizedBlockNum {
finalizedHeader = newTip
} else if s.lastFinalizedBlockNum > 0 {
// Try to get the milestone end block header (now available after flush)
if h, err := s.execution.GetHeader(ctx, s.lastFinalizedBlockNum); err == nil && h != nil {
finalizedHeader = h
}
}

blockNum := newTip.Number.Uint64()
age := common.PrettyAge(time.Unix(int64(newTip.Time), 0))
s.logger.Info(syncLogPrefix("update fork choice"), "block", blockNum, "hash", newTip.Hash(), "age", age)
fcStartTime := time.Now()
Expand Down Expand Up @@ -251,11 +268,22 @@ func (s *Sync) handleMilestoneTipMismatch(ctx context.Context, ccb *CanonicalCha
func (s *Sync) applyNewMilestoneOnTip(ctx context.Context, event EventNewMilestone, ccb *CanonicalChainBuilder) error {
milestone := event
if milestone.EndBlock().Uint64() <= ccb.Root().Number.Uint64() {
s.logger.Debug(syncLogPrefix("skipping milestone - already behind root"),
"milestoneEnd", milestone.EndBlock().Uint64(),
"ccbRoot", ccb.Root().Number.Uint64(),
)
return nil
}

// milestone is ahead of our current tip
if milestone.EndBlock().Uint64() > ccb.Tip().Number.Uint64() {
// Track finality even for future milestones - the milestone IS finality from Heimdall.
// This lets commitExecution() set finalizedHeader = newTip for blocks within this range.
endBlock := milestone.EndBlock().Uint64()
if endBlock > s.lastFinalizedBlockNum {
s.lastFinalizedBlockNum = endBlock
}

s.logger.Debug(syncLogPrefix("putting milestone event back in the queue because our tip is behind the milestone"),
"milestoneId", milestone.RawId(),
"milestoneStart", milestone.StartBlock().Uint64(),
Expand Down Expand Up @@ -291,6 +319,15 @@ func (s *Sync) applyNewMilestoneOnTip(ctx context.Context, event EventNewMilesto
if endBlock > 0 {
pruneTo = endBlock - 1
}

// Track the milestone end block for forkchoice finality.
// This enables the execution stage to skip changeset generation for finalized blocks.
// Use max to avoid lowering the value when an older at-tip milestone is processed
// after a newer ahead-of-tip milestone has already been recorded.
if endBlock > s.lastFinalizedBlockNum {
s.lastFinalizedBlockNum = endBlock
}

return ccb.PruneRoot(pruneTo)
}

Expand Down Expand Up @@ -869,6 +906,7 @@ func (s *Sync) Run(ctx context.Context) error {

if s.config.PolygonPosSingleSlotFinality {
if result.latestTip.Number.Uint64() >= s.config.PolygonPosSingleSlotFinalityBlockAt {
s.logger.Info(syncLogPrefix("switching to engine API mode (SSF)"), "tip", result.latestTip.Number.Uint64())
s.engineAPISwitcher.SetConsuming(true)
return nil
}
Expand Down Expand Up @@ -949,6 +987,9 @@ func (s *Sync) initialiseCcb(ctx context.Context, result syncToTipResult) (*Cano
if result.latestWaypoint.EndBlock().Uint64() > tipNum {
return nil, fmt.Errorf("unexpected rootNum > tipNum: %d > %d", rootNum, tipNum)
}
// Initialize lastFinalizedBlockNum from the latest waypoint (milestone or checkpoint)
s.lastFinalizedBlockNum = rootNum
s.logger.Debug(syncLogPrefix("initialized milestone finality"), "lastFinalizedBlock", s.lastFinalizedBlockNum)
}

s.logger.Debug(syncLogPrefix("initialising canonical chain builder"), "rootNum", rootNum, "tipNum", tipNum)
Expand Down Expand Up @@ -986,6 +1027,7 @@ type syncToTipResult struct {
}

func (s *Sync) syncToTip(ctx context.Context) (syncToTipResult, error) {
s.logger.Debug(syncLogPrefix("syncToTip starting"))
latestTipOnStart, err := s.execution.CurrentHeader(ctx)
if err != nil {
return syncToTipResult{}, err
Expand Down Expand Up @@ -1046,6 +1088,31 @@ func (s *Sync) syncToTip(ctx context.Context) (syncToTipResult, error) {
}
}

// If we didn't get a waypoint from sync (e.g., already at tip), fetch the latest milestone
// This ensures we have milestone finality info for the execution stage optimization
if finalisedTip.latestWaypoint == nil {
s.logger.Info(syncLogPrefix("no waypoint from sync, fetching latest milestone..."))
latestMilestone, ok, err := s.heimdallSync.SynchronizeMilestones(ctx)
if err != nil {
s.logger.Warn(syncLogPrefix("failed to get latest milestone for finality"), "err", err)
} else if ok && latestMilestone != nil {
finalisedTip.latestWaypoint = latestMilestone
s.logger.Info(syncLogPrefix("fetched latest milestone for finality"),
"milestoneEndBlock", latestMilestone.EndBlock().Uint64(),
)
} else {
s.logger.Warn(syncLogPrefix("SynchronizeMilestones returned no milestone"), "ok", ok, "milestoneNil", latestMilestone == nil)
}
} else {
s.logger.Info(syncLogPrefix("waypoint from sync available"),
"waypointEndBlock", finalisedTip.latestWaypoint.EndBlock().Uint64(),
)
}

s.logger.Info(syncLogPrefix("syncToTip finished"),
"tipNum", finalisedTip.latestTip.Number.Uint64(),
"hasWaypoint", finalisedTip.latestWaypoint != nil,
)
return finalisedTip, nil
}

Expand Down Expand Up @@ -1091,8 +1158,14 @@ func (s *Sync) sync(
return syncToTipResult{}, false, nil
}

// Track the waypoint end block for forkchoice finality
waypointEndBlock := waypoint.EndBlock().Uint64()
if waypointEndBlock > s.lastFinalizedBlockNum {
s.lastFinalizedBlockNum = waypointEndBlock
}

// notify about latest waypoint end block so that eth_syncing API doesn't flicker on initial sync
s.notifications.NewLastBlockSeen(waypoint.EndBlock().Uint64())
s.notifications.NewLastBlockSeen(waypointEndBlock)

newTip, err := blockDownload(ctx, tip.Number.Uint64()+1, syncTo)
if err != nil {
Expand Down
Loading