From ba23b0be134dad59eebef364021c8fdae8afe382 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 19 May 2026 05:45:17 +1000 Subject: [PATCH 1/3] refactor(safedb): move L1AtSafeHead lookup into SafeDB The walkback that resolves "earliest L1 at which an L2 block became safe" previously lived in op-supernode/virtual_node and went through the SafeHeadAtL1 point-query interface, requiring repeated SeekLT lookups and a special case for the earliest recorded entry (the cursorL2 == target exact-BlockID branch). That special case is unreachable from callers that only know the target L2 number, which is why FirstProvableSafeHeadNumber in #20833 has to advance the target by +1. Push the lookup into SafeDB itself: it iterates with a single Pebble cursor (Last + Prev) and returns the first entry meeting target directly. SafeDB only writes entries when the deriver actually advances the safe head, so an entry's L1 is the canonical L1 at which that L2 became safe; the new method can therefore answer target == firstL2 without any +1 gymnastics. The two error semantics live in the safedb package now: - ErrL1AtSafeHeadNotFound (transient: empty DB or target > latestL2) - ErrL1AtSafeHeadUnavailable (permanent: target < firstL2, predates records) simpleVirtualNode.L1AtSafeHead now delegates to db.L1AtSafeHead and keeps only the rollup-genesis special case (which SafeDB can't know about). Replaces the walkback in #20833 / #20581 with a single iterator pass. --- op-node/node/api.go | 4 + op-node/node/safedb/disabled.go | 5 + op-node/node/safedb/safedb.go | 74 ++++++++++++ op-node/node/safedb/safedb_test.go | 74 ++++++++++++ op-node/node/server_test.go | 9 ++ .../virtual_node/virtual_node.go | 110 ++++-------------- .../virtual_node/virtual_node_test.go | 32 +++++ 7 files changed, 219 insertions(+), 89 deletions(-) diff --git a/op-node/node/api.go b/op-node/node/api.go index 413896f443b..0c835958954 100644 --- a/op-node/node/api.go +++ b/op-node/node/api.go @@ -44,6 +44,10 @@ type driverClient interface { type SafeDBReader interface { SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (l1 eth.BlockID, l2 eth.BlockID, err error) + // L1AtSafeHead returns the earliest L1 block at which the recorded L2 safe + // head reached at least targetL2Num. See safedb.L1AtSafeHead for the full + // contract and error semantics. + L1AtSafeHead(ctx context.Context, targetL2Num uint64) (l1 eth.BlockID, safeHead eth.BlockID, err error) } type adminAPI struct { diff --git a/op-node/node/safedb/disabled.go b/op-node/node/safedb/disabled.go index 09ff5cf2433..b6f9d076738 100644 --- a/op-node/node/safedb/disabled.go +++ b/op-node/node/safedb/disabled.go @@ -31,6 +31,11 @@ func (d *DisabledDB) SafeHeadReset(_ eth.L2BlockRef) error { return nil } +func (d *DisabledDB) L1AtSafeHead(_ context.Context, _ uint64) (l1 eth.BlockID, safeHead eth.BlockID, err error) { + err = ErrNotEnabled + return +} + func (d *DisabledDB) Close() error { return nil } diff --git a/op-node/node/safedb/safedb.go b/op-node/node/safedb/safedb.go index 0530a476a3b..1c900d720a0 100644 --- a/op-node/node/safedb/safedb.go +++ b/op-node/node/safedb/safedb.go @@ -18,6 +18,16 @@ var ( ErrNotFound = errors.New("not found") ErrInvalidEntry = errors.New("invalid db entry") ErrClosed = errors.New("safe db closed") + + // ErrL1AtSafeHeadNotFound is transient: SafeDB hasn't observed the target + // yet (DB empty, or target above the latest recorded L2 safe head). + // Callers should back off and retry. + ErrL1AtSafeHeadNotFound = errors.New("l1 at safe head not found") + + // ErrL1AtSafeHeadUnavailable is permanent on this node: the transition + // into the requested L2 safe head predates the first recorded entry, so + // it cannot be recovered by retrying. + ErrL1AtSafeHeadUnavailable = errors.New("l1 at safe head history unavailable") ) const ( @@ -196,6 +206,70 @@ func (d *SafeDB) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (l1Block e return } +// L1AtSafeHead returns the earliest L1 block at which the recorded L2 safe head +// reached at least targetL2Num, along with the L2 safe head recorded at that L1. +// +// SafeDB records (L1 source, L2 safe head) pairs only when the deriver actually +// advances the safe head, so an entry's L1 is the canonical L1 at which that L2 +// became safe. This method walks the recorded entries (latest -> earliest) using +// a single Pebble iterator and stops as soon as a strictly-lower predecessor is +// found, which proves the cursor's L1 is the first to meet target. +// +// Returns ErrL1AtSafeHeadNotFound when the DB is empty or target is ahead of the +// latest recorded entry (transient — retry). Returns ErrL1AtSafeHeadUnavailable +// when target is below the first recorded entry (permanent on this node — the +// transition predates available history). +func (d *SafeDB) L1AtSafeHead(ctx context.Context, targetL2Num uint64) (l1 eth.BlockID, safeHead eth.BlockID, err error) { + d.m.RLock() + defer d.m.RUnlock() + if d.closed { + err = ErrClosed + return + } + iter, err := d.db.NewIterWithContext(ctx, safeByL1BlockNumKey.IterRange()) + if err != nil { + return + } + defer iter.Close() + + if valid := iter.Last(); !valid { + err = ErrL1AtSafeHeadNotFound + return + } + cursorL1, cursorL2, err := decodeEntry(iter) + if err != nil { + return + } + if targetL2Num > cursorL2.Number { + err = ErrL1AtSafeHeadNotFound + return + } + for iter.Prev() { + prevL1, prevL2, derr := decodeEntry(iter) + if derr != nil { + err = derr + return + } + if prevL2.Number < targetL2Num { + return cursorL1, cursorL2, nil + } + cursorL1, cursorL2 = prevL1, prevL2 + } + if cursorL2.Number == targetL2Num { + return cursorL1, cursorL2, nil + } + err = ErrL1AtSafeHeadUnavailable + return +} + +func decodeEntry(iter *pebble.Iterator) (eth.BlockID, eth.BlockID, error) { + val, err := iter.ValueAndErr() + if err != nil { + return eth.BlockID{}, eth.BlockID{}, err + } + return decodeSafeByL1BlockNum(iter.Key(), val) +} + func (d *SafeDB) Close() error { d.m.Lock() defer d.m.Unlock() diff --git a/op-node/node/safedb/safedb_test.go b/op-node/node/safedb/safedb_test.go index 03fae503c0f..edad9b6a30f 100644 --- a/op-node/node/safedb/safedb_test.go +++ b/op-node/node/safedb/safedb_test.go @@ -302,6 +302,80 @@ func TestTruncateOnSafeHeadReset_AfterLastEntry(t *testing.T) { verifySafeHeads() } +func TestL1AtSafeHead(t *testing.T) { + logger := testlog.Logger(t, log.LvlInfo) + dir := t.TempDir() + db, err := NewSafeDB(logger, dir) + require.NoError(t, err) + defer db.Close() + + l1a := eth.BlockID{Hash: common.Hash{0x01, 0xaa}, Number: 100} + l1b := eth.BlockID{Hash: common.Hash{0x01, 0xbb}, Number: 110} + l1c := eth.BlockID{Hash: common.Hash{0x01, 0xcc}, Number: 120} + l2a := eth.L2BlockRef{Hash: common.Hash{0x02, 0xaa}, Number: 500} + l2b := eth.L2BlockRef{Hash: common.Hash{0x02, 0xbb}, Number: 510} + l2c := eth.L2BlockRef{Hash: common.Hash{0x02, 0xcc}, Number: 520} + + t.Run("EmptyDB", func(t *testing.T) { + _, _, err := db.L1AtSafeHead(context.Background(), 500) + require.ErrorIs(t, err, ErrL1AtSafeHeadNotFound) + }) + + require.NoError(t, db.SafeHeadUpdated(l2a, l1a)) + require.NoError(t, db.SafeHeadUpdated(l2b, l1b)) + require.NoError(t, db.SafeHeadUpdated(l2c, l1c)) + + t.Run("TargetEqualsFirstL2", func(t *testing.T) { + // SafeDB only records real (L1 source -> new L2 safe head) transitions, + // so the first entry's L1 is the L1 at which firstL2 became safe. + // This is the case the previous walkback algorithm could not answer + // without a +1 hack on the caller side. + l1, l2, err := db.L1AtSafeHead(context.Background(), l2a.Number) + require.NoError(t, err) + require.Equal(t, l1a, l1) + require.Equal(t, l2a.ID(), l2) + }) + + t.Run("TargetBetweenEntries", func(t *testing.T) { + // The first L1 at which L2 >= 505 became safe is l1b (where L2 jumped from 500 to 510). + l1, l2, err := db.L1AtSafeHead(context.Background(), 505) + require.NoError(t, err) + require.Equal(t, l1b, l1) + require.Equal(t, l2b.ID(), l2) + }) + + t.Run("TargetEqualsRecordedL2", func(t *testing.T) { + l1, l2, err := db.L1AtSafeHead(context.Background(), l2b.Number) + require.NoError(t, err) + require.Equal(t, l1b, l1) + require.Equal(t, l2b.ID(), l2) + }) + + t.Run("TargetEqualsLatestL2", func(t *testing.T) { + l1, l2, err := db.L1AtSafeHead(context.Background(), l2c.Number) + require.NoError(t, err) + require.Equal(t, l1c, l1) + require.Equal(t, l2c.ID(), l2) + }) + + t.Run("TargetAboveLatest", func(t *testing.T) { + _, _, err := db.L1AtSafeHead(context.Background(), l2c.Number+1) + require.ErrorIs(t, err, ErrL1AtSafeHeadNotFound) + }) + + t.Run("TargetBelowFirst", func(t *testing.T) { + // The transition into this L2 number predates SafeDB's records and + // cannot be recovered by waiting. + _, _, err := db.L1AtSafeHead(context.Background(), l2a.Number-1) + require.ErrorIs(t, err, ErrL1AtSafeHeadUnavailable) + }) +} + +func TestL1AtSafeHead_Disabled(t *testing.T) { + _, _, err := Disabled.L1AtSafeHead(context.Background(), 500) + require.ErrorIs(t, err, ErrNotEnabled) +} + func TestKeysFollowNaturalByteOrdering(t *testing.T) { vals := []uint64{0, 1, math.MaxUint32 - 1, math.MaxUint32, math.MaxUint32 + 1, math.MaxUint64 - 1, math.MaxUint64} for i := 1; i < len(vals); i++ { diff --git a/op-node/node/server_test.go b/op-node/node/server_test.go index ca15b02161b..8b9955a398a 100644 --- a/op-node/node/server_test.go +++ b/op-node/node/server_test.go @@ -344,3 +344,12 @@ func (m *mockSafeDBReader) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) func (m *mockSafeDBReader) ExpectSafeHeadAtL1(l1BlockNum uint64, l1 eth.BlockID, safeHead eth.BlockID, err error) { m.Mock.On("SafeHeadAtL1", l1BlockNum).Return(l1, safeHead, &err) } + +func (m *mockSafeDBReader) L1AtSafeHead(ctx context.Context, targetL2Num uint64) (l1 eth.BlockID, safeHead eth.BlockID, err error) { + r := m.Mock.MethodCalled("L1AtSafeHead", targetL2Num) + return r[0].(eth.BlockID), r[1].(eth.BlockID), *r[2].(*error) +} + +func (m *mockSafeDBReader) ExpectL1AtSafeHead(targetL2Num uint64, l1 eth.BlockID, safeHead eth.BlockID, err error) { + m.Mock.On("L1AtSafeHead", targetL2Num).Return(l1, safeHead, &err) +} diff --git a/op-supernode/supernode/chain_container/virtual_node/virtual_node.go b/op-supernode/supernode/chain_container/virtual_node/virtual_node.go index 042bb1d0b06..7bd4c4513d9 100644 --- a/op-supernode/supernode/chain_container/virtual_node/virtual_node.go +++ b/op-supernode/supernode/chain_container/virtual_node/virtual_node.go @@ -3,7 +3,6 @@ package virtual_node import ( "context" "errors" - "math" "sync" "time" @@ -212,17 +211,17 @@ func (v *simpleVirtualNode) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) return db.SafeHeadAtL1(ctx, l1BlockNum) } -// ErrL1AtSafeHeadNotFound: transient — SafeDB hasn't observed the answer yet -// (target ahead of latest, or DB empty at startup). Retry. -var ErrL1AtSafeHeadNotFound = errors.New("l1 at safe head not found") +// ErrL1AtSafeHeadNotFound is re-exported from the safedb package: transient, +// SafeDB hasn't observed the target yet. Retry. +var ErrL1AtSafeHeadNotFound = safedb.ErrL1AtSafeHeadNotFound -// ErrL1AtSafeHeadUnavailable: permanent on this node — the crossing happened -// before SafeDB started recording (snap/CL-sync bootstrap), or the walkback -// reached the genesis bound. Retrying won't help; operator must intervene. -var ErrL1AtSafeHeadUnavailable = errors.New("l1 at safe head history unavailable") +// ErrL1AtSafeHeadUnavailable is re-exported from the safedb package: permanent +// on this node, the crossing predates SafeDB history. Operator must intervene. +var ErrL1AtSafeHeadUnavailable = safedb.ErrL1AtSafeHeadUnavailable -// L1AtSafeHead finds the earliest L1 block at which the provided L2 block became local safe, -// using the monotonicity of SafeDB (L2 safe head number is non-decreasing over L1). +// L1AtSafeHead returns the earliest L1 block at which the provided L2 block +// became local-safe. The lookup is delegated to SafeDB, which can iterate its +// recorded entries directly with a Pebble cursor. func (v *simpleVirtualNode) L1AtSafeHead(ctx context.Context, target eth.BlockID) (eth.BlockID, error) { v.mu.Lock() inner := v.inner @@ -235,91 +234,24 @@ func (v *simpleVirtualNode) L1AtSafeHead(ctx context.Context, target eth.BlockID return eth.BlockID{}, ErrVirtualNodeNotRunning } - // Special case: genesis L2 block is trivially safe at genesis L1 - // Note: We use L1 block 0 (not cfg.Genesis.L1) because contracts may have been deployed - // earlier than cfg.Genesis.L1, allowing dispute games with L1 heads prior to cfg.Genesis.L1 + // Special case: genesis L2 block is trivially safe at L1 block 0. + // SafeDB doesn't know about the rollup genesis, so handle it here. + // Note: We use L1 block 0 (not cfg.Genesis.L1) because contracts may have + // been deployed earlier than cfg.Genesis.L1, allowing dispute games with + // L1 heads prior to cfg.Genesis.L1. if target == v.cfg.Rollup.Genesis.L2 { - // Return L1 block 0 (L1 genesis) - l1Genesis := eth.BlockID{Number: 0} // Hash not necessary - return l1Genesis, nil + return eth.BlockID{Number: 0}, nil } - // Get the latest entry to start the walkback - latestL1, latestL2, err := db.SafeHeadAtL1(ctx, math.MaxUint64-1) + l1, _, err := db.L1AtSafeHead(ctx, target.Number) if err != nil { - // Empty DB on startup is transient; anything else is a real failure. - if errors.Is(err, safedb.ErrNotFound) { - v.log.Debug("L1AtSafeHead: SafeDB empty, no entries yet", - "target_l2_num", target.Number, "target_l2_hash", target.Hash) - return eth.BlockID{}, ErrL1AtSafeHeadNotFound - } - v.log.Debug("L1AtSafeHead: latest lookup failed", "err", err) + v.log.Debug("L1AtSafeHead: lookup failed", + "target_l2_num", target.Number, "target_l2_hash", target.Hash, "err", err) return eth.BlockID{}, err } - v.log.Debug("L1AtSafeHead: latest bounds", "latest_l1", latestL1.Number, "latest_l2_num", latestL2.Number, "latest_l2_hash", latestL2.Hash) - if latestL2.Number < target.Number { - v.log.Debug("L1AtSafeHead: target beyond latest", "latest_l2", latestL2.Number, "target", target.Number) - return eth.BlockID{}, ErrL1AtSafeHeadNotFound - } - v.log.Debug("L1AtSafeHead: target within latest", "latest_l2", latestL2.Number, "target", target.Number) - // Walk back until the cursor would drop below the target. cursor tracks - // the earliest entry we've successfully resolved; on failure it is the - // first (earliest) recorded SafeDB entry, which is the most useful piece - // of diagnostic context for the operator. - cursor := latestL1 - cursorL2 := latestL2 - genesisL1 := v.cfg.Rollup.Genesis.L1.Number - steps := 0 - for { - steps++ - if cursor.Number <= 0 || cursor.Number <= genesisL1 { - // Walkback crossed the genesis bound without ever dropping below - // target: the crossing is older than anything we have. Permanent. - v.log.Warn("L1AtSafeHead: reached genesis bound without crossing target", - "target_l2_num", target.Number, "target_l2_hash", target.Hash, - "earliest_l1", cursor.Number, "earliest_l2", cursorL2.Number, - "genesis_l1", genesisL1) - return eth.BlockID{}, ErrL1AtSafeHeadUnavailable - } - prev := cursor.Number - 1 - l1Prev, l2Prev, err := db.SafeHeadAtL1(ctx, prev) - if err != nil { - // Probed below the earliest SafeDB entry: snap/CL-sync bootstrap - // gap. If the earliest entry is the exact target, it is still a - // valid lower bound because SafeDB recorded that L2 at cursor L1. - // Otherwise the target predates available history. - // cursor is the earliest entry in the DB (nothing exists at - // or below cursor.Number - 1, which is what we just probed). - if errors.Is(err, safedb.ErrNotFound) { - if cursorL2 == target { - v.log.Debug("L1AtSafeHead: target matches earliest SafeDB entry", - "target_l2_num", target.Number, "target_l2_hash", target.Hash, - "earliest_l1", cursor.Number) - return cursor, nil - } - v.log.Warn("L1AtSafeHead: walkback ran past earliest SafeDB entry", - "target_l2_num", target.Number, "target_l2_hash", target.Hash, - "earliest_l1", cursor.Number, "earliest_l2", cursorL2.Number, - "probe_l1", prev, "genesis_l1", genesisL1) - return eth.BlockID{}, ErrL1AtSafeHeadUnavailable - } - v.log.Error("L1AtSafeHead: walkback lookup failed, stopping", - "target_l2_num", target.Number, "target_l2_hash", target.Hash, - "earliest_l1", cursor.Number, "earliest_l2", cursorL2.Number, - "probe_l1", prev, "err", err) - return eth.BlockID{}, err - } - if l2Prev.Number >= target.Number { - // Still meets or exceeds target; continue walking back - cursor = l1Prev - cursorL2 = l2Prev - continue - } - // Dropped below target; current cursor is the first that meets/exceeds - break - } - v.log.Debug("L1AtSafeHead: result", "l1", cursor, "steps", steps) - return cursor, nil + v.log.Debug("L1AtSafeHead: result", + "target_l2_num", target.Number, "target_l2_hash", target.Hash, "l1", l1) + return l1, nil } func (v *simpleVirtualNode) SyncStatus(ctx context.Context) (*eth.SyncStatus, error) { diff --git a/op-supernode/supernode/chain_container/virtual_node/virtual_node_test.go b/op-supernode/supernode/chain_container/virtual_node/virtual_node_test.go index 900e350fbe8..3d30f81083f 100644 --- a/op-supernode/supernode/chain_container/virtual_node/virtual_node_test.go +++ b/op-supernode/supernode/chain_container/virtual_node/virtual_node_test.go @@ -5,6 +5,7 @@ import ( "errors" "math/big" "regexp" + "sort" "testing" "time" @@ -111,6 +112,37 @@ func (m *mockSafeDBReader) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) return entry.l1, entry.l2, nil } +func (m *mockSafeDBReader) L1AtSafeHead(ctx context.Context, targetL2Num uint64) (eth.BlockID, eth.BlockID, error) { + if len(m.entries) == 0 { + return eth.BlockID{}, eth.BlockID{}, safedb.ErrL1AtSafeHeadNotFound + } + // Find the earliest L1 (smallest L1 num) whose recorded L2 >= target. + type rec struct { + l1Num uint64 + l1 eth.BlockID + l2 eth.BlockID + } + var sorted []rec + for num, e := range m.entries { + sorted = append(sorted, rec{l1Num: num, l1: e.l1, l2: e.l2}) + } + sort.Slice(sorted, func(i, j int) bool { return sorted[i].l1Num < sorted[j].l1Num }) + first := sorted[0] + last := sorted[len(sorted)-1] + if targetL2Num > last.l2.Number { + return eth.BlockID{}, eth.BlockID{}, safedb.ErrL1AtSafeHeadNotFound + } + if targetL2Num < first.l2.Number { + return eth.BlockID{}, eth.BlockID{}, safedb.ErrL1AtSafeHeadUnavailable + } + for _, r := range sorted { + if r.l2.Number >= targetL2Num { + return r.l1, r.l2, nil + } + } + return eth.BlockID{}, eth.BlockID{}, safedb.ErrL1AtSafeHeadNotFound +} + // Test helpers func createTestConfig() *opnodecfg.Config { return &opnodecfg.Config{ From d02e6544d52c5dea38e2382dc7d14bbb115bed2e Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 19 May 2026 05:50:01 +1000 Subject: [PATCH 2/3] trim comments --- op-node/node/api.go | 3 +-- op-node/node/safedb/safedb.go | 25 +++++-------------- op-node/node/safedb/safedb_test.go | 9 ++----- .../virtual_node/virtual_node.go | 23 +++++++---------- 4 files changed, 18 insertions(+), 42 deletions(-) diff --git a/op-node/node/api.go b/op-node/node/api.go index 0c835958954..58f5864e9e8 100644 --- a/op-node/node/api.go +++ b/op-node/node/api.go @@ -45,8 +45,7 @@ type driverClient interface { type SafeDBReader interface { SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (l1 eth.BlockID, l2 eth.BlockID, err error) // L1AtSafeHead returns the earliest L1 block at which the recorded L2 safe - // head reached at least targetL2Num. See safedb.L1AtSafeHead for the full - // contract and error semantics. + // head reached at least targetL2Num. See safedb.L1AtSafeHead. L1AtSafeHead(ctx context.Context, targetL2Num uint64) (l1 eth.BlockID, safeHead eth.BlockID, err error) } diff --git a/op-node/node/safedb/safedb.go b/op-node/node/safedb/safedb.go index 1c900d720a0..ff1239d3bf3 100644 --- a/op-node/node/safedb/safedb.go +++ b/op-node/node/safedb/safedb.go @@ -19,14 +19,10 @@ var ( ErrInvalidEntry = errors.New("invalid db entry") ErrClosed = errors.New("safe db closed") - // ErrL1AtSafeHeadNotFound is transient: SafeDB hasn't observed the target - // yet (DB empty, or target above the latest recorded L2 safe head). - // Callers should back off and retry. + // ErrL1AtSafeHeadNotFound is transient: target above latest, or DB empty. ErrL1AtSafeHeadNotFound = errors.New("l1 at safe head not found") - // ErrL1AtSafeHeadUnavailable is permanent on this node: the transition - // into the requested L2 safe head predates the first recorded entry, so - // it cannot be recovered by retrying. + // ErrL1AtSafeHeadUnavailable is permanent: target predates recorded history. ErrL1AtSafeHeadUnavailable = errors.New("l1 at safe head history unavailable") ) @@ -206,19 +202,10 @@ func (d *SafeDB) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (l1Block e return } -// L1AtSafeHead returns the earliest L1 block at which the recorded L2 safe head -// reached at least targetL2Num, along with the L2 safe head recorded at that L1. -// -// SafeDB records (L1 source, L2 safe head) pairs only when the deriver actually -// advances the safe head, so an entry's L1 is the canonical L1 at which that L2 -// became safe. This method walks the recorded entries (latest -> earliest) using -// a single Pebble iterator and stops as soon as a strictly-lower predecessor is -// found, which proves the cursor's L1 is the first to meet target. -// -// Returns ErrL1AtSafeHeadNotFound when the DB is empty or target is ahead of the -// latest recorded entry (transient — retry). Returns ErrL1AtSafeHeadUnavailable -// when target is below the first recorded entry (permanent on this node — the -// transition predates available history). +// L1AtSafeHead returns the earliest L1 block at which the recorded L2 safe +// head reached at least targetL2Num, along with the L2 safe head recorded at +// that L1. Each SafeDB entry records a real deriver-emitted transition, so +// the answer is exact when target is within recorded history. func (d *SafeDB) L1AtSafeHead(ctx context.Context, targetL2Num uint64) (l1 eth.BlockID, safeHead eth.BlockID, err error) { d.m.RLock() defer d.m.RUnlock() diff --git a/op-node/node/safedb/safedb_test.go b/op-node/node/safedb/safedb_test.go index edad9b6a30f..bf1778f2fbb 100644 --- a/op-node/node/safedb/safedb_test.go +++ b/op-node/node/safedb/safedb_test.go @@ -326,10 +326,8 @@ func TestL1AtSafeHead(t *testing.T) { require.NoError(t, db.SafeHeadUpdated(l2c, l1c)) t.Run("TargetEqualsFirstL2", func(t *testing.T) { - // SafeDB only records real (L1 source -> new L2 safe head) transitions, - // so the first entry's L1 is the L1 at which firstL2 became safe. - // This is the case the previous walkback algorithm could not answer - // without a +1 hack on the caller side. + // The first entry's L1 is a real recorded transition, so target == + // firstL2 must resolve to firstL1 without a +1 workaround. l1, l2, err := db.L1AtSafeHead(context.Background(), l2a.Number) require.NoError(t, err) require.Equal(t, l1a, l1) @@ -337,7 +335,6 @@ func TestL1AtSafeHead(t *testing.T) { }) t.Run("TargetBetweenEntries", func(t *testing.T) { - // The first L1 at which L2 >= 505 became safe is l1b (where L2 jumped from 500 to 510). l1, l2, err := db.L1AtSafeHead(context.Background(), 505) require.NoError(t, err) require.Equal(t, l1b, l1) @@ -364,8 +361,6 @@ func TestL1AtSafeHead(t *testing.T) { }) t.Run("TargetBelowFirst", func(t *testing.T) { - // The transition into this L2 number predates SafeDB's records and - // cannot be recovered by waiting. _, _, err := db.L1AtSafeHead(context.Background(), l2a.Number-1) require.ErrorIs(t, err, ErrL1AtSafeHeadUnavailable) }) diff --git a/op-supernode/supernode/chain_container/virtual_node/virtual_node.go b/op-supernode/supernode/chain_container/virtual_node/virtual_node.go index 7bd4c4513d9..c3db4e62370 100644 --- a/op-supernode/supernode/chain_container/virtual_node/virtual_node.go +++ b/op-supernode/supernode/chain_container/virtual_node/virtual_node.go @@ -211,17 +211,14 @@ func (v *simpleVirtualNode) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) return db.SafeHeadAtL1(ctx, l1BlockNum) } -// ErrL1AtSafeHeadNotFound is re-exported from the safedb package: transient, -// SafeDB hasn't observed the target yet. Retry. -var ErrL1AtSafeHeadNotFound = safedb.ErrL1AtSafeHeadNotFound - -// ErrL1AtSafeHeadUnavailable is re-exported from the safedb package: permanent -// on this node, the crossing predates SafeDB history. Operator must intervene. -var ErrL1AtSafeHeadUnavailable = safedb.ErrL1AtSafeHeadUnavailable +// Re-exported from safedb for callers that still reference these via virtual_node. +var ( + ErrL1AtSafeHeadNotFound = safedb.ErrL1AtSafeHeadNotFound + ErrL1AtSafeHeadUnavailable = safedb.ErrL1AtSafeHeadUnavailable +) // L1AtSafeHead returns the earliest L1 block at which the provided L2 block -// became local-safe. The lookup is delegated to SafeDB, which can iterate its -// recorded entries directly with a Pebble cursor. +// became local-safe, delegating the lookup to SafeDB. func (v *simpleVirtualNode) L1AtSafeHead(ctx context.Context, target eth.BlockID) (eth.BlockID, error) { v.mu.Lock() inner := v.inner @@ -234,11 +231,9 @@ func (v *simpleVirtualNode) L1AtSafeHead(ctx context.Context, target eth.BlockID return eth.BlockID{}, ErrVirtualNodeNotRunning } - // Special case: genesis L2 block is trivially safe at L1 block 0. - // SafeDB doesn't know about the rollup genesis, so handle it here. - // Note: We use L1 block 0 (not cfg.Genesis.L1) because contracts may have - // been deployed earlier than cfg.Genesis.L1, allowing dispute games with - // L1 heads prior to cfg.Genesis.L1. + // Genesis L2 is trivially safe at L1 block 0. Use 0 rather than + // cfg.Genesis.L1 because contracts may pre-date cfg.Genesis.L1, allowing + // dispute games anchored to earlier L1 heads. if target == v.cfg.Rollup.Genesis.L2 { return eth.BlockID{Number: 0}, nil } From 0a328e513ce0319a184117cb96f34f6f6a104523 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 19 May 2026 05:58:32 +1000 Subject: [PATCH 3/3] use decodeEntry in SafeHeadAtL1 --- op-node/node/safedb/safedb.go | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/op-node/node/safedb/safedb.go b/op-node/node/safedb/safedb.go index ff1239d3bf3..4956bf18ee2 100644 --- a/op-node/node/safedb/safedb.go +++ b/op-node/node/safedb/safedb.go @@ -193,12 +193,7 @@ func (d *SafeDB) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (l1Block e err = ErrNotFound return } - // Found an entry at or before the requested L1 block - val, err := iter.ValueAndErr() - if err != nil { - return - } - l1Block, safeHead, err = decodeSafeByL1BlockNum(iter.Key(), val) + l1Block, safeHead, err = decodeEntry(iter) return }