From f6e86b29d2f5a4449a865ec53368a76d813e4ae5 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Fri, 19 Dec 2025 10:19:09 +1000 Subject: [PATCH 1/7] op-supernode: Don't treat all errors as not found. --- .../supernode/activity/superroot/superroot.go | 17 ++++++++++------- .../chain_container/chain_container.go | 1 + .../engine_controller/engine_controller.go | 7 +++++-- .../engine_controller/engine_controller_test.go | 3 ++- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 31d4a9c29fe0b..ce20391dba1c8 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -2,6 +2,7 @@ package superroot import ( "context" + "errors" "fmt" "github.com/ethereum-optimism/optimism/op-service/eth" @@ -96,9 +97,11 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest for chainID, chain := range s.chains { // verifiedAt returns the L2 block which is fully verified at the given timestamp, and the minimum L1 block at which verification is possible verifiedL2, verifiedL1, err := chain.VerifiedAt(ctx, timestamp) - if err != nil { - s.log.Warn("failed to get verified L1", "chain_id", chainID.String(), "err", err) - return atTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) + if errors.Is(err, ethereum.NotFound) { + return atTimestampResponse{}, fmt.Errorf("%w: verified L2 block not found for chain %v at timestamp %v", err, chainID, timestamp) + } else if err != nil { + s.log.Warn("failed to get verified block", "chain_id", chainID.String(), "err", err) + return atTimestampResponse{}, fmt.Errorf("failed to get verified block: %w", err) } verified[chainID] = L2WithRequiredL1{ L2: verifiedL2, @@ -111,20 +114,20 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest outRoot, err := chain.OutputRootAtL2BlockNumber(ctx, verifiedL2.Number) if err != nil { s.log.Warn("failed to compute output root at L2 block", "chain_id", chainID.String(), "l2_number", verifiedL2.Number, "err", err) - return atTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) + return atTimestampResponse{}, fmt.Errorf("failed to compute output root at L2 block %d for chain ID %v: %w", verifiedL2.Number, chainID, err) } chainOutputs = append(chainOutputs, eth.ChainIDAndOutput{ChainID: chainID, Output: outRoot}) // Optimistic output is the full output at the optimistic L2 block for the timestamp optimisticOut, err := chain.OptimisticOutputAtTimestamp(ctx, timestamp) if err != nil { - s.log.Warn("failed to get optimistic L1", "chain_id", chainID.String(), "err", err) - return atTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) + s.log.Warn("failed to get optimistic block", "chain_id", chainID.String(), "err", err) + return atTimestampResponse{}, fmt.Errorf("failed to get optimistic block at timestamp %v for chain ID %v: %w", timestamp, chainID, err) } // Also include the source L1 for context _, optimisticL1, err := chain.OptimisticAt(ctx, timestamp) if err != nil { s.log.Warn("failed to get optimistic source L1", "chain_id", chainID.String(), "err", err) - return atTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) + return atTimestampResponse{}, fmt.Errorf("failed to get optimistic source L1 at timestamp %v for chain ID %v: %w", timestamp, chainID, err) } optimistic[chainID] = OutputWithSource{ Output: optimisticOut, diff --git a/op-supernode/supernode/chain_container/chain_container.go b/op-supernode/supernode/chain_container/chain_container.go index 0e262ee94db1c..f3122b0be240f 100644 --- a/op-supernode/supernode/chain_container/chain_container.go +++ b/op-supernode/supernode/chain_container/chain_container.go @@ -276,6 +276,7 @@ func (c *simpleChainContainer) CurrentL1(ctx context.Context) (eth.BlockRef, err } // VerifiedAt returns the verified L2 and L1 blocks for the given L2 timestamp. +// Must return ethereum.NotFound if there is no safe block at the specified timestamp. func (c *simpleChainContainer) VerifiedAt(ctx context.Context, ts uint64) (l2, l1 eth.BlockID, err error) { l2Block, err := c.SafeBlockAtTimestamp(ctx, ts) if err != nil { diff --git a/op-supernode/supernode/chain_container/engine_controller/engine_controller.go b/op-supernode/supernode/chain_container/engine_controller/engine_controller.go index 1fbe41706b2d8..1da93c3a93d83 100644 --- a/op-supernode/supernode/chain_container/engine_controller/engine_controller.go +++ b/op-supernode/supernode/chain_container/engine_controller/engine_controller.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/eth" opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics" "github.com/ethereum-optimism/optimism/op-service/sources" + "github.com/ethereum/go-ethereum" gethlog "github.com/ethereum/go-ethereum/log" ) @@ -16,6 +17,7 @@ import ( type EngineController interface { // SafeBlockAtTimestamp returns the L2 block ref for the block at or before the given timestamp, // clamped to the current SAFE head. + // Must return ethereum.NotFound if there is no safe block at the specified timestamp. SafeBlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) // OutputV0AtBlockNumber returns the output preimage for the given L2 block number. OutputV0AtBlockNumber(ctx context.Context, num uint64) (*eth.OutputV0, error) @@ -60,9 +62,10 @@ func NewEngineControllerFromConfig(ctx context.Context, log gethlog.Logger, vncf var ( ErrNoEngineClient = errors.New("engine client not initialized") ErrNoRollupConfig = errors.New("rollup config not available") - ErrNotFound = errors.New("not found") ) +// SafeBlockAtTimestamp returns the L2 block ref for the block at or before the given timestamp, +// clamped to the current SAFE head. Must return ethereum.NotFound if no safe block is available at the timestamp. func (e *simpleEngineController) SafeBlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) { if e.l2 == nil { return eth.L2BlockRef{}, ErrNoEngineClient @@ -81,7 +84,7 @@ func (e *simpleEngineController) SafeBlockAtTimestamp(ctx context.Context, ts ui } if num > safeHead.Number { e.log.Warn("engine_controller: target block number exceeds safe head", "targetBlockNumber", num, "safeHead", safeHead.Number) - return eth.L2BlockRef{}, ErrNotFound + return eth.L2BlockRef{}, ethereum.NotFound } e.log.Debug("engine_controller: computed safe block number from timestamp", "timestamp", ts, "targetBlockNumber", num, "safeHead", safeHead.Number, "safeHeadErr", err) diff --git a/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go b/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go index 6545b15f7ef20..013544f985d49 100644 --- a/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go +++ b/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.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/go-ethereum" "github.com/ethereum/go-ethereum/common" gethlog "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" @@ -94,7 +95,7 @@ func TestEngineController_TargetBlockNumber(t *testing.T) { require.Equal(t, m.ref, numRef) // ts = genesis + 2*1000 => block #1000, with safe head now below target _, err = ec.SafeBlockAtTimestamp(context.Background(), 1_000+2*1000) - require.ErrorIs(t, err, ErrNotFound) + require.ErrorIs(t, err, ethereum.NotFound) } func TestEngineController_SentinelErrors(t *testing.T) { From aedc49002856dbfbbcaf7f69eaa03ee183835ef8 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Fri, 19 Dec 2025 10:33:23 +1000 Subject: [PATCH 2/7] op-service: Define JSON marshalling for SuperV1 --- op-service/eth/super_root.go | 22 ++++++++++++++++++++++ op-service/eth/super_root_test.go | 28 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/op-service/eth/super_root.go b/op-service/eth/super_root.go index 1b9601ccbd1f0..8334a37b310a6 100644 --- a/op-service/eth/super_root.go +++ b/op-service/eth/super_root.go @@ -78,6 +78,28 @@ func (o *SuperV1) Marshal() []byte { return buf } +func (o *SuperV1) MarshalJSON() ([]byte, error) { + return json.Marshal(&superV1JsonMarshalling{ + Timestamp: hexutil.Uint64(o.Timestamp), + Chains: o.Chains, + }) +} + +func (o *SuperV1) UnmarshalJSON(input []byte) error { + var dec superV1JsonMarshalling + if err := json.Unmarshal(input, &dec); err != nil { + return err + } + o.Timestamp = uint64(dec.Timestamp) + o.Chains = dec.Chains + return nil +} + +type superV1JsonMarshalling struct { + Timestamp hexutil.Uint64 `json:"timestamp"` + Chains []ChainIDAndOutput `json:"chains"` +} + func UnmarshalSuperRoot(data []byte) (Super, error) { if len(data) < 1 { return nil, ErrInvalidSuperRoot diff --git a/op-service/eth/super_root_test.go b/op-service/eth/super_root_test.go index 316b94e63e2f9..f7968dceeba16 100644 --- a/op-service/eth/super_root_test.go +++ b/op-service/eth/super_root_test.go @@ -2,6 +2,7 @@ package eth import ( "encoding/binary" + "encoding/json" "testing" "github.com/stretchr/testify/require" @@ -77,6 +78,33 @@ func TestSuperRootV1Codec(t *testing.T) { }) } +func TestSuperRootV1JSON(t *testing.T) { + t.Run("UseHexForTimestamp", func(t *testing.T) { + chainA := ChainIDAndOutput{ChainID: ChainIDFromUInt64(11), Output: Bytes32{0x01}} + superRoot := NewSuperV1(7000, chainA) + jsonData, err := json.Marshal(superRoot) + require.NoError(t, err) + + values := make(map[string]any) + err = json.Unmarshal(jsonData, &values) + require.NoError(t, err) + require.Equal(t, "0x1b58", values["timestamp"]) + }) + + t.Run("RoundTrip", func(t *testing.T) { + chainA := ChainIDAndOutput{ChainID: ChainIDFromUInt64(11), Output: Bytes32{0x01}} + chainB := ChainIDAndOutput{ChainID: ChainIDFromUInt64(12), Output: Bytes32{0x02}} + chainC := ChainIDAndOutput{ChainID: ChainIDFromUInt64(13), Output: Bytes32{0x03}} + superRoot := NewSuperV1(7000, chainA, chainB, chainC) + data, err := json.Marshal(superRoot) + require.NoError(t, err) + var actual SuperV1 + err = json.Unmarshal(data, &actual) + require.NoError(t, err) + require.Equal(t, superRoot, &actual) + }) +} + func TestResponseToSuper(t *testing.T) { t.Run("SingleChain", func(t *testing.T) { input := SuperRootResponse{ From 896c1fa7e4be9f85d6e27ca2406a66dabce9ab42 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Fri, 19 Dec 2025 11:05:16 +1000 Subject: [PATCH 3/7] op-supernode: Update superroot_atTimestamp response format * Move the response type to the eth package so it can be reused. * Remove unnecessary detail * Not found responses still return the CurrentL1 instead of an error to allow challenger to differentiate between proposals in the future and blocks that just haven't been fully processed yet --- op-service/eth/superroot_at_timestamp.go | 32 +++++++ .../supernode/activity/superroot/superroot.go | 87 ++++++------------- .../activity/superroot/superroot_test.go | 16 ++-- 3 files changed, 64 insertions(+), 71 deletions(-) create mode 100644 op-service/eth/superroot_at_timestamp.go diff --git a/op-service/eth/superroot_at_timestamp.go b/op-service/eth/superroot_at_timestamp.go new file mode 100644 index 0000000000000..0393a8fe9cb1f --- /dev/null +++ b/op-service/eth/superroot_at_timestamp.go @@ -0,0 +1,32 @@ +package eth + +// OutputWithRequiredL1 is the full Output and its source L1 block +type OutputWithRequiredL1 struct { + Output *OutputResponse `json:"output"` + RequiredL1 BlockID `json:"required_l1"` +} + +type SuperRootResponseData struct { + // UnverifiedAtTimestamp is the L2 block that would be applied if verification were assumed to be successful, + // and the minimum L1 block required to derive them. + UnverifiedAtTimestamp map[ChainID]OutputWithRequiredL1 `json:"unverified_at_timestamp"` + + // VerifiedRequiredL1 is the minimum L1 block including the required data to fully verify all blocks at this timestamp + VerifiedRequiredL1 BlockID `json:"verified_required_l1"` + + // Super is the unhashed data for the superroot at the given timestamp after all verification is applied. + Super Super `json:"super"` + + // SuperRoot is the superroot at the given timestamp after all verification is applied. + SuperRoot Bytes32 `json:"super_root"` +} + +// AtTimestampResponse is the response superroot_atTimestamp +type SuperRootAtTimestampResponse struct { + // CurrentL1 is the highest L1 block that has been fully derived and verified by all chains. + CurrentL1 BlockID `json:"current_l1"` + + // Data provides information about the super root at the requested timestamp if present. If block data at the + // requested timestamp is not present, the data will be nil. + Data *SuperRootResponseData +} diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index ce20391dba1c8..4eeb2de68c646 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -33,61 +33,27 @@ func (s *Superroot) RPCService() interface{} { return &superrootAPI{s: s} } type superrootAPI struct{ s *Superroot } -// OutputWithSource is the full Output and its source L1 block -type OutputWithSource struct { - Output *eth.OutputResponse - SourceL1 eth.BlockID -} - -// L2WithRequiredL1 is a verified L2 block and the minimum L1 block at which the verification is possible -type L2WithRequiredL1 struct { - L2 eth.BlockID - MinRequiredL1 eth.BlockID -} - -// atTimestampResponse is the response superroot_atTimestamp -// it contains: -// - CurrentL1Derived: the current L1 block that each chain has derived up to (without any verification) -// - CurrentL1Verified: the current L1 block that each verifier has processed up to -// - VerifiedAtTimestamp: the L2 blocks which are fully verified at the given timestamp, and the minimum L1 block at which verification is possible -// - OptimisticAtTimestamp: the L2 blocks which would be applied if verification were assumed to be successful, and their L1 sources -// - SuperRoot: the superroot at the given timestamp using verified L2 blocks -type atTimestampResponse struct { - CurrentL1Derived map[eth.ChainID]eth.BlockID - CurrentL1Verified map[string]eth.BlockID - VerifiedAtTimestamp map[eth.ChainID]L2WithRequiredL1 - OptimisticAtTimestamp map[eth.ChainID]OutputWithSource - MinCurrentL1 eth.BlockID - MinVerifiedRequiredL1 eth.BlockID - SuperRoot eth.Bytes32 -} - // AtTimestamp computes the super-root at the given timestamp, plus additional information about the current L1s, verified L2s, and optimistic L2s -func (api *superrootAPI) AtTimestamp(ctx context.Context, timestamp uint64) (atTimestampResponse, error) { +func (api *superrootAPI) AtTimestamp(ctx context.Context, timestamp uint64) (eth.SuperRootAtTimestampResponse, error) { return api.s.atTimestamp(ctx, timestamp) } -func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimestampResponse, error) { - currentL1Derived := map[eth.ChainID]eth.BlockID{} - // there are no Verification Activities yet, so there is no call to make to collect their CurrentL1 - // this will be replaced with a call to the Verification Activities when they are implemented - currentL1Verified := map[string]eth.BlockID{} - verified := map[eth.ChainID]L2WithRequiredL1{} - optimistic := map[eth.ChainID]OutputWithSource{} +func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (eth.SuperRootAtTimestampResponse, error) { + optimistic := map[eth.ChainID]eth.OutputWithRequiredL1{} minCurrentL1 := eth.BlockID{} minVerifiedRequiredL1 := eth.BlockID{} chainOutputs := make([]eth.ChainIDAndOutput, 0, len(s.chains)) - // get current l1s + // Get current l1s // this informs callers that the chains local views have considered at least up to this L1 block - // but does not guarantee verifiers have processed this L1 block yet. This field is likely unhelpful, but I await feedback to confirm + // TODO(#18651): Currently there are no verifiers to consider, but once there are, this needs to be updated to consider if + // they have also processed the L1 data. for chainID, chain := range s.chains { currentL1, err := chain.CurrentL1(ctx) if err != nil { s.log.Warn("failed to get current L1", "chain_id", chainID.String(), "err", err) - return atTimestampResponse{}, err + return eth.SuperRootAtTimestampResponse{}, err } - currentL1Derived[chainID] = currentL1.ID() if currentL1.ID().Number < minCurrentL1.Number || minCurrentL1 == (eth.BlockID{}) { minCurrentL1 = currentL1.ID() } @@ -98,14 +64,13 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest // verifiedAt returns the L2 block which is fully verified at the given timestamp, and the minimum L1 block at which verification is possible verifiedL2, verifiedL1, err := chain.VerifiedAt(ctx, timestamp) if errors.Is(err, ethereum.NotFound) { - return atTimestampResponse{}, fmt.Errorf("%w: verified L2 block not found for chain %v at timestamp %v", err, chainID, timestamp) + return eth.SuperRootAtTimestampResponse{ + CurrentL1: minCurrentL1, + Data: nil, // No super root available + }, nil } else if err != nil { s.log.Warn("failed to get verified block", "chain_id", chainID.String(), "err", err) - return atTimestampResponse{}, fmt.Errorf("failed to get verified block: %w", err) - } - verified[chainID] = L2WithRequiredL1{ - L2: verifiedL2, - MinRequiredL1: verifiedL1, + return eth.SuperRootAtTimestampResponse{}, fmt.Errorf("failed to get verified block: %w", err) } if verifiedL1.Number < minVerifiedRequiredL1.Number || minVerifiedRequiredL1 == (eth.BlockID{}) { minVerifiedRequiredL1 = verifiedL1 @@ -114,24 +79,24 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest outRoot, err := chain.OutputRootAtL2BlockNumber(ctx, verifiedL2.Number) if err != nil { s.log.Warn("failed to compute output root at L2 block", "chain_id", chainID.String(), "l2_number", verifiedL2.Number, "err", err) - return atTimestampResponse{}, fmt.Errorf("failed to compute output root at L2 block %d for chain ID %v: %w", verifiedL2.Number, chainID, err) + return eth.SuperRootAtTimestampResponse{}, fmt.Errorf("failed to compute output root at L2 block %d for chain ID %v: %w", verifiedL2.Number, chainID, err) } chainOutputs = append(chainOutputs, eth.ChainIDAndOutput{ChainID: chainID, Output: outRoot}) // Optimistic output is the full output at the optimistic L2 block for the timestamp optimisticOut, err := chain.OptimisticOutputAtTimestamp(ctx, timestamp) if err != nil { s.log.Warn("failed to get optimistic block", "chain_id", chainID.String(), "err", err) - return atTimestampResponse{}, fmt.Errorf("failed to get optimistic block at timestamp %v for chain ID %v: %w", timestamp, chainID, err) + return eth.SuperRootAtTimestampResponse{}, fmt.Errorf("failed to get optimistic block at timestamp %v for chain ID %v: %w", timestamp, chainID, err) } // Also include the source L1 for context _, optimisticL1, err := chain.OptimisticAt(ctx, timestamp) if err != nil { s.log.Warn("failed to get optimistic source L1", "chain_id", chainID.String(), "err", err) - return atTimestampResponse{}, fmt.Errorf("failed to get optimistic source L1 at timestamp %v for chain ID %v: %w", timestamp, chainID, err) + return eth.SuperRootAtTimestampResponse{}, fmt.Errorf("failed to get optimistic source L1 at timestamp %v for chain ID %v: %w", timestamp, chainID, err) } - optimistic[chainID] = OutputWithSource{ - Output: optimisticOut, - SourceL1: optimisticL1, + optimistic[chainID] = eth.OutputWithRequiredL1{ + Output: optimisticOut, + RequiredL1: optimisticL1, } } @@ -139,13 +104,13 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest superV1 := eth.NewSuperV1(timestamp, chainOutputs...) superRoot := eth.SuperRoot(superV1) - return atTimestampResponse{ - CurrentL1Derived: currentL1Derived, - CurrentL1Verified: currentL1Verified, - VerifiedAtTimestamp: verified, - OptimisticAtTimestamp: optimistic, - MinCurrentL1: minCurrentL1, - MinVerifiedRequiredL1: minVerifiedRequiredL1, - SuperRoot: superRoot, + return eth.SuperRootAtTimestampResponse{ + CurrentL1: minCurrentL1, + Data: ð.SuperRootResponseData{ + UnverifiedAtTimestamp: optimistic, + VerifiedRequiredL1: minVerifiedRequiredL1, + Super: superV1, + SuperRoot: superRoot, + }, }, nil } diff --git a/op-supernode/supernode/activity/superroot/superroot_test.go b/op-supernode/supernode/activity/superroot/superroot_test.go index f85acd8572ee2..75f974e3296b7 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -97,14 +97,12 @@ func TestSuperroot_AtTimestamp_Succeeds(t *testing.T) { api := &superrootAPI{s: s} out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) - require.Len(t, out.CurrentL1Derived, 2) - require.Len(t, out.VerifiedAtTimestamp, 2) - require.Len(t, out.OptimisticAtTimestamp, 2) + require.Len(t, out.Data.UnverifiedAtTimestamp, 2) // min values - require.Equal(t, uint64(2000), out.MinCurrentL1.Number) - require.Equal(t, uint64(1000), out.MinVerifiedRequiredL1.Number) + require.Equal(t, uint64(2000), out.CurrentL1.Number) + require.Equal(t, uint64(1000), out.Data.VerifiedRequiredL1.Number) // With zero outputs, the superroot will be deterministic, just ensure it's set - _ = out.SuperRoot + _ = out.Data.SuperRoot } func TestSuperroot_AtTimestamp_ComputesSuperRoot(t *testing.T) { @@ -141,7 +139,7 @@ func TestSuperroot_AtTimestamp_ComputesSuperRoot(t *testing.T) { {ChainID: eth.ChainIDFromUInt64(420), Output: out2}, } expected := eth.SuperRoot(eth.NewSuperV1(ts, chainOutputs...)) - require.Equal(t, expected, resp.SuperRoot) + require.Equal(t, expected, resp.Data.SuperRoot) } func TestSuperroot_AtTimestamp_ErrorOnCurrentL1(t *testing.T) { @@ -206,9 +204,7 @@ func TestSuperroot_AtTimestamp_EmptyChains(t *testing.T) { api := &superrootAPI{s: s} out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) - require.Len(t, out.CurrentL1Derived, 0) - require.Len(t, out.VerifiedAtTimestamp, 0) - require.Len(t, out.OptimisticAtTimestamp, 0) + require.Len(t, out.Data.UnverifiedAtTimestamp, 0) } // assertErr returns a generic error instance used to signal mock failures. From 7424be43839cd31861d798626fbb9dc078c61389 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Fri, 19 Dec 2025 11:19:37 +1000 Subject: [PATCH 4/7] op-supernode: Make optimistic blocks available even when full super root is not available. --- op-service/eth/superroot_at_timestamp.go | 9 ++++-- .../supernode/activity/superroot/superroot.go | 28 +++++++++--------- .../activity/superroot/superroot_test.go | 29 +++++++++++++++++-- 3 files changed, 48 insertions(+), 18 deletions(-) diff --git a/op-service/eth/superroot_at_timestamp.go b/op-service/eth/superroot_at_timestamp.go index 0393a8fe9cb1f..5790543a8999b 100644 --- a/op-service/eth/superroot_at_timestamp.go +++ b/op-service/eth/superroot_at_timestamp.go @@ -7,9 +7,6 @@ type OutputWithRequiredL1 struct { } type SuperRootResponseData struct { - // UnverifiedAtTimestamp is the L2 block that would be applied if verification were assumed to be successful, - // and the minimum L1 block required to derive them. - UnverifiedAtTimestamp map[ChainID]OutputWithRequiredL1 `json:"unverified_at_timestamp"` // VerifiedRequiredL1 is the minimum L1 block including the required data to fully verify all blocks at this timestamp VerifiedRequiredL1 BlockID `json:"verified_required_l1"` @@ -26,6 +23,12 @@ type SuperRootAtTimestampResponse struct { // CurrentL1 is the highest L1 block that has been fully derived and verified by all chains. CurrentL1 BlockID `json:"current_l1"` + // OptimisticAtTimestamp is the L2 block that would be applied if verification were assumed to be successful, + // and the minimum L1 block required to derive them. If Data is nil, some chains may be absent from this map, + // indicating that there is no optimistic block for the chain at the requested timestamp that can be derived + // from the L1 data currently processed. + OptimisticAtTimestamp map[ChainID]OutputWithRequiredL1 `json:"optimistic_at_timestamp"` + // Data provides information about the super root at the requested timestamp if present. If block data at the // requested timestamp is not present, the data will be nil. Data *SuperRootResponseData diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 4eeb2de68c646..eb1310e127571 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -59,15 +59,14 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (eth.Supe } } + notFound := false // collect verified and optimistic L2 and L1 blocks at the given timestamp for chainID, chain := range s.chains { // verifiedAt returns the L2 block which is fully verified at the given timestamp, and the minimum L1 block at which verification is possible verifiedL2, verifiedL1, err := chain.VerifiedAt(ctx, timestamp) if errors.Is(err, ethereum.NotFound) { - return eth.SuperRootAtTimestampResponse{ - CurrentL1: minCurrentL1, - Data: nil, // No super root available - }, nil + notFound = true + continue // To allow other chains to be populate unverified blocks } else if err != nil { s.log.Warn("failed to get verified block", "chain_id", chainID.String(), "err", err) return eth.SuperRootAtTimestampResponse{}, fmt.Errorf("failed to get verified block: %w", err) @@ -104,13 +103,16 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (eth.Supe superV1 := eth.NewSuperV1(timestamp, chainOutputs...) superRoot := eth.SuperRoot(superV1) - return eth.SuperRootAtTimestampResponse{ - CurrentL1: minCurrentL1, - Data: ð.SuperRootResponseData{ - UnverifiedAtTimestamp: optimistic, - VerifiedRequiredL1: minVerifiedRequiredL1, - Super: superV1, - SuperRoot: superRoot, - }, - }, nil + response := eth.SuperRootAtTimestampResponse{ + CurrentL1: minCurrentL1, + OptimisticAtTimestamp: optimistic, + } + if !notFound { + response.Data = ð.SuperRootResponseData{ + VerifiedRequiredL1: minVerifiedRequiredL1, + Super: superV1, + SuperRoot: superRoot, + } + } + return response, nil } diff --git a/op-supernode/supernode/activity/superroot/superroot_test.go b/op-supernode/supernode/activity/superroot/superroot_test.go index 75f974e3296b7..c5d4d50003efd 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -7,6 +7,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/eth" cc "github.com/ethereum-optimism/optimism/op-supernode/supernode/chain_container" + "github.com/ethereum/go-ethereum" gethlog "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" ) @@ -97,7 +98,7 @@ func TestSuperroot_AtTimestamp_Succeeds(t *testing.T) { api := &superrootAPI{s: s} out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) - require.Len(t, out.Data.UnverifiedAtTimestamp, 2) + require.Len(t, out.OptimisticAtTimestamp, 2) // min values require.Equal(t, uint64(2000), out.CurrentL1.Number) require.Equal(t, uint64(1000), out.Data.VerifiedRequiredL1.Number) @@ -168,6 +169,30 @@ func TestSuperroot_AtTimestamp_ErrorOnVerifiedAt(t *testing.T) { require.Error(t, err) } +func TestSuperroot_AtTimestamp_NotFoundOnVerifiedAt(t *testing.T) { + t.Parallel() + chains := map[eth.ChainID]cc.ChainContainer{ + eth.ChainIDFromUInt64(10): &mockCC{ + verifiedErr: fmt.Errorf("nope: %w", ethereum.NotFound), + }, + eth.ChainIDFromUInt64(11): &mockCC{ + verL2: eth.BlockID{Number: 200}, + verL1: eth.BlockID{Number: 1100}, + optL2: eth.BlockID{Number: 200}, + optL1: eth.BlockID{Number: 1100}, + output: eth.Bytes32{0x12}, + currentL1: eth.BlockRef{Number: 2100}, + }, + } + s := New(gethlog.New(), chains) + api := &superrootAPI{s: s} + actual, err := api.AtTimestamp(context.Background(), 123) + require.NoError(t, err) + require.Nil(t, actual.Data) + require.NotContains(t, actual.OptimisticAtTimestamp, eth.ChainIDFromUInt64(10)) + require.Contains(t, actual.OptimisticAtTimestamp, eth.ChainIDFromUInt64(11)) +} + func TestSuperroot_AtTimestamp_ErrorOnOutputRoot(t *testing.T) { t.Parallel() chains := map[eth.ChainID]cc.ChainContainer{ @@ -204,7 +229,7 @@ func TestSuperroot_AtTimestamp_EmptyChains(t *testing.T) { api := &superrootAPI{s: s} out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) - require.Len(t, out.Data.UnverifiedAtTimestamp, 0) + require.Len(t, out.OptimisticAtTimestamp, 0) } // assertErr returns a generic error instance used to signal mock failures. From 552f1943f678e1ba33567a43c0bdf7823dc35a25 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Fri, 19 Dec 2025 12:25:30 +1000 Subject: [PATCH 5/7] op-supernode: Add JSON tag to data field. --- op-service/eth/superroot_at_timestamp.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/op-service/eth/superroot_at_timestamp.go b/op-service/eth/superroot_at_timestamp.go index 5790543a8999b..c842233f4981f 100644 --- a/op-service/eth/superroot_at_timestamp.go +++ b/op-service/eth/superroot_at_timestamp.go @@ -31,5 +31,5 @@ type SuperRootAtTimestampResponse struct { // Data provides information about the super root at the requested timestamp if present. If block data at the // requested timestamp is not present, the data will be nil. - Data *SuperRootResponseData + Data *SuperRootResponseData `json:"data,omitempty"` } From f32ac40c0e0a24e237ee9ed5062c65be6b9c231a Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Fri, 19 Dec 2025 12:32:51 +1000 Subject: [PATCH 6/7] op-supernode: Fix super root calculation --- op-supernode/supernode/activity/superroot/superroot.go | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index eb1310e127571..8daee3650e759 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -99,15 +99,14 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (eth.Supe } } - // Build super root from collected outputs - superV1 := eth.NewSuperV1(timestamp, chainOutputs...) - superRoot := eth.SuperRoot(superV1) - response := eth.SuperRootAtTimestampResponse{ CurrentL1: minCurrentL1, OptimisticAtTimestamp: optimistic, } if !notFound { + // Build super root from collected outputs + superV1 := eth.NewSuperV1(timestamp, chainOutputs...) + superRoot := eth.SuperRoot(superV1) response.Data = ð.SuperRootResponseData{ VerifiedRequiredL1: minVerifiedRequiredL1, Super: superV1, From 94e356ba0f68e431113e75c3529162f3fda1d184 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Mon, 22 Dec 2025 06:13:45 +1000 Subject: [PATCH 7/7] op-supernode: Fix grammar --- op-supernode/supernode/activity/superroot/superroot.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 8daee3650e759..0224fd3c10cdd 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -66,7 +66,7 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (eth.Supe verifiedL2, verifiedL1, err := chain.VerifiedAt(ctx, timestamp) if errors.Is(err, ethereum.NotFound) { notFound = true - continue // To allow other chains to be populate unverified blocks + continue // To allow other chains to populate unverified blocks } else if err != nil { s.log.Warn("failed to get verified block", "chain_id", chainID.String(), "err", err) return eth.SuperRootAtTimestampResponse{}, fmt.Errorf("failed to get verified block: %w", err)