Skip to content
Merged
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
3 changes: 3 additions & 0 deletions op-node/node/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ 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.
L1AtSafeHead(ctx context.Context, targetL2Num uint64) (l1 eth.BlockID, safeHead eth.BlockID, err error)
}

type adminAPI struct {
Expand Down
5 changes: 5 additions & 0 deletions op-node/node/safedb/disabled.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
62 changes: 59 additions & 3 deletions op-node/node/safedb/safedb.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ var (
ErrNotFound = errors.New("not found")
ErrInvalidEntry = errors.New("invalid db entry")
ErrClosed = errors.New("safe db closed")

// ErrL1AtSafeHeadNotFound is transient: target above latest, or DB empty.
ErrL1AtSafeHeadNotFound = errors.New("l1 at safe head not found")

// ErrL1AtSafeHeadUnavailable is permanent: target predates recorded history.
ErrL1AtSafeHeadUnavailable = errors.New("l1 at safe head history unavailable")
)

const (
Expand Down Expand Up @@ -187,15 +193,65 @@ 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()
l1Block, safeHead, err = decodeEntry(iter)
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. 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()
if d.closed {
err = ErrClosed
return
}
iter, err := d.db.NewIterWithContext(ctx, safeByL1BlockNumKey.IterRange())
if err != nil {
return
}
l1Block, safeHead, err = decodeSafeByL1BlockNum(iter.Key(), val)
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()
Expand Down
69 changes: 69 additions & 0 deletions op-node/node/safedb/safedb_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -302,6 +302,75 @@ 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) {
// 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)
require.Equal(t, l2a.ID(), l2)
})

t.Run("TargetBetweenEntries", func(t *testing.T) {
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) {
_, _, 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++ {
Expand Down
9 changes: 9 additions & 0 deletions op-node/node/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
107 changes: 17 additions & 90 deletions op-supernode/supernode/chain_container/virtual_node/virtual_node.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package virtual_node
import (
"context"
"errors"
"math"
"sync"
"time"

Expand Down Expand Up @@ -212,17 +211,14 @@ 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")

// 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")
// Re-exported from safedb for callers that still reference these via virtual_node.
var (
ErrL1AtSafeHeadNotFound = safedb.ErrL1AtSafeHeadNotFound
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, delegating the lookup to SafeDB.
func (v *simpleVirtualNode) L1AtSafeHead(ctx context.Context, target eth.BlockID) (eth.BlockID, error) {
v.mu.Lock()
inner := v.inner
Expand All @@ -235,91 +231,22 @@ 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
// 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 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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"errors"
"math/big"
"regexp"
"sort"
"testing"
"time"

Expand Down Expand Up @@ -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{
Expand Down
Loading