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{ diff --git a/op-service/eth/superroot_at_timestamp.go b/op-service/eth/superroot_at_timestamp.go new file mode 100644 index 0000000000000..c842233f4981f --- /dev/null +++ b/op-service/eth/superroot_at_timestamp.go @@ -0,0 +1,35 @@ +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 { + + // 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"` + + // 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 `json:"data,omitempty"` +} diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 31d4a9c29fe0b..0224fd3c10cdd 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" @@ -32,77 +33,43 @@ 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() } } + 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 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) - } - verified[chainID] = L2WithRequiredL1{ - L2: verifiedL2, - MinRequiredL1: verifiedL1, + if errors.Is(err, ethereum.NotFound) { + notFound = true + 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) } if verifiedL1.Number < minVerifiedRequiredL1.Number || minVerifiedRequiredL1 == (eth.BlockID{}) { minVerifiedRequiredL1 = verifiedL1 @@ -111,38 +78,40 @@ 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 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 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 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("%w: %w", ethereum.NotFound, 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, } } - // Build super root from collected outputs - superV1 := eth.NewSuperV1(timestamp, chainOutputs...) - superRoot := eth.SuperRoot(superV1) - - return atTimestampResponse{ - CurrentL1Derived: currentL1Derived, - CurrentL1Verified: currentL1Verified, - VerifiedAtTimestamp: verified, + response := eth.SuperRootAtTimestampResponse{ + CurrentL1: minCurrentL1, OptimisticAtTimestamp: optimistic, - MinCurrentL1: minCurrentL1, - MinVerifiedRequiredL1: minVerifiedRequiredL1, - SuperRoot: superRoot, - }, nil + } + if !notFound { + // Build super root from collected outputs + superV1 := eth.NewSuperV1(timestamp, chainOutputs...) + superRoot := eth.SuperRoot(superV1) + 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 f85acd8572ee2..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,14 +98,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) // 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 +140,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) { @@ -170,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{ @@ -206,8 +229,6 @@ 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) } 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) {