diff --git a/op-acceptance-tests/tests/superfaultproofs/superfaultproofs.go b/op-acceptance-tests/tests/superfaultproofs/superfaultproofs.go index e3d5cd82ff8e0..aafbc06078671 100644 --- a/op-acceptance-tests/tests/superfaultproofs/superfaultproofs.go +++ b/op-acceptance-tests/tests/superfaultproofs/superfaultproofs.go @@ -133,7 +133,7 @@ func optimisticBlockAtTimestamp(t devtest.T, queryAPI apis.SupernodeQueryAPI, ch t.Require().NoError(err) out, ok := resp.OptimisticAtTimestamp[chainID] t.Require().Truef(ok, "no optimistic output for chain %v at timestamp %d", chainID, timestamp) - return interopTypes.OptimisticBlock{BlockHash: out.Output.BlockRef.Hash, OutputRoot: out.Output.OutputRoot} + return interopTypes.OptimisticBlock{BlockHash: out.Output.BlockHash, OutputRoot: out.OutputRoot} } // marshalTransition serializes a transition state with the given super root, step, and progress. diff --git a/op-challenger/game/fault/trace/super/provider_supernode.go b/op-challenger/game/fault/trace/super/provider_supernode.go index cdea05fb9b00b..41ada9f842192 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode.go +++ b/op-challenger/game/fault/trace/super/provider_supernode.go @@ -129,8 +129,8 @@ func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types } expectedState.PendingProgress = append(expectedState.PendingProgress, interopTypes.OptimisticBlock{ - BlockHash: optimistic.Output.BlockRef.Hash, - OutputRoot: optimistic.Output.OutputRoot, + BlockHash: optimistic.Output.BlockHash, + OutputRoot: optimistic.OutputRoot, }) } return expectedState.Marshal(), nil diff --git a/op-challenger/game/fault/trace/super/provider_supernode_test.go b/op-challenger/game/fault/trace/super/provider_supernode_test.go index db0d9664a921a..191d118c47234 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode_test.go +++ b/op-challenger/game/fault/trace/super/provider_supernode_test.go @@ -146,13 +146,14 @@ func TestSuperNodeProvider_Get(t *testing.T) { // Make super roots be safe only after L1 head prev.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} next.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} + unsafeOutput := ð.OutputV0{ + StateRoot: eth.Bytes32{0xdf}, + MessagePasserStorageRoot: eth.Bytes32{0xde}, + BlockHash: common.Hash{0xcd}, + } next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)] = eth.OutputWithRequiredL1{ - Output: ð.OutputResponse{ - OutputRoot: eth.Bytes32{0xad}, - BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, - WithdrawalStorageRoot: common.Hash{0xde}, - StateRoot: common.Hash{0xdf}, - }, + Output: unsafeOutput, + OutputRoot: eth.OutputRoot(unsafeOutput), RequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, } stubSuperNode.Add(prev) @@ -172,13 +173,14 @@ func TestSuperNodeProvider_Get(t *testing.T) { // Make super roots be safe only after L1 head prev.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} next.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} + unsafeOutput := ð.OutputV0{ + StateRoot: eth.Bytes32{0xdf}, + MessagePasserStorageRoot: eth.Bytes32{0xde}, + BlockHash: common.Hash{0xcd}, + } next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)] = eth.OutputWithRequiredL1{ - Output: ð.OutputResponse{ - OutputRoot: eth.Bytes32{0xad}, - BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, - WithdrawalStorageRoot: common.Hash{0xde}, - StateRoot: common.Hash{0xdf}, - }, + Output: unsafeOutput, + OutputRoot: eth.OutputRoot(unsafeOutput), RequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, } stubSuperNode.Add(prev) @@ -422,18 +424,6 @@ func createSuperNodeProvider(t *testing.T) (*SuperNodeTraceProvider, *stubSuperN return provider, stubSuperNode, l1Head } -func toOutputResponse(output *eth.OutputV0) *eth.OutputResponse { - return ð.OutputResponse{ - Version: output.Version(), - OutputRoot: eth.OutputRoot(output), - BlockRef: eth.L2BlockRef{ - Hash: output.BlockHash, - }, - WithdrawalStorageRoot: common.Hash(output.MessagePasserStorageRoot), - StateRoot: common.Hash(output.StateRoot), - } -} - func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (eth.SuperRootAtTimestampResponse, eth.SuperRootAtTimestampResponse) { rng := rand.New(rand.NewSource(1)) outputA1 := testutils.RandomOutputV0(rng) @@ -455,11 +445,13 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (eth.SuperRootAtTimestam ChainIDs: []eth.ChainID{chainID1, chainID2}, OptimisticAtTimestamp: map[eth.ChainID]eth.OutputWithRequiredL1{ chainID1: { - Output: toOutputResponse(outputA1), + Output: outputA1, + OutputRoot: eth.OutputRoot(outputA1), RequiredL1: l1Head, }, chainID2: { - Output: toOutputResponse(outputB1), + Output: outputB1, + OutputRoot: eth.OutputRoot(outputB1), RequiredL1: l1Head, }, }, @@ -474,11 +466,13 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (eth.SuperRootAtTimestam ChainIDs: []eth.ChainID{chainID1, chainID2}, OptimisticAtTimestamp: map[eth.ChainID]eth.OutputWithRequiredL1{ chainID1: { - Output: toOutputResponse(outputA2), + Output: outputA2, + OutputRoot: eth.OutputRoot(outputA2), RequiredL1: l1Head, }, chainID2: { - Output: toOutputResponse(outputB2), + Output: outputB2, + OutputRoot: eth.OutputRoot(outputB2), RequiredL1: l1Head, }, }, @@ -492,13 +486,15 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (eth.SuperRootAtTimestam } func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvider, prev eth.SuperRootAtTimestampResponse, next eth.SuperRootAtTimestampResponse) { + chain1 := next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)] chain1OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, - OutputRoot: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, + BlockHash: chain1.Output.BlockHash, + OutputRoot: chain1.OutputRoot, } + chain2 := next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)] chain2OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, - OutputRoot: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, + BlockHash: chain2.Output.BlockHash, + OutputRoot: chain2.OutputRoot, } expectedFirstStep := &interopTypes.TransitionState{ SuperRoot: prev.Data.Super.Marshal(), diff --git a/op-service/eth/superroot_at_timestamp.go b/op-service/eth/superroot_at_timestamp.go index 0a058253dd982..cacda91732af8 100644 --- a/op-service/eth/superroot_at_timestamp.go +++ b/op-service/eth/superroot_at_timestamp.go @@ -2,10 +2,11 @@ package eth import "encoding/json" -// OutputWithRequiredL1 is the full Output and its source L1 block +// OutputWithRequiredL1 is the OutputV0 pre-image, its hash, and its source L1 block type OutputWithRequiredL1 struct { - Output *OutputResponse `json:"output"` - RequiredL1 BlockID `json:"required_l1"` + Output *OutputV0 `json:"output"` + OutputRoot Bytes32 `json:"output_root"` + RequiredL1 BlockID `json:"required_l1"` } type SuperRootResponseData struct { diff --git a/op-service/sources/supernode_client_test.go b/op-service/sources/supernode_client_test.go index d6d0ad9d14577..adde1626672c1 100644 --- a/op-service/sources/supernode_client_test.go +++ b/op-service/sources/supernode_client_test.go @@ -24,6 +24,11 @@ func TestSuperNodeClient_SuperRootAtTimestamp(t *testing.T) { chainA := eth.ChainIDFromUInt64(1) chainB := eth.ChainIDFromUInt64(4) + chainAOutput := ð.OutputV0{ + StateRoot: eth.Bytes32{0xaa}, + MessagePasserStorageRoot: eth.Bytes32{0xff}, + BlockHash: common.Hash{0x22}, + } expected := eth.SuperRootAtTimestampResponse{ CurrentL1: eth.BlockID{ Number: 305, @@ -32,23 +37,8 @@ func TestSuperNodeClient_SuperRootAtTimestamp(t *testing.T) { ChainIDs: []eth.ChainID{chainA, chainB}, OptimisticAtTimestamp: map[eth.ChainID]eth.OutputWithRequiredL1{ chainA: { - Output: ð.OutputResponse{ - Version: eth.Bytes32{0x01}, - OutputRoot: eth.Bytes32{0x11, 0x12}, - BlockRef: eth.L2BlockRef{ - Hash: common.Hash{0x22}, - Number: 472, - ParentHash: common.Hash{0xdd}, - Time: 9895839, - L1Origin: eth.BlockID{ - Hash: common.Hash{0xee}, - Number: 9802, - }, - SequenceNumber: 4982, - }, - WithdrawalStorageRoot: common.Hash{0xff}, - StateRoot: common.Hash{0xaa}, - }, + Output: chainAOutput, + OutputRoot: eth.OutputRoot(chainAOutput), RequiredL1: eth.BlockID{ Hash: common.Hash{0xbb}, Number: 7842, @@ -89,6 +79,11 @@ func TestSuperNodeClient_SuperRootAtTimestamp(t *testing.T) { chainA := eth.ChainIDFromUInt64(1) chainB := eth.ChainIDFromUInt64(4) + chainAOutput := ð.OutputV0{ + StateRoot: eth.Bytes32{0xaa}, + MessagePasserStorageRoot: eth.Bytes32{0xff}, + BlockHash: common.Hash{0x22}, + } expected := eth.SuperRootAtTimestampResponse{ CurrentL1: eth.BlockID{ Number: 305, @@ -97,23 +92,8 @@ func TestSuperNodeClient_SuperRootAtTimestamp(t *testing.T) { ChainIDs: []eth.ChainID{chainA, chainB}, OptimisticAtTimestamp: map[eth.ChainID]eth.OutputWithRequiredL1{ chainA: { - Output: ð.OutputResponse{ - Version: eth.Bytes32{0x01}, - OutputRoot: eth.Bytes32{0x11, 0x12}, - BlockRef: eth.L2BlockRef{ - Hash: common.Hash{0x22}, - Number: 472, - ParentHash: common.Hash{0xdd}, - Time: 9895839, - L1Origin: eth.BlockID{ - Hash: common.Hash{0xee}, - Number: 9802, - }, - SequenceNumber: 4982, - }, - WithdrawalStorageRoot: common.Hash{0xff}, - StateRoot: common.Hash{0xaa}, - }, + Output: chainAOutput, + OutputRoot: eth.OutputRoot(chainAOutput), RequiredL1: eth.BlockID{ Hash: common.Hash{0xbb}, Number: 7842, diff --git a/op-supernode/supernode/activity/interop/algo_test.go b/op-supernode/supernode/activity/interop/algo_test.go index 981cba0178076..0842c2f2a369f 100644 --- a/op-supernode/supernode/activity/interop/algo_test.go +++ b/op-supernode/supernode/activity/interop/algo_test.go @@ -923,7 +923,7 @@ func (m *algoMockChain) OptimisticAt(ctx context.Context, ts uint64) (eth.BlockI func (m *algoMockChain) OutputRootAtL2BlockNumber(ctx context.Context, l2BlockNum uint64) (eth.Bytes32, error) { return eth.Bytes32{}, nil } -func (m *algoMockChain) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputResponse, error) { +func (m *algoMockChain) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputV0, error) { return nil, nil } func (m *algoMockChain) FetchReceipts(ctx context.Context, blockID eth.BlockID) (eth.BlockInfo, types.Receipts, error) { diff --git a/op-supernode/supernode/activity/interop/interop_test.go b/op-supernode/supernode/activity/interop/interop_test.go index 26cea6e696b4e..16f723e8b403c 100644 --- a/op-supernode/supernode/activity/interop/interop_test.go +++ b/op-supernode/supernode/activity/interop/interop_test.go @@ -1403,7 +1403,7 @@ func (m *mockChainContainer) OptimisticAt(ctx context.Context, ts uint64) (eth.B func (m *mockChainContainer) OutputRootAtL2BlockNumber(ctx context.Context, l2BlockNum uint64) (eth.Bytes32, error) { return eth.Bytes32{}, nil } -func (m *mockChainContainer) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputResponse, error) { +func (m *mockChainContainer) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputV0, error) { return nil, nil } func (m *mockChainContainer) FetchReceipts(ctx context.Context, blockID eth.BlockID) (eth.BlockInfo, types.Receipts, error) { diff --git a/op-supernode/supernode/activity/supernode/supernode_test.go b/op-supernode/supernode/activity/supernode/supernode_test.go index a0bbc4fe6c65e..e68dde166fda1 100644 --- a/op-supernode/supernode/activity/supernode/supernode_test.go +++ b/op-supernode/supernode/activity/supernode/supernode_test.go @@ -75,8 +75,8 @@ func (m *mockCC) OutputRootAtL2BlockNumber(ctx context.Context, l2BlockNum uint6 return eth.Bytes32{}, nil } -func (m *mockCC) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputResponse, error) { - return ð.OutputResponse{}, nil +func (m *mockCC) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputV0, error) { + return ð.OutputV0{}, nil } func (m *mockCC) RewindEngine(ctx context.Context, timestamp uint64, invalidatedBlock eth.BlockRef) error { diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 0c3661317049a..497aca7cec051 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -103,6 +103,7 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (eth.Supe } optimistic[chainID] = eth.OutputWithRequiredL1{ Output: optimisticOut, + OutputRoot: eth.OutputRoot(optimisticOut), RequiredL1: optimisticL1, } } diff --git a/op-supernode/supernode/activity/superroot/superroot_test.go b/op-supernode/supernode/activity/superroot/superroot_test.go index 95048dcf30b91..7e35c36e93cd4 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -78,12 +78,11 @@ func (m *mockCC) OutputRootAtL2BlockNumber(ctx context.Context, l2BlockNum uint6 } return m.output, nil } -func (m *mockCC) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputResponse, error) { +func (m *mockCC) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputV0, error) { if m.optimisticErr != nil { return nil, m.optimisticErr } - // Return minimal output response; tests only assert presence/count - return ð.OutputResponse{}, nil + return ð.OutputV0{}, nil } func (m *mockCC) RewindEngine(ctx context.Context, timestamp uint64, invalidatedBlock eth.BlockRef) error { return nil diff --git a/op-supernode/supernode/chain_container/chain_container.go b/op-supernode/supernode/chain_container/chain_container.go index eb2e2053bb51d..bde9522404470 100644 --- a/op-supernode/supernode/chain_container/chain_container.go +++ b/op-supernode/supernode/chain_container/chain_container.go @@ -41,7 +41,7 @@ type ChainContainer interface { VerifiedAt(ctx context.Context, ts uint64) (l2, l1 eth.BlockID, err error) OptimisticAt(ctx context.Context, ts uint64) (l2, l1 eth.BlockID, err error) OutputRootAtL2BlockNumber(ctx context.Context, l2BlockNum uint64) (eth.Bytes32, error) - OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputResponse, error) + OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputV0, error) // RewindEngine rewinds the engine to the highest block with timestamp less than or equal to the given timestamp. // invalidatedBlock is the block that triggered the rewind and is passed to reset callbacks. // WARNING: this is a dangerous stateful operation and is intended to be called only @@ -465,23 +465,27 @@ func (c *simpleChainContainer) OptimisticAt(ctx context.Context, ts uint64) (l2, return l2Block.ID(), l1Block, nil } -// OptimisticOutputAtTimestamp returns the full Output for the optimistic L2 block at the given timestamp. -// For now this simply calls the op-node's normal OutputAtBlock for the block number computed from the timestamp. -func (c *simpleChainContainer) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputResponse, error) { - if c.rollupClient == nil { - return nil, fmt.Errorf("rollup client not initialized") - } - // Determine the optimistic L2 block at timestamp (currently same as safe block at ts) - l2Block, err := c.LocalSafeBlockAtTimestamp(ctx, ts) +// OptimisticOutputAtTimestamp returns the OutputV0 for the "optimistic" L2 block at the given timestamp. +// If the block at this height has been denied (invalidated and replaced), the optimistic output +// is the original (pre-replacement) block's output from the deny list — because optimistically +// the block would not have been replaced. Otherwise it returns the current local safe block's output. +func (c *simpleChainContainer) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputV0, error) { + blockNum, err := c.TimestampToBlockNumber(ctx, ts) if err != nil { - return nil, fmt.Errorf("failed to resolve L2 block at timestamp: %w", err) + return nil, fmt.Errorf("failed to convert timestamp to block number: %w", err) } - // Call the standard OutputAtBlock RPC - out, err := c.rollupClient.OutputAtBlock(ctx, l2Block.Number) - if err != nil { - return nil, fmt.Errorf("failed to get output at block %d: %w", l2Block.Number, err) + + if c.denyList != nil { + outV0, err := c.denyList.LastDeniedOutputV0(blockNum) + if err != nil { + return nil, fmt.Errorf("failed to query deny list at height %d: %w", blockNum, err) + } + if outV0 != nil { + return outV0, nil + } } - return out, nil + + return c.OutputV0AtBlockNumber(ctx, blockNum) } // FetchReceipts fetches the receipts for a given block by hash. diff --git a/op-supernode/supernode/chain_container/chain_container_test.go b/op-supernode/supernode/chain_container/chain_container_test.go index d9e75abbe536f..1b51047c2e04a 100644 --- a/op-supernode/supernode/chain_container/chain_container_test.go +++ b/op-supernode/supernode/chain_container/chain_container_test.go @@ -1167,6 +1167,111 @@ func TestChainContainer_LocalSafeBlockAtTimestamp(t *testing.T) { } } +func TestChainContainer_OptimisticOutputAtTimestamp_ReturnsDeniedOutput(t *testing.T) { + t.Parallel() + + genesisTime := uint64(1000) + blockTime := uint64(2) + vncfg := createTestVNConfig() + vncfg.Rollup.Genesis.L2Time = genesisTime + vncfg.Rollup.BlockTime = blockTime + log := createTestLogger(t) + + dl, err := OpenDenyList(filepath.Join(t.TempDir(), "denylist")) + require.NoError(t, err) + defer dl.Close() + + stateRoot := eth.Bytes32(common.HexToHash("0xabcd")) + msgPasserRoot := eth.Bytes32(common.HexToHash("0x1234")) + payloadHash := common.HexToHash("0xdead") + + // Block at height 5: timestamp = 1000 + 5*2 = 1010 + height := uint64(5) + ts := genesisTime + height*blockTime + require.NoError(t, dl.Add(height, payloadHash, 0, stateRoot, msgPasserRoot)) + + container := &simpleChainContainer{ + vncfg: vncfg, + denyList: dl, + log: log, + } + + out, err := container.OptimisticOutputAtTimestamp(context.Background(), ts) + require.NoError(t, err) + + require.Equal(t, ð.OutputV0{ + StateRoot: stateRoot, + MessagePasserStorageRoot: msgPasserRoot, + BlockHash: payloadHash, + }, out) +} + +func TestChainContainer_OptimisticOutputAtTimestamp_UsesLatestDeniedRecord(t *testing.T) { + t.Parallel() + + genesisTime := uint64(1000) + blockTime := uint64(2) + vncfg := createTestVNConfig() + vncfg.Rollup.Genesis.L2Time = genesisTime + vncfg.Rollup.BlockTime = blockTime + log := createTestLogger(t) + + dl, err := OpenDenyList(filepath.Join(t.TempDir(), "denylist")) + require.NoError(t, err) + defer dl.Close() + + height := uint64(5) + ts := genesisTime + height*blockTime + + // Add two denied records at the same height — the latest should win + firstHash := common.HexToHash("0x1111") + require.NoError(t, dl.Add(height, firstHash, 100, eth.Bytes32{0x01}, eth.Bytes32{0x02})) + + latestHash := common.HexToHash("0x2222") + latestState := eth.Bytes32(common.HexToHash("0xlatest")) + latestMsgPasser := eth.Bytes32(common.HexToHash("0xlatestmp")) + require.NoError(t, dl.Add(height, latestHash, 200, latestState, latestMsgPasser)) + + container := &simpleChainContainer{ + vncfg: vncfg, + denyList: dl, + log: log, + } + + out, err := container.OptimisticOutputAtTimestamp(context.Background(), ts) + require.NoError(t, err) + require.Equal(t, latestHash, out.BlockHash) + require.Equal(t, latestState, out.StateRoot) + require.Equal(t, latestMsgPasser, out.MessagePasserStorageRoot) +} + +func TestChainContainer_OptimisticOutputAtTimestamp_FallsThroughWhenNoDenied(t *testing.T) { + t.Parallel() + + genesisTime := uint64(1000) + blockTime := uint64(2) + vncfg := createTestVNConfig() + vncfg.Rollup.Genesis.L2Time = genesisTime + vncfg.Rollup.BlockTime = blockTime + log := createTestLogger(t) + + // Empty deny list — no denied records at any height + dl, err := OpenDenyList(filepath.Join(t.TempDir(), "denylist")) + require.NoError(t, err) + defer dl.Close() + + container := &simpleChainContainer{ + vncfg: vncfg, + denyList: dl, + log: log, + // No engine set, so the fallback path will error — proving we reached it + } + + _, err = container.OptimisticOutputAtTimestamp(context.Background(), genesisTime+5*blockTime) + require.Error(t, err) + require.ErrorIs(t, err, engine_controller.ErrNoEngineClient) +} + func TestChainContainer_SyncStatus_UninitializedVirtualNode(t *testing.T) { t.Parallel() diff --git a/op-supernode/supernode/chain_container/invalidation.go b/op-supernode/supernode/chain_container/invalidation.go index b82f9b3b16321..a515b4c1ee876 100644 --- a/op-supernode/supernode/chain_container/invalidation.go +++ b/op-supernode/supernode/chain_container/invalidation.go @@ -123,6 +123,41 @@ func (d *DenyList) Add(height uint64, payloadHash common.Hash, decisionTimestamp }) } +// LastDeniedOutputV0 returns the OutputV0 for the most recently denied block at the given height. +// Returns nil if no blocks are denied at that height. +// Note: supernode does not currently behave in well defined ways when there are multiple denied blocks at the same height. +func (d *DenyList) LastDeniedOutputV0(height uint64) (*eth.OutputV0, error) { + d.mu.RLock() + defer d.mu.RUnlock() + + key := heightToKey(height) + var result *eth.OutputV0 + + err := d.db.View(func(tx *bolt.Tx) error { + b := tx.Bucket(denyListBucketName) + existing := b.Get(key) + if existing == nil { + return nil + } + + records, err := decodeDenyRecords(existing) + if err != nil { + return err + } + if len(records) > 0 { + r := records[len(records)-1] + result = ð.OutputV0{ + StateRoot: r.StateRoot, + MessagePasserStorageRoot: r.MessagePasserStorageRoot, + BlockHash: r.PayloadHash, + } + } + return nil + }) + + return result, err +} + // GetOutputV0 reconstructs and returns the full OutputV0 for a denied block. // Returns nil if the hash is not denied at that height. func (d *DenyList) GetOutputV0(height uint64, payloadHash common.Hash) (*eth.OutputV0, error) { diff --git a/op-supernode/supernode/chain_container/invalidation_test.go b/op-supernode/supernode/chain_container/invalidation_test.go index b3891677867d6..f967de8322ea6 100644 --- a/op-supernode/supernode/chain_container/invalidation_test.go +++ b/op-supernode/supernode/chain_container/invalidation_test.go @@ -766,6 +766,54 @@ func TestDenyList_GetOutputV0(t *testing.T) { }) } +func TestDenyList_LastDeniedOutputV0(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dl, err := OpenDenyList(dir) + require.NoError(t, err) + defer dl.Close() + + t.Run("returns nil for empty height", func(t *testing.T) { + got, err := dl.LastDeniedOutputV0(999) + require.NoError(t, err) + require.Nil(t, got) + }) + + t.Run("returns the single record", func(t *testing.T) { + hash := common.HexToHash("0xaaaa") + stateRoot := eth.Bytes32(common.HexToHash("0xstate1")) + msgPasser := eth.Bytes32(common.HexToHash("0xmp1")) + require.NoError(t, dl.Add(50, hash, 100, stateRoot, msgPasser)) + + got, err := dl.LastDeniedOutputV0(50) + require.NoError(t, err) + require.Equal(t, ð.OutputV0{ + StateRoot: stateRoot, + MessagePasserStorageRoot: msgPasser, + BlockHash: hash, + }, got) + }) + + t.Run("returns the last record when multiple exist", func(t *testing.T) { + firstHash := common.HexToHash("0xbbbb") + require.NoError(t, dl.Add(60, firstHash, 100, eth.Bytes32{0x01}, eth.Bytes32{0x02})) + + lastHash := common.HexToHash("0xcccc") + lastState := eth.Bytes32(common.HexToHash("0xlast_state")) + lastMsgPasser := eth.Bytes32(common.HexToHash("0xlast_mp")) + require.NoError(t, dl.Add(60, lastHash, 200, lastState, lastMsgPasser)) + + got, err := dl.LastDeniedOutputV0(60) + require.NoError(t, err) + require.Equal(t, ð.OutputV0{ + StateRoot: lastState, + MessagePasserStorageRoot: lastMsgPasser, + BlockHash: lastHash, + }, got) + }) +} + func TestDenyList_OutputPersistence(t *testing.T) { t.Parallel()