From 8614c85c67cadbeeabd69b74b0691aba40929229 Mon Sep 17 00:00:00 2001 From: axelKingsley Date: Wed, 1 Apr 2026 12:37:33 -0500 Subject: [PATCH 1/6] feat(op-supernode): persist full Output in DenyList for optimistic root computation Made-with: Cursor --- .../supernode/activity/interop/algo.go | 20 +- .../supernode/activity/interop/algo_test.go | 10 +- .../supernode/activity/interop/cycle.go | 9 +- .../supernode/activity/interop/decide_test.go | 4 +- .../supernode/activity/interop/interop.go | 37 ++- .../activity/interop/interop_test.go | 80 +++--- .../supernode/activity/interop/types.go | 20 +- .../supernode/activity/interop/types_test.go | 91 ++++++- .../supernode/activity/interop/verified_db.go | 8 +- .../activity/interop/verified_db_test.go | 14 +- .../activity/supernode/supernode_test.go | 8 +- .../activity/superroot/superroot_test.go | 8 +- .../chain_container/chain_container.go | 8 +- .../supernode/chain_container/invalidation.go | 82 ++++-- .../chain_container/invalidation_test.go | 255 +++++++++++++++--- .../chain_container/super_authority.go | 17 ++ 16 files changed, 533 insertions(+), 138 deletions(-) diff --git a/op-supernode/supernode/activity/interop/algo.go b/op-supernode/supernode/activity/interop/algo.go index 957511b549286..00295024f5c7d 100644 --- a/op-supernode/supernode/activity/interop/algo.go +++ b/op-supernode/supernode/activity/interop/algo.go @@ -63,7 +63,7 @@ func (i *Interop) verifyInteropMessages(ts uint64, blocksAtTimestamp blockPerCha result := Result{ Timestamp: ts, L2Heads: make(blockPerChain), - InvalidHeads: make(blockPerChain), + InvalidHeads: make(map[eth.ChainID]InvalidHead), } if l1Inclusion, err := i.l1Inclusion(ts, blocksAtTimestamp); err != nil { @@ -108,7 +108,11 @@ func (i *Interop) verifyInteropMessages(ts uint64, blocksAtTimestamp blockPerCha "expected", expectedBlock.Hash, "got", firstBlock.Hash, ) - result.InvalidHeads[chainID] = expectedBlock + invalid, err := i.newInvalidHead(chainID, expectedBlock) + if err != nil { + return Result{}, fmt.Errorf("chain %s: %w", chainID, err) + } + result.InvalidHeads[chainID] = invalid } result.L2Heads[chainID] = expectedBlock continue @@ -125,7 +129,11 @@ func (i *Interop) verifyInteropMessages(ts uint64, blocksAtTimestamp blockPerCha "expected", expectedBlock.Hash, "got", blockRef.Hash, ) - result.InvalidHeads[chainID] = expectedBlock + invalid, err := i.newInvalidHead(chainID, expectedBlock) + if err != nil { + return Result{}, fmt.Errorf("chain %s: %w", chainID, err) + } + result.InvalidHeads[chainID] = invalid result.L2Heads[chainID] = expectedBlock continue } @@ -149,7 +157,11 @@ func (i *Interop) verifyInteropMessages(ts uint64, blocksAtTimestamp blockPerCha result.L2Heads[chainID] = expectedBlock if !blockValid { - result.InvalidHeads[chainID] = expectedBlock + invalid, err := i.newInvalidHead(chainID, expectedBlock) + if err != nil { + return Result{}, fmt.Errorf("chain %s: %w", chainID, err) + } + result.InvalidHeads[chainID] = invalid } } diff --git a/op-supernode/supernode/activity/interop/algo_test.go b/op-supernode/supernode/activity/interop/algo_test.go index 965894dd726c9..822188a971911 100644 --- a/op-supernode/supernode/activity/interop/algo_test.go +++ b/op-supernode/supernode/activity/interop/algo_test.go @@ -495,7 +495,7 @@ func TestVerifyInteropMessages(t *testing.T) { expectedBlock := eth.BlockID{Number: 100, Hash: common.HexToHash("0xExpected")} require.False(t, result.IsValid()) require.Contains(t, result.InvalidHeads, chainID) - require.Equal(t, expectedBlock, result.InvalidHeads[chainID]) + require.Equal(t, expectedBlock, result.InvalidHeads[chainID].BlockID) }, }, { @@ -930,9 +930,15 @@ func (m *algoMockChain) RewindEngine(ctx context.Context, timestamp uint64, inva return nil } func (m *algoMockChain) BlockTime() uint64 { return 1 } -func (m *algoMockChain) InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64) (bool, error) { +func (m *algoMockChain) InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64, stateRoot, messagePasserStorageRoot eth.Bytes32) (bool, error) { return false, nil } +func (m *algoMockChain) OutputV0AtBlockNumber(ctx context.Context, l2BlockNum uint64) (*eth.OutputV0, error) { + return ð.OutputV0{}, nil +} +func (m *algoMockChain) GetDeniedOutput(height uint64, payloadHash common.Hash) (*eth.OutputV0, error) { + return nil, nil +} func (m *algoMockChain) PruneDeniedAtOrAfterTimestamp(timestamp uint64) (map[uint64][]common.Hash, error) { return nil, nil } diff --git a/op-supernode/supernode/activity/interop/cycle.go b/op-supernode/supernode/activity/interop/cycle.go index 145ebc2be653e..6d6551c8d1b9d 100644 --- a/op-supernode/supernode/activity/interop/cycle.go +++ b/op-supernode/supernode/activity/interop/cycle.go @@ -3,6 +3,7 @@ package interop import ( "cmp" "errors" + "fmt" "slices" "github.com/ethereum-optimism/optimism/op-service/eth" @@ -210,9 +211,13 @@ func (i *Interop) verifyCycleMessages(ts uint64, blocksAtTimestamp map[eth.Chain // (bystander chains that have same-ts EMs but aren't part of the cycle are spared) cycleChains := collectCycleParticipants(graph) if len(cycleChains) > 0 { - result.InvalidHeads = make(map[eth.ChainID]eth.BlockID) + result.InvalidHeads = make(map[eth.ChainID]InvalidHead) for chainID := range cycleChains { - result.InvalidHeads[chainID] = blocksAtTimestamp[chainID] + invalid, err := i.newInvalidHead(chainID, blocksAtTimestamp[chainID]) + if err != nil { + return Result{}, fmt.Errorf("chain %s: %w", chainID, err) + } + result.InvalidHeads[chainID] = invalid } } } diff --git a/op-supernode/supernode/activity/interop/decide_test.go b/op-supernode/supernode/activity/interop/decide_test.go index 6c4749a98a7b6..a08157c9c9d10 100644 --- a/op-supernode/supernode/activity/interop/decide_test.go +++ b/op-supernode/supernode/activity/interop/decide_test.go @@ -81,8 +81,8 @@ func TestDecideVerifiedResult(t *testing.T) { L2Heads: map[eth.ChainID]eth.BlockID{ eth.ChainIDFromUInt64(1): {Hash: common.HexToHash("0xa"), Number: 100}, }, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - eth.ChainIDFromUInt64(2): {Hash: common.HexToHash("0xbad"), Number: 200}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + eth.ChainIDFromUInt64(2): {BlockID: eth.BlockID{Hash: common.HexToHash("0xbad"), Number: 200}}, }, } diff --git a/op-supernode/supernode/activity/interop/interop.go b/op-supernode/supernode/activity/interop/interop.go index 29816ca212776..9969cd905c8e7 100644 --- a/op-supernode/supernode/activity/interop/interop.go +++ b/op-supernode/supernode/activity/interop/interop.go @@ -400,7 +400,7 @@ func (i *Interop) verify(ts uint64, blocksAtTS map[eth.ChainID]eth.BlockID) (Res if len(cycleResult.InvalidHeads) > 0 { if result.InvalidHeads == nil { - result.InvalidHeads = make(map[eth.ChainID]eth.BlockID) + result.InvalidHeads = make(map[eth.ChainID]InvalidHead) } for chainID, invalidBlock := range cycleResult.InvalidHeads { result.InvalidHeads[chainID] = invalidBlock @@ -410,6 +410,25 @@ func (i *Interop) verify(ts uint64, blocksAtTS map[eth.ChainID]eth.BlockID) (Res return result, nil } +// newInvalidHead constructs a fully-formed InvalidHead with the output preimage +// fields already attached. Returns an error if the output cannot be computed — +// at verification time the engine should always have the block, so failure +// indicates a transient RPC issue or a serious invariant violation. +func (i *Interop) newInvalidHead(chainID eth.ChainID, blockID eth.BlockID) (InvalidHead, error) { + head := InvalidHead{BlockID: blockID} + chain, ok := i.chains[chainID] + if !ok { + return head, fmt.Errorf("chain %s not found", chainID) + } + outputV0, err := chain.OutputV0AtBlockNumber(i.ctx, blockID.Number) + if err != nil { + return head, fmt.Errorf("chain %s: failed to compute OutputV0 for block %d: %w", chainID, blockID.Number, err) + } + head.StateRoot = outputV0.StateRoot + head.MessagePasserStorageRoot = outputV0.MessagePasserStorageRoot + return head, nil +} + func (i *Interop) buildPendingTransition(output StepOutput, obs RoundObservation) (PendingTransition, error) { switch output.Decision { case DecisionAdvance, DecisionInvalidate: @@ -457,11 +476,13 @@ func (i *Interop) applyPendingTransition(pending PendingTransition) (bool, error return false, nil } invalidations := make([]PendingInvalidation, 0, len(pending.Result.InvalidHeads)) - for chainID, blockID := range pending.Result.InvalidHeads { + for chainID, invalidHead := range pending.Result.InvalidHeads { invalidations = append(invalidations, PendingInvalidation{ - ChainID: chainID, - BlockID: blockID, - Timestamp: pending.Result.Timestamp, + ChainID: chainID, + BlockID: invalidHead.BlockID, + Timestamp: pending.Result.Timestamp, + StateRoot: invalidHead.StateRoot, + MessagePasserStorageRoot: invalidHead.MessagePasserStorageRoot, }) } sort.Slice(invalidations, func(i, j int) bool { @@ -482,7 +503,7 @@ func (i *Interop) applyPendingTransition(pending PendingTransition) (bool, error } var failedAny bool for _, p := range invalidations { - if err := i.invalidateBlock(p.ChainID, p.BlockID, p.Timestamp); err != nil { + if err := i.invalidateBlock(p.ChainID, p.BlockID, p.Timestamp, p.StateRoot, p.MessagePasserStorageRoot); err != nil { i.log.Error("invalidation failed, transition preserved for retry on restart", "chain", p.ChainID, "block", p.BlockID, "err", err) failedAny = true @@ -795,11 +816,11 @@ func (i *Interop) Reset(chainID eth.ChainID, timestamp uint64, invalidatedBlock // invalidateBlock notifies the chain container to add the block to the denylist // and potentially rewind if the chain is currently using that block. -func (i *Interop) invalidateBlock(chainID eth.ChainID, blockID eth.BlockID, decisionTimestamp uint64) error { +func (i *Interop) invalidateBlock(chainID eth.ChainID, blockID eth.BlockID, decisionTimestamp uint64, stateRoot, messagePasserStorageRoot eth.Bytes32) error { chain, ok := i.chains[chainID] if !ok { return fmt.Errorf("chain %s not found", chainID) } - _, err := chain.InvalidateBlock(i.ctx, blockID.Number, blockID.Hash, decisionTimestamp) + _, err := chain.InvalidateBlock(i.ctx, blockID.Number, blockID.Hash, decisionTimestamp, stateRoot, messagePasserStorageRoot) return err } diff --git a/op-supernode/supernode/activity/interop/interop_test.go b/op-supernode/supernode/activity/interop/interop_test.go index 1dd1a29bf2437..dea17c52f9074 100644 --- a/op-supernode/supernode/activity/interop/interop_test.go +++ b/op-supernode/supernode/activity/interop/interop_test.go @@ -621,8 +621,8 @@ func TestProgressInteropWithCycleVerify(t *testing.T) { return Result{ Timestamp: ts, L2Heads: blocks, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - chain8453: blocks[chain8453], + InvalidHeads: map[eth.ChainID]InvalidHead{ + chain8453: {BlockID: blocks[chain8453]}, }, }, nil } @@ -675,8 +675,8 @@ func TestProgressInteropWithCycleVerify(t *testing.T) { return Result{ Timestamp: ts, L2Heads: blocks, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - chain10: blocks[chain10], + InvalidHeads: map[eth.ChainID]InvalidHead{ + chain10: {BlockID: blocks[chain10]}, }, }, nil } @@ -686,8 +686,8 @@ func TestProgressInteropWithCycleVerify(t *testing.T) { return Result{ Timestamp: ts, L2Heads: blocks, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - chain8453: blocks[chain8453], + InvalidHeads: map[eth.ChainID]InvalidHead{ + chain8453: {BlockID: blocks[chain8453]}, }, }, nil } @@ -855,8 +855,8 @@ func TestApplyResultCompat(t *testing.T) { L2Heads: map[eth.ChainID]eth.BlockID{ mock.id: {Number: 500, Hash: common.HexToHash("0xL2")}, }, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - mock.id: {Number: 500, Hash: common.HexToHash("0xBAD")}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + mock.id: {BlockID: eth.BlockID{Number: 500, Hash: common.HexToHash("0xBAD")}}, }, } @@ -901,7 +901,7 @@ func TestInvalidateBlock(t *testing.T) { run: func(t *testing.T, h *interopTestHarness) { mock := h.Mock(10) blockID := eth.BlockID{Number: 500, Hash: common.HexToHash("0xBAD")} - err := h.interop.invalidateBlock(mock.id, blockID, 0) + err := h.interop.invalidateBlock(mock.id, blockID, 0, eth.Bytes32{}, eth.Bytes32{}) require.NoError(t, err) require.Len(t, mock.invalidateBlockCalls, 1) @@ -918,7 +918,7 @@ func TestInvalidateBlock(t *testing.T) { mock := h.Mock(10) unknownChain := eth.ChainIDFromUInt64(999) blockID := eth.BlockID{Number: 500, Hash: common.HexToHash("0xBAD")} - err := h.interop.invalidateBlock(unknownChain, blockID, 0) + err := h.interop.invalidateBlock(unknownChain, blockID, 0, eth.Bytes32{}, eth.Bytes32{}) require.Error(t, err) require.Contains(t, err.Error(), "not found") @@ -935,7 +935,7 @@ func TestInvalidateBlock(t *testing.T) { run: func(t *testing.T, h *interopTestHarness) { mock := h.Mock(10) blockID := eth.BlockID{Number: 500, Hash: common.HexToHash("0xBAD")} - err := h.interop.invalidateBlock(mock.id, blockID, 0) + err := h.interop.invalidateBlock(mock.id, blockID, 0, eth.Bytes32{}, eth.Bytes32{}) require.Error(t, err) require.Contains(t, err.Error(), "engine failure") @@ -957,9 +957,9 @@ func TestInvalidateBlock(t *testing.T) { mock1.id: {Number: 500, Hash: common.HexToHash("0xL2-1")}, mock2.id: {Number: 600, Hash: common.HexToHash("0xL2-2")}, }, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - mock1.id: {Number: 500, Hash: common.HexToHash("0xBAD1")}, - mock2.id: {Number: 600, Hash: common.HexToHash("0xBAD2")}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + mock1.id: {BlockID: eth.BlockID{Number: 500, Hash: common.HexToHash("0xBAD1")}}, + mock2.id: {BlockID: eth.BlockID{Number: 600, Hash: common.HexToHash("0xBAD2")}}, }, } @@ -1090,7 +1090,7 @@ func TestProgressAndRecord(t *testing.T) { Timestamp: ts, L1Inclusion: eth.BlockID{Number: 999, Hash: common.HexToHash("0xShouldNotBeUsed")}, L2Heads: blocks, - InvalidHeads: map[eth.ChainID]eth.BlockID{mock.id: {Number: 100}}, + InvalidHeads: map[eth.ChainID]InvalidHead{mock.id: {BlockID: eth.BlockID{Number: 100}}}, }, nil } @@ -1201,7 +1201,7 @@ func TestResult_IsEmpty(t *testing.T) { {"only timestamp", Result{Timestamp: 1000}, true}, {"with L1Head", Result{Timestamp: 1000, L1Inclusion: eth.BlockID{Number: 100}}, false}, {"with L2Heads", Result{Timestamp: 1000, L2Heads: map[eth.ChainID]eth.BlockID{eth.ChainIDFromUInt64(10): {Number: 50}}}, false}, - {"with InvalidHeads", Result{Timestamp: 1000, InvalidHeads: map[eth.ChainID]eth.BlockID{eth.ChainIDFromUInt64(10): {Number: 50}}}, false}, + {"with InvalidHeads", Result{Timestamp: 1000, InvalidHeads: map[eth.ChainID]InvalidHead{eth.ChainIDFromUInt64(10): {BlockID: eth.BlockID{Number: 50}}}}, false}, } for _, tt := range tests { @@ -1310,8 +1310,10 @@ type mockChainContainer struct { } type invalidateBlockCall struct { - height uint64 - payloadHash common.Hash + height uint64 + payloadHash common.Hash + stateRoot eth.Bytes32 + messagePasserStorageRoot eth.Bytes32 } func newMockChainContainer(id uint64) *mockChainContainer { @@ -1435,15 +1437,27 @@ func (m *mockChainContainer) RewindEngine(ctx context.Context, timestamp uint64, return m.rewindEngineErr } func (m *mockChainContainer) BlockTime() uint64 { return 1 } -func (m *mockChainContainer) InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64) (bool, error) { +func (m *mockChainContainer) InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64, stateRoot, messagePasserStorageRoot eth.Bytes32) (bool, error) { m.mu.Lock() - m.invalidateBlockCalls = append(m.invalidateBlockCalls, invalidateBlockCall{height: height, payloadHash: payloadHash}) + m.invalidateBlockCalls = append(m.invalidateBlockCalls, invalidateBlockCall{ + height: height, payloadHash: payloadHash, stateRoot: stateRoot, messagePasserStorageRoot: messagePasserStorageRoot, + }) m.mu.Unlock() if m.callLog != nil { m.callLog.record(m.id, "InvalidateBlock") } return m.invalidateBlockRet, m.invalidateBlockErr } +func (m *mockChainContainer) OutputV0AtBlockNumber(ctx context.Context, l2BlockNum uint64) (*eth.OutputV0, error) { + return ð.OutputV0{ + StateRoot: eth.Bytes32(common.HexToHash("0xmockstate")), + MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xmockmsg")), + BlockHash: common.BigToHash(common.Big0.SetUint64(l2BlockNum)), + }, nil +} +func (m *mockChainContainer) GetDeniedOutput(height uint64, payloadHash common.Hash) (*eth.OutputV0, error) { + return nil, nil +} func (m *mockChainContainer) PruneDeniedAtOrAfterTimestamp(timestamp uint64) (map[uint64][]common.Hash, error) { if m.pruneDeniedResult != nil { return m.pruneDeniedResult, nil @@ -1484,8 +1498,8 @@ func TestWAL_PreservedOnInvalidationFailure(t *testing.T) { L2Heads: map[eth.ChainID]eth.BlockID{ mock.id: {Number: 500, Hash: common.HexToHash("0xL2")}, }, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - mock.id: {Number: 500, Hash: common.HexToHash("0xBAD")}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + mock.id: {BlockID: eth.BlockID{Number: 500, Hash: common.HexToHash("0xBAD")}}, }, } @@ -1533,8 +1547,8 @@ func TestPendingTransition_RecoverInvalidatePreservedOnFailure(t *testing.T) { Result: &Result{ Timestamp: 1000, L1Inclusion: eth.BlockID{Hash: common.HexToHash("0xL1"), Number: 100}, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - mock.id: {Hash: common.HexToHash("0xBAD"), Number: 500}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + mock.id: {BlockID: eth.BlockID{Hash: common.HexToHash("0xBAD"), Number: 500}}, }, }, } @@ -2107,8 +2121,8 @@ func TestFreezeAllBeforeRewind(t *testing.T) { chain10: {Number: 500, Hash: common.HexToHash("0xL2-10")}, chain8453: {Number: 600, Hash: common.HexToHash("0xL2-8453")}, }, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - chain10: {Number: 500, Hash: common.HexToHash("0xBAD")}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + chain10: {BlockID: eth.BlockID{Number: 500, Hash: common.HexToHash("0xBAD")}}, }, } @@ -2178,9 +2192,9 @@ func TestFreezeAllBeforeRewind(t *testing.T) { chain8453: {Number: 600, Hash: common.HexToHash("0xL2-8453")}, chain42: {Number: 700, Hash: common.HexToHash("0xL2-42")}, }, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - chain10: {Number: 500, Hash: common.HexToHash("0xBAD10")}, - chain8453: {Number: 600, Hash: common.HexToHash("0xBAD8453")}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + chain10: {BlockID: eth.BlockID{Number: 500, Hash: common.HexToHash("0xBAD10")}}, + chain8453: {BlockID: eth.BlockID{Number: 600, Hash: common.HexToHash("0xBAD8453")}}, }, } @@ -2223,8 +2237,8 @@ func TestFreezeAllBeforeRewind(t *testing.T) { chain10: {Number: 500, Hash: common.HexToHash("0xL2-10")}, chain8453: {Number: 600, Hash: common.HexToHash("0xL2-8453")}, }, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - chain10: {Number: 500, Hash: common.HexToHash("0xBAD")}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + chain10: {BlockID: eth.BlockID{Number: 500, Hash: common.HexToHash("0xBAD")}}, }, } @@ -2276,8 +2290,8 @@ func TestFreezeAllBeforeRewind(t *testing.T) { L2Heads: map[eth.ChainID]eth.BlockID{ chain10: {Number: 500, Hash: common.HexToHash("0xL2-10")}, }, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - chain10: {Number: 500, Hash: common.HexToHash("0xBAD")}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + chain10: {BlockID: eth.BlockID{Number: 500, Hash: common.HexToHash("0xBAD")}}, }, } diff --git a/op-supernode/supernode/activity/interop/types.go b/op-supernode/supernode/activity/interop/types.go index cc8c5af59d8d3..f035468a9db60 100644 --- a/op-supernode/supernode/activity/interop/types.go +++ b/op-supernode/supernode/activity/interop/types.go @@ -13,13 +13,31 @@ type VerifiedResult struct { L2Heads map[eth.ChainID]eth.BlockID `json:"l2Heads"` } +// InvalidHead pairs a block identifier with the output preimage fields needed +// for optimistic root computation in the superroot API. The full OutputV0 can +// be reconstructed on demand via OutputV0() since BlockHash is already in BlockID. +type InvalidHead struct { + eth.BlockID + StateRoot eth.Bytes32 `json:"stateRoot"` + MessagePasserStorageRoot eth.Bytes32 `json:"messagePasserStorageRoot"` +} + +// OutputV0 reconstructs the full output from the stored preimage fields. +func (h InvalidHead) OutputV0() *eth.OutputV0 { + return ð.OutputV0{ + StateRoot: h.StateRoot, + MessagePasserStorageRoot: h.MessagePasserStorageRoot, + BlockHash: h.Hash, + } +} + // Result represents the result of interop validation at a specific timestamp given current data. // it contains all the same information as VerifiedResult, but also contains a list of invalid heads. type Result struct { Timestamp uint64 `json:"timestamp"` L1Inclusion eth.BlockID `json:"l1Inclusion"` L2Heads map[eth.ChainID]eth.BlockID `json:"l2Heads"` - InvalidHeads map[eth.ChainID]eth.BlockID `json:"invalidHeads"` + InvalidHeads map[eth.ChainID]InvalidHead `json:"invalidHeads"` } // PendingTransition is the generic write-ahead-log entry for an effectful diff --git a/op-supernode/supernode/activity/interop/types_test.go b/op-supernode/supernode/activity/interop/types_test.go index 31271bf765301..52398ba5d9e69 100644 --- a/op-supernode/supernode/activity/interop/types_test.go +++ b/op-supernode/supernode/activity/interop/types_test.go @@ -1,6 +1,7 @@ package interop import ( + "encoding/json" "testing" "github.com/ethereum-optimism/optimism/op-service/eth" @@ -26,7 +27,7 @@ func TestResult_IsValid(t *testing.T) { Timestamp: 100, L1Inclusion: eth.BlockID{Number: 1}, L2Heads: map[eth.ChainID]eth.BlockID{eth.ChainIDFromUInt64(10): {Number: 100}}, - InvalidHeads: map[eth.ChainID]eth.BlockID{}, + InvalidHeads: map[eth.ChainID]InvalidHead{}, } require.True(t, r.IsValid()) }) @@ -36,8 +37,8 @@ func TestResult_IsValid(t *testing.T) { Timestamp: 100, L1Inclusion: eth.BlockID{Number: 1}, L2Heads: map[eth.ChainID]eth.BlockID{eth.ChainIDFromUInt64(10): {Number: 100}}, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - eth.ChainIDFromUInt64(10): {Number: 100, Hash: common.HexToHash("0xbad")}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + eth.ChainIDFromUInt64(10): {BlockID: eth.BlockID{Number: 100, Hash: common.HexToHash("0xbad")}}, }, } require.False(t, r.IsValid()) @@ -46,9 +47,9 @@ func TestResult_IsValid(t *testing.T) { t.Run("returns false with multiple invalid heads", func(t *testing.T) { r := Result{ Timestamp: 100, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - eth.ChainIDFromUInt64(10): {Number: 100}, - eth.ChainIDFromUInt64(8453): {Number: 200}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + eth.ChainIDFromUInt64(10): {BlockID: eth.BlockID{Number: 100}}, + eth.ChainIDFromUInt64(8453): {BlockID: eth.BlockID{Number: 200}}, }, } require.False(t, r.IsValid()) @@ -72,8 +73,8 @@ func TestResult_ToVerifiedResult(t *testing.T) { chainID1: {Hash: common.HexToHash("0x2222"), Number: 200}, chainID2: {Hash: common.HexToHash("0x3333"), Number: 300}, }, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - chainID1: {Hash: common.HexToHash("0xbad"), Number: 199}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + chainID1: {BlockID: eth.BlockID{Hash: common.HexToHash("0xbad"), Number: 199}}, }, } @@ -117,8 +118,8 @@ func TestResult_ToVerifiedResult(t *testing.T) { L2Heads: map[eth.ChainID]eth.BlockID{ chainID: {Number: 200}, }, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - chainID: {Number: 199}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + chainID: {BlockID: eth.BlockID{Number: 199}}, }, } @@ -128,3 +129,73 @@ func TestResult_ToVerifiedResult(t *testing.T) { require.Len(t, r.InvalidHeads, 1) }) } + +func TestInvalidHead_JSONRoundTrip(t *testing.T) { + t.Parallel() + + original := InvalidHead{ + BlockID: eth.BlockID{ + Hash: common.HexToHash("0xdead"), + Number: 500, + }, + StateRoot: eth.Bytes32(common.HexToHash("0xstate")), + MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xmsgpasser")), + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded InvalidHead + require.NoError(t, json.Unmarshal(data, &decoded)) + + require.Equal(t, original.BlockID, decoded.BlockID) + require.Equal(t, original.StateRoot, decoded.StateRoot) + require.Equal(t, original.MessagePasserStorageRoot, decoded.MessagePasserStorageRoot) +} + +func TestInvalidHead_JSONRoundTrip_ZeroRoots(t *testing.T) { + t.Parallel() + + original := InvalidHead{ + BlockID: eth.BlockID{ + Hash: common.HexToHash("0xbeef"), + Number: 42, + }, + StateRoot: eth.Bytes32{}, + MessagePasserStorageRoot: eth.Bytes32{}, + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded InvalidHead + require.NoError(t, json.Unmarshal(data, &decoded)) + + require.Equal(t, original.BlockID, decoded.BlockID) + require.Equal(t, original.StateRoot, decoded.StateRoot) + require.Equal(t, original.MessagePasserStorageRoot, decoded.MessagePasserStorageRoot) +} + +func TestPendingInvalidation_JSONRoundTrip(t *testing.T) { + t.Parallel() + + original := PendingInvalidation{ + ChainID: eth.ChainIDFromUInt64(10), + BlockID: eth.BlockID{Hash: common.HexToHash("0xbad"), Number: 100}, + Timestamp: 42, + StateRoot: eth.Bytes32(common.HexToHash("0xstate")), + MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xmsgpasser")), + } + + data, err := json.Marshal(original) + require.NoError(t, err) + + var decoded PendingInvalidation + require.NoError(t, json.Unmarshal(data, &decoded)) + + require.Equal(t, original.ChainID, decoded.ChainID) + require.Equal(t, original.BlockID, decoded.BlockID) + require.Equal(t, original.Timestamp, decoded.Timestamp) + require.Equal(t, original.StateRoot, decoded.StateRoot) + require.Equal(t, original.MessagePasserStorageRoot, decoded.MessagePasserStorageRoot) +} diff --git a/op-supernode/supernode/activity/interop/verified_db.go b/op-supernode/supernode/activity/interop/verified_db.go index 48fc353dac607..b675bb676448d 100644 --- a/op-supernode/supernode/activity/interop/verified_db.go +++ b/op-supernode/supernode/activity/interop/verified_db.go @@ -33,9 +33,11 @@ var pendingTransitionKey = []byte("pending") // PendingInvalidation records a chain invalidation that needs to be executed. type PendingInvalidation struct { - ChainID eth.ChainID `json:"chainID"` - BlockID eth.BlockID `json:"blockID"` - Timestamp uint64 `json:"timestamp"` // the interop decision timestamp + ChainID eth.ChainID `json:"chainID"` + BlockID eth.BlockID `json:"blockID"` + Timestamp uint64 `json:"timestamp"` // the interop decision timestamp + StateRoot eth.Bytes32 `json:"stateRoot"` + MessagePasserStorageRoot eth.Bytes32 `json:"messagePasserStorageRoot"` } // VerifiedDB provides persistence for verified timestamps using bbolt. diff --git a/op-supernode/supernode/activity/interop/verified_db_test.go b/op-supernode/supernode/activity/interop/verified_db_test.go index f42ae00104239..46551b0aa3327 100644 --- a/op-supernode/supernode/activity/interop/verified_db_test.go +++ b/op-supernode/supernode/activity/interop/verified_db_test.go @@ -354,9 +354,17 @@ func TestVerifiedDB_PendingTransition(t *testing.T) { Result: &Result{ Timestamp: 42, L1Inclusion: eth.BlockID{Hash: common.HexToHash("0x1111"), Number: 42}, - InvalidHeads: map[eth.ChainID]eth.BlockID{ - eth.ChainIDFromUInt64(1): {Hash: common.HexToHash("0xaaaa"), Number: 100}, - eth.ChainIDFromUInt64(2): {Hash: common.HexToHash("0xbbbb"), Number: 200}, + InvalidHeads: map[eth.ChainID]InvalidHead{ + eth.ChainIDFromUInt64(1): { + BlockID: eth.BlockID{Hash: common.HexToHash("0xaaaa"), Number: 100}, + StateRoot: eth.Bytes32(common.HexToHash("0xstate1")), + MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xmsg1")), + }, + eth.ChainIDFromUInt64(2): { + BlockID: eth.BlockID{Hash: common.HexToHash("0xbbbb"), Number: 200}, + StateRoot: eth.Bytes32(common.HexToHash("0xstate2")), + MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xmsg2")), + }, }, }, } diff --git a/op-supernode/supernode/activity/supernode/supernode_test.go b/op-supernode/supernode/activity/supernode/supernode_test.go index bcab76ee70a6a..a0bbc4fe6c65e 100644 --- a/op-supernode/supernode/activity/supernode/supernode_test.go +++ b/op-supernode/supernode/activity/supernode/supernode_test.go @@ -97,9 +97,15 @@ func (m *mockCC) ID() eth.ChainID { func (m *mockCC) BlockTime() uint64 { return 1 } -func (m *mockCC) InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64) (bool, error) { +func (m *mockCC) InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64, stateRoot, messagePasserStorageRoot eth.Bytes32) (bool, error) { return false, nil } +func (m *mockCC) OutputV0AtBlockNumber(ctx context.Context, l2BlockNum uint64) (*eth.OutputV0, error) { + return ð.OutputV0{}, nil +} +func (m *mockCC) GetDeniedOutput(height uint64, payloadHash common.Hash) (*eth.OutputV0, error) { + return nil, nil +} func (m *mockCC) IsDenied(height uint64, payloadHash common.Hash) (bool, error) { return false, nil diff --git a/op-supernode/supernode/activity/superroot/superroot_test.go b/op-supernode/supernode/activity/superroot/superroot_test.go index f154d6766d1bd..95048dcf30b91 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -102,9 +102,15 @@ func (m *mockCC) ID() eth.ChainID { } func (m *mockCC) BlockTime() uint64 { return 1 } -func (m *mockCC) InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64) (bool, error) { +func (m *mockCC) InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64, stateRoot, messagePasserStorageRoot eth.Bytes32) (bool, error) { return false, nil } +func (m *mockCC) OutputV0AtBlockNumber(ctx context.Context, l2BlockNum uint64) (*eth.OutputV0, error) { + return ð.OutputV0{}, nil +} +func (m *mockCC) GetDeniedOutput(height uint64, payloadHash common.Hash) (*eth.OutputV0, error) { + return nil, nil +} func (m *mockCC) PruneDeniedAtOrAfterTimestamp(timestamp uint64) (map[uint64][]common.Hash, error) { return nil, nil } diff --git a/op-supernode/supernode/chain_container/chain_container.go b/op-supernode/supernode/chain_container/chain_container.go index 20b69011657d9..eb2e2053bb51d 100644 --- a/op-supernode/supernode/chain_container/chain_container.go +++ b/op-supernode/supernode/chain_container/chain_container.go @@ -61,13 +61,14 @@ type ChainContainer interface { BlockTime() uint64 // InvalidateBlock adds a block to the deny list and triggers a rewind if the chain // currently uses that block at the specified height. + // output is the marshaled eth.Output preimage for optimistic root computation. // WARNING: this is a dangerous stateful operation and is intended to be called only // by interop transition application. Other callers should not use it until the // interface is refactored to make that ownership explicit. // TODO(#19561): remove this footgun by moving reorg-triggering operations behind a // smaller interop-owned interface. // Returns true if a rewind was triggered, false otherwise. - InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64) (bool, error) + InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64, stateRoot, messagePasserStorageRoot eth.Bytes32) (bool, error) // PruneDeniedAtOrAfterTimestamp removes deny-list entries with DecisionTimestamp >= timestamp. // Returns map of removed hashes by height. PruneDeniedAtOrAfterTimestamp(timestamp uint64) (map[uint64][]common.Hash, error) @@ -77,6 +78,11 @@ type ChainContainer interface { PauseAndStopVN(ctx context.Context) error // IsDenied checks if a block hash is on the deny list at the given height. IsDenied(height uint64, payloadHash common.Hash) (bool, error) + // GetDeniedOutput returns the reconstructed OutputV0 for a denied block. + // Returns nil if the block is not denied at that height. + GetDeniedOutput(height uint64, payloadHash common.Hash) (*eth.OutputV0, error) + // OutputV0AtBlockNumber returns the full OutputV0 for the block at the given number. + OutputV0AtBlockNumber(ctx context.Context, l2BlockNum uint64) (*eth.OutputV0, error) // SetResetCallback sets a callback that is invoked when the chain resets. // The supernode uses this to notify activities about chain resets. SetResetCallback(cb ResetCallback) diff --git a/op-supernode/supernode/chain_container/invalidation.go b/op-supernode/supernode/chain_container/invalidation.go index 7bfffac5f6ad2..b82f9b3b16321 100644 --- a/op-supernode/supernode/chain_container/invalidation.go +++ b/op-supernode/supernode/chain_container/invalidation.go @@ -9,6 +9,7 @@ import ( "path/filepath" "sync" + "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum/go-ethereum/common" bolt "go.etcd.io/bbolt" ) @@ -27,10 +28,13 @@ type DenyList struct { mu sync.RWMutex } -// DenyRecord stores a denied payload hash along with decision provenance. +// DenyRecord stores a denied payload hash along with decision provenance +// and the output preimage fields for optimistic root computation. type DenyRecord struct { - PayloadHash common.Hash `json:"payloadHash"` - DecisionTimestamp uint64 `json:"decisionTimestamp"` + PayloadHash common.Hash `json:"payloadHash"` + DecisionTimestamp uint64 `json:"decisionTimestamp"` + StateRoot eth.Bytes32 `json:"stateRoot"` + MessagePasserStorageRoot eth.Bytes32 `json:"messagePasserStorageRoot"` } func encodeDenyRecords(records []DenyRecord) ([]byte, error) { @@ -42,24 +46,8 @@ func decodeDenyRecords(raw []byte) ([]DenyRecord, error) { return nil, nil } var records []DenyRecord - if err := json.Unmarshal(raw, &records); err == nil { - return records, nil - } - // Backward compatibility: legacy format is concatenated 32-byte hashes. - // Legacy entries get DecisionTimestamp: 0, which means they are never - // removed by PruneAtOrAfterTimestamp (since rewind timestamps are always - // well above 0). This is the safe default — deny decisions from before - // provenance tracking was added should be preserved rather than silently - // dropped. - if len(raw)%common.HashLength != 0 { - return nil, fmt.Errorf("invalid denylist record payload length %d", len(raw)) - } - records = make([]DenyRecord, 0, len(raw)/common.HashLength) - for i := 0; i+common.HashLength <= len(raw); i += common.HashLength { - records = append(records, DenyRecord{ - PayloadHash: common.BytesToHash(raw[i : i+common.HashLength]), - DecisionTimestamp: 0, - }) + if err := json.Unmarshal(raw, &records); err != nil { + return nil, fmt.Errorf("failed to decode denylist records: %w", err) } return records, nil } @@ -97,8 +85,9 @@ func heightToKey(height uint64) []byte { } // Add adds a payload hash to the deny list at the given block height. +// stateRoot and messagePasserStorageRoot are the output preimage fields for optimistic root computation. // Multiple hashes can be denied at the same height. -func (d *DenyList) Add(height uint64, payloadHash common.Hash, decisionTimestamp uint64) error { +func (d *DenyList) Add(height uint64, payloadHash common.Hash, decisionTimestamp uint64, stateRoot, messagePasserStorageRoot eth.Bytes32) error { d.mu.Lock() defer d.mu.Unlock() @@ -113,7 +102,6 @@ func (d *DenyList) Add(height uint64, payloadHash common.Hash, decisionTimestamp return err } - // Check if hash already exists for _, r := range records { if r.PayloadHash == payloadHash { return nil @@ -121,8 +109,10 @@ func (d *DenyList) Add(height uint64, payloadHash common.Hash, decisionTimestamp } records = append(records, DenyRecord{ - PayloadHash: payloadHash, - DecisionTimestamp: decisionTimestamp, + PayloadHash: payloadHash, + DecisionTimestamp: decisionTimestamp, + StateRoot: stateRoot, + MessagePasserStorageRoot: messagePasserStorageRoot, }) encoded, err := encodeDenyRecords(records) @@ -133,6 +123,42 @@ func (d *DenyList) Add(height uint64, payloadHash common.Hash, decisionTimestamp }) } +// 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) { + 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 + } + for _, r := range records { + if r.PayloadHash == payloadHash { + result = ð.OutputV0{ + StateRoot: r.StateRoot, + MessagePasserStorageRoot: r.MessagePasserStorageRoot, + BlockHash: payloadHash, + } + return nil + } + } + return nil + }) + + return result, err +} + // Contains checks if a payload hash is denied at the given block height. func (d *DenyList) Contains(height uint64, payloadHash common.Hash) (bool, error) { d.mu.RLock() @@ -278,7 +304,7 @@ func (d *DenyList) Close() error { // smaller interop-owned interface. // Returns true if a rewind was triggered, false otherwise. // Note: Genesis block (height=0) cannot be invalidated as there is no prior block to rewind to. -func (c *simpleChainContainer) InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64) (bool, error) { +func (c *simpleChainContainer) InvalidateBlock(ctx context.Context, height uint64, payloadHash common.Hash, decisionTimestamp uint64, stateRoot, messagePasserStorageRoot eth.Bytes32) (bool, error) { if c.denyList == nil { return false, fmt.Errorf("deny list not initialized") } @@ -288,8 +314,8 @@ func (c *simpleChainContainer) InvalidateBlock(ctx context.Context, height uint6 return false, fmt.Errorf("cannot invalidate genesis block (height=0)") } - // Add to deny list first - if err := c.denyList.Add(height, payloadHash, decisionTimestamp); err != nil { + // Add to deny list with the output preimage fields + if err := c.denyList.Add(height, payloadHash, decisionTimestamp, stateRoot, messagePasserStorageRoot); err != nil { return false, fmt.Errorf("failed to add block to deny list: %w", err) } diff --git a/op-supernode/supernode/chain_container/invalidation_test.go b/op-supernode/supernode/chain_container/invalidation_test.go index a910ed11069dc..b3891677867d6 100644 --- a/op-supernode/supernode/chain_container/invalidation_test.go +++ b/op-supernode/supernode/chain_container/invalidation_test.go @@ -13,7 +13,6 @@ import ( "github.com/ethereum/go-ethereum/core/types" gethlog "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" - bolt "go.etcd.io/bbolt" ) func TestDenyList_AddAndContains(t *testing.T) { @@ -28,7 +27,7 @@ func TestDenyList_AddAndContains(t *testing.T) { name: "single hash at height", setup: func(t *testing.T, dl *DenyList) { hash := common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111") - require.NoError(t, dl.Add(100, hash, 0)) + require.NoError(t, dl.Add(100, hash, 0, eth.Bytes32{}, eth.Bytes32{})) }, check: func(t *testing.T, dl *DenyList) { hash := common.HexToHash("0x1111111111111111111111111111111111111111111111111111111111111111") @@ -46,7 +45,7 @@ func TestDenyList_AddAndContains(t *testing.T) { common.HexToHash("0xcccc"), } for _, h := range hashes { - require.NoError(t, dl.Add(50, h, 0)) + require.NoError(t, dl.Add(50, h, 0, eth.Bytes32{}, eth.Bytes32{})) } }, check: func(t *testing.T, dl *DenyList) { @@ -66,7 +65,7 @@ func TestDenyList_AddAndContains(t *testing.T) { name: "hash at wrong height returns false", setup: func(t *testing.T, dl *DenyList) { hash := common.HexToHash("0xdddd") - require.NoError(t, dl.Add(10, hash, 0)) + require.NoError(t, dl.Add(10, hash, 0, eth.Bytes32{}, eth.Bytes32{})) }, check: func(t *testing.T, dl *DenyList) { hash := common.HexToHash("0xdddd") @@ -85,9 +84,9 @@ func TestDenyList_AddAndContains(t *testing.T) { name: "duplicate add is idempotent", setup: func(t *testing.T, dl *DenyList) { hash := common.HexToHash("0xeeee") - require.NoError(t, dl.Add(200, hash, 0)) - require.NoError(t, dl.Add(200, hash, 0)) // Add again - require.NoError(t, dl.Add(200, hash, 0)) // And again + require.NoError(t, dl.Add(200, hash, 0, eth.Bytes32{}, eth.Bytes32{})) + require.NoError(t, dl.Add(200, hash, 0, eth.Bytes32{}, eth.Bytes32{})) // Add again + require.NoError(t, dl.Add(200, hash, 0, eth.Bytes32{}, eth.Bytes32{})) // And again }, check: func(t *testing.T, dl *DenyList) { hash := common.HexToHash("0xeeee") @@ -137,7 +136,7 @@ func TestDenyList_Persistence(t *testing.T) { {300, common.HexToHash("0x4444")}, } for _, h := range hashes { - require.NoError(t, dl.Add(h.height, h.hash, 0)) + require.NoError(t, dl.Add(h.height, h.hash, 0, eth.Bytes32{}, eth.Bytes32{})) } require.NoError(t, dl.Close()) @@ -219,7 +218,7 @@ func TestDenyList_GetDeniedHashes(t *testing.T) { setup: func(t *testing.T, dl *DenyList) { for i := 0; i < 5; i++ { hash := common.BigToHash(common.Big1.Add(common.Big1, common.Big0.SetInt64(int64(i)))) - require.NoError(t, dl.Add(100, hash, 0)) + require.NoError(t, dl.Add(100, hash, 0, eth.Bytes32{}, eth.Bytes32{})) } }, check: func(t *testing.T, dl *DenyList) { @@ -232,8 +231,8 @@ func TestDenyList_GetDeniedHashes(t *testing.T) { name: "empty for clean height", setup: func(t *testing.T, dl *DenyList) { // Add hashes at other heights - require.NoError(t, dl.Add(10, common.HexToHash("0xaaaa"), 0)) - require.NoError(t, dl.Add(30, common.HexToHash("0xbbbb"), 0)) + require.NoError(t, dl.Add(10, common.HexToHash("0xaaaa"), 0, eth.Bytes32{}, eth.Bytes32{})) + require.NoError(t, dl.Add(30, common.HexToHash("0xbbbb"), 0, eth.Bytes32{}, eth.Bytes32{})) }, check: func(t *testing.T, dl *DenyList) { hashes, err := dl.GetDeniedHashes(20) @@ -245,12 +244,12 @@ func TestDenyList_GetDeniedHashes(t *testing.T) { name: "isolated by height", setup: func(t *testing.T, dl *DenyList) { // Add different hashes at different heights - require.NoError(t, dl.Add(10, common.HexToHash("0x1010"), 0)) - require.NoError(t, dl.Add(10, common.HexToHash("0x1011"), 0)) - require.NoError(t, dl.Add(20, common.HexToHash("0x2020"), 0)) - require.NoError(t, dl.Add(20, common.HexToHash("0x2021"), 0)) - require.NoError(t, dl.Add(20, common.HexToHash("0x2022"), 0)) - require.NoError(t, dl.Add(30, common.HexToHash("0x3030"), 0)) + require.NoError(t, dl.Add(10, common.HexToHash("0x1010"), 0, eth.Bytes32{}, eth.Bytes32{})) + require.NoError(t, dl.Add(10, common.HexToHash("0x1011"), 0, eth.Bytes32{}, eth.Bytes32{})) + require.NoError(t, dl.Add(20, common.HexToHash("0x2020"), 0, eth.Bytes32{}, eth.Bytes32{})) + require.NoError(t, dl.Add(20, common.HexToHash("0x2021"), 0, eth.Bytes32{}, eth.Bytes32{})) + require.NoError(t, dl.Add(20, common.HexToHash("0x2022"), 0, eth.Bytes32{}, eth.Bytes32{})) + require.NoError(t, dl.Add(30, common.HexToHash("0x3030"), 0, eth.Bytes32{}, eth.Bytes32{})) }, check: func(t *testing.T, dl *DenyList) { hashes10, err := dl.GetDeniedHashes(10) @@ -408,7 +407,7 @@ func TestInvalidateBlock(t *testing.T) { } ctx := context.Background() - rewound, err := c.InvalidateBlock(ctx, 0, common.HexToHash("0xgenesis"), 0) + rewound, err := c.InvalidateBlock(ctx, 0, common.HexToHash("0xgenesis"), 0, eth.Bytes32{}, eth.Bytes32{}) require.Error(t, err) require.Contains(t, err.Error(), "cannot invalidate genesis block") @@ -449,9 +448,16 @@ func TestInvalidateBlock(t *testing.T) { c.engine = mockEng } - // Call InvalidateBlock + testStateRoot := eth.Bytes32(common.HexToHash("0xstate")) + testMsgPasserRoot := eth.Bytes32(common.HexToHash("0xmsgpasser")) + testOut := ð.OutputV0{ + StateRoot: testStateRoot, + MessagePasserStorageRoot: testMsgPasserRoot, + BlockHash: tt.payloadHash, + } + ctx := context.Background() - rewound, err := c.InvalidateBlock(ctx, tt.height, tt.payloadHash, 0) + rewound, err := c.InvalidateBlock(ctx, tt.height, tt.payloadHash, 0, testStateRoot, testMsgPasserRoot) require.NoError(t, err) // Verify rewind behavior @@ -466,10 +472,59 @@ func TestInvalidateBlock(t *testing.T) { found, err := dl.Contains(tt.height, tt.payloadHash) require.NoError(t, err) require.True(t, found, "hash should be in denylist after InvalidateBlock") + + storedOutput, err := dl.GetOutputV0(tt.height, tt.payloadHash) + require.NoError(t, err) + require.Equal(t, testOut, storedOutput, "OutputV0 should be stored in denylist after InvalidateBlock") }) } } +func TestGetDeniedOutput(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dl, err := OpenDenyList(filepath.Join(dir, "denylist")) + require.NoError(t, err) + defer dl.Close() + + testStateRoot := eth.Bytes32(common.HexToHash("0xstate")) + testMsgPasserRoot := eth.Bytes32(common.HexToHash("0xmsgpasser")) + hash := common.HexToHash("0xdead") + + c := &simpleChainContainer{ + denyList: dl, + log: testLogger(), + } + + t.Run("returns output after InvalidateBlock", func(t *testing.T) { + require.NoError(t, dl.Add(100, hash, 0, testStateRoot, testMsgPasserRoot)) + + got, err := c.GetDeniedOutput(100, hash) + require.NoError(t, err) + want := ð.OutputV0{ + StateRoot: testStateRoot, + MessagePasserStorageRoot: testMsgPasserRoot, + BlockHash: hash, + } + require.Equal(t, want, got) + }) + + t.Run("returns nil for non-denied block", func(t *testing.T) { + got, err := c.GetDeniedOutput(999, common.HexToHash("0xunknown")) + require.NoError(t, err) + require.Nil(t, got) + }) + + t.Run("returns error when denylist not initialized", func(t *testing.T) { + noDenyList := &simpleChainContainer{ + log: testLogger(), + } + _, err := noDenyList.GetDeniedOutput(100, hash) + require.Error(t, err) + }) +} + func TestIsDenied(t *testing.T) { t.Parallel() @@ -517,7 +572,7 @@ func TestIsDenied(t *testing.T) { defer dl.Close() // Setup - require.NoError(t, dl.Add(tt.setupHeight, tt.setupHash, 0)) + require.NoError(t, dl.Add(tt.setupHeight, tt.setupHash, 0, eth.Bytes32{}, eth.Bytes32{})) // Create container c := &simpleChainContainer{ @@ -574,7 +629,7 @@ func TestDenyList_ConcurrentAccess(t *testing.T) { hash := makeHash(accessorID, j) // Write - err := dl.Add(height, hash, 0) + err := dl.Add(height, hash, 0, eth.Bytes32{}, eth.Bytes32{}) require.NoError(t, err) // Read own write @@ -617,9 +672,9 @@ func TestDenyList_PruneAtOrAfterTimestamp(t *testing.T) { hashA := common.HexToHash("0xaaaa") hashB := common.HexToHash("0xbbbb") hashC := common.HexToHash("0xcccc") - require.NoError(t, dl.Add(100, hashA, 10)) - require.NoError(t, dl.Add(100, hashB, 11)) - require.NoError(t, dl.Add(200, hashC, 12)) + require.NoError(t, dl.Add(100, hashA, 10, eth.Bytes32{}, eth.Bytes32{})) + require.NoError(t, dl.Add(100, hashB, 11, eth.Bytes32{}, eth.Bytes32{})) + require.NoError(t, dl.Add(200, hashC, 12, eth.Bytes32{}, eth.Bytes32{})) removed, err := dl.PruneAtOrAfterTimestamp(11) require.NoError(t, err) @@ -641,32 +696,154 @@ func TestDenyList_PruneAtOrAfterTimestamp(t *testing.T) { require.Empty(t, records200) } -func TestDenyList_DecodesLegacyHashOnlyFormat(t *testing.T) { +func TestDenyList_GetOutputV0(t *testing.T) { t.Parallel() + dir := t.TempDir() dl, err := OpenDenyList(dir) require.NoError(t, err) defer dl.Close() - hashA := common.HexToHash("0xaaaa") - hashB := common.HexToHash("0xbbbb") - raw := append(hashA.Bytes(), hashB.Bytes()...) + out := ð.OutputV0{ + StateRoot: eth.Bytes32(common.HexToHash("0xstate")), + MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xmsgpasser")), + BlockHash: common.HexToHash("0xblock"), + } + hash := common.HexToHash("0x1111") + require.NoError(t, dl.Add(100, hash, 0, out.StateRoot, out.MessagePasserStorageRoot)) + + want := ð.OutputV0{ + StateRoot: out.StateRoot, + MessagePasserStorageRoot: out.MessagePasserStorageRoot, + BlockHash: hash, + } + + t.Run("returns stored OutputV0 for matching hash and height", func(t *testing.T) { + got, err := dl.GetOutputV0(100, hash) + require.NoError(t, err) + require.Equal(t, want, got) + }) + + t.Run("returns nil for wrong hash at same height", func(t *testing.T) { + got, err := dl.GetOutputV0(100, common.HexToHash("0x9999")) + require.NoError(t, err) + require.Nil(t, got) + }) + + t.Run("returns nil for correct hash at wrong height", func(t *testing.T) { + got, err := dl.GetOutputV0(200, hash) + require.NoError(t, err) + require.Nil(t, got) + }) + + t.Run("returns nil for empty height", func(t *testing.T) { + got, err := dl.GetOutputV0(999, common.HexToHash("0xabcd")) + require.NoError(t, err) + require.Nil(t, got) + }) + + t.Run("isolates outputs per hash at same height", func(t *testing.T) { + out2 := ð.OutputV0{ + StateRoot: eth.Bytes32(common.HexToHash("0xstate2")), + MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xmsgpasser2")), + BlockHash: common.HexToHash("0xblock2"), + } + hash2 := common.HexToHash("0x2222") + require.NoError(t, dl.Add(100, hash2, 0, out2.StateRoot, out2.MessagePasserStorageRoot)) + + got1, err := dl.GetOutputV0(100, hash) + require.NoError(t, err) + require.Equal(t, want, got1) - // Write legacy format directly to bbolt - dl.mu.Lock() - err = dl.db.Update(func(tx *bolt.Tx) error { - return tx.Bucket(denyListBucketName).Put(heightToKey(100), raw) + want2 := ð.OutputV0{ + StateRoot: out2.StateRoot, + MessagePasserStorageRoot: out2.MessagePasserStorageRoot, + BlockHash: hash2, + } + got2, err := dl.GetOutputV0(100, hash2) + require.NoError(t, err) + require.Equal(t, want2, got2) }) - dl.mu.Unlock() +} + +func TestDenyList_OutputPersistence(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "denylist") + + out := ð.OutputV0{ + StateRoot: eth.Bytes32(common.HexToHash("0xpersist_state")), + MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xpersist_msgpasser")), + BlockHash: common.HexToHash("0xpersist_block"), + } + hash := common.HexToHash("0xdead") + want := ð.OutputV0{ + StateRoot: out.StateRoot, + MessagePasserStorageRoot: out.MessagePasserStorageRoot, + BlockHash: hash, + } + + // Write and close + dl, err := OpenDenyList(dir) + require.NoError(t, err) + require.NoError(t, dl.Add(100, hash, 42, out.StateRoot, out.MessagePasserStorageRoot)) + require.NoError(t, dl.Close()) + + // Reopen and verify output persisted + dl2, err := OpenDenyList(dir) + require.NoError(t, err) + defer dl2.Close() + + got, err := dl2.GetOutputV0(100, hash) + require.NoError(t, err) + require.Equal(t, want, got) + + records, err := dl2.GetDeniedRecords(100) + require.NoError(t, err) + require.Len(t, records, 1) + require.Equal(t, hash, records[0].PayloadHash) + require.Equal(t, uint64(42), records[0].DecisionTimestamp) + require.Equal(t, out.StateRoot, records[0].StateRoot) + require.Equal(t, out.MessagePasserStorageRoot, records[0].MessagePasserStorageRoot) +} + +func TestDenyList_GetDeniedRecords_IncludesRoots(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + dl, err := OpenDenyList(dir) require.NoError(t, err) + defer dl.Close() + + out1 := ð.OutputV0{ + StateRoot: eth.Bytes32(common.HexToHash("0xstate1")), + MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xmsg1")), + BlockHash: common.HexToHash("0xblock1"), + } + out2 := ð.OutputV0{ + StateRoot: eth.Bytes32(common.HexToHash("0xstate2")), + MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xmsg2")), + BlockHash: common.HexToHash("0xblock2"), + } + hash1 := common.HexToHash("0xaaaa") + hash2 := common.HexToHash("0xbbbb") + + require.NoError(t, dl.Add(100, hash1, 10, out1.StateRoot, out1.MessagePasserStorageRoot)) + require.NoError(t, dl.Add(100, hash2, 11, out2.StateRoot, out2.MessagePasserStorageRoot)) records, err := dl.GetDeniedRecords(100) require.NoError(t, err) require.Len(t, records, 2) - require.Equal(t, DenyRecord{PayloadHash: hashA, DecisionTimestamp: 0}, records[0]) - require.Equal(t, DenyRecord{PayloadHash: hashB, DecisionTimestamp: 0}, records[1]) - found, err := dl.Contains(100, hashA) - require.NoError(t, err) - require.True(t, found) + recordMap := make(map[common.Hash]DenyRecord) + for _, r := range records { + recordMap[r.PayloadHash] = r + } + + require.Equal(t, out1.StateRoot, recordMap[hash1].StateRoot) + require.Equal(t, out1.MessagePasserStorageRoot, recordMap[hash1].MessagePasserStorageRoot) + require.Equal(t, uint64(10), recordMap[hash1].DecisionTimestamp) + require.Equal(t, out2.StateRoot, recordMap[hash2].StateRoot) + require.Equal(t, out2.MessagePasserStorageRoot, recordMap[hash2].MessagePasserStorageRoot) + require.Equal(t, uint64(11), recordMap[hash2].DecisionTimestamp) } diff --git a/op-supernode/supernode/chain_container/super_authority.go b/op-supernode/supernode/chain_container/super_authority.go index 1455c8abf81cf..19d9d911de93c 100644 --- a/op-supernode/supernode/chain_container/super_authority.go +++ b/op-supernode/supernode/chain_container/super_authority.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup" "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-supernode/supernode/chain_container/engine_controller" "github.com/ethereum/go-ethereum/common" ) @@ -91,5 +92,21 @@ func (c *simpleChainContainer) IsDenied(height uint64, payloadHash common.Hash) return c.denyList.Contains(height, payloadHash) } +// GetDeniedOutput returns the reconstructed OutputV0 for a denied block. +func (c *simpleChainContainer) GetDeniedOutput(height uint64, payloadHash common.Hash) (*eth.OutputV0, error) { + if c.denyList == nil { + return nil, fmt.Errorf("deny list not initialized") + } + return c.denyList.GetOutputV0(height, payloadHash) +} + +// OutputV0AtBlockNumber returns the full OutputV0 for the block at the given number. +func (c *simpleChainContainer) OutputV0AtBlockNumber(ctx context.Context, l2BlockNum uint64) (*eth.OutputV0, error) { + if c.engine == nil { + return nil, engine_controller.ErrNoEngineClient + } + return c.engine.OutputV0AtBlockNumber(ctx, l2BlockNum) +} + // Interface satisfaction static check var _ rollup.SuperAuthority = (*simpleChainContainer)(nil) From 8be4ce81fc31ec290a834e365e3737821bf0d034 Mon Sep 17 00:00:00 2001 From: axelKingsley Date: Wed, 1 Apr 2026 16:07:11 -0500 Subject: [PATCH 2/6] address PR comments Made-with: Cursor --- .../supernode/activity/interop/algo_test.go | 26 +++++++++++++------ .../supernode/activity/interop/interop.go | 4 +++ .../activity/interop/interop_test.go | 2 +- .../supernode/activity/interop/types.go | 9 ------- 4 files changed, 23 insertions(+), 18 deletions(-) diff --git a/op-supernode/supernode/activity/interop/algo_test.go b/op-supernode/supernode/activity/interop/algo_test.go index 822188a971911..981cba0178076 100644 --- a/op-supernode/supernode/activity/interop/algo_test.go +++ b/op-supernode/supernode/activity/interop/algo_test.go @@ -24,10 +24,15 @@ import ( // ============================================================================= // newMockChainWithL1 creates a mock chain with the specified L1 block for OptimisticAt -func newMockChainWithL1(chainID eth.ChainID, l1Block eth.BlockID) *algoMockChain { +func newMockChainWithL1(chainID eth.ChainID, l1Block eth.BlockID, blocks ...eth.BlockID) *algoMockChain { + hashes := make(map[uint64]common.Hash, len(blocks)) + for _, b := range blocks { + hashes[b.Number] = b.Hash + } return &algoMockChain{ id: chainID, optimisticL1: l1Block, + blockHashes: hashes, } } @@ -485,7 +490,7 @@ func TestVerifyInteropMessages(t *testing.T) { interop := &Interop{ log: gethlog.New(), logsDBs: map[eth.ChainID]LogsDB{chainID: mockDB}, - chains: map[eth.ChainID]cc.ChainContainer{chainID: newMockChainWithL1(chainID, l1Block)}, + chains: map[eth.ChainID]cc.ChainContainer{chainID: newMockChainWithL1(chainID, l1Block, expectedBlock)}, } return interop, 1000, map[eth.ChainID]eth.BlockID{chainID: expectedBlock} @@ -534,7 +539,7 @@ func TestVerifyInteropMessages(t *testing.T) { }, chains: map[eth.ChainID]cc.ChainContainer{ sourceChainID: newMockChainWithL1(sourceChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), - destChainID: newMockChainWithL1(destChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), + destChainID: newMockChainWithL1(destChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}, destBlock), }, } @@ -585,7 +590,7 @@ func TestVerifyInteropMessages(t *testing.T) { }, chains: map[eth.ChainID]cc.ChainContainer{ sourceChainID: newMockChainWithL1(sourceChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), - destChainID: newMockChainWithL1(destChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), + destChainID: newMockChainWithL1(destChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}, destBlock), }, } @@ -629,7 +634,7 @@ func TestVerifyInteropMessages(t *testing.T) { }, chains: map[eth.ChainID]cc.ChainContainer{ unknownSourceChain: newMockChainWithL1(unknownSourceChain, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), - destChainID: newMockChainWithL1(destChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), + destChainID: newMockChainWithL1(destChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}, destBlock), }, } @@ -682,7 +687,7 @@ func TestVerifyInteropMessages(t *testing.T) { }, chains: map[eth.ChainID]cc.ChainContainer{ sourceChainID: newMockChainWithL1(sourceChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), - destChainID: newMockChainWithL1(destChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), + destChainID: newMockChainWithL1(destChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}, destBlock), }, } @@ -739,7 +744,7 @@ func TestVerifyInteropMessages(t *testing.T) { invalidChainID: invalidDB, }, chains: map[eth.ChainID]cc.ChainContainer{ - invalidChainID: newMockChainWithL1(invalidChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), + invalidChainID: newMockChainWithL1(invalidChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}, invalidBlock), sourceChainID: newMockChainWithL1(sourceChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), validChainID: newMockChainWithL1(validChainID, eth.BlockID{Number: 40, Hash: common.HexToHash("0xL1")}), }, @@ -889,6 +894,7 @@ type algoMockChain struct { optimisticL2 eth.BlockID optimisticL1 eth.BlockID optimisticAtErr error + blockHashes map[uint64]common.Hash } func (m *algoMockChain) ID() eth.ChainID { return m.id } @@ -934,7 +940,11 @@ func (m *algoMockChain) InvalidateBlock(ctx context.Context, height uint64, payl return false, nil } func (m *algoMockChain) OutputV0AtBlockNumber(ctx context.Context, l2BlockNum uint64) (*eth.OutputV0, error) { - return ð.OutputV0{}, nil + out := ð.OutputV0{} + if m.blockHashes != nil { + out.BlockHash = m.blockHashes[l2BlockNum] + } + return out, nil } func (m *algoMockChain) GetDeniedOutput(height uint64, payloadHash common.Hash) (*eth.OutputV0, error) { return nil, nil diff --git a/op-supernode/supernode/activity/interop/interop.go b/op-supernode/supernode/activity/interop/interop.go index 9969cd905c8e7..4f4be1ced9d42 100644 --- a/op-supernode/supernode/activity/interop/interop.go +++ b/op-supernode/supernode/activity/interop/interop.go @@ -424,6 +424,10 @@ func (i *Interop) newInvalidHead(chainID eth.ChainID, blockID eth.BlockID) (Inva if err != nil { return head, fmt.Errorf("chain %s: failed to compute OutputV0 for block %d: %w", chainID, blockID.Number, err) } + if outputV0.BlockHash != blockID.Hash { + return head, fmt.Errorf("chain %s: block %d hash changed (expected %s, got %s): possible reorg", + chainID, blockID.Number, blockID.Hash, outputV0.BlockHash) + } head.StateRoot = outputV0.StateRoot head.MessagePasserStorageRoot = outputV0.MessagePasserStorageRoot return head, nil diff --git a/op-supernode/supernode/activity/interop/interop_test.go b/op-supernode/supernode/activity/interop/interop_test.go index dea17c52f9074..26cea6e696b4e 100644 --- a/op-supernode/supernode/activity/interop/interop_test.go +++ b/op-supernode/supernode/activity/interop/interop_test.go @@ -1452,7 +1452,7 @@ func (m *mockChainContainer) OutputV0AtBlockNumber(ctx context.Context, l2BlockN return ð.OutputV0{ StateRoot: eth.Bytes32(common.HexToHash("0xmockstate")), MessagePasserStorageRoot: eth.Bytes32(common.HexToHash("0xmockmsg")), - BlockHash: common.BigToHash(common.Big0.SetUint64(l2BlockNum)), + BlockHash: common.BigToHash(new(big.Int).SetUint64(l2BlockNum)), }, nil } func (m *mockChainContainer) GetDeniedOutput(height uint64, payloadHash common.Hash) (*eth.OutputV0, error) { diff --git a/op-supernode/supernode/activity/interop/types.go b/op-supernode/supernode/activity/interop/types.go index f035468a9db60..11617611197a7 100644 --- a/op-supernode/supernode/activity/interop/types.go +++ b/op-supernode/supernode/activity/interop/types.go @@ -22,15 +22,6 @@ type InvalidHead struct { MessagePasserStorageRoot eth.Bytes32 `json:"messagePasserStorageRoot"` } -// OutputV0 reconstructs the full output from the stored preimage fields. -func (h InvalidHead) OutputV0() *eth.OutputV0 { - return ð.OutputV0{ - StateRoot: h.StateRoot, - MessagePasserStorageRoot: h.MessagePasserStorageRoot, - BlockHash: h.Hash, - } -} - // Result represents the result of interop validation at a specific timestamp given current data. // it contains all the same information as VerifiedResult, but also contains a list of invalid heads. type Result struct { From 58d28df267b4fbbceb350ebd2bf0886841c7a706 Mon Sep 17 00:00:00 2001 From: axelKingsley Date: Wed, 1 Apr 2026 14:32:48 -0500 Subject: [PATCH 3/6] feat(op-supernode): return denied block output as optimistic in superroot API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OptimisticOutputAtTimestamp now checks the deny list before falling back to the local safe block. When a block at a given height has been invalidated, the original (pre-replacement) block's output is returned as the "optimistic" output — reflecting what the block would have been without verification. This gives superroot API callers a meaningful distinction between the verified and optimistic views. Adds DenyList.LastDeniedOutputV0(height) to retrieve the most recently denied OutputV0 at a height in a single DB read. Made-with: Cursor --- .../chain_container/chain_container.go | 29 ++++- .../chain_container/chain_container_test.go | 111 ++++++++++++++++++ .../supernode/chain_container/invalidation.go | 35 ++++++ .../chain_container/invalidation_test.go | 48 ++++++++ 4 files changed, 219 insertions(+), 4 deletions(-) diff --git a/op-supernode/supernode/chain_container/chain_container.go b/op-supernode/supernode/chain_container/chain_container.go index eb2e2053bb51d..ab60751f65491 100644 --- a/op-supernode/supernode/chain_container/chain_container.go +++ b/op-supernode/supernode/chain_container/chain_container.go @@ -465,18 +465,39 @@ 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. +// OptimisticOutputAtTimestamp returns the output 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.OutputResponse, error) { + blockNum, err := c.TimestampToBlockNumber(ctx, ts) + if err != nil { + return nil, fmt.Errorf("failed to convert timestamp to block number: %w", 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 ð.OutputResponse{ + Version: eth.OutputVersionV0, + OutputRoot: eth.OutputRoot(outV0), + BlockRef: eth.L2BlockRef{Number: blockNum, Hash: outV0.BlockHash, Time: ts}, + WithdrawalStorageRoot: common.Hash(outV0.MessagePasserStorageRoot), + StateRoot: common.Hash(outV0.StateRoot), + }, nil + } + } + 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) if err != nil { return nil, fmt.Errorf("failed to resolve L2 block at timestamp: %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) diff --git a/op-supernode/supernode/chain_container/chain_container_test.go b/op-supernode/supernode/chain_container/chain_container_test.go index d9e75abbe536f..8c50f07db1691 100644 --- a/op-supernode/supernode/chain_container/chain_container_test.go +++ b/op-supernode/supernode/chain_container/chain_container_test.go @@ -1167,6 +1167,117 @@ 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) + + expectedV0 := ð.OutputV0{ + StateRoot: stateRoot, + MessagePasserStorageRoot: msgPasserRoot, + BlockHash: payloadHash, + } + require.Equal(t, eth.OutputRoot(expectedV0), out.OutputRoot) + require.Equal(t, common.Hash(stateRoot), out.StateRoot) + require.Equal(t, common.Hash(msgPasserRoot), out.WithdrawalStorageRoot) + require.Equal(t, payloadHash, out.BlockRef.Hash) + require.Equal(t, height, out.BlockRef.Number) + require.Equal(t, ts, out.BlockRef.Time) +} + +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.BlockRef.Hash) + require.Equal(t, common.Hash(latestState), out.StateRoot) + require.Equal(t, common.Hash(latestMsgPasser), out.WithdrawalStorageRoot) +} + +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 rollupClient set, so the fallback path will error — proving we reached it + } + + _, err = container.OptimisticOutputAtTimestamp(context.Background(), genesisTime+5*blockTime) + require.Error(t, err) + require.Contains(t, err.Error(), "rollup client not initialized") +} + 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() From 91ce3347e41b3fe281bea127dbf06d0a144d4cfa Mon Sep 17 00:00:00 2001 From: axelKingsley Date: Thu, 2 Apr 2026 16:32:15 -0500 Subject: [PATCH 4/6] refactor: use OutputV0 instead of OutputResponse in OutputWithRequiredL1 The SuperRootAtTimestampResponse's OutputWithRequiredL1 carried a full OutputResponse (with L2BlockRef, Version, Status, etc.) when consumers only ever accessed BlockHash and OutputRoot. Replace with *OutputV0 which directly contains the three output preimage fields (StateRoot, MessagePasserStorageRoot, BlockHash), eliminating dead weight from the superroot API. Made-with: Cursor --- .../superfaultproofs/superfaultproofs.go | 2 +- .../fault/trace/super/provider_supernode.go | 4 +- .../trace/super/provider_supernode_test.go | 48 +++++++------------ op-service/eth/superroot_at_timestamp.go | 6 +-- op-service/sources/supernode_client_test.go | 40 ++++------------ .../supernode/activity/superroot/superroot.go | 6 ++- 6 files changed, 37 insertions(+), 69 deletions(-) diff --git a/op-acceptance-tests/tests/superfaultproofs/superfaultproofs.go b/op-acceptance-tests/tests/superfaultproofs/superfaultproofs.go index 6af3f61268c9c..5a1696ee21517 100644 --- a/op-acceptance-tests/tests/superfaultproofs/superfaultproofs.go +++ b/op-acceptance-tests/tests/superfaultproofs/superfaultproofs.go @@ -110,7 +110,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.OutputRoot.BlockHash, OutputRoot: eth.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..cd383dbe9228b 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.OutputRoot.BlockHash, + OutputRoot: eth.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..f26694302ca07 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode_test.go +++ b/op-challenger/game/fault/trace/super/provider_supernode_test.go @@ -147,11 +147,10 @@ func TestSuperNodeProvider_Get(t *testing.T) { 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}} 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}, + OutputRoot: ð.OutputV0{ + StateRoot: eth.Bytes32{0xdf}, + MessagePasserStorageRoot: eth.Bytes32{0xde}, + BlockHash: common.Hash{0xcd}, }, RequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, } @@ -173,11 +172,10 @@ func TestSuperNodeProvider_Get(t *testing.T) { 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}} 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}, + OutputRoot: ð.OutputV0{ + StateRoot: eth.Bytes32{0xdf}, + MessagePasserStorageRoot: eth.Bytes32{0xde}, + BlockHash: common.Hash{0xcd}, }, RequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, } @@ -422,18 +420,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 +441,11 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (eth.SuperRootAtTimestam ChainIDs: []eth.ChainID{chainID1, chainID2}, OptimisticAtTimestamp: map[eth.ChainID]eth.OutputWithRequiredL1{ chainID1: { - Output: toOutputResponse(outputA1), + OutputRoot: outputA1, RequiredL1: l1Head, }, chainID2: { - Output: toOutputResponse(outputB1), + OutputRoot: outputB1, RequiredL1: l1Head, }, }, @@ -474,11 +460,11 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (eth.SuperRootAtTimestam ChainIDs: []eth.ChainID{chainID1, chainID2}, OptimisticAtTimestamp: map[eth.ChainID]eth.OutputWithRequiredL1{ chainID1: { - Output: toOutputResponse(outputA2), + OutputRoot: outputA2, RequiredL1: l1Head, }, chainID2: { - Output: toOutputResponse(outputB2), + OutputRoot: outputB2, RequiredL1: l1Head, }, }, @@ -492,13 +478,15 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (eth.SuperRootAtTimestam } func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvider, prev eth.SuperRootAtTimestampResponse, next eth.SuperRootAtTimestampResponse) { + chain1Out := next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)].OutputRoot chain1OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, - OutputRoot: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, + BlockHash: chain1Out.BlockHash, + OutputRoot: eth.OutputRoot(chain1Out), } + chain2Out := next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)].OutputRoot chain2OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, - OutputRoot: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, + BlockHash: chain2Out.BlockHash, + OutputRoot: eth.OutputRoot(chain2Out), } 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..0d4292705d097 100644 --- a/op-service/eth/superroot_at_timestamp.go +++ b/op-service/eth/superroot_at_timestamp.go @@ -2,10 +2,10 @@ package eth import "encoding/json" -// OutputWithRequiredL1 is the full Output and its source L1 block +// OutputWithRequiredL1 is the OutputV0 pre-image and its source L1 block type OutputWithRequiredL1 struct { - Output *OutputResponse `json:"output"` - RequiredL1 BlockID `json:"required_l1"` + OutputRoot *OutputV0 `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..c4a776ba7123d 100644 --- a/op-service/sources/supernode_client_test.go +++ b/op-service/sources/supernode_client_test.go @@ -32,22 +32,10 @@ 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}, + OutputRoot: ð.OutputV0{ + StateRoot: eth.Bytes32{0xaa}, + MessagePasserStorageRoot: eth.Bytes32{0xff}, + BlockHash: common.Hash{0x22}, }, RequiredL1: eth.BlockID{ Hash: common.Hash{0xbb}, @@ -97,22 +85,10 @@ 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}, + OutputRoot: ð.OutputV0{ + StateRoot: eth.Bytes32{0xaa}, + MessagePasserStorageRoot: eth.Bytes32{0xff}, + BlockHash: common.Hash{0x22}, }, RequiredL1: eth.BlockID{ Hash: common.Hash{0xbb}, diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 0c3661317049a..69c4b9e54d3fa 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -102,7 +102,11 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (eth.Supe return eth.SuperRootAtTimestampResponse{}, fmt.Errorf("failed to get optimistic source L1 at timestamp %v for chain ID %v: %w", timestamp, chainID, err) } optimistic[chainID] = eth.OutputWithRequiredL1{ - Output: optimisticOut, + OutputRoot: ð.OutputV0{ + StateRoot: eth.Bytes32(optimisticOut.StateRoot), + MessagePasserStorageRoot: eth.Bytes32(optimisticOut.WithdrawalStorageRoot), + BlockHash: optimisticOut.BlockRef.Hash, + }, RequiredL1: optimisticL1, } } From bef0145a024b8b2c84ec434ffeb88798e0488c1d Mon Sep 17 00:00:00 2001 From: axelKingsley Date: Thu, 2 Apr 2026 16:59:19 -0500 Subject: [PATCH 5/6] refactor: OptimisticOutputAtTimestamp returns *OutputV0 directly The only caller immediately converted the OutputResponse into an OutputV0. Return *OutputV0 from the interface and implementation, eliminating the intermediate OutputResponse construction. The deny list path returns the OutputV0 directly; the fallback path now delegates to OutputV0AtBlockNumber instead of going through the rollup RPC client. Made-with: Cursor --- .../supernode/activity/interop/algo_test.go | 2 +- .../activity/interop/interop_test.go | 2 +- .../activity/supernode/supernode_test.go | 4 +-- .../supernode/activity/superroot/superroot.go | 6 +---- .../activity/superroot/superroot_test.go | 5 ++-- .../chain_container/chain_container.go | 27 ++++--------------- .../chain_container/chain_container_test.go | 20 +++++--------- 7 files changed, 19 insertions(+), 47 deletions(-) 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 69c4b9e54d3fa..5c0a838cc3755 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -102,11 +102,7 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (eth.Supe return eth.SuperRootAtTimestampResponse{}, fmt.Errorf("failed to get optimistic source L1 at timestamp %v for chain ID %v: %w", timestamp, chainID, err) } optimistic[chainID] = eth.OutputWithRequiredL1{ - OutputRoot: ð.OutputV0{ - StateRoot: eth.Bytes32(optimisticOut.StateRoot), - MessagePasserStorageRoot: eth.Bytes32(optimisticOut.WithdrawalStorageRoot), - BlockHash: optimisticOut.BlockRef.Hash, - }, + 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 ab60751f65491..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,11 +465,11 @@ func (c *simpleChainContainer) OptimisticAt(ctx context.Context, ts uint64) (l2, return l2Block.ID(), l1Block, nil } -// OptimisticOutputAtTimestamp returns the output for the "optimistic" L2 block at the given timestamp. +// 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.OutputResponse, error) { +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 convert timestamp to block number: %w", err) @@ -481,28 +481,11 @@ func (c *simpleChainContainer) OptimisticOutputAtTimestamp(ctx context.Context, return nil, fmt.Errorf("failed to query deny list at height %d: %w", blockNum, err) } if outV0 != nil { - return ð.OutputResponse{ - Version: eth.OutputVersionV0, - OutputRoot: eth.OutputRoot(outV0), - BlockRef: eth.L2BlockRef{Number: blockNum, Hash: outV0.BlockHash, Time: ts}, - WithdrawalStorageRoot: common.Hash(outV0.MessagePasserStorageRoot), - StateRoot: common.Hash(outV0.StateRoot), - }, nil + return outV0, nil } } - if c.rollupClient == nil { - return nil, fmt.Errorf("rollup client not initialized") - } - l2Block, err := c.LocalSafeBlockAtTimestamp(ctx, ts) - if err != nil { - return nil, fmt.Errorf("failed to resolve L2 block at timestamp: %w", err) - } - 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) - } - 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 8c50f07db1691..1b51047c2e04a 100644 --- a/op-supernode/supernode/chain_container/chain_container_test.go +++ b/op-supernode/supernode/chain_container/chain_container_test.go @@ -1199,17 +1199,11 @@ func TestChainContainer_OptimisticOutputAtTimestamp_ReturnsDeniedOutput(t *testi out, err := container.OptimisticOutputAtTimestamp(context.Background(), ts) require.NoError(t, err) - expectedV0 := ð.OutputV0{ + require.Equal(t, ð.OutputV0{ StateRoot: stateRoot, MessagePasserStorageRoot: msgPasserRoot, BlockHash: payloadHash, - } - require.Equal(t, eth.OutputRoot(expectedV0), out.OutputRoot) - require.Equal(t, common.Hash(stateRoot), out.StateRoot) - require.Equal(t, common.Hash(msgPasserRoot), out.WithdrawalStorageRoot) - require.Equal(t, payloadHash, out.BlockRef.Hash) - require.Equal(t, height, out.BlockRef.Number) - require.Equal(t, ts, out.BlockRef.Time) + }, out) } func TestChainContainer_OptimisticOutputAtTimestamp_UsesLatestDeniedRecord(t *testing.T) { @@ -1246,9 +1240,9 @@ func TestChainContainer_OptimisticOutputAtTimestamp_UsesLatestDeniedRecord(t *te out, err := container.OptimisticOutputAtTimestamp(context.Background(), ts) require.NoError(t, err) - require.Equal(t, latestHash, out.BlockRef.Hash) - require.Equal(t, common.Hash(latestState), out.StateRoot) - require.Equal(t, common.Hash(latestMsgPasser), out.WithdrawalStorageRoot) + 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) { @@ -1270,12 +1264,12 @@ func TestChainContainer_OptimisticOutputAtTimestamp_FallsThroughWhenNoDenied(t * vncfg: vncfg, denyList: dl, log: log, - // No rollupClient set, so the fallback path will error — proving we reached it + // 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.Contains(t, err.Error(), "rollup client not initialized") + require.ErrorIs(t, err, engine_controller.ErrNoEngineClient) } func TestChainContainer_SyncStatus_UninitializedVirtualNode(t *testing.T) { From 900b74fa9b53d43940e9639f0c478a4ae19fda54 Mon Sep 17 00:00:00 2001 From: axelKingsley Date: Fri, 3 Apr 2026 09:13:05 -0500 Subject: [PATCH 6/6] refactor: split OutputWithRequiredL1 into Output and OutputRoot fields Rename the OutputV0 pointer from OutputRoot to Output and add a pre-computed OutputRoot (Bytes32) so callers can inspect the hash at a glance without recomputing it. Made-with: Cursor --- .../superfaultproofs/superfaultproofs.go | 2 +- .../fault/trace/super/provider_supernode.go | 4 +- .../trace/super/provider_supernode_test.go | 48 +++++++++++-------- op-service/eth/superroot_at_timestamp.go | 5 +- op-service/sources/supernode_client_test.go | 24 ++++++---- .../supernode/activity/superroot/superroot.go | 3 +- 6 files changed, 50 insertions(+), 36 deletions(-) diff --git a/op-acceptance-tests/tests/superfaultproofs/superfaultproofs.go b/op-acceptance-tests/tests/superfaultproofs/superfaultproofs.go index 5a407cf4abc17..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.OutputRoot.BlockHash, OutputRoot: eth.OutputRoot(out.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 cd383dbe9228b..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.OutputRoot.BlockHash, - OutputRoot: eth.OutputRoot(optimistic.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 f26694302ca07..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,12 +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{ - OutputRoot: ð.OutputV0{ - StateRoot: eth.Bytes32{0xdf}, - MessagePasserStorageRoot: eth.Bytes32{0xde}, - BlockHash: common.Hash{0xcd}, - }, + Output: unsafeOutput, + OutputRoot: eth.OutputRoot(unsafeOutput), RequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, } stubSuperNode.Add(prev) @@ -171,12 +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{ - OutputRoot: ð.OutputV0{ - StateRoot: eth.Bytes32{0xdf}, - MessagePasserStorageRoot: eth.Bytes32{0xde}, - BlockHash: common.Hash{0xcd}, - }, + Output: unsafeOutput, + OutputRoot: eth.OutputRoot(unsafeOutput), RequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, } stubSuperNode.Add(prev) @@ -441,11 +445,13 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (eth.SuperRootAtTimestam ChainIDs: []eth.ChainID{chainID1, chainID2}, OptimisticAtTimestamp: map[eth.ChainID]eth.OutputWithRequiredL1{ chainID1: { - OutputRoot: outputA1, + Output: outputA1, + OutputRoot: eth.OutputRoot(outputA1), RequiredL1: l1Head, }, chainID2: { - OutputRoot: outputB1, + Output: outputB1, + OutputRoot: eth.OutputRoot(outputB1), RequiredL1: l1Head, }, }, @@ -460,11 +466,13 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (eth.SuperRootAtTimestam ChainIDs: []eth.ChainID{chainID1, chainID2}, OptimisticAtTimestamp: map[eth.ChainID]eth.OutputWithRequiredL1{ chainID1: { - OutputRoot: outputA2, + Output: outputA2, + OutputRoot: eth.OutputRoot(outputA2), RequiredL1: l1Head, }, chainID2: { - OutputRoot: outputB2, + Output: outputB2, + OutputRoot: eth.OutputRoot(outputB2), RequiredL1: l1Head, }, }, @@ -478,15 +486,15 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (eth.SuperRootAtTimestam } func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvider, prev eth.SuperRootAtTimestampResponse, next eth.SuperRootAtTimestampResponse) { - chain1Out := next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)].OutputRoot + chain1 := next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)] chain1OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: chain1Out.BlockHash, - OutputRoot: eth.OutputRoot(chain1Out), + BlockHash: chain1.Output.BlockHash, + OutputRoot: chain1.OutputRoot, } - chain2Out := next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)].OutputRoot + chain2 := next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)] chain2OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: chain2Out.BlockHash, - OutputRoot: eth.OutputRoot(chain2Out), + 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 0d4292705d097..cacda91732af8 100644 --- a/op-service/eth/superroot_at_timestamp.go +++ b/op-service/eth/superroot_at_timestamp.go @@ -2,9 +2,10 @@ package eth import "encoding/json" -// OutputWithRequiredL1 is the OutputV0 pre-image and its source L1 block +// OutputWithRequiredL1 is the OutputV0 pre-image, its hash, and its source L1 block type OutputWithRequiredL1 struct { - OutputRoot *OutputV0 `json:"output_root"` + Output *OutputV0 `json:"output"` + OutputRoot Bytes32 `json:"output_root"` RequiredL1 BlockID `json:"required_l1"` } diff --git a/op-service/sources/supernode_client_test.go b/op-service/sources/supernode_client_test.go index c4a776ba7123d..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,11 +37,8 @@ func TestSuperNodeClient_SuperRootAtTimestamp(t *testing.T) { ChainIDs: []eth.ChainID{chainA, chainB}, OptimisticAtTimestamp: map[eth.ChainID]eth.OutputWithRequiredL1{ chainA: { - OutputRoot: ð.OutputV0{ - StateRoot: eth.Bytes32{0xaa}, - MessagePasserStorageRoot: eth.Bytes32{0xff}, - BlockHash: common.Hash{0x22}, - }, + Output: chainAOutput, + OutputRoot: eth.OutputRoot(chainAOutput), RequiredL1: eth.BlockID{ Hash: common.Hash{0xbb}, Number: 7842, @@ -77,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, @@ -85,11 +92,8 @@ func TestSuperNodeClient_SuperRootAtTimestamp(t *testing.T) { ChainIDs: []eth.ChainID{chainA, chainB}, OptimisticAtTimestamp: map[eth.ChainID]eth.OutputWithRequiredL1{ chainA: { - OutputRoot: ð.OutputV0{ - StateRoot: eth.Bytes32{0xaa}, - MessagePasserStorageRoot: eth.Bytes32{0xff}, - BlockHash: common.Hash{0x22}, - }, + Output: chainAOutput, + OutputRoot: eth.OutputRoot(chainAOutput), RequiredL1: eth.BlockID{ Hash: common.Hash{0xbb}, Number: 7842, diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 5c0a838cc3755..497aca7cec051 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -102,7 +102,8 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (eth.Supe return eth.SuperRootAtTimestampResponse{}, fmt.Errorf("failed to get optimistic source L1 at timestamp %v for chain ID %v: %w", timestamp, chainID, err) } optimistic[chainID] = eth.OutputWithRequiredL1{ - OutputRoot: optimisticOut, + Output: optimisticOut, + OutputRoot: eth.OutputRoot(optimisticOut), RequiredL1: optimisticL1, } }