From 22c98ab6292bbfdc561730e51656939540a55791 Mon Sep 17 00:00:00 2001 From: inphi Date: Mon, 2 Feb 2026 17:34:43 -0500 Subject: [PATCH 1/2] supernode: Handle genesis L2 block in L1AtSafeHead lookup Add special case in L1AtSafeHead to return genesis L1 directly when querying for the genesis L2 block, avoiding unnecessary SafeDB walkback that would otherwise fail at genesis boundary. Co-Authored-By: Claude Opus 4.5 --- .../virtual_node/virtual_node.go | 6 + .../virtual_node/virtual_node_test.go | 175 ++++++++++++++++++ 2 files changed, 181 insertions(+) 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 7da188e2e36..cce8ab9191f 100644 --- a/op-supernode/supernode/chain_container/virtual_node/virtual_node.go +++ b/op-supernode/supernode/chain_container/virtual_node/virtual_node.go @@ -224,6 +224,12 @@ func (v *simpleVirtualNode) L1AtSafeHead(ctx context.Context, target eth.BlockID if db == nil { return eth.BlockID{}, ErrVirtualNodeNotRunning } + + // Special case: genesis L2 block is trivially safe at genesis L1 + if target.Number == v.cfg.Rollup.Genesis.L2.Number { + return v.cfg.Rollup.Genesis.L1, nil + } + // Get the latest entry to start the walkback latestL1, latestL2, err := db.SafeHeadAtL1(ctx, math.MaxUint64-1) if err != nil { 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 1e57d197f9e..481717d2d11 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 @@ -65,6 +65,51 @@ func (m *mockInnerNode) SafeDB() rollupNode.SafeDBReader { return m.db } func (m *mockInnerNode) SyncStatus() *eth.SyncStatus { return ð.SyncStatus{} } +// mockSafeDBReader is a mock implementation of SafeDBReader for testing L1AtSafeHead +type mockSafeDBReader struct { + // entries maps L1 block number to (L1 BlockID, L2 BlockID) + entries map[uint64]struct { + l1 eth.BlockID + l2 eth.BlockID + } +} + +func newMockSafeDBReader() *mockSafeDBReader { + return &mockSafeDBReader{ + entries: make(map[uint64]struct { + l1 eth.BlockID + l2 eth.BlockID + }), + } +} + +func (m *mockSafeDBReader) addEntry(l1Num uint64, l1Hash, l2Hash [32]byte, l2Num uint64) { + m.entries[l1Num] = struct { + l1 eth.BlockID + l2 eth.BlockID + }{ + l1: eth.BlockID{Number: l1Num, Hash: l1Hash}, + l2: eth.BlockID{Number: l2Num, Hash: l2Hash}, + } +} + +func (m *mockSafeDBReader) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (eth.BlockID, eth.BlockID, error) { + // Find the entry at or before l1BlockNum + var best uint64 + found := false + for num := range m.entries { + if num <= l1BlockNum && (!found || num > best) { + best = num + found = true + } + } + if !found { + return eth.BlockID{}, eth.BlockID{}, errors.New("no entry found") + } + entry := m.entries[best] + return entry.l1, entry.l2, nil +} + // Test helpers func createTestConfig() *opnodecfg.Config { return &opnodecfg.Config{ @@ -371,3 +416,133 @@ func TestVirtualNode_InnerNodeIntegration(t *testing.T) { cancel() }) } + +// TestVirtualNode_L1AtSafeHead tests the L1AtSafeHead function +func TestVirtualNode_L1AtSafeHead(t *testing.T) { + t.Parallel() + + genesisL1 := eth.BlockID{Number: 100, Hash: [32]byte{0x01}} + genesisL2 := eth.BlockID{Number: 0, Hash: [32]byte{0x02}} + + createConfigWithGenesis := func() *opnodecfg.Config { + return &opnodecfg.Config{ + Rollup: rollup.Config{ + L2ChainID: big.NewInt(420), + Genesis: rollup.Genesis{ + L1: genesisL1, + L2: genesisL2, + }, + }, + } + } + + t.Run("returns error when inner node is nil", func(t *testing.T) { + cfg := createConfigWithGenesis() + log := createTestLogger() + vn := NewVirtualNode(cfg, log, nil, "test") + + _, err := vn.L1AtSafeHead(context.Background(), eth.BlockID{Number: 10}) + require.ErrorIs(t, err, ErrVirtualNodeNotRunning) + }) + + t.Run("returns error when SafeDB is nil", func(t *testing.T) { + cfg := createConfigWithGenesis() + log := createTestLogger() + vn := NewVirtualNode(cfg, log, nil, "test") + + mock := newMockInnerNode() + mock.db = nil + vn.inner = mock + vn.state = VNStateRunning + + _, err := vn.L1AtSafeHead(context.Background(), eth.BlockID{Number: 10}) + require.ErrorIs(t, err, ErrVirtualNodeNotRunning) + }) + + t.Run("genesis L2 target returns genesis L1 directly", func(t *testing.T) { + cfg := createConfigWithGenesis() + log := createTestLogger() + vn := NewVirtualNode(cfg, log, nil, "test") + + // Set up mock with SafeDB - but it shouldn't be called for genesis + mockDB := newMockSafeDBReader() + mock := newMockInnerNode() + mock.db = mockDB + vn.inner = mock + vn.state = VNStateRunning + + // Query for genesis L2 block + result, err := vn.L1AtSafeHead(context.Background(), genesisL2) + require.NoError(t, err) + require.Equal(t, genesisL1, result) + }) + + t.Run("genesis L2 target returns genesis L1 even with matching number but different hash", func(t *testing.T) { + cfg := createConfigWithGenesis() + log := createTestLogger() + vn := NewVirtualNode(cfg, log, nil, "test") + + mockDB := newMockSafeDBReader() + mock := newMockInnerNode() + mock.db = mockDB + vn.inner = mock + vn.state = VNStateRunning + + // Query with same number as genesis but different hash + // The current implementation only checks number, so this should still return genesis L1 + target := eth.BlockID{Number: genesisL2.Number, Hash: [32]byte{0xff}} + result, err := vn.L1AtSafeHead(context.Background(), target) + require.NoError(t, err) + require.Equal(t, genesisL1, result) + }) + + t.Run("non-genesis target uses walkback to find earliest L1", func(t *testing.T) { + cfg := createConfigWithGenesis() + log := createTestLogger() + vn := NewVirtualNode(cfg, log, nil, "test") + + mockDB := newMockSafeDBReader() + // Set up entries: L1 block -> L2 safe head + // L1=100 (genesis) -> L2=0 + // L1=101 -> L2=5 + // L1=102 -> L2=10 + // L1=103 -> L2=15 + // L1=104 -> L2=20 + mockDB.addEntry(100, [32]byte{0x01}, [32]byte{0x02}, 0) + mockDB.addEntry(101, [32]byte{0x03}, [32]byte{0x04}, 5) + mockDB.addEntry(102, [32]byte{0x05}, [32]byte{0x06}, 10) + mockDB.addEntry(103, [32]byte{0x07}, [32]byte{0x08}, 15) + mockDB.addEntry(104, [32]byte{0x09}, [32]byte{0x0a}, 20) + + mock := newMockInnerNode() + mock.db = mockDB + vn.inner = mock + vn.state = VNStateRunning + + // Query for L2 block 10 - should return L1=102 (earliest L1 where L2 safe head >= 10) + target := eth.BlockID{Number: 10, Hash: [32]byte{0x06}} + result, err := vn.L1AtSafeHead(context.Background(), target) + require.NoError(t, err) + require.Equal(t, uint64(102), result.Number) + }) + + t.Run("target beyond latest returns error", func(t *testing.T) { + cfg := createConfigWithGenesis() + log := createTestLogger() + vn := NewVirtualNode(cfg, log, nil, "test") + + mockDB := newMockSafeDBReader() + mockDB.addEntry(100, [32]byte{0x01}, [32]byte{0x02}, 0) + mockDB.addEntry(101, [32]byte{0x03}, [32]byte{0x04}, 5) + + mock := newMockInnerNode() + mock.db = mockDB + vn.inner = mock + vn.state = VNStateRunning + + // Query for L2 block 100 - beyond latest L2 safe head (5) + target := eth.BlockID{Number: 100, Hash: [32]byte{}} + _, err := vn.L1AtSafeHead(context.Background(), target) + require.ErrorIs(t, err, ErrL1AtSafeHeadNotFound) + }) +} From e0d32eed2a239183afd3671818ed8b2afc3126d7 Mon Sep 17 00:00:00 2001 From: inphi Date: Tue, 3 Feb 2026 13:53:46 -0500 Subject: [PATCH 2/2] for the genesis case; check the entire blockid --- .../chain_container/virtual_node/virtual_node.go | 2 +- .../chain_container/virtual_node/virtual_node_test.go | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) 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 cce8ab9191f..576d0efe9bc 100644 --- a/op-supernode/supernode/chain_container/virtual_node/virtual_node.go +++ b/op-supernode/supernode/chain_container/virtual_node/virtual_node.go @@ -226,7 +226,7 @@ func (v *simpleVirtualNode) L1AtSafeHead(ctx context.Context, target eth.BlockID } // Special case: genesis L2 block is trivially safe at genesis L1 - if target.Number == v.cfg.Rollup.Genesis.L2.Number { + if target == v.cfg.Rollup.Genesis.L2 { return v.cfg.Rollup.Genesis.L1, nil } 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 481717d2d11..810dc016b23 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 @@ -477,7 +477,7 @@ func TestVirtualNode_L1AtSafeHead(t *testing.T) { require.Equal(t, genesisL1, result) }) - t.Run("genesis L2 target returns genesis L1 even with matching number but different hash", func(t *testing.T) { + t.Run("genesis L2 number with different hash is not treated as genesis", func(t *testing.T) { cfg := createConfigWithGenesis() log := createTestLogger() vn := NewVirtualNode(cfg, log, nil, "test") @@ -489,11 +489,11 @@ func TestVirtualNode_L1AtSafeHead(t *testing.T) { vn.state = VNStateRunning // Query with same number as genesis but different hash - // The current implementation only checks number, so this should still return genesis L1 + // Should NOT match genesis since both number AND hash must match target := eth.BlockID{Number: genesisL2.Number, Hash: [32]byte{0xff}} - result, err := vn.L1AtSafeHead(context.Background(), target) - require.NoError(t, err) - require.Equal(t, genesisL1, result) + _, err := vn.L1AtSafeHead(context.Background(), target) + // Returns error because mockDB is empty and walkback fails + require.Error(t, err) }) t.Run("non-genesis target uses walkback to find earliest L1", func(t *testing.T) {