From 868e1c61e18003219a6485750174976dedc401ca Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 16 Dec 2025 11:45:01 +1000 Subject: [PATCH 01/11] op-challenger: PoC for super node trace provider. --- .../fault/trace/super/provider_supernode.go | 165 ++++++++++++++++++ .../supernode/activity/superroot/superroot.go | 38 ++-- 2 files changed, 187 insertions(+), 16 deletions(-) create mode 100644 op-challenger/game/fault/trace/super/provider_supernode.go diff --git a/op-challenger/game/fault/trace/super/provider_supernode.go b/op-challenger/game/fault/trace/super/provider_supernode.go new file mode 100644 index 0000000000000..4934d9d4832f4 --- /dev/null +++ b/op-challenger/game/fault/trace/super/provider_supernode.go @@ -0,0 +1,165 @@ +package super + +import ( + "context" + "errors" + "fmt" + + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" + interopTypes "github.com/ethereum-optimism/optimism/op-program/client/interop/types" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-supernode/supernode/activity/superroot" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/log" +) + +type SuperNodeRootProvider interface { + // TODO: If AtTimestampResponse is being reused it should be put in op-service + SuperRootAtTimestamp(ctx context.Context, timestamp uint64) (superroot.AtTimestampResponse, error) +} + +type SuperNodeTraceProvider struct { + PreimagePrestateProvider + logger log.Logger + rollupCfgs *RollupConfigs + rootProvider SuperNodeRootProvider + prestateTimestamp uint64 + poststateTimestamp uint64 + l1Head eth.BlockID + gameDepth types.Depth +} + +func NewSuperNodeTraceProvider(logger log.Logger, rollupCfgs *RollupConfigs, prestateProvider PreimagePrestateProvider, rootProvider SuperNodeRootProvider, l1Head eth.BlockID, gameDepth types.Depth, prestateTimestamp, poststateTimestamp uint64) *SuperNodeTraceProvider { + return &SuperNodeTraceProvider{ + logger: logger, + rollupCfgs: rollupCfgs, + PreimagePrestateProvider: prestateProvider, + rootProvider: rootProvider, + prestateTimestamp: prestateTimestamp, + poststateTimestamp: poststateTimestamp, + l1Head: l1Head, + gameDepth: gameDepth, + } +} + +func (s *SuperNodeTraceProvider) Get(ctx context.Context, pos types.Position) (common.Hash, error) { + preimage, err := s.GetPreimageBytes(ctx, pos) + if err != nil { + return common.Hash{}, err + } + return crypto.Keccak256Hash(preimage), nil +} + +func (s *SuperNodeTraceProvider) getPreimageBytesAtTimestampBoundary(ctx context.Context, timestamp uint64) ([]byte, error) { + root, err := s.rootProvider.SuperRootAtTimestamp(ctx, timestamp) + // TODO: Ideally we could check here if the node is in sync enough using root.CurrentL1Verified + // but we would need to get that value even if the response is not found + // TODO: Also make sure the client when written actually returns ethereum.NotFound not just the string "not found" + if errors.Is(err, ethereum.NotFound) { + // No block at this timestamp so it must be invalid + return InvalidTransition, nil + } else if err != nil { + return nil, fmt.Errorf("failed to retrieve super root at timestamp %v: %w", timestamp, err) + } + if root.MaxVerifiedRequiredL1.Number > s.l1Head.Number { + return InvalidTransition, nil + } + return root.Super.Marshal(), nil +} + +func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types.Position) ([]byte, error) { + // Find the timestamp and step at position + timestamp, step, err := s.ComputeStep(pos) + if err != nil { + return nil, err + } + s.logger.Trace("Getting claim", "pos", pos.ToGIndex(), "timestamp", timestamp, "step", step) + if step == 0 { + return s.getPreimageBytesAtTimestampBoundary(ctx, timestamp) + } + // Fetch the super root at the next timestamp since we are part way through the transition to it + prevRoot, err := s.rootProvider.SuperRootAtTimestamp(ctx, timestamp) + // TODO: Ideally we could check here if the node is in sync enough using root.CurrentL1Verified (as above) + if errors.Is(err, ethereum.NotFound) { + // No block at this timestamp so it must be invalid + return InvalidTransition, nil + } else if err != nil { + return nil, fmt.Errorf("failed to retrieve previous super root at timestamp %v: %w", timestamp, err) + } + if prevRoot.MaxVerifiedRequiredL1.Number > s.l1Head.Number { + // The previous root was not safe at the game L1 head so we must have already transitioned to the invalid hash + // prior to this step and it then repeats forever. + return InvalidTransition, nil + } + nextTimestamp := timestamp + 1 + nextRoot, err := s.rootProvider.SuperRootAtTimestamp(ctx, nextTimestamp) + // TODO: Ideally we could check here if the node is in sync enough using root.CurrentL1Verified (as above) + // Note that if we do teh in sync check on every call we could safely be load balanced and would just error if + // we get load balanced to a node that is not in sync enough. Unclear how useful that is if we keep hitting an unhealthy node though. + // May need to have a SuperRootAtTimestamps (plural) method to get prev and next in one call. + if errors.Is(err, ethereum.NotFound) { + // No block at this timestamp so it must be invalid + return InvalidTransition, nil + } else if err != nil { + return nil, fmt.Errorf("failed to retrieve next super root at timestamp %v: %w", nextTimestamp, err) + } + + prevSuper := prevRoot.Super + expectedState := interopTypes.TransitionState{ + SuperRoot: prevSuper.Marshal(), + PendingProgress: make([]interopTypes.OptimisticBlock, 0, step), + Step: step, + } + nextSuperV1, ok := nextRoot.Super.(*eth.SuperV1) + if !ok { + return nil, fmt.Errorf("unsupported super root type %T", nextRoot.Super) + } + for i := uint64(0); i < min(step, uint64(len(nextSuperV1.Chains))); i++ { + chainInfo := nextSuperV1.Chains[i] + // Check if the chain's optimistic root was safe at the game's L1 head + if verified, ok := nextRoot.VerifiedAtTimestamp[chainInfo.ChainID]; !ok { + return nil, fmt.Errorf("no safe head known for chain %v at %v: %w", chainInfo.ChainID, nextTimestamp, err) + } else if verified.MinRequiredL1.Number > s.l1Head.Number { + return InvalidTransition, nil + } + + rawOutput := nextRoot.OptimisticAtTimestamp[chainInfo.ChainID].Output + expectedState.PendingProgress = append(expectedState.PendingProgress, interopTypes.OptimisticBlock{ + BlockHash: rawOutput.BlockRef.Hash, + OutputRoot: rawOutput.OutputRoot, + }) + } + return expectedState.Marshal(), nil +} + +func (s *SuperNodeTraceProvider) ComputeStep(pos types.Position) (timestamp uint64, step uint64, err error) { + bigIdx := pos.TraceIndex(s.gameDepth) + if !bigIdx.IsUint64() { + err = fmt.Errorf("%w: %v", ErrIndexTooBig, bigIdx) + return + } + + traceIdx := bigIdx.Uint64() + 1 + timestampIncrements := traceIdx / StepsPerTimestamp + timestamp = s.prestateTimestamp + timestampIncrements + if timestamp >= s.poststateTimestamp { // Apply trace extension once the claimed timestamp is reached + timestamp = s.poststateTimestamp + step = 0 + } else { + step = traceIdx % StepsPerTimestamp + } + return +} + +func (s *SuperNodeTraceProvider) GetStepData(_ context.Context, _ types.Position) (prestate []byte, proofData []byte, preimageData *types.PreimageOracleData, err error) { + return nil, nil, nil, ErrGetStepData +} + +func (s *SuperNodeTraceProvider) GetL2BlockNumberChallenge(_ context.Context) (*types.InvalidL2BlockNumberChallenge, error) { + // Never need to challenge L2 block number for super root games. + return nil, types.ErrL2BlockNumberValid +} + +var _ types.TraceProvider = (*SuperNodeTraceProvider)(nil) diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 31d4a9c29fe0b..db644fe72cead 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -44,29 +44,34 @@ type L2WithRequiredL1 struct { MinRequiredL1 eth.BlockID } -// atTimestampResponse is the response superroot_atTimestamp +// 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 { +type AtTimestampResponse struct { + // TODO: We should probably specify json names for these 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 + MaxVerifiedRequiredL1 eth.BlockID + // Not entirely sure it's a good idea to include the actual super implementation here rather than extracting the data from it + // But it sure is convenient. + // If we do this we need to sepecify json names in the eth.SuperV1 struct + Super eth.Super + 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) (AtTimestampResponse, error) { return api.s.atTimestamp(ctx, timestamp) } -func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimestampResponse, error) { +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 @@ -74,7 +79,7 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest verified := map[eth.ChainID]L2WithRequiredL1{} optimistic := map[eth.ChainID]OutputWithSource{} minCurrentL1 := eth.BlockID{} - minVerifiedRequiredL1 := eth.BlockID{} + maxVerifiedRequiredL1 := eth.BlockID{} chainOutputs := make([]eth.ChainIDAndOutput, 0, len(s.chains)) // get current l1s @@ -84,7 +89,7 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest 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 AtTimestampResponse{}, err } currentL1Derived[chainID] = currentL1.ID() if currentL1.ID().Number < minCurrentL1.Number || minCurrentL1 == (eth.BlockID{}) { @@ -98,33 +103,33 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest 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) + return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) } verified[chainID] = L2WithRequiredL1{ L2: verifiedL2, MinRequiredL1: verifiedL1, } - if verifiedL1.Number < minVerifiedRequiredL1.Number || minVerifiedRequiredL1 == (eth.BlockID{}) { - minVerifiedRequiredL1 = verifiedL1 + if verifiedL1.Number > maxVerifiedRequiredL1.Number || maxVerifiedRequiredL1 == (eth.BlockID{}) { + maxVerifiedRequiredL1 = verifiedL1 } // Compute output root at or before timestamp using the verified L2 block number 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("%w: %w", ethereum.NotFound, 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) + return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, 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("%w: %w", ethereum.NotFound, err) } optimistic[chainID] = OutputWithSource{ Output: optimisticOut, @@ -136,13 +141,14 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest superV1 := eth.NewSuperV1(timestamp, chainOutputs...) superRoot := eth.SuperRoot(superV1) - return atTimestampResponse{ + return AtTimestampResponse{ CurrentL1Derived: currentL1Derived, CurrentL1Verified: currentL1Verified, VerifiedAtTimestamp: verified, OptimisticAtTimestamp: optimistic, MinCurrentL1: minCurrentL1, - MinVerifiedRequiredL1: minVerifiedRequiredL1, + MaxVerifiedRequiredL1: maxVerifiedRequiredL1, + Super: superV1, SuperRoot: superRoot, }, nil } From 3d8b678455cdb0f100bc82fdcfce07f015510acf Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 16 Dec 2025 12:37:55 +1000 Subject: [PATCH 02/11] Add some more TODOs. --- op-challenger/game/generic/player.go | 5 ++++- op-supernode/supernode/activity/superroot/superroot.go | 4 ++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/op-challenger/game/generic/player.go b/op-challenger/game/generic/player.go index 5545a51b7095f..6e3d0155330da 100644 --- a/op-challenger/game/generic/player.go +++ b/op-challenger/game/generic/player.go @@ -135,7 +135,10 @@ func (g *GamePlayer) ProgressGame(ctx context.Context) gameTypes.GameStatus { return g.status } g.logger.Trace("Checking if actions are required") - if err := g.actor.Act(ctx); err != nil { + if err := g.actor.Act(ctx); errors.Is(err, client.ErrNotInSync) { + g.logger.Warn("Local node not sufficiently up to date to act on game", "err", err) + return g.status + } else if err != nil { g.logger.Error("Error when acting on game", "err", err) } status, err := g.loader.GetStatus(ctx) diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index db644fe72cead..0d6c8f323319c 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -103,6 +103,7 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimest verifiedL2, verifiedL1, err := chain.VerifiedAt(ctx, timestamp) if err != nil { s.log.Warn("failed to get verified L1", "chain_id", chainID.String(), "err", err) + // TODO: It doesn't seem safe to translate all errors to not found return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) } verified[chainID] = L2WithRequiredL1{ @@ -116,6 +117,7 @@ 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) + // TODO: It doesn't seem safe to translate all errors to not found return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) } chainOutputs = append(chainOutputs, eth.ChainIDAndOutput{ChainID: chainID, Output: outRoot}) @@ -123,12 +125,14 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimest optimisticOut, err := chain.OptimisticOutputAtTimestamp(ctx, timestamp) if err != nil { s.log.Warn("failed to get optimistic L1", "chain_id", chainID.String(), "err", err) + // TODO: It doesn't seem safe to translate all errors to not found return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, 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) + // TODO: It doesn't seem safe to translate all errors to not found return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) } optimistic[chainID] = OutputWithSource{ From 0bfadddaee585e85296e72114c684e8f0207adfb Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 16 Dec 2025 12:44:15 +1000 Subject: [PATCH 03/11] op-challenger: Update unit test a bit --- op-supernode/supernode/activity/superroot/superroot_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/op-supernode/supernode/activity/superroot/superroot_test.go b/op-supernode/supernode/activity/superroot/superroot_test.go index f85acd8572ee2..2844013b0daf7 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -102,7 +102,7 @@ func TestSuperroot_AtTimestamp_Succeeds(t *testing.T) { 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(1100), out.MaxVerifiedRequiredL1.Number) // With zero outputs, the superroot will be deterministic, just ensure it's set _ = out.SuperRoot } From 3db588c91fea97863f62aca5cad162d726e3195f Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 16 Dec 2025 12:45:47 +1000 Subject: [PATCH 04/11] Fix spelling --- op-challenger/game/fault/trace/super/provider_supernode.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/op-challenger/game/fault/trace/super/provider_supernode.go b/op-challenger/game/fault/trace/super/provider_supernode.go index 4934d9d4832f4..bec22f1763006 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode.go +++ b/op-challenger/game/fault/trace/super/provider_supernode.go @@ -96,7 +96,7 @@ func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types nextTimestamp := timestamp + 1 nextRoot, err := s.rootProvider.SuperRootAtTimestamp(ctx, nextTimestamp) // TODO: Ideally we could check here if the node is in sync enough using root.CurrentL1Verified (as above) - // Note that if we do teh in sync check on every call we could safely be load balanced and would just error if + // Note that if we do the in sync check on every call we could safely be load balanced and would just error if // we get load balanced to a node that is not in sync enough. Unclear how useful that is if we keep hitting an unhealthy node though. // May need to have a SuperRootAtTimestamps (plural) method to get prev and next in one call. if errors.Is(err, ethereum.NotFound) { From f726ae64565cec383ba22089bf74c8bd476fdb83 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 16 Dec 2025 13:47:55 +1000 Subject: [PATCH 05/11] Use the right source for optimistic head safety. --- .../game/fault/trace/super/provider_supernode.go | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/op-challenger/game/fault/trace/super/provider_supernode.go b/op-challenger/game/fault/trace/super/provider_supernode.go index bec22f1763006..75fbef3c6386c 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode.go +++ b/op-challenger/game/fault/trace/super/provider_supernode.go @@ -119,16 +119,18 @@ func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types for i := uint64(0); i < min(step, uint64(len(nextSuperV1.Chains))); i++ { chainInfo := nextSuperV1.Chains[i] // Check if the chain's optimistic root was safe at the game's L1 head - if verified, ok := nextRoot.VerifiedAtTimestamp[chainInfo.ChainID]; !ok { + optimistic, ok := nextRoot.OptimisticAtTimestamp[chainInfo.ChainID] + if !ok { return nil, fmt.Errorf("no safe head known for chain %v at %v: %w", chainInfo.ChainID, nextTimestamp, err) - } else if verified.MinRequiredL1.Number > s.l1Head.Number { + } + if optimistic.SourceL1.Number > s.l1Head.Number { + // Not enough data on L1 to derive the optimistic block, move to invalid transition. return InvalidTransition, nil } - rawOutput := nextRoot.OptimisticAtTimestamp[chainInfo.ChainID].Output expectedState.PendingProgress = append(expectedState.PendingProgress, interopTypes.OptimisticBlock{ - BlockHash: rawOutput.BlockRef.Hash, - OutputRoot: rawOutput.OutputRoot, + BlockHash: optimistic.Output.BlockRef.Hash, + OutputRoot: optimistic.Output.OutputRoot, }) } return expectedState.Marshal(), nil From c64536b19170214136b10e2bc9f01f6592d1378a Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Tue, 16 Dec 2025 14:22:53 +1000 Subject: [PATCH 06/11] op-challenger: Port unit tests for provider. --- .../fault/trace/super/provider_supernode.go | 4 +- .../trace/super/provider_supernode_test.go | 423 ++++++++++++++++++ .../supernode/activity/superroot/superroot.go | 5 +- .../activity/superroot/superroot_test.go | 3 +- 4 files changed, 429 insertions(+), 6 deletions(-) create mode 100644 op-challenger/game/fault/trace/super/provider_supernode_test.go diff --git a/op-challenger/game/fault/trace/super/provider_supernode.go b/op-challenger/game/fault/trace/super/provider_supernode.go index 75fbef3c6386c..f78c632cf420c 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode.go +++ b/op-challenger/game/fault/trace/super/provider_supernode.go @@ -23,7 +23,6 @@ type SuperNodeRootProvider interface { type SuperNodeTraceProvider struct { PreimagePrestateProvider logger log.Logger - rollupCfgs *RollupConfigs rootProvider SuperNodeRootProvider prestateTimestamp uint64 poststateTimestamp uint64 @@ -31,10 +30,9 @@ type SuperNodeTraceProvider struct { gameDepth types.Depth } -func NewSuperNodeTraceProvider(logger log.Logger, rollupCfgs *RollupConfigs, prestateProvider PreimagePrestateProvider, rootProvider SuperNodeRootProvider, l1Head eth.BlockID, gameDepth types.Depth, prestateTimestamp, poststateTimestamp uint64) *SuperNodeTraceProvider { +func NewSuperNodeTraceProvider(logger log.Logger, prestateProvider PreimagePrestateProvider, rootProvider SuperNodeRootProvider, l1Head eth.BlockID, gameDepth types.Depth, prestateTimestamp, poststateTimestamp uint64) *SuperNodeTraceProvider { return &SuperNodeTraceProvider{ logger: logger, - rollupCfgs: rollupCfgs, PreimagePrestateProvider: prestateProvider, rootProvider: rootProvider, prestateTimestamp: prestateTimestamp, diff --git a/op-challenger/game/fault/trace/super/provider_supernode_test.go b/op-challenger/game/fault/trace/super/provider_supernode_test.go new file mode 100644 index 0000000000000..fa7d7cddbe388 --- /dev/null +++ b/op-challenger/game/fault/trace/super/provider_supernode_test.go @@ -0,0 +1,423 @@ +package super + +import ( + "context" + "fmt" + "math/big" + "math/rand" + "testing" + + "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" + interopTypes "github.com/ethereum-optimism/optimism/op-program/client/interop/types" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum-optimism/optimism/op-service/testutils" + "github.com/ethereum-optimism/optimism/op-supernode/supernode/activity/superroot" + "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +func TestSuperNodeProvider_Get(t *testing.T) { + t.Run("AtPostState", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + expectedSuper := eth.NewSuperV1(poststateTimestamp, eth.ChainIDAndOutput{ + ChainID: eth.ChainIDFromUInt64(1), + Output: eth.Bytes32{0xbb}, + }) + response := superroot.AtTimestampResponse{ + //MinCurrentL1: l1Head, + MaxVerifiedRequiredL1: l1Head, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + } + stubSupervisor.Add(response) + claim, err := provider.Get(context.Background(), types.RootPosition) + require.NoError(t, err) + require.Equal(t, common.Hash(eth.SuperRoot(expectedSuper)), claim) + }) + + t.Run("AtNewTimestamp", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + expectedSuper := eth.NewSuperV1(prestateTimestamp+1, eth.ChainIDAndOutput{ + ChainID: eth.ChainIDFromUInt64(1), + Output: eth.Bytes32{0xbb}, + }) + response := superroot.AtTimestampResponse{ + MaxVerifiedRequiredL1: l1Head, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + } + stubSupervisor.Add(response) + claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(StepsPerTimestamp-1))) + require.NoError(t, err) + require.Equal(t, common.Hash(eth.SuperRoot(expectedSuper)), claim) + }) + + t.Run("ValidTransitionBetweenFirstTwoSuperRoots", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + prev, next := createValidSuperNodeSuperRoots(l1Head) + stubSupervisor.Add(prev) + stubSupervisor.Add(next) + + expectSuperNodeValidTransition(t, provider, prev, next) + }) + + t.Run("Step0SuperRootIsSafeBeforeGameL1Head", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + expectedSuper := eth.NewSuperV1(poststateTimestamp, eth.ChainIDAndOutput{ + ChainID: eth.ChainIDFromUInt64(1), + Output: eth.Bytes32{0xbb}, + }) + response := superroot.AtTimestampResponse{ + MaxVerifiedRequiredL1: eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + } + stubSupervisor.Add(response) + claim, err := provider.Get(context.Background(), types.RootPosition) + require.NoError(t, err) + require.Equal(t, common.Hash(eth.SuperRoot(expectedSuper)), claim) + }) + + t.Run("Step0SuperRootNotSafeAtGameL1Head", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + expectedSuper := eth.NewSuperV1(poststateTimestamp, eth.ChainIDAndOutput{ + ChainID: eth.ChainIDFromUInt64(1), + Output: eth.Bytes32{0xbb}, + }) + response := superroot.AtTimestampResponse{ + MaxVerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + } + stubSupervisor.Add(response) + claim, err := provider.Get(context.Background(), types.RootPosition) + require.NoError(t, err) + require.Equal(t, InvalidTransitionHash, claim) + }) + + t.Run("NextSuperRootSafeBeforeGameL1Head", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + prev, next := createValidSuperNodeSuperRoots(l1Head) + // Make super roots be safe earlier + prev.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xaa}} + next.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 5, Hash: common.Hash{0xbb}} + stubSupervisor.Add(prev) + stubSupervisor.Add(next) + expectSuperNodeValidTransition(t, provider, prev, next) + }) + + t.Run("PreviousSuperRootNotSafeAtGameL1Head", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + prev, next := createValidSuperNodeSuperRoots(l1Head) + // Make super roots be safe only after L1 head + prev.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xaa}} + next.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 2, Hash: common.Hash{0xbb}} + stubSupervisor.Add(prev) + stubSupervisor.Add(next) + + // All steps should be the invalid transition hash. + for i := int64(0); i < StepsPerTimestamp+1; i++ { + claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(i))) + require.NoError(t, err) + require.Equalf(t, InvalidTransitionHash, claim, "incorrect claim at index %d", i) + } + }) + + t.Run("FirstChainUnsafe", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + prev, next := createValidSuperNodeSuperRoots(l1Head) + // Make super roots be safe only after L1 head + prev.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} + next.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} + next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)] = superroot.OutputWithSource{ + Output: ð.OutputResponse{ + OutputRoot: eth.Bytes32{0xad}, + BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, + WithdrawalStorageRoot: common.Hash{0xde}, + StateRoot: common.Hash{0xdf}, + }, + SourceL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, + } + stubSupervisor.Add(prev) + stubSupervisor.Add(next) + + // All steps should be the invalid transition hash. + for i := int64(0); i < StepsPerTimestamp+1; i++ { + claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(i))) + require.NoError(t, err) + require.Equalf(t, InvalidTransitionHash, claim, "incorrect claim at index %d", i) + } + }) + + t.Run("SecondChainUnsafe", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + prev, next := createValidSuperNodeSuperRoots(l1Head) + // Make super roots be safe only after L1 head + prev.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} + next.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} + next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)] = superroot.OutputWithSource{ + Output: ð.OutputResponse{ + OutputRoot: eth.Bytes32{0xad}, + BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, + WithdrawalStorageRoot: common.Hash{0xde}, + StateRoot: common.Hash{0xdf}, + }, + SourceL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, + } + stubSupervisor.Add(prev) + stubSupervisor.Add(next) + + // First step should be valid because we can reach the required block on chain 1 + claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(0))) + require.NoError(t, err) + require.NotEqual(t, InvalidTransitionHash, claim, "incorrect claim at index 0") + + // Remaining steps should be the invalid transition hash. + for i := int64(1); i < StepsPerTimestamp+1; i++ { + claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(i))) + require.NoError(t, err) + require.Equalf(t, InvalidTransitionHash, claim, "incorrect claim at index %d", i) + } + }) + + t.Run("Step0ForTimestampBeyondChainHead", func(t *testing.T) { + provider, _, _ := createSuperNodeProvider(t) + // No response added so supervisor will return not found. + claim, err := provider.Get(context.Background(), types.RootPosition) + require.NoError(t, err) + require.Equal(t, InvalidTransitionHash, claim) + }) + + t.Run("NextSuperRootTimestampBeyondChainHead", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + prev, _ := createValidSuperNodeSuperRoots(l1Head) + stubSupervisor.Add(prev) + // Next super root response is not added so supervisor will return not found + + // All steps should be the invalid transition hash. + for i := int64(0); i < StepsPerTimestamp+1; i++ { + claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(i))) + require.NoError(t, err) + require.Equalf(t, InvalidTransitionHash, claim, "incorrect claim at index %d", i) + } + }) + + t.Run("PreviousSuperRootTimestampBeyondChainHead", func(t *testing.T) { + provider, _, _ := createSuperNodeProvider(t) + // No super root responses are added so supervisor will return not found + + // All steps should be the invalid transition hash. + for i := int64(0); i < StepsPerTimestamp+1; i++ { + claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(i))) + require.NoError(t, err) + require.Equalf(t, InvalidTransitionHash, claim, "incorrect claim at index %d", i) + } + }) +} + +func TestSuperNodeProvider_ComputeStep(t *testing.T) { + t.Run("ErrorWhenTraceIndexTooBig", func(t *testing.T) { + // Uses a big game depth so the trace index doesn't fit in uint64 + provider := NewSuperNodeTraceProvider(testlog.Logger(t, log.LvlInfo), nil, &stubSuperNodeRootProvider{}, eth.BlockID{}, 65, prestateTimestamp, poststateTimestamp) + // Left-most position in top game + _, _, err := provider.ComputeStep(types.RootPosition) + require.ErrorIs(t, err, ErrIndexTooBig) + }) + + t.Run("FirstTimestampSteps", func(t *testing.T) { + provider, _, _ := createSuperNodeProvider(t) + for i := int64(0); i < StepsPerTimestamp-1; i++ { + timestamp, step, err := provider.ComputeStep(types.NewPosition(gameDepth, big.NewInt(i))) + require.NoError(t, err) + // The prestate must be a super root and is on the timestamp boundary. + // So the first step has the same timestamp and increments step from 0 to 1. + require.Equalf(t, prestateTimestamp, timestamp, "Incorrect timestamp at trace index %d", i) + require.Equalf(t, uint64(i+1), step, "Incorrect step at trace index %d", i) + } + }) + + t.Run("SecondTimestampSteps", func(t *testing.T) { + provider, _, _ := createSuperNodeProvider(t) + for i := int64(-1); i < StepsPerTimestamp-1; i++ { + traceIndex := StepsPerTimestamp + i + timestamp, step, err := provider.ComputeStep(types.NewPosition(gameDepth, big.NewInt(traceIndex))) + require.NoError(t, err) + // We should now be iterating through the steps of the second timestamp - 1s after the prestate + require.Equalf(t, prestateTimestamp+1, timestamp, "Incorrect timestamp at trace index %d", traceIndex) + require.Equalf(t, uint64(i+1), step, "Incorrect step at trace index %d", traceIndex) + } + }) + + t.Run("LimitToPoststateTimestamp", func(t *testing.T) { + provider, _, _ := createSuperNodeProvider(t) + timestamp, step, err := provider.ComputeStep(types.RootPosition) + require.NoError(t, err) + require.Equal(t, poststateTimestamp, timestamp, "Incorrect timestamp at root position") + require.Equal(t, uint64(0), step, "Incorrect step at trace index at root position") + }) + + t.Run("StepShouldLoopBackToZero", func(t *testing.T) { + provider, _, _ := createSuperNodeProvider(t) + prevTimestamp := prestateTimestamp + prevStep := uint64(0) // Absolute prestate is always on a timestamp boundary, so step 0 + for traceIndex := int64(0); traceIndex < 5*StepsPerTimestamp; traceIndex++ { + timestamp, step, err := provider.ComputeStep(types.NewPosition(gameDepth, big.NewInt(traceIndex))) + require.NoError(t, err) + if timestamp == prevTimestamp { + require.Equal(t, prevStep+1, step, "Incorrect step at trace index %d", traceIndex) + } else { + require.Equal(t, prevTimestamp+1, timestamp, "Incorrect timestamp at trace index %d", traceIndex) + require.Zero(t, step, "Incorrect step at trace index %d", traceIndex) + require.Equal(t, uint64(StepsPerTimestamp-1), prevStep, "Should only loop back to step 0 after the consolidation step") + } + prevTimestamp = timestamp + prevStep = step + } + }) +} + +func TestSuperNodeProvider_GetStepDataReturnsError(t *testing.T) { + provider, _, _ := createSuperNodeProvider(t) + _, _, _, err := provider.GetStepData(context.Background(), types.RootPosition) + require.ErrorIs(t, err, ErrGetStepData) +} + +func TestSuperNodeProvider_GetL2BlockNumberChallengeReturnsError(t *testing.T) { + provider, _, _ := createSuperNodeProvider(t) + _, err := provider.GetL2BlockNumberChallenge(context.Background()) + require.ErrorIs(t, err, types.ErrL2BlockNumberValid) +} + +func createSuperNodeProvider(t *testing.T) (*SuperNodeTraceProvider, *stubSuperNodeRootProvider, eth.BlockID) { + logger := testlog.Logger(t, log.LvlInfo) + l1Head := eth.BlockID{Number: 23542, Hash: common.Hash{0xab, 0xcd}} + stubSupervisor := &stubSuperNodeRootProvider{ + rootsByTimestamp: make(map[uint64]superroot.AtTimestampResponse), + } + provider := NewSuperNodeTraceProvider(logger, nil, stubSupervisor, l1Head, gameDepth, prestateTimestamp, poststateTimestamp) + return provider, stubSupervisor, 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) (superroot.AtTimestampResponse, superroot.AtTimestampResponse) { + rng := rand.New(rand.NewSource(1)) + outputA1 := testutils.RandomOutputV0(rng) + outputA2 := testutils.RandomOutputV0(rng) + outputB1 := testutils.RandomOutputV0(rng) + outputB2 := testutils.RandomOutputV0(rng) + prevSuper := eth.NewSuperV1( + prestateTimestamp, + eth.ChainIDAndOutput{ChainID: eth.ChainIDFromUInt64(1), Output: eth.OutputRoot(outputA1)}, + eth.ChainIDAndOutput{ChainID: eth.ChainIDFromUInt64(2), Output: eth.OutputRoot(outputB1)}) + nextSuper := eth.NewSuperV1(prestateTimestamp+1, + eth.ChainIDAndOutput{ChainID: eth.ChainIDFromUInt64(1), Output: eth.OutputRoot(outputA2)}, + eth.ChainIDAndOutput{ChainID: eth.ChainIDFromUInt64(2), Output: eth.OutputRoot(outputB2)}) + + prevResponse := superroot.AtTimestampResponse{ + OptimisticAtTimestamp: map[eth.ChainID]superroot.OutputWithSource{ + eth.ChainIDFromUInt64(1): { + Output: toOutputResponse(outputA1), + SourceL1: l1Head, + }, + eth.ChainIDFromUInt64(2): { + Output: toOutputResponse(outputB1), + SourceL1: l1Head, + }, + }, + MinCurrentL1: l1Head, + MaxVerifiedRequiredL1: l1Head, + Super: prevSuper, + SuperRoot: eth.SuperRoot(prevSuper), + } + nextResponse := superroot.AtTimestampResponse{ + OptimisticAtTimestamp: map[eth.ChainID]superroot.OutputWithSource{ + eth.ChainIDFromUInt64(1): { + Output: toOutputResponse(outputA2), + SourceL1: l1Head, + }, + eth.ChainIDFromUInt64(2): { + Output: toOutputResponse(outputB2), + SourceL1: l1Head, + }, + }, + MinCurrentL1: l1Head, + MaxVerifiedRequiredL1: l1Head, + Super: nextSuper, + SuperRoot: eth.SuperRoot(nextSuper), + } + return prevResponse, nextResponse +} + +func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvider, prev superroot.AtTimestampResponse, next superroot.AtTimestampResponse) { + chain1OptimisticBlock := interopTypes.OptimisticBlock{ + BlockHash: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, + OutputRoot: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, + } + chain2OptimisticBlock := interopTypes.OptimisticBlock{ + BlockHash: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, + OutputRoot: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, + } + expectedFirstStep := &interopTypes.TransitionState{ + SuperRoot: prev.Super.Marshal(), + PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock}, + Step: 1, + } + claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(0))) + require.NoError(t, err) + require.Equal(t, expectedFirstStep.Hash(), claim) + + expectedSecondStep := &interopTypes.TransitionState{ + SuperRoot: prev.Super.Marshal(), + PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock, chain2OptimisticBlock}, + Step: 2, + } + claim, err = provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(1))) + require.NoError(t, err) + require.Equal(t, expectedSecondStep.Hash(), claim) + + for step := uint64(3); step < StepsPerTimestamp; step++ { + expectedPaddingStep := &interopTypes.TransitionState{ + SuperRoot: prev.Super.Marshal(), + PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock, chain2OptimisticBlock}, + Step: step, + } + claim, err = provider.Get(context.Background(), types.NewPosition(gameDepth, new(big.Int).SetUint64(step-1))) + require.NoError(t, err) + require.Equalf(t, expectedPaddingStep.Hash(), claim, "incorrect hash at step %v", step) + } +} + +type stubSuperNodeRootProvider struct { + rootsByTimestamp map[uint64]superroot.AtTimestampResponse +} + +func (s *stubSuperNodeRootProvider) Add(root superroot.AtTimestampResponse) { + if s.rootsByTimestamp == nil { + s.rootsByTimestamp = make(map[uint64]superroot.AtTimestampResponse) + } + superV1 := root.Super.(*eth.SuperV1) + s.rootsByTimestamp[superV1.Timestamp] = root +} + +func (s *stubSuperNodeRootProvider) SuperRootAtTimestamp(_ context.Context, timestamp uint64) (superroot.AtTimestampResponse, error) { + root, ok := s.rootsByTimestamp[timestamp] + if !ok { + // Note: Client implementation specifically returns ethereum.NotFound + return superroot.AtTimestampResponse{}, fmt.Errorf("timestamp %v %w", timestamp, ethereum.NotFound) + } + return root, nil +} diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 0d6c8f323319c..f6b4afe8e10d9 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.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" + "github.com/ethereum/go-ethereum/common/hexutil" gethlog "github.com/ethereum/go-ethereum/log" ) @@ -67,8 +68,8 @@ type AtTimestampResponse struct { } // 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) { - return api.s.atTimestamp(ctx, timestamp) +func (api *superrootAPI) AtTimestamp(ctx context.Context, timestamp hexutil.Uint64) (AtTimestampResponse, error) { + return api.s.atTimestamp(ctx, uint64(timestamp)) } func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimestampResponse, error) { diff --git a/op-supernode/supernode/activity/superroot/superroot_test.go b/op-supernode/supernode/activity/superroot/superroot_test.go index 2844013b0daf7..1a86a95674a33 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/common/hexutil" gethlog "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" ) @@ -132,7 +133,7 @@ func TestSuperroot_AtTimestamp_ComputesSuperRoot(t *testing.T) { ts := uint64(123) s := New(gethlog.New(), chains) api := &superrootAPI{s: s} - resp, err := api.AtTimestamp(context.Background(), ts) + resp, err := api.AtTimestamp(context.Background(), hexutil.Uint64(ts)) require.NoError(t, err) // Compute expected super root From 355af0e203e9710840d2ea3de96ca2b31f2c28e2 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Wed, 17 Dec 2025 14:13:01 +1000 Subject: [PATCH 07/11] Lots of JSON tags and opinionated renames. --- .../fault/trace/super/provider_supernode.go | 8 +- .../trace/super/provider_supernode_test.go | 92 +++++++++---------- op-service/eth/super_root.go | 22 +++++ op-service/eth/super_root_test.go | 28 ++++++ .../supernode/activity/superroot/superroot.go | 68 +++++++------- .../activity/superroot/superroot_test.go | 10 +- 6 files changed, 135 insertions(+), 93 deletions(-) diff --git a/op-challenger/game/fault/trace/super/provider_supernode.go b/op-challenger/game/fault/trace/super/provider_supernode.go index f78c632cf420c..f0f92ae52066b 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode.go +++ b/op-challenger/game/fault/trace/super/provider_supernode.go @@ -61,7 +61,7 @@ func (s *SuperNodeTraceProvider) getPreimageBytesAtTimestampBoundary(ctx context } else if err != nil { return nil, fmt.Errorf("failed to retrieve super root at timestamp %v: %w", timestamp, err) } - if root.MaxVerifiedRequiredL1.Number > s.l1Head.Number { + if root.VerifiedRequiredL1.Number > s.l1Head.Number { return InvalidTransition, nil } return root.Super.Marshal(), nil @@ -86,7 +86,7 @@ func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types } else if err != nil { return nil, fmt.Errorf("failed to retrieve previous super root at timestamp %v: %w", timestamp, err) } - if prevRoot.MaxVerifiedRequiredL1.Number > s.l1Head.Number { + if prevRoot.VerifiedRequiredL1.Number > s.l1Head.Number { // The previous root was not safe at the game L1 head so we must have already transitioned to the invalid hash // prior to this step and it then repeats forever. return InvalidTransition, nil @@ -117,11 +117,11 @@ func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types for i := uint64(0); i < min(step, uint64(len(nextSuperV1.Chains))); i++ { chainInfo := nextSuperV1.Chains[i] // Check if the chain's optimistic root was safe at the game's L1 head - optimistic, ok := nextRoot.OptimisticAtTimestamp[chainInfo.ChainID] + optimistic, ok := nextRoot.UnverifiedAtTimestamp[chainInfo.ChainID] if !ok { return nil, fmt.Errorf("no safe head known for chain %v at %v: %w", chainInfo.ChainID, nextTimestamp, err) } - if optimistic.SourceL1.Number > s.l1Head.Number { + if optimistic.RequiredL1.Number > s.l1Head.Number { // Not enough data on L1 to derive the optimistic block, move to invalid transition. return InvalidTransition, 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 fa7d7cddbe388..18f874e0b9d71 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode_test.go +++ b/op-challenger/game/fault/trace/super/provider_supernode_test.go @@ -28,9 +28,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { }) response := superroot.AtTimestampResponse{ //MinCurrentL1: l1Head, - MaxVerifiedRequiredL1: l1Head, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), + VerifiedRequiredL1: l1Head, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), } stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.RootPosition) @@ -45,9 +45,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { Output: eth.Bytes32{0xbb}, }) response := superroot.AtTimestampResponse{ - MaxVerifiedRequiredL1: l1Head, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), + VerifiedRequiredL1: l1Head, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), } stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(StepsPerTimestamp-1))) @@ -71,9 +71,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { Output: eth.Bytes32{0xbb}, }) response := superroot.AtTimestampResponse{ - MaxVerifiedRequiredL1: eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xcc}}, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), } stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.RootPosition) @@ -88,9 +88,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { Output: eth.Bytes32{0xbb}, }) response := superroot.AtTimestampResponse{ - MaxVerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), } stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.RootPosition) @@ -102,8 +102,8 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe earlier - prev.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xaa}} - next.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 5, Hash: common.Hash{0xbb}} + prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xaa}} + next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 5, Hash: common.Hash{0xbb}} stubSupervisor.Add(prev) stubSupervisor.Add(next) expectSuperNodeValidTransition(t, provider, prev, next) @@ -113,8 +113,8 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe only after L1 head - prev.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xaa}} - next.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 2, Hash: common.Hash{0xbb}} + prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xaa}} + next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 2, Hash: common.Hash{0xbb}} stubSupervisor.Add(prev) stubSupervisor.Add(next) @@ -130,16 +130,16 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe only after L1 head - prev.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} - next.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} - next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)] = superroot.OutputWithSource{ + prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} + next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} + next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)] = superroot.OutputWithRequiredL1{ Output: ð.OutputResponse{ OutputRoot: eth.Bytes32{0xad}, BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, WithdrawalStorageRoot: common.Hash{0xde}, StateRoot: common.Hash{0xdf}, }, - SourceL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, + RequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, } stubSupervisor.Add(prev) stubSupervisor.Add(next) @@ -156,16 +156,16 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe only after L1 head - prev.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} - next.MaxVerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} - next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)] = superroot.OutputWithSource{ + prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} + next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} + next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)] = superroot.OutputWithRequiredL1{ Output: ð.OutputResponse{ OutputRoot: eth.Bytes32{0xad}, BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, WithdrawalStorageRoot: common.Hash{0xde}, StateRoot: common.Hash{0xdf}, }, - SourceL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, + RequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}}, } stubSupervisor.Add(prev) stubSupervisor.Add(next) @@ -328,48 +328,48 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (superroot.AtTimestampRe eth.ChainIDAndOutput{ChainID: eth.ChainIDFromUInt64(2), Output: eth.OutputRoot(outputB2)}) prevResponse := superroot.AtTimestampResponse{ - OptimisticAtTimestamp: map[eth.ChainID]superroot.OutputWithSource{ + UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ eth.ChainIDFromUInt64(1): { - Output: toOutputResponse(outputA1), - SourceL1: l1Head, + Output: toOutputResponse(outputA1), + RequiredL1: l1Head, }, eth.ChainIDFromUInt64(2): { - Output: toOutputResponse(outputB1), - SourceL1: l1Head, + Output: toOutputResponse(outputB1), + RequiredL1: l1Head, }, }, - MinCurrentL1: l1Head, - MaxVerifiedRequiredL1: l1Head, - Super: prevSuper, - SuperRoot: eth.SuperRoot(prevSuper), + CurrentL1: l1Head, + VerifiedRequiredL1: l1Head, + Super: prevSuper, + SuperRoot: eth.SuperRoot(prevSuper), } nextResponse := superroot.AtTimestampResponse{ - OptimisticAtTimestamp: map[eth.ChainID]superroot.OutputWithSource{ + UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ eth.ChainIDFromUInt64(1): { - Output: toOutputResponse(outputA2), - SourceL1: l1Head, + Output: toOutputResponse(outputA2), + RequiredL1: l1Head, }, eth.ChainIDFromUInt64(2): { - Output: toOutputResponse(outputB2), - SourceL1: l1Head, + Output: toOutputResponse(outputB2), + RequiredL1: l1Head, }, }, - MinCurrentL1: l1Head, - MaxVerifiedRequiredL1: l1Head, - Super: nextSuper, - SuperRoot: eth.SuperRoot(nextSuper), + CurrentL1: l1Head, + VerifiedRequiredL1: l1Head, + Super: nextSuper, + SuperRoot: eth.SuperRoot(nextSuper), } return prevResponse, nextResponse } func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvider, prev superroot.AtTimestampResponse, next superroot.AtTimestampResponse) { chain1OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, - OutputRoot: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, + BlockHash: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, + OutputRoot: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, } chain2OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, - OutputRoot: next.OptimisticAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, + BlockHash: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, + OutputRoot: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, } expectedFirstStep := &interopTypes.TransitionState{ SuperRoot: prev.Super.Marshal(), 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-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index f6b4afe8e10d9..27abcaab8067e 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -34,37 +34,36 @@ 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 +type OutputWithRequiredL1 struct { + Output *eth.OutputResponse `json:"output"` + RequiredL1 eth.BlockID `json:"required_l1"` } // 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 + L2 eth.BlockID `json:"l2"` + RequiredL1 eth.BlockID `json:"required_l1"` } // 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 { - // TODO: We should probably specify json names for these - CurrentL1Derived map[eth.ChainID]eth.BlockID - CurrentL1Verified map[string]eth.BlockID - VerifiedAtTimestamp map[eth.ChainID]L2WithRequiredL1 - OptimisticAtTimestamp map[eth.ChainID]OutputWithSource - MinCurrentL1 eth.BlockID - MaxVerifiedRequiredL1 eth.BlockID - // Not entirely sure it's a good idea to include the actual super implementation here rather than extracting the data from it - // But it sure is convenient. - // If we do this we need to sepecify json names in the eth.SuperV1 struct - Super eth.Super - SuperRoot eth.Bytes32 + // CurrentL1Derived is a map from chain ID to the highest L1 block that has been fully derived for that chain. It may not have been fully validated. + CurrentL1Derived map[eth.ChainID]eth.BlockID `json:"current_l1_derived"` + + // UnverifiedAtTimestamp is the L2 block which would be applied if verification were assumed to be successful, and the minimum L1 block required to derive them. + UnverifiedAtTimestamp map[eth.ChainID]OutputWithRequiredL1 `json:"unverified_at_timestamp"` + + // CurrentL1 is the highest L1 block that has been fully derived and verified by all chains. + CurrentL1 eth.BlockID `json:"current_l1"` + + // VerifiedRequiredL1 is the minimum L1 block including the required data to fully verify all blocks at this timestamp + VerifiedRequiredL1 eth.BlockID `json:"verified_required_l1"` + + // Super is the unhashed data for the superroot at the given timestamp after all verification is applied. + Super eth.Super `json:"super"` + + // SuperRoot is the superroot at the given timestamp after all verification is applied. + SuperRoot eth.Bytes32 `json:"super_root"` } // AtTimestamp computes the super-root at the given timestamp, plus additional information about the current L1s, verified L2s, and optimistic L2s @@ -74,11 +73,8 @@ func (api *superrootAPI) AtTimestamp(ctx context.Context, timestamp hexutil.Uint 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{} + optimistic := map[eth.ChainID]OutputWithRequiredL1{} minCurrentL1 := eth.BlockID{} maxVerifiedRequiredL1 := eth.BlockID{} chainOutputs := make([]eth.ChainIDAndOutput, 0, len(s.chains)) @@ -108,8 +104,8 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimest return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) } verified[chainID] = L2WithRequiredL1{ - L2: verifiedL2, - MinRequiredL1: verifiedL1, + L2: verifiedL2, + RequiredL1: verifiedL1, } if verifiedL1.Number > maxVerifiedRequiredL1.Number || maxVerifiedRequiredL1 == (eth.BlockID{}) { maxVerifiedRequiredL1 = verifiedL1 @@ -136,9 +132,9 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimest // TODO: It doesn't seem safe to translate all errors to not found return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) } - optimistic[chainID] = OutputWithSource{ - Output: optimisticOut, - SourceL1: optimisticL1, + optimistic[chainID] = OutputWithRequiredL1{ + Output: optimisticOut, + RequiredL1: optimisticL1, } } @@ -148,11 +144,9 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimest return AtTimestampResponse{ CurrentL1Derived: currentL1Derived, - CurrentL1Verified: currentL1Verified, - VerifiedAtTimestamp: verified, - OptimisticAtTimestamp: optimistic, - MinCurrentL1: minCurrentL1, - MaxVerifiedRequiredL1: maxVerifiedRequiredL1, + UnverifiedAtTimestamp: optimistic, + CurrentL1: minCurrentL1, + VerifiedRequiredL1: maxVerifiedRequiredL1, 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 1a86a95674a33..d73fffb85aeb1 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -99,11 +99,10 @@ func TestSuperroot_AtTimestamp_Succeeds(t *testing.T) { 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.UnverifiedAtTimestamp, 2) // min values - require.Equal(t, uint64(2000), out.MinCurrentL1.Number) - require.Equal(t, uint64(1100), out.MaxVerifiedRequiredL1.Number) + require.Equal(t, uint64(2000), out.CurrentL1.Number) + require.Equal(t, uint64(1100), out.VerifiedRequiredL1.Number) // With zero outputs, the superroot will be deterministic, just ensure it's set _ = out.SuperRoot } @@ -208,8 +207,7 @@ func TestSuperroot_AtTimestamp_EmptyChains(t *testing.T) { 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.UnverifiedAtTimestamp, 0) } // assertErr returns a generic error instance used to signal mock failures. From 09d06285923d0e94c62dad07ea24ded7a75bd3e5 Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Thu, 18 Dec 2025 11:11:39 +1000 Subject: [PATCH 08/11] op-challenger: Setup response format to allow returning sync data when block data not available instead of not found error. --- .../fault/trace/super/provider_supernode.go | 56 ++--- .../trace/super/provider_supernode_test.go | 203 ++++++++++++------ .../supernode/activity/superroot/superroot.go | 39 ++-- .../activity/superroot/superroot_test.go | 10 +- 4 files changed, 197 insertions(+), 111 deletions(-) diff --git a/op-challenger/game/fault/trace/super/provider_supernode.go b/op-challenger/game/fault/trace/super/provider_supernode.go index f0f92ae52066b..f02844ccd36de 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode.go +++ b/op-challenger/game/fault/trace/super/provider_supernode.go @@ -2,14 +2,13 @@ package super import ( "context" - "errors" "fmt" + "github.com/ethereum-optimism/optimism/op-challenger/game/client" "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" interopTypes "github.com/ethereum-optimism/optimism/op-program/client/interop/types" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-supernode/supernode/activity/superroot" - "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/crypto" "github.com/ethereum/go-ethereum/log" @@ -52,19 +51,21 @@ func (s *SuperNodeTraceProvider) Get(ctx context.Context, pos types.Position) (c func (s *SuperNodeTraceProvider) getPreimageBytesAtTimestampBoundary(ctx context.Context, timestamp uint64) ([]byte, error) { root, err := s.rootProvider.SuperRootAtTimestamp(ctx, timestamp) - // TODO: Ideally we could check here if the node is in sync enough using root.CurrentL1Verified - // but we would need to get that value even if the response is not found - // TODO: Also make sure the client when written actually returns ethereum.NotFound not just the string "not found" - if errors.Is(err, ethereum.NotFound) { + if err != nil { + return nil, fmt.Errorf("failed to retrieve super root at timestamp %v: %w", timestamp, err) + } + if root.CurrentL1.Number < s.l1Head.Number { + // Node has not processed the game's L1 head so it is not safe to play until it syncs further. + return nil, client.ErrNotInSync + } + if root.Data == nil { // No block at this timestamp so it must be invalid return InvalidTransition, nil - } else if err != nil { - return nil, fmt.Errorf("failed to retrieve super root at timestamp %v: %w", timestamp, err) } - if root.VerifiedRequiredL1.Number > s.l1Head.Number { + if root.Data.VerifiedRequiredL1.Number > s.l1Head.Number { return InvalidTransition, nil } - return root.Super.Marshal(), nil + return root.Data.Super.Marshal(), nil } func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types.Position) ([]byte, error) { @@ -79,45 +80,48 @@ func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types } // Fetch the super root at the next timestamp since we are part way through the transition to it prevRoot, err := s.rootProvider.SuperRootAtTimestamp(ctx, timestamp) - // TODO: Ideally we could check here if the node is in sync enough using root.CurrentL1Verified (as above) - if errors.Is(err, ethereum.NotFound) { + if err != nil { + return nil, fmt.Errorf("failed to retrieve previous super root at timestamp %v: %w", timestamp, err) + } + if prevRoot.CurrentL1.Number < s.l1Head.Number { + return nil, client.ErrNotInSync + } + if prevRoot.Data == nil { // No block at this timestamp so it must be invalid return InvalidTransition, nil - } else if err != nil { - return nil, fmt.Errorf("failed to retrieve previous super root at timestamp %v: %w", timestamp, err) } - if prevRoot.VerifiedRequiredL1.Number > s.l1Head.Number { + if prevRoot.Data.VerifiedRequiredL1.Number > s.l1Head.Number { // The previous root was not safe at the game L1 head so we must have already transitioned to the invalid hash // prior to this step and it then repeats forever. return InvalidTransition, nil } nextTimestamp := timestamp + 1 nextRoot, err := s.rootProvider.SuperRootAtTimestamp(ctx, nextTimestamp) - // TODO: Ideally we could check here if the node is in sync enough using root.CurrentL1Verified (as above) - // Note that if we do the in sync check on every call we could safely be load balanced and would just error if - // we get load balanced to a node that is not in sync enough. Unclear how useful that is if we keep hitting an unhealthy node though. - // May need to have a SuperRootAtTimestamps (plural) method to get prev and next in one call. - if errors.Is(err, ethereum.NotFound) { + if err != nil { + return nil, fmt.Errorf("failed to retrieve next super root at timestamp %v: %w", nextTimestamp, err) + } + if nextRoot.CurrentL1.Number < s.l1Head.Number { + return nil, client.ErrNotInSync + } + if nextRoot.Data == nil { // No block at this timestamp so it must be invalid return InvalidTransition, nil - } else if err != nil { - return nil, fmt.Errorf("failed to retrieve next super root at timestamp %v: %w", nextTimestamp, err) } - prevSuper := prevRoot.Super + prevSuper := prevRoot.Data.Super expectedState := interopTypes.TransitionState{ SuperRoot: prevSuper.Marshal(), PendingProgress: make([]interopTypes.OptimisticBlock, 0, step), Step: step, } - nextSuperV1, ok := nextRoot.Super.(*eth.SuperV1) + nextSuperV1, ok := nextRoot.Data.Super.(*eth.SuperV1) if !ok { - return nil, fmt.Errorf("unsupported super root type %T", nextRoot.Super) + return nil, fmt.Errorf("unsupported super root type %T", nextRoot.Data.Super) } for i := uint64(0); i < min(step, uint64(len(nextSuperV1.Chains))); i++ { chainInfo := nextSuperV1.Chains[i] // Check if the chain's optimistic root was safe at the game's L1 head - optimistic, ok := nextRoot.UnverifiedAtTimestamp[chainInfo.ChainID] + optimistic, ok := nextRoot.Data.UnverifiedAtTimestamp[chainInfo.ChainID] if !ok { return nil, fmt.Errorf("no safe head known for chain %v at %v: %w", chainInfo.ChainID, nextTimestamp, err) } 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 18f874e0b9d71..af4c96e320a00 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode_test.go +++ b/op-challenger/game/fault/trace/super/provider_supernode_test.go @@ -7,13 +7,13 @@ import ( "math/rand" "testing" + "github.com/ethereum-optimism/optimism/op-challenger/game/client" "github.com/ethereum-optimism/optimism/op-challenger/game/fault/types" interopTypes "github.com/ethereum-optimism/optimism/op-program/client/interop/types" "github.com/ethereum-optimism/optimism/op-service/eth" "github.com/ethereum-optimism/optimism/op-service/testlog" "github.com/ethereum-optimism/optimism/op-service/testutils" "github.com/ethereum-optimism/optimism/op-supernode/supernode/activity/superroot" - "github.com/ethereum/go-ethereum" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" @@ -27,10 +27,12 @@ func TestSuperNodeProvider_Get(t *testing.T) { Output: eth.Bytes32{0xbb}, }) response := superroot.AtTimestampResponse{ - //MinCurrentL1: l1Head, - VerifiedRequiredL1: l1Head, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + VerifiedRequiredL1: l1Head, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }, } stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.RootPosition) @@ -45,9 +47,12 @@ func TestSuperNodeProvider_Get(t *testing.T) { Output: eth.Bytes32{0xbb}, }) response := superroot.AtTimestampResponse{ - VerifiedRequiredL1: l1Head, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + VerifiedRequiredL1: l1Head, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }, } stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(StepsPerTimestamp-1))) @@ -71,9 +76,12 @@ func TestSuperNodeProvider_Get(t *testing.T) { Output: eth.Bytes32{0xbb}, }) response := superroot.AtTimestampResponse{ - VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xcc}}, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }, } stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.RootPosition) @@ -88,9 +96,12 @@ func TestSuperNodeProvider_Get(t *testing.T) { Output: eth.Bytes32{0xbb}, }) response := superroot.AtTimestampResponse{ - VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }, } stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.RootPosition) @@ -102,8 +113,8 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe earlier - prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xaa}} - next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 5, Hash: common.Hash{0xbb}} + prev.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xaa}} + next.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 5, Hash: common.Hash{0xbb}} stubSupervisor.Add(prev) stubSupervisor.Add(next) expectSuperNodeValidTransition(t, provider, prev, next) @@ -113,8 +124,8 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe only after L1 head - prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xaa}} - next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 2, Hash: common.Hash{0xbb}} + prev.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xaa}} + next.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 2, Hash: common.Hash{0xbb}} stubSupervisor.Add(prev) stubSupervisor.Add(next) @@ -130,9 +141,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe only after L1 head - prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} - next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} - next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)] = superroot.OutputWithRequiredL1{ + 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.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)] = superroot.OutputWithRequiredL1{ Output: ð.OutputResponse{ OutputRoot: eth.Bytes32{0xad}, BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, @@ -156,9 +167,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe only after L1 head - prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} - next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} - next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)] = superroot.OutputWithRequiredL1{ + 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.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)] = superroot.OutputWithRequiredL1{ Output: ð.OutputResponse{ OutputRoot: eth.Bytes32{0xad}, BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, @@ -184,8 +195,12 @@ func TestSuperNodeProvider_Get(t *testing.T) { }) t.Run("Step0ForTimestampBeyondChainHead", func(t *testing.T) { - provider, _, _ := createSuperNodeProvider(t) - // No response added so supervisor will return not found. + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + stubSupervisor.AddAtTimestamp(poststateTimestamp, superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: nil, + }) + claim, err := provider.Get(context.Background(), types.RootPosition) require.NoError(t, err) require.Equal(t, InvalidTransitionHash, claim) @@ -195,8 +210,10 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, _ := createValidSuperNodeSuperRoots(l1Head) stubSupervisor.Add(prev) - // Next super root response is not added so supervisor will return not found - + stubSupervisor.AddAtTimestamp(prestateTimestamp+1, superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: nil, + }) // All steps should be the invalid transition hash. for i := int64(0); i < StepsPerTimestamp+1; i++ { claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(i))) @@ -206,8 +223,15 @@ func TestSuperNodeProvider_Get(t *testing.T) { }) t.Run("PreviousSuperRootTimestampBeyondChainHead", func(t *testing.T) { - provider, _, _ := createSuperNodeProvider(t) - // No super root responses are added so supervisor will return not found + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + stubSupervisor.AddAtTimestamp(prestateTimestamp, superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: nil, + }) + stubSupervisor.AddAtTimestamp(prestateTimestamp+1, superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: nil, + }) // All steps should be the invalid transition hash. for i := int64(0); i < StepsPerTimestamp+1; i++ { @@ -216,6 +240,47 @@ func TestSuperNodeProvider_Get(t *testing.T) { require.Equalf(t, InvalidTransitionHash, claim, "incorrect claim at index %d", i) } }) + + t.Run("Step0NotInSync", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + expectedSuper := eth.NewSuperV1(poststateTimestamp, eth.ChainIDAndOutput{ + ChainID: eth.ChainIDFromUInt64(1), + Output: eth.Bytes32{0xbb}, + }) + response := superroot.AtTimestampResponse{ + CurrentL1: eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}}, + Data: &superroot.SuperRootResponseData{ + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }, + } + stubSupervisor.Add(response) + _, err := provider.Get(context.Background(), types.RootPosition) + require.ErrorIs(t, err, client.ErrNotInSync) + }) + + t.Run("PreviousSuperRootNotInSync", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + stubSupervisor.AddAtTimestamp(prestateTimestamp, superroot.AtTimestampResponse{ + CurrentL1: eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}}, + }) + _, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(1))) + require.ErrorIs(t, err, client.ErrNotInSync) + }) + + t.Run("NextSuperRootNotInSync", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + prev, _ := createValidSuperNodeSuperRoots(l1Head) + // Previous gives an in sync response + stubSupervisor.Add(prev) + // But next gives an out of sync response + stubSupervisor.AddAtTimestamp(prestateTimestamp+1, superroot.AtTimestampResponse{ + CurrentL1: eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}}, + }) + _, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(1))) + require.ErrorIs(t, err, client.ErrNotInSync) + }) } func TestSuperNodeProvider_ComputeStep(t *testing.T) { @@ -328,51 +393,55 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (superroot.AtTimestampRe eth.ChainIDAndOutput{ChainID: eth.ChainIDFromUInt64(2), Output: eth.OutputRoot(outputB2)}) prevResponse := superroot.AtTimestampResponse{ - UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ - eth.ChainIDFromUInt64(1): { - Output: toOutputResponse(outputA1), - RequiredL1: l1Head, - }, - eth.ChainIDFromUInt64(2): { - Output: toOutputResponse(outputB1), - RequiredL1: l1Head, + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ + eth.ChainIDFromUInt64(1): { + Output: toOutputResponse(outputA1), + RequiredL1: l1Head, + }, + eth.ChainIDFromUInt64(2): { + Output: toOutputResponse(outputB1), + RequiredL1: l1Head, + }, }, + VerifiedRequiredL1: l1Head, + Super: prevSuper, + SuperRoot: eth.SuperRoot(prevSuper), }, - CurrentL1: l1Head, - VerifiedRequiredL1: l1Head, - Super: prevSuper, - SuperRoot: eth.SuperRoot(prevSuper), } nextResponse := superroot.AtTimestampResponse{ - UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ - eth.ChainIDFromUInt64(1): { - Output: toOutputResponse(outputA2), - RequiredL1: l1Head, - }, - eth.ChainIDFromUInt64(2): { - Output: toOutputResponse(outputB2), - RequiredL1: l1Head, + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ + eth.ChainIDFromUInt64(1): { + Output: toOutputResponse(outputA2), + RequiredL1: l1Head, + }, + eth.ChainIDFromUInt64(2): { + Output: toOutputResponse(outputB2), + RequiredL1: l1Head, + }, }, + VerifiedRequiredL1: l1Head, + Super: nextSuper, + SuperRoot: eth.SuperRoot(nextSuper), }, - CurrentL1: l1Head, - VerifiedRequiredL1: l1Head, - Super: nextSuper, - SuperRoot: eth.SuperRoot(nextSuper), } return prevResponse, nextResponse } func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvider, prev superroot.AtTimestampResponse, next superroot.AtTimestampResponse) { chain1OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, - OutputRoot: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, + BlockHash: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, + OutputRoot: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, } chain2OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, - OutputRoot: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, + BlockHash: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, + OutputRoot: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, } expectedFirstStep := &interopTypes.TransitionState{ - SuperRoot: prev.Super.Marshal(), + SuperRoot: prev.Data.Super.Marshal(), PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock}, Step: 1, } @@ -381,7 +450,7 @@ func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvid require.Equal(t, expectedFirstStep.Hash(), claim) expectedSecondStep := &interopTypes.TransitionState{ - SuperRoot: prev.Super.Marshal(), + SuperRoot: prev.Data.Super.Marshal(), PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock, chain2OptimisticBlock}, Step: 2, } @@ -391,7 +460,7 @@ func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvid for step := uint64(3); step < StepsPerTimestamp; step++ { expectedPaddingStep := &interopTypes.TransitionState{ - SuperRoot: prev.Super.Marshal(), + SuperRoot: prev.Data.Super.Marshal(), PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock, chain2OptimisticBlock}, Step: step, } @@ -406,18 +475,22 @@ type stubSuperNodeRootProvider struct { } func (s *stubSuperNodeRootProvider) Add(root superroot.AtTimestampResponse) { + superV1 := root.Data.Super.(*eth.SuperV1) + s.AddAtTimestamp(superV1.Timestamp, root) +} + +func (s *stubSuperNodeRootProvider) AddAtTimestamp(timestamp uint64, root superroot.AtTimestampResponse) { if s.rootsByTimestamp == nil { s.rootsByTimestamp = make(map[uint64]superroot.AtTimestampResponse) } - superV1 := root.Super.(*eth.SuperV1) - s.rootsByTimestamp[superV1.Timestamp] = root + s.rootsByTimestamp[timestamp] = root } func (s *stubSuperNodeRootProvider) SuperRootAtTimestamp(_ context.Context, timestamp uint64) (superroot.AtTimestampResponse, error) { root, ok := s.rootsByTimestamp[timestamp] if !ok { - // Note: Client implementation specifically returns ethereum.NotFound - return superroot.AtTimestampResponse{}, fmt.Errorf("timestamp %v %w", timestamp, ethereum.NotFound) + // This is not the not found response - the test just didn't configure a response, so return a generic error + return superroot.AtTimestampResponse{}, fmt.Errorf("wowsers, now response for timestamp %v", timestamp) } return root, nil } diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 27abcaab8067e..4cefc41c8bc21 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -45,17 +45,11 @@ type L2WithRequiredL1 struct { RequiredL1 eth.BlockID `json:"required_l1"` } -// AtTimestampResponse is the response superroot_atTimestamp -type AtTimestampResponse struct { - // CurrentL1Derived is a map from chain ID to the highest L1 block that has been fully derived for that chain. It may not have been fully validated. - CurrentL1Derived map[eth.ChainID]eth.BlockID `json:"current_l1_derived"` - - // UnverifiedAtTimestamp is the L2 block which would be applied if verification were assumed to be successful, and the minimum L1 block required to derive them. +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[eth.ChainID]OutputWithRequiredL1 `json:"unverified_at_timestamp"` - // CurrentL1 is the highest L1 block that has been fully derived and verified by all chains. - CurrentL1 eth.BlockID `json:"current_l1"` - // VerifiedRequiredL1 is the minimum L1 block including the required data to fully verify all blocks at this timestamp VerifiedRequiredL1 eth.BlockID `json:"verified_required_l1"` @@ -66,6 +60,19 @@ type AtTimestampResponse struct { SuperRoot eth.Bytes32 `json:"super_root"` } +// AtTimestampResponse is the response superroot_atTimestamp +type AtTimestampResponse struct { + // CurrentL1Derived is a map from chain ID to the highest L1 block that has been fully derived for that chain. It may not have been fully validated. + CurrentL1Derived map[eth.ChainID]eth.BlockID `json:"current_l1_derived"` + + // CurrentL1 is the highest L1 block that has been fully derived and verified by all chains. + CurrentL1 eth.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 +} + // 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 hexutil.Uint64) (AtTimestampResponse, error) { return api.s.atTimestamp(ctx, uint64(timestamp)) @@ -143,11 +150,13 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimest superRoot := eth.SuperRoot(superV1) return AtTimestampResponse{ - CurrentL1Derived: currentL1Derived, - UnverifiedAtTimestamp: optimistic, - CurrentL1: minCurrentL1, - VerifiedRequiredL1: maxVerifiedRequiredL1, - Super: superV1, - SuperRoot: superRoot, + CurrentL1Derived: currentL1Derived, + CurrentL1: minCurrentL1, + Data: &SuperRootResponseData{ + UnverifiedAtTimestamp: optimistic, + VerifiedRequiredL1: maxVerifiedRequiredL1, + 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 d73fffb85aeb1..edfe64a5f2107 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -99,12 +99,12 @@ func TestSuperroot_AtTimestamp_Succeeds(t *testing.T) { out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) require.Len(t, out.CurrentL1Derived, 2) - require.Len(t, out.UnverifiedAtTimestamp, 2) + require.Len(t, out.Data.UnverifiedAtTimestamp, 2) // min values require.Equal(t, uint64(2000), out.CurrentL1.Number) - require.Equal(t, uint64(1100), out.VerifiedRequiredL1.Number) + require.Equal(t, uint64(1100), 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 +141,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) { @@ -207,7 +207,7 @@ func TestSuperroot_AtTimestamp_EmptyChains(t *testing.T) { out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) require.Len(t, out.CurrentL1Derived, 0) - require.Len(t, out.UnverifiedAtTimestamp, 0) + require.Len(t, out.Data.UnverifiedAtTimestamp, 0) } // assertErr returns a generic error instance used to signal mock failures. From 45b6fe806977ecffa2f822f5c2f6721c02b2306f Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Thu, 18 Dec 2025 11:27:08 +1000 Subject: [PATCH 09/11] op-supernode: Only return not found responses when the block is not found, not on all errors. --- .../supernode/activity/superroot/superroot.go | 24 +++++++++++-------- .../activity/superroot/superroot_test.go | 15 ++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 4cefc41c8bc21..c958bf76c0da3 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -2,11 +2,12 @@ package superroot import ( "context" + "errors" "fmt" "github.com/ethereum-optimism/optimism/op-service/eth" cc "github.com/ethereum-optimism/optimism/op-supernode/supernode/chain_container" - "github.com/ethereum/go-ethereum" + "github.com/ethereum-optimism/optimism/op-supernode/supernode/chain_container/engine_controller" "github.com/ethereum/go-ethereum/common/hexutil" gethlog "github.com/ethereum/go-ethereum/log" ) @@ -105,10 +106,16 @@ 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 { + if errors.Is(err, engine_controller.ErrNotFound) { + // We don't have a fully verified block at the specified timestamp so no super root can be produced. + // Return only the current derived L1 block info. + return AtTimestampResponse{ + CurrentL1Derived: currentL1Derived, + CurrentL1: minCurrentL1, + }, nil + } else if err != nil { s.log.Warn("failed to get verified L1", "chain_id", chainID.String(), "err", err) - // TODO: It doesn't seem safe to translate all errors to not found - return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) + return AtTimestampResponse{}, fmt.Errorf("failed to get verified L1 for chain ID %v: %w", chainID, err) } verified[chainID] = L2WithRequiredL1{ L2: verifiedL2, @@ -121,23 +128,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) - // TODO: It doesn't seem safe to translate all errors to not found - return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) + return AtTimestampResponse{}, fmt.Errorf("failed to compute output root at L2 block %v 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) - // TODO: It doesn't seem safe to translate all errors to not found - return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) + return AtTimestampResponse{}, fmt.Errorf("failed to get optimistic L1 for chain ID %v: %w", 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) - // TODO: It doesn't seem safe to translate all errors to not found - return AtTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) + return AtTimestampResponse{}, fmt.Errorf("failed to get optimistic L1 for chain ID %v: %w", chainID, err) } optimistic[chainID] = OutputWithRequiredL1{ Output: optimisticOut, diff --git a/op-supernode/supernode/activity/superroot/superroot_test.go b/op-supernode/supernode/activity/superroot/superroot_test.go index edfe64a5f2107..ee1cdf67543f0 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-optimism/optimism/op-supernode/supernode/chain_container/engine_controller" "github.com/ethereum/go-ethereum/common/hexutil" gethlog "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" @@ -170,6 +171,20 @@ func TestSuperroot_AtTimestamp_ErrorOnVerifiedAt(t *testing.T) { require.Error(t, err) } +func TestSuperroot_AtTimestamp_NotFoundErrorOnVerifiedAt(t *testing.T) { + t.Parallel() + chains := map[eth.ChainID]cc.ChainContainer{ + eth.ChainIDFromUInt64(10): &mockCC{ + verifiedErr: fmt.Errorf("no block: %w", engine_controller.ErrNotFound), + }, + } + s := New(gethlog.New(), chains) + api := &superrootAPI{s: s} + response, err := api.AtTimestamp(context.Background(), 123) + require.NoError(t, err) + require.Nil(t, response.Data) +} + func TestSuperroot_AtTimestamp_ErrorOnOutputRoot(t *testing.T) { t.Parallel() chains := map[eth.ChainID]cc.ChainContainer{ From ea0cacaf0d62f2a0ffc2b102e7d8f3f480441dbf Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Thu, 18 Dec 2025 13:44:35 +1000 Subject: [PATCH 10/11] op-challenger: Request previous and next roots in a single request. Ensures that challenger gets all data required to calculate the claim in a single request. --- .../fault/trace/super/provider_supernode.go | 51 ++-- .../trace/super/provider_supernode_test.go | 245 +++++++----------- .../supernode/activity/superroot/superroot.go | 66 +++-- .../activity/superroot/superroot_test.go | 12 +- 4 files changed, 173 insertions(+), 201 deletions(-) diff --git a/op-challenger/game/fault/trace/super/provider_supernode.go b/op-challenger/game/fault/trace/super/provider_supernode.go index f02844ccd36de..89d046ee18dc8 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode.go +++ b/op-challenger/game/fault/trace/super/provider_supernode.go @@ -16,7 +16,7 @@ import ( type SuperNodeRootProvider interface { // TODO: If AtTimestampResponse is being reused it should be put in op-service - SuperRootAtTimestamp(ctx context.Context, timestamp uint64) (superroot.AtTimestampResponse, error) + SuperRootAtTimestamps(ctx context.Context, timestamp ...uint64) (superroot.AtTimestampResponse, error) } type SuperNodeTraceProvider struct { @@ -50,7 +50,7 @@ func (s *SuperNodeTraceProvider) Get(ctx context.Context, pos types.Position) (c } func (s *SuperNodeTraceProvider) getPreimageBytesAtTimestampBoundary(ctx context.Context, timestamp uint64) ([]byte, error) { - root, err := s.rootProvider.SuperRootAtTimestamp(ctx, timestamp) + root, err := s.rootProvider.SuperRootAtTimestamps(ctx, timestamp) if err != nil { return nil, fmt.Errorf("failed to retrieve super root at timestamp %v: %w", timestamp, err) } @@ -58,14 +58,17 @@ func (s *SuperNodeTraceProvider) getPreimageBytesAtTimestampBoundary(ctx context // Node has not processed the game's L1 head so it is not safe to play until it syncs further. return nil, client.ErrNotInSync } - if root.Data == nil { + if len(root.Data) != 1 { + return nil, fmt.Errorf("unexpected number of super roots at timestamp %v: %d", timestamp, len(root.Data)) + } + if root.Data[0] == nil { // No block at this timestamp so it must be invalid return InvalidTransition, nil } - if root.Data.VerifiedRequiredL1.Number > s.l1Head.Number { + if root.Data[0].VerifiedRequiredL1.Number > s.l1Head.Number { return InvalidTransition, nil } - return root.Data.Super.Marshal(), nil + return root.Data[0].Super.Marshal(), nil } func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types.Position) ([]byte, error) { @@ -78,50 +81,48 @@ func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types if step == 0 { return s.getPreimageBytesAtTimestampBoundary(ctx, timestamp) } - // Fetch the super root at the next timestamp since we are part way through the transition to it - prevRoot, err := s.rootProvider.SuperRootAtTimestamp(ctx, timestamp) + + nextTimestamp := timestamp + 1 + response, err := s.rootProvider.SuperRootAtTimestamps(ctx, timestamp, nextTimestamp) if err != nil { - return nil, fmt.Errorf("failed to retrieve previous super root at timestamp %v: %w", timestamp, err) + return nil, fmt.Errorf("failed to retrieve super roots at timestamps %v and %v: %w", timestamp, timestamp+1, err) } - if prevRoot.CurrentL1.Number < s.l1Head.Number { + if response.CurrentL1.Number < s.l1Head.Number { return nil, client.ErrNotInSync } - if prevRoot.Data == nil { - // No block at this timestamp so it must be invalid + if len(response.Data) != 2 { + return nil, fmt.Errorf("unexpected number of super roots at timestamps %v and %v: %d", timestamp, timestamp+1, len(response.Data)) + } + prevRoot := response.Data[0] + nextRoot := response.Data[1] + + if prevRoot == nil { return InvalidTransition, nil } - if prevRoot.Data.VerifiedRequiredL1.Number > s.l1Head.Number { + if prevRoot.VerifiedRequiredL1.Number > s.l1Head.Number { // The previous root was not safe at the game L1 head so we must have already transitioned to the invalid hash // prior to this step and it then repeats forever. return InvalidTransition, nil } - nextTimestamp := timestamp + 1 - nextRoot, err := s.rootProvider.SuperRootAtTimestamp(ctx, nextTimestamp) - if err != nil { - return nil, fmt.Errorf("failed to retrieve next super root at timestamp %v: %w", nextTimestamp, err) - } - if nextRoot.CurrentL1.Number < s.l1Head.Number { - return nil, client.ErrNotInSync - } - if nextRoot.Data == nil { + if nextRoot == nil { // No block at this timestamp so it must be invalid return InvalidTransition, nil } - prevSuper := prevRoot.Data.Super + prevSuper := prevRoot.Super expectedState := interopTypes.TransitionState{ SuperRoot: prevSuper.Marshal(), PendingProgress: make([]interopTypes.OptimisticBlock, 0, step), Step: step, } - nextSuperV1, ok := nextRoot.Data.Super.(*eth.SuperV1) + nextSuperV1, ok := nextRoot.Super.(*eth.SuperV1) if !ok { - return nil, fmt.Errorf("unsupported super root type %T", nextRoot.Data.Super) + return nil, fmt.Errorf("unsupported super root type %T", nextRoot.Super) } for i := uint64(0); i < min(step, uint64(len(nextSuperV1.Chains))); i++ { chainInfo := nextSuperV1.Chains[i] // Check if the chain's optimistic root was safe at the game's L1 head - optimistic, ok := nextRoot.Data.UnverifiedAtTimestamp[chainInfo.ChainID] + optimistic, ok := nextRoot.UnverifiedAtTimestamp[chainInfo.ChainID] if !ok { return nil, fmt.Errorf("no safe head known for chain %v at %v: %w", chainInfo.ChainID, nextTimestamp, err) } 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 af4c96e320a00..693d3b0c5d16d 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode_test.go +++ b/op-challenger/game/fault/trace/super/provider_supernode_test.go @@ -2,7 +2,6 @@ package super import ( "context" - "fmt" "math/big" "math/rand" "testing" @@ -26,15 +25,11 @@ func TestSuperNodeProvider_Get(t *testing.T) { ChainID: eth.ChainIDFromUInt64(1), Output: eth.Bytes32{0xbb}, }) - response := superroot.AtTimestampResponse{ - CurrentL1: l1Head, - Data: &superroot.SuperRootResponseData{ - VerifiedRequiredL1: l1Head, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), - }, - } - stubSupervisor.Add(response) + stubSupervisor.Add(superroot.SuperRootResponseData{ + VerifiedRequiredL1: l1Head, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }) claim, err := provider.Get(context.Background(), types.RootPosition) require.NoError(t, err) require.Equal(t, common.Hash(eth.SuperRoot(expectedSuper)), claim) @@ -46,15 +41,11 @@ func TestSuperNodeProvider_Get(t *testing.T) { ChainID: eth.ChainIDFromUInt64(1), Output: eth.Bytes32{0xbb}, }) - response := superroot.AtTimestampResponse{ - CurrentL1: l1Head, - Data: &superroot.SuperRootResponseData{ - VerifiedRequiredL1: l1Head, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), - }, - } - stubSupervisor.Add(response) + stubSupervisor.Add(superroot.SuperRootResponseData{ + VerifiedRequiredL1: l1Head, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }) claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(StepsPerTimestamp-1))) require.NoError(t, err) require.Equal(t, common.Hash(eth.SuperRoot(expectedSuper)), claim) @@ -75,15 +66,11 @@ func TestSuperNodeProvider_Get(t *testing.T) { ChainID: eth.ChainIDFromUInt64(1), Output: eth.Bytes32{0xbb}, }) - response := superroot.AtTimestampResponse{ - CurrentL1: l1Head, - Data: &superroot.SuperRootResponseData{ - VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xcc}}, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), - }, - } - stubSupervisor.Add(response) + stubSupervisor.Add(superroot.SuperRootResponseData{ + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }) claim, err := provider.Get(context.Background(), types.RootPosition) require.NoError(t, err) require.Equal(t, common.Hash(eth.SuperRoot(expectedSuper)), claim) @@ -95,15 +82,11 @@ func TestSuperNodeProvider_Get(t *testing.T) { ChainID: eth.ChainIDFromUInt64(1), Output: eth.Bytes32{0xbb}, }) - response := superroot.AtTimestampResponse{ - CurrentL1: l1Head, - Data: &superroot.SuperRootResponseData{ - VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), - }, - } - stubSupervisor.Add(response) + stubSupervisor.Add(superroot.SuperRootResponseData{ + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }) claim, err := provider.Get(context.Background(), types.RootPosition) require.NoError(t, err) require.Equal(t, InvalidTransitionHash, claim) @@ -113,8 +96,8 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe earlier - prev.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xaa}} - next.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 5, Hash: common.Hash{0xbb}} + prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xaa}} + next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 5, Hash: common.Hash{0xbb}} stubSupervisor.Add(prev) stubSupervisor.Add(next) expectSuperNodeValidTransition(t, provider, prev, next) @@ -124,8 +107,8 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe only after L1 head - prev.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xaa}} - next.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 2, Hash: common.Hash{0xbb}} + prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xaa}} + next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 2, Hash: common.Hash{0xbb}} stubSupervisor.Add(prev) stubSupervisor.Add(next) @@ -141,9 +124,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // 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}} - next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)] = superroot.OutputWithRequiredL1{ + prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} + next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} + next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)] = superroot.OutputWithRequiredL1{ Output: ð.OutputResponse{ OutputRoot: eth.Bytes32{0xad}, BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, @@ -167,9 +150,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // 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}} - next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)] = superroot.OutputWithRequiredL1{ + prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} + next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} + next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)] = superroot.OutputWithRequiredL1{ Output: ð.OutputResponse{ OutputRoot: eth.Bytes32{0xad}, BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, @@ -195,11 +178,8 @@ func TestSuperNodeProvider_Get(t *testing.T) { }) t.Run("Step0ForTimestampBeyondChainHead", func(t *testing.T) { - provider, stubSupervisor, l1Head := createSuperNodeProvider(t) - stubSupervisor.AddAtTimestamp(poststateTimestamp, superroot.AtTimestampResponse{ - CurrentL1: l1Head, - Data: nil, - }) + provider, _, _ := createSuperNodeProvider(t) + // No super root at timestamp claim, err := provider.Get(context.Background(), types.RootPosition) require.NoError(t, err) @@ -210,10 +190,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, _ := createValidSuperNodeSuperRoots(l1Head) stubSupervisor.Add(prev) - stubSupervisor.AddAtTimestamp(prestateTimestamp+1, superroot.AtTimestampResponse{ - CurrentL1: l1Head, - Data: nil, - }) + // No super root at next timestamp + + // TODO: Why are all these all invalid??? What if some chains had published a block and others hadn't yet? // All steps should be the invalid transition hash. for i := int64(0); i < StepsPerTimestamp+1; i++ { claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(i))) @@ -223,15 +202,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { }) t.Run("PreviousSuperRootTimestampBeyondChainHead", func(t *testing.T) { - provider, stubSupervisor, l1Head := createSuperNodeProvider(t) - stubSupervisor.AddAtTimestamp(prestateTimestamp, superroot.AtTimestampResponse{ - CurrentL1: l1Head, - Data: nil, - }) - stubSupervisor.AddAtTimestamp(prestateTimestamp+1, superroot.AtTimestampResponse{ - CurrentL1: l1Head, - Data: nil, - }) + provider, _, _ := createSuperNodeProvider(t) + // No super root at previous timestamp + // No super root at next timestamp // All steps should be the invalid transition hash. for i := int64(0); i < StepsPerTimestamp+1; i++ { @@ -247,37 +220,19 @@ func TestSuperNodeProvider_Get(t *testing.T) { ChainID: eth.ChainIDFromUInt64(1), Output: eth.Bytes32{0xbb}, }) - response := superroot.AtTimestampResponse{ - CurrentL1: eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}}, - Data: &superroot.SuperRootResponseData{ - VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), - }, - } - stubSupervisor.Add(response) - _, err := provider.Get(context.Background(), types.RootPosition) - require.ErrorIs(t, err, client.ErrNotInSync) - }) - - t.Run("PreviousSuperRootNotInSync", func(t *testing.T) { - provider, stubSupervisor, l1Head := createSuperNodeProvider(t) - stubSupervisor.AddAtTimestamp(prestateTimestamp, superroot.AtTimestampResponse{ - CurrentL1: eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}}, + stubSupervisor.currentL1 = eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}} + stubSupervisor.Add(superroot.SuperRootResponseData{ + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), }) - _, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(1))) + _, err := provider.Get(context.Background(), types.RootPosition) require.ErrorIs(t, err, client.ErrNotInSync) }) - t.Run("NextSuperRootNotInSync", func(t *testing.T) { + t.Run("NotStep0NotInSync", func(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) - prev, _ := createValidSuperNodeSuperRoots(l1Head) - // Previous gives an in sync response - stubSupervisor.Add(prev) - // But next gives an out of sync response - stubSupervisor.AddAtTimestamp(prestateTimestamp+1, superroot.AtTimestampResponse{ - CurrentL1: eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}}, - }) + stubSupervisor.currentL1 = eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}} _, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(1))) require.ErrorIs(t, err, client.ErrNotInSync) }) @@ -360,7 +315,8 @@ func createSuperNodeProvider(t *testing.T) (*SuperNodeTraceProvider, *stubSuperN logger := testlog.Logger(t, log.LvlInfo) l1Head := eth.BlockID{Number: 23542, Hash: common.Hash{0xab, 0xcd}} stubSupervisor := &stubSuperNodeRootProvider{ - rootsByTimestamp: make(map[uint64]superroot.AtTimestampResponse), + currentL1: l1Head, + rootsByTimestamp: make(map[uint64]superroot.SuperRootResponseData), } provider := NewSuperNodeTraceProvider(logger, nil, stubSupervisor, l1Head, gameDepth, prestateTimestamp, poststateTimestamp) return provider, stubSupervisor, l1Head @@ -378,7 +334,7 @@ func toOutputResponse(output *eth.OutputV0) *eth.OutputResponse { } } -func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (superroot.AtTimestampResponse, superroot.AtTimestampResponse) { +func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (superroot.SuperRootResponseData, superroot.SuperRootResponseData) { rng := rand.New(rand.NewSource(1)) outputA1 := testutils.RandomOutputV0(rng) outputA2 := testutils.RandomOutputV0(rng) @@ -392,56 +348,50 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (superroot.AtTimestampRe eth.ChainIDAndOutput{ChainID: eth.ChainIDFromUInt64(1), Output: eth.OutputRoot(outputA2)}, eth.ChainIDAndOutput{ChainID: eth.ChainIDFromUInt64(2), Output: eth.OutputRoot(outputB2)}) - prevResponse := superroot.AtTimestampResponse{ - CurrentL1: l1Head, - Data: &superroot.SuperRootResponseData{ - UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ - eth.ChainIDFromUInt64(1): { - Output: toOutputResponse(outputA1), - RequiredL1: l1Head, - }, - eth.ChainIDFromUInt64(2): { - Output: toOutputResponse(outputB1), - RequiredL1: l1Head, - }, + prevResponse := superroot.SuperRootResponseData{ + UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ + eth.ChainIDFromUInt64(1): { + Output: toOutputResponse(outputA1), + RequiredL1: l1Head, + }, + eth.ChainIDFromUInt64(2): { + Output: toOutputResponse(outputB1), + RequiredL1: l1Head, }, - VerifiedRequiredL1: l1Head, - Super: prevSuper, - SuperRoot: eth.SuperRoot(prevSuper), }, + VerifiedRequiredL1: l1Head, + Super: prevSuper, + SuperRoot: eth.SuperRoot(prevSuper), } - nextResponse := superroot.AtTimestampResponse{ - CurrentL1: l1Head, - Data: &superroot.SuperRootResponseData{ - UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ - eth.ChainIDFromUInt64(1): { - Output: toOutputResponse(outputA2), - RequiredL1: l1Head, - }, - eth.ChainIDFromUInt64(2): { - Output: toOutputResponse(outputB2), - RequiredL1: l1Head, - }, + nextResponse := superroot.SuperRootResponseData{ + UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ + eth.ChainIDFromUInt64(1): { + Output: toOutputResponse(outputA2), + RequiredL1: l1Head, + }, + eth.ChainIDFromUInt64(2): { + Output: toOutputResponse(outputB2), + RequiredL1: l1Head, }, - VerifiedRequiredL1: l1Head, - Super: nextSuper, - SuperRoot: eth.SuperRoot(nextSuper), }, + VerifiedRequiredL1: l1Head, + Super: nextSuper, + SuperRoot: eth.SuperRoot(nextSuper), } return prevResponse, nextResponse } -func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvider, prev superroot.AtTimestampResponse, next superroot.AtTimestampResponse) { +func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvider, prev superroot.SuperRootResponseData, next superroot.SuperRootResponseData) { chain1OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, - OutputRoot: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, + BlockHash: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, + OutputRoot: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, } chain2OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, - OutputRoot: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, + BlockHash: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, + OutputRoot: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, } expectedFirstStep := &interopTypes.TransitionState{ - SuperRoot: prev.Data.Super.Marshal(), + SuperRoot: prev.Super.Marshal(), PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock}, Step: 1, } @@ -450,7 +400,7 @@ func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvid require.Equal(t, expectedFirstStep.Hash(), claim) expectedSecondStep := &interopTypes.TransitionState{ - SuperRoot: prev.Data.Super.Marshal(), + SuperRoot: prev.Super.Marshal(), PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock, chain2OptimisticBlock}, Step: 2, } @@ -460,7 +410,7 @@ func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvid for step := uint64(3); step < StepsPerTimestamp; step++ { expectedPaddingStep := &interopTypes.TransitionState{ - SuperRoot: prev.Data.Super.Marshal(), + SuperRoot: prev.Super.Marshal(), PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock, chain2OptimisticBlock}, Step: step, } @@ -471,26 +421,29 @@ func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvid } type stubSuperNodeRootProvider struct { - rootsByTimestamp map[uint64]superroot.AtTimestampResponse + currentL1 eth.BlockID + rootsByTimestamp map[uint64]superroot.SuperRootResponseData } -func (s *stubSuperNodeRootProvider) Add(root superroot.AtTimestampResponse) { - superV1 := root.Data.Super.(*eth.SuperV1) - s.AddAtTimestamp(superV1.Timestamp, root) -} - -func (s *stubSuperNodeRootProvider) AddAtTimestamp(timestamp uint64, root superroot.AtTimestampResponse) { +func (s *stubSuperNodeRootProvider) Add(root superroot.SuperRootResponseData) { if s.rootsByTimestamp == nil { - s.rootsByTimestamp = make(map[uint64]superroot.AtTimestampResponse) + s.rootsByTimestamp = make(map[uint64]superroot.SuperRootResponseData) } - s.rootsByTimestamp[timestamp] = root + v1 := root.Super.(*eth.SuperV1) + + s.rootsByTimestamp[v1.Timestamp] = root } -func (s *stubSuperNodeRootProvider) SuperRootAtTimestamp(_ context.Context, timestamp uint64) (superroot.AtTimestampResponse, error) { - root, ok := s.rootsByTimestamp[timestamp] - if !ok { - // This is not the not found response - the test just didn't configure a response, so return a generic error - return superroot.AtTimestampResponse{}, fmt.Errorf("wowsers, now response for timestamp %v", timestamp) +func (s *stubSuperNodeRootProvider) SuperRootAtTimestamps(_ context.Context, timestamps ...uint64) (superroot.AtTimestampResponse, error) { + response := superroot.AtTimestampResponse{ + CurrentL1: s.currentL1, + Data: make([]*superroot.SuperRootResponseData, len(timestamps)), + } + for i, timestamp := range timestamps { + root, ok := s.rootsByTimestamp[timestamp] + if ok { + response.Data[i] = &root + } } - return root, nil + return response, nil } diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index c958bf76c0da3..dedcf5e74c193 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -71,21 +71,22 @@ type AtTimestampResponse 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 } // 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 hexutil.Uint64) (AtTimestampResponse, error) { - return api.s.atTimestamp(ctx, uint64(timestamp)) + return api.s.atTimestamps(ctx, timestamp) } -func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimestampResponse, error) { +// AtTimestamp computes the super-root at the given timestamp, plus additional information about the current L1s, verified L2s, and optimistic L2s +func (api *superrootAPI) AtTimestamps(ctx context.Context, timestamps []hexutil.Uint64) (AtTimestampResponse, error) { + return api.s.atTimestamps(ctx, timestamps...) +} + +func (s *Superroot) atTimestamps(ctx context.Context, timestamps ...hexutil.Uint64) (AtTimestampResponse, error) { currentL1Derived := map[eth.ChainID]eth.BlockID{} - verified := map[eth.ChainID]L2WithRequiredL1{} - optimistic := map[eth.ChainID]OutputWithRequiredL1{} minCurrentL1 := eth.BlockID{} - maxVerifiedRequiredL1 := eth.BlockID{} - chainOutputs := make([]eth.ChainIDAndOutput, 0, len(s.chains)) // get current l1s // this informs callers that the chains local views have considered at least up to this L1 block @@ -102,6 +103,30 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimest } } + data := make([]*SuperRootResponseData, len(timestamps)) + for i, timestamp := range timestamps { + superRootData, err := s.dataAtTimestamp(ctx, uint64(timestamp)) + if errors.Is(err, engine_controller.ErrNotFound) { + // Leave this entry in data as nil as no super root is available + continue + } else if err != nil { + return AtTimestampResponse{}, fmt.Errorf("failed to compute superroot at timestamp %v: %w", timestamp, err) + } + data[i] = superRootData + } + return AtTimestampResponse{ + CurrentL1Derived: currentL1Derived, + CurrentL1: minCurrentL1, + Data: data, + }, nil +} + +func (s *Superroot) dataAtTimestamp(ctx context.Context, timestamp uint64) (*SuperRootResponseData, error) { + verified := map[eth.ChainID]L2WithRequiredL1{} + optimistic := map[eth.ChainID]OutputWithRequiredL1{} + maxVerifiedRequiredL1 := eth.BlockID{} + chainOutputs := make([]eth.ChainIDAndOutput, 0, len(s.chains)) + // 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 @@ -109,13 +134,10 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimest if errors.Is(err, engine_controller.ErrNotFound) { // We don't have a fully verified block at the specified timestamp so no super root can be produced. // Return only the current derived L1 block info. - return AtTimestampResponse{ - CurrentL1Derived: currentL1Derived, - CurrentL1: minCurrentL1, - }, nil + return nil, engine_controller.ErrNotFound } else if err != nil { s.log.Warn("failed to get verified L1", "chain_id", chainID.String(), "err", err) - return AtTimestampResponse{}, fmt.Errorf("failed to get verified L1 for chain ID %v: %w", chainID, err) + return nil, fmt.Errorf("failed to get verified L1 for chain ID %v: %w", chainID, err) } verified[chainID] = L2WithRequiredL1{ L2: verifiedL2, @@ -128,20 +150,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("failed to compute output root at L2 block %v for chain ID %v: %w", verifiedL2.Number, chainID, err) + return nil, fmt.Errorf("failed to compute output root at L2 block %v 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("failed to get optimistic L1 for chain ID %v: %w", chainID, err) + return nil, fmt.Errorf("failed to get optimistic L1 for chain ID %v: %w", 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 L1 for chain ID %v: %w", chainID, err) + return nil, fmt.Errorf("failed to get optimistic L1 for chain ID %v: %w", chainID, err) } optimistic[chainID] = OutputWithRequiredL1{ Output: optimisticOut, @@ -153,14 +175,10 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimest superV1 := eth.NewSuperV1(timestamp, chainOutputs...) superRoot := eth.SuperRoot(superV1) - return AtTimestampResponse{ - CurrentL1Derived: currentL1Derived, - CurrentL1: minCurrentL1, - Data: &SuperRootResponseData{ - UnverifiedAtTimestamp: optimistic, - VerifiedRequiredL1: maxVerifiedRequiredL1, - Super: superV1, - SuperRoot: superRoot, - }, + return &SuperRootResponseData{ + UnverifiedAtTimestamp: optimistic, + VerifiedRequiredL1: maxVerifiedRequiredL1, + 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 ee1cdf67543f0..ac7c624f73474 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -100,12 +100,12 @@ func TestSuperroot_AtTimestamp_Succeeds(t *testing.T) { out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) require.Len(t, out.CurrentL1Derived, 2) - require.Len(t, out.Data.UnverifiedAtTimestamp, 2) + require.Len(t, out.Data[0].UnverifiedAtTimestamp, 2) // min values require.Equal(t, uint64(2000), out.CurrentL1.Number) - require.Equal(t, uint64(1100), out.Data.VerifiedRequiredL1.Number) + require.Equal(t, uint64(1100), out.Data[0].VerifiedRequiredL1.Number) // With zero outputs, the superroot will be deterministic, just ensure it's set - _ = out.Data.SuperRoot + _ = out.Data[0].SuperRoot } func TestSuperroot_AtTimestamp_ComputesSuperRoot(t *testing.T) { @@ -142,7 +142,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.Data.SuperRoot) + require.Equal(t, expected, resp.Data[0].SuperRoot) } func TestSuperroot_AtTimestamp_ErrorOnCurrentL1(t *testing.T) { @@ -182,7 +182,7 @@ func TestSuperroot_AtTimestamp_NotFoundErrorOnVerifiedAt(t *testing.T) { api := &superrootAPI{s: s} response, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) - require.Nil(t, response.Data) + require.Nil(t, response.Data[0]) } func TestSuperroot_AtTimestamp_ErrorOnOutputRoot(t *testing.T) { @@ -222,7 +222,7 @@ func TestSuperroot_AtTimestamp_EmptyChains(t *testing.T) { out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) require.Len(t, out.CurrentL1Derived, 0) - require.Len(t, out.Data.UnverifiedAtTimestamp, 0) + require.Len(t, out.Data[0].UnverifiedAtTimestamp, 0) } // assertErr returns a generic error instance used to signal mock failures. From 1f0c0a4492d0022b806693a6a1cc04be9c0e864d Mon Sep 17 00:00:00 2001 From: Adrian Sutton Date: Fri, 19 Dec 2025 10:25:57 +1000 Subject: [PATCH 11/11] Revert "op-challenger: Request previous and next roots in a single request." Doesn't seem worth it. This reverts commit ea0cacaf0d62f2a0ffc2b102e7d8f3f480441dbf. --- .../fault/trace/super/provider_supernode.go | 51 ++-- .../trace/super/provider_supernode_test.go | 245 +++++++++++------- .../supernode/activity/superroot/superroot.go | 66 ++--- .../activity/superroot/superroot_test.go | 12 +- 4 files changed, 201 insertions(+), 173 deletions(-) diff --git a/op-challenger/game/fault/trace/super/provider_supernode.go b/op-challenger/game/fault/trace/super/provider_supernode.go index 89d046ee18dc8..f02844ccd36de 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode.go +++ b/op-challenger/game/fault/trace/super/provider_supernode.go @@ -16,7 +16,7 @@ import ( type SuperNodeRootProvider interface { // TODO: If AtTimestampResponse is being reused it should be put in op-service - SuperRootAtTimestamps(ctx context.Context, timestamp ...uint64) (superroot.AtTimestampResponse, error) + SuperRootAtTimestamp(ctx context.Context, timestamp uint64) (superroot.AtTimestampResponse, error) } type SuperNodeTraceProvider struct { @@ -50,7 +50,7 @@ func (s *SuperNodeTraceProvider) Get(ctx context.Context, pos types.Position) (c } func (s *SuperNodeTraceProvider) getPreimageBytesAtTimestampBoundary(ctx context.Context, timestamp uint64) ([]byte, error) { - root, err := s.rootProvider.SuperRootAtTimestamps(ctx, timestamp) + root, err := s.rootProvider.SuperRootAtTimestamp(ctx, timestamp) if err != nil { return nil, fmt.Errorf("failed to retrieve super root at timestamp %v: %w", timestamp, err) } @@ -58,17 +58,14 @@ func (s *SuperNodeTraceProvider) getPreimageBytesAtTimestampBoundary(ctx context // Node has not processed the game's L1 head so it is not safe to play until it syncs further. return nil, client.ErrNotInSync } - if len(root.Data) != 1 { - return nil, fmt.Errorf("unexpected number of super roots at timestamp %v: %d", timestamp, len(root.Data)) - } - if root.Data[0] == nil { + if root.Data == nil { // No block at this timestamp so it must be invalid return InvalidTransition, nil } - if root.Data[0].VerifiedRequiredL1.Number > s.l1Head.Number { + if root.Data.VerifiedRequiredL1.Number > s.l1Head.Number { return InvalidTransition, nil } - return root.Data[0].Super.Marshal(), nil + return root.Data.Super.Marshal(), nil } func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types.Position) ([]byte, error) { @@ -81,48 +78,50 @@ func (s *SuperNodeTraceProvider) GetPreimageBytes(ctx context.Context, pos types if step == 0 { return s.getPreimageBytesAtTimestampBoundary(ctx, timestamp) } - - nextTimestamp := timestamp + 1 - response, err := s.rootProvider.SuperRootAtTimestamps(ctx, timestamp, nextTimestamp) + // Fetch the super root at the next timestamp since we are part way through the transition to it + prevRoot, err := s.rootProvider.SuperRootAtTimestamp(ctx, timestamp) if err != nil { - return nil, fmt.Errorf("failed to retrieve super roots at timestamps %v and %v: %w", timestamp, timestamp+1, err) + return nil, fmt.Errorf("failed to retrieve previous super root at timestamp %v: %w", timestamp, err) } - if response.CurrentL1.Number < s.l1Head.Number { + if prevRoot.CurrentL1.Number < s.l1Head.Number { return nil, client.ErrNotInSync } - if len(response.Data) != 2 { - return nil, fmt.Errorf("unexpected number of super roots at timestamps %v and %v: %d", timestamp, timestamp+1, len(response.Data)) - } - prevRoot := response.Data[0] - nextRoot := response.Data[1] - - if prevRoot == nil { + if prevRoot.Data == nil { + // No block at this timestamp so it must be invalid return InvalidTransition, nil } - if prevRoot.VerifiedRequiredL1.Number > s.l1Head.Number { + if prevRoot.Data.VerifiedRequiredL1.Number > s.l1Head.Number { // The previous root was not safe at the game L1 head so we must have already transitioned to the invalid hash // prior to this step and it then repeats forever. return InvalidTransition, nil } - if nextRoot == nil { + nextTimestamp := timestamp + 1 + nextRoot, err := s.rootProvider.SuperRootAtTimestamp(ctx, nextTimestamp) + if err != nil { + return nil, fmt.Errorf("failed to retrieve next super root at timestamp %v: %w", nextTimestamp, err) + } + if nextRoot.CurrentL1.Number < s.l1Head.Number { + return nil, client.ErrNotInSync + } + if nextRoot.Data == nil { // No block at this timestamp so it must be invalid return InvalidTransition, nil } - prevSuper := prevRoot.Super + prevSuper := prevRoot.Data.Super expectedState := interopTypes.TransitionState{ SuperRoot: prevSuper.Marshal(), PendingProgress: make([]interopTypes.OptimisticBlock, 0, step), Step: step, } - nextSuperV1, ok := nextRoot.Super.(*eth.SuperV1) + nextSuperV1, ok := nextRoot.Data.Super.(*eth.SuperV1) if !ok { - return nil, fmt.Errorf("unsupported super root type %T", nextRoot.Super) + return nil, fmt.Errorf("unsupported super root type %T", nextRoot.Data.Super) } for i := uint64(0); i < min(step, uint64(len(nextSuperV1.Chains))); i++ { chainInfo := nextSuperV1.Chains[i] // Check if the chain's optimistic root was safe at the game's L1 head - optimistic, ok := nextRoot.UnverifiedAtTimestamp[chainInfo.ChainID] + optimistic, ok := nextRoot.Data.UnverifiedAtTimestamp[chainInfo.ChainID] if !ok { return nil, fmt.Errorf("no safe head known for chain %v at %v: %w", chainInfo.ChainID, nextTimestamp, err) } 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 693d3b0c5d16d..af4c96e320a00 100644 --- a/op-challenger/game/fault/trace/super/provider_supernode_test.go +++ b/op-challenger/game/fault/trace/super/provider_supernode_test.go @@ -2,6 +2,7 @@ package super import ( "context" + "fmt" "math/big" "math/rand" "testing" @@ -25,11 +26,15 @@ func TestSuperNodeProvider_Get(t *testing.T) { ChainID: eth.ChainIDFromUInt64(1), Output: eth.Bytes32{0xbb}, }) - stubSupervisor.Add(superroot.SuperRootResponseData{ - VerifiedRequiredL1: l1Head, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), - }) + response := superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + VerifiedRequiredL1: l1Head, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }, + } + stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.RootPosition) require.NoError(t, err) require.Equal(t, common.Hash(eth.SuperRoot(expectedSuper)), claim) @@ -41,11 +46,15 @@ func TestSuperNodeProvider_Get(t *testing.T) { ChainID: eth.ChainIDFromUInt64(1), Output: eth.Bytes32{0xbb}, }) - stubSupervisor.Add(superroot.SuperRootResponseData{ - VerifiedRequiredL1: l1Head, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), - }) + response := superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + VerifiedRequiredL1: l1Head, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }, + } + stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(StepsPerTimestamp-1))) require.NoError(t, err) require.Equal(t, common.Hash(eth.SuperRoot(expectedSuper)), claim) @@ -66,11 +75,15 @@ func TestSuperNodeProvider_Get(t *testing.T) { ChainID: eth.ChainIDFromUInt64(1), Output: eth.Bytes32{0xbb}, }) - stubSupervisor.Add(superroot.SuperRootResponseData{ - VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xcc}}, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), - }) + response := superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }, + } + stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.RootPosition) require.NoError(t, err) require.Equal(t, common.Hash(eth.SuperRoot(expectedSuper)), claim) @@ -82,11 +95,15 @@ func TestSuperNodeProvider_Get(t *testing.T) { ChainID: eth.ChainIDFromUInt64(1), Output: eth.Bytes32{0xbb}, }) - stubSupervisor.Add(superroot.SuperRootResponseData{ - VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), - }) + response := superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }, + } + stubSupervisor.Add(response) claim, err := provider.Get(context.Background(), types.RootPosition) require.NoError(t, err) require.Equal(t, InvalidTransitionHash, claim) @@ -96,8 +113,8 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe earlier - prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xaa}} - next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 5, Hash: common.Hash{0xbb}} + prev.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 10, Hash: common.Hash{0xaa}} + next.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number - 5, Hash: common.Hash{0xbb}} stubSupervisor.Add(prev) stubSupervisor.Add(next) expectSuperNodeValidTransition(t, provider, prev, next) @@ -107,8 +124,8 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe only after L1 head - prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xaa}} - next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 2, Hash: common.Hash{0xbb}} + prev.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xaa}} + next.Data.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 2, Hash: common.Hash{0xbb}} stubSupervisor.Add(prev) stubSupervisor.Add(next) @@ -124,9 +141,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe only after L1 head - prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} - next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} - next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)] = superroot.OutputWithRequiredL1{ + 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.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)] = superroot.OutputWithRequiredL1{ Output: ð.OutputResponse{ OutputRoot: eth.Bytes32{0xad}, BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, @@ -150,9 +167,9 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, next := createValidSuperNodeSuperRoots(l1Head) // Make super roots be safe only after L1 head - prev.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number, Hash: common.Hash{0xaa}} - next.VerifiedRequiredL1 = eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xbb}} - next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)] = superroot.OutputWithRequiredL1{ + 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.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)] = superroot.OutputWithRequiredL1{ Output: ð.OutputResponse{ OutputRoot: eth.Bytes32{0xad}, BlockRef: eth.L2BlockRef{Hash: common.Hash{0xcd}}, @@ -178,8 +195,11 @@ func TestSuperNodeProvider_Get(t *testing.T) { }) t.Run("Step0ForTimestampBeyondChainHead", func(t *testing.T) { - provider, _, _ := createSuperNodeProvider(t) - // No super root at timestamp + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + stubSupervisor.AddAtTimestamp(poststateTimestamp, superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: nil, + }) claim, err := provider.Get(context.Background(), types.RootPosition) require.NoError(t, err) @@ -190,9 +210,10 @@ func TestSuperNodeProvider_Get(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) prev, _ := createValidSuperNodeSuperRoots(l1Head) stubSupervisor.Add(prev) - // No super root at next timestamp - - // TODO: Why are all these all invalid??? What if some chains had published a block and others hadn't yet? + stubSupervisor.AddAtTimestamp(prestateTimestamp+1, superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: nil, + }) // All steps should be the invalid transition hash. for i := int64(0); i < StepsPerTimestamp+1; i++ { claim, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(i))) @@ -202,9 +223,15 @@ func TestSuperNodeProvider_Get(t *testing.T) { }) t.Run("PreviousSuperRootTimestampBeyondChainHead", func(t *testing.T) { - provider, _, _ := createSuperNodeProvider(t) - // No super root at previous timestamp - // No super root at next timestamp + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + stubSupervisor.AddAtTimestamp(prestateTimestamp, superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: nil, + }) + stubSupervisor.AddAtTimestamp(prestateTimestamp+1, superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: nil, + }) // All steps should be the invalid transition hash. for i := int64(0); i < StepsPerTimestamp+1; i++ { @@ -220,19 +247,37 @@ func TestSuperNodeProvider_Get(t *testing.T) { ChainID: eth.ChainIDFromUInt64(1), Output: eth.Bytes32{0xbb}, }) - stubSupervisor.currentL1 = eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}} - stubSupervisor.Add(superroot.SuperRootResponseData{ - VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, - Super: expectedSuper, - SuperRoot: eth.SuperRoot(expectedSuper), - }) + response := superroot.AtTimestampResponse{ + CurrentL1: eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}}, + Data: &superroot.SuperRootResponseData{ + VerifiedRequiredL1: eth.BlockID{Number: l1Head.Number + 1, Hash: common.Hash{0xcc}}, + Super: expectedSuper, + SuperRoot: eth.SuperRoot(expectedSuper), + }, + } + stubSupervisor.Add(response) _, err := provider.Get(context.Background(), types.RootPosition) require.ErrorIs(t, err, client.ErrNotInSync) }) - t.Run("NotStep0NotInSync", func(t *testing.T) { + t.Run("PreviousSuperRootNotInSync", func(t *testing.T) { provider, stubSupervisor, l1Head := createSuperNodeProvider(t) - stubSupervisor.currentL1 = eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}} + stubSupervisor.AddAtTimestamp(prestateTimestamp, superroot.AtTimestampResponse{ + CurrentL1: eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}}, + }) + _, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(1))) + require.ErrorIs(t, err, client.ErrNotInSync) + }) + + t.Run("NextSuperRootNotInSync", func(t *testing.T) { + provider, stubSupervisor, l1Head := createSuperNodeProvider(t) + prev, _ := createValidSuperNodeSuperRoots(l1Head) + // Previous gives an in sync response + stubSupervisor.Add(prev) + // But next gives an out of sync response + stubSupervisor.AddAtTimestamp(prestateTimestamp+1, superroot.AtTimestampResponse{ + CurrentL1: eth.BlockID{Number: l1Head.Number - 1, Hash: common.Hash{0xaa}}, + }) _, err := provider.Get(context.Background(), types.NewPosition(gameDepth, big.NewInt(1))) require.ErrorIs(t, err, client.ErrNotInSync) }) @@ -315,8 +360,7 @@ func createSuperNodeProvider(t *testing.T) (*SuperNodeTraceProvider, *stubSuperN logger := testlog.Logger(t, log.LvlInfo) l1Head := eth.BlockID{Number: 23542, Hash: common.Hash{0xab, 0xcd}} stubSupervisor := &stubSuperNodeRootProvider{ - currentL1: l1Head, - rootsByTimestamp: make(map[uint64]superroot.SuperRootResponseData), + rootsByTimestamp: make(map[uint64]superroot.AtTimestampResponse), } provider := NewSuperNodeTraceProvider(logger, nil, stubSupervisor, l1Head, gameDepth, prestateTimestamp, poststateTimestamp) return provider, stubSupervisor, l1Head @@ -334,7 +378,7 @@ func toOutputResponse(output *eth.OutputV0) *eth.OutputResponse { } } -func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (superroot.SuperRootResponseData, superroot.SuperRootResponseData) { +func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (superroot.AtTimestampResponse, superroot.AtTimestampResponse) { rng := rand.New(rand.NewSource(1)) outputA1 := testutils.RandomOutputV0(rng) outputA2 := testutils.RandomOutputV0(rng) @@ -348,50 +392,56 @@ func createValidSuperNodeSuperRoots(l1Head eth.BlockID) (superroot.SuperRootResp eth.ChainIDAndOutput{ChainID: eth.ChainIDFromUInt64(1), Output: eth.OutputRoot(outputA2)}, eth.ChainIDAndOutput{ChainID: eth.ChainIDFromUInt64(2), Output: eth.OutputRoot(outputB2)}) - prevResponse := superroot.SuperRootResponseData{ - UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ - eth.ChainIDFromUInt64(1): { - Output: toOutputResponse(outputA1), - RequiredL1: l1Head, - }, - eth.ChainIDFromUInt64(2): { - Output: toOutputResponse(outputB1), - RequiredL1: l1Head, + prevResponse := superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ + eth.ChainIDFromUInt64(1): { + Output: toOutputResponse(outputA1), + RequiredL1: l1Head, + }, + eth.ChainIDFromUInt64(2): { + Output: toOutputResponse(outputB1), + RequiredL1: l1Head, + }, }, + VerifiedRequiredL1: l1Head, + Super: prevSuper, + SuperRoot: eth.SuperRoot(prevSuper), }, - VerifiedRequiredL1: l1Head, - Super: prevSuper, - SuperRoot: eth.SuperRoot(prevSuper), } - nextResponse := superroot.SuperRootResponseData{ - UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ - eth.ChainIDFromUInt64(1): { - Output: toOutputResponse(outputA2), - RequiredL1: l1Head, - }, - eth.ChainIDFromUInt64(2): { - Output: toOutputResponse(outputB2), - RequiredL1: l1Head, + nextResponse := superroot.AtTimestampResponse{ + CurrentL1: l1Head, + Data: &superroot.SuperRootResponseData{ + UnverifiedAtTimestamp: map[eth.ChainID]superroot.OutputWithRequiredL1{ + eth.ChainIDFromUInt64(1): { + Output: toOutputResponse(outputA2), + RequiredL1: l1Head, + }, + eth.ChainIDFromUInt64(2): { + Output: toOutputResponse(outputB2), + RequiredL1: l1Head, + }, }, + VerifiedRequiredL1: l1Head, + Super: nextSuper, + SuperRoot: eth.SuperRoot(nextSuper), }, - VerifiedRequiredL1: l1Head, - Super: nextSuper, - SuperRoot: eth.SuperRoot(nextSuper), } return prevResponse, nextResponse } -func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvider, prev superroot.SuperRootResponseData, next superroot.SuperRootResponseData) { +func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvider, prev superroot.AtTimestampResponse, next superroot.AtTimestampResponse) { chain1OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, - OutputRoot: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, + BlockHash: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.BlockRef.Hash, + OutputRoot: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(1)].Output.OutputRoot, } chain2OptimisticBlock := interopTypes.OptimisticBlock{ - BlockHash: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, - OutputRoot: next.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, + BlockHash: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.BlockRef.Hash, + OutputRoot: next.Data.UnverifiedAtTimestamp[eth.ChainIDFromUInt64(2)].Output.OutputRoot, } expectedFirstStep := &interopTypes.TransitionState{ - SuperRoot: prev.Super.Marshal(), + SuperRoot: prev.Data.Super.Marshal(), PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock}, Step: 1, } @@ -400,7 +450,7 @@ func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvid require.Equal(t, expectedFirstStep.Hash(), claim) expectedSecondStep := &interopTypes.TransitionState{ - SuperRoot: prev.Super.Marshal(), + SuperRoot: prev.Data.Super.Marshal(), PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock, chain2OptimisticBlock}, Step: 2, } @@ -410,7 +460,7 @@ func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvid for step := uint64(3); step < StepsPerTimestamp; step++ { expectedPaddingStep := &interopTypes.TransitionState{ - SuperRoot: prev.Super.Marshal(), + SuperRoot: prev.Data.Super.Marshal(), PendingProgress: []interopTypes.OptimisticBlock{chain1OptimisticBlock, chain2OptimisticBlock}, Step: step, } @@ -421,29 +471,26 @@ func expectSuperNodeValidTransition(t *testing.T, provider *SuperNodeTraceProvid } type stubSuperNodeRootProvider struct { - currentL1 eth.BlockID - rootsByTimestamp map[uint64]superroot.SuperRootResponseData + rootsByTimestamp map[uint64]superroot.AtTimestampResponse } -func (s *stubSuperNodeRootProvider) Add(root superroot.SuperRootResponseData) { +func (s *stubSuperNodeRootProvider) Add(root superroot.AtTimestampResponse) { + superV1 := root.Data.Super.(*eth.SuperV1) + s.AddAtTimestamp(superV1.Timestamp, root) +} + +func (s *stubSuperNodeRootProvider) AddAtTimestamp(timestamp uint64, root superroot.AtTimestampResponse) { if s.rootsByTimestamp == nil { - s.rootsByTimestamp = make(map[uint64]superroot.SuperRootResponseData) + s.rootsByTimestamp = make(map[uint64]superroot.AtTimestampResponse) } - v1 := root.Super.(*eth.SuperV1) - - s.rootsByTimestamp[v1.Timestamp] = root + s.rootsByTimestamp[timestamp] = root } -func (s *stubSuperNodeRootProvider) SuperRootAtTimestamps(_ context.Context, timestamps ...uint64) (superroot.AtTimestampResponse, error) { - response := superroot.AtTimestampResponse{ - CurrentL1: s.currentL1, - Data: make([]*superroot.SuperRootResponseData, len(timestamps)), - } - for i, timestamp := range timestamps { - root, ok := s.rootsByTimestamp[timestamp] - if ok { - response.Data[i] = &root - } +func (s *stubSuperNodeRootProvider) SuperRootAtTimestamp(_ context.Context, timestamp uint64) (superroot.AtTimestampResponse, error) { + root, ok := s.rootsByTimestamp[timestamp] + if !ok { + // This is not the not found response - the test just didn't configure a response, so return a generic error + return superroot.AtTimestampResponse{}, fmt.Errorf("wowsers, now response for timestamp %v", timestamp) } - return response, nil + return root, nil } diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index dedcf5e74c193..c958bf76c0da3 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -71,22 +71,21 @@ type AtTimestampResponse 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 } // 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 hexutil.Uint64) (AtTimestampResponse, error) { - return api.s.atTimestamps(ctx, timestamp) + return api.s.atTimestamp(ctx, uint64(timestamp)) } -// AtTimestamp computes the super-root at the given timestamp, plus additional information about the current L1s, verified L2s, and optimistic L2s -func (api *superrootAPI) AtTimestamps(ctx context.Context, timestamps []hexutil.Uint64) (AtTimestampResponse, error) { - return api.s.atTimestamps(ctx, timestamps...) -} - -func (s *Superroot) atTimestamps(ctx context.Context, timestamps ...hexutil.Uint64) (AtTimestampResponse, error) { +func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (AtTimestampResponse, error) { currentL1Derived := map[eth.ChainID]eth.BlockID{} + verified := map[eth.ChainID]L2WithRequiredL1{} + optimistic := map[eth.ChainID]OutputWithRequiredL1{} minCurrentL1 := eth.BlockID{} + maxVerifiedRequiredL1 := eth.BlockID{} + chainOutputs := make([]eth.ChainIDAndOutput, 0, len(s.chains)) // get current l1s // this informs callers that the chains local views have considered at least up to this L1 block @@ -103,30 +102,6 @@ func (s *Superroot) atTimestamps(ctx context.Context, timestamps ...hexutil.Uint } } - data := make([]*SuperRootResponseData, len(timestamps)) - for i, timestamp := range timestamps { - superRootData, err := s.dataAtTimestamp(ctx, uint64(timestamp)) - if errors.Is(err, engine_controller.ErrNotFound) { - // Leave this entry in data as nil as no super root is available - continue - } else if err != nil { - return AtTimestampResponse{}, fmt.Errorf("failed to compute superroot at timestamp %v: %w", timestamp, err) - } - data[i] = superRootData - } - return AtTimestampResponse{ - CurrentL1Derived: currentL1Derived, - CurrentL1: minCurrentL1, - Data: data, - }, nil -} - -func (s *Superroot) dataAtTimestamp(ctx context.Context, timestamp uint64) (*SuperRootResponseData, error) { - verified := map[eth.ChainID]L2WithRequiredL1{} - optimistic := map[eth.ChainID]OutputWithRequiredL1{} - maxVerifiedRequiredL1 := eth.BlockID{} - chainOutputs := make([]eth.ChainIDAndOutput, 0, len(s.chains)) - // 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 @@ -134,10 +109,13 @@ func (s *Superroot) dataAtTimestamp(ctx context.Context, timestamp uint64) (*Sup if errors.Is(err, engine_controller.ErrNotFound) { // We don't have a fully verified block at the specified timestamp so no super root can be produced. // Return only the current derived L1 block info. - return nil, engine_controller.ErrNotFound + return AtTimestampResponse{ + CurrentL1Derived: currentL1Derived, + CurrentL1: minCurrentL1, + }, nil } else if err != nil { s.log.Warn("failed to get verified L1", "chain_id", chainID.String(), "err", err) - return nil, fmt.Errorf("failed to get verified L1 for chain ID %v: %w", chainID, err) + return AtTimestampResponse{}, fmt.Errorf("failed to get verified L1 for chain ID %v: %w", chainID, err) } verified[chainID] = L2WithRequiredL1{ L2: verifiedL2, @@ -150,20 +128,20 @@ func (s *Superroot) dataAtTimestamp(ctx context.Context, timestamp uint64) (*Sup 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 nil, fmt.Errorf("failed to compute output root at L2 block %v for chain ID %v: %w", verifiedL2.Number, chainID, err) + return AtTimestampResponse{}, fmt.Errorf("failed to compute output root at L2 block %v 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 nil, fmt.Errorf("failed to get optimistic L1 for chain ID %v: %w", chainID, err) + return AtTimestampResponse{}, fmt.Errorf("failed to get optimistic L1 for chain ID %v: %w", 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 nil, fmt.Errorf("failed to get optimistic L1 for chain ID %v: %w", chainID, err) + return AtTimestampResponse{}, fmt.Errorf("failed to get optimistic L1 for chain ID %v: %w", chainID, err) } optimistic[chainID] = OutputWithRequiredL1{ Output: optimisticOut, @@ -175,10 +153,14 @@ func (s *Superroot) dataAtTimestamp(ctx context.Context, timestamp uint64) (*Sup superV1 := eth.NewSuperV1(timestamp, chainOutputs...) superRoot := eth.SuperRoot(superV1) - return &SuperRootResponseData{ - UnverifiedAtTimestamp: optimistic, - VerifiedRequiredL1: maxVerifiedRequiredL1, - Super: superV1, - SuperRoot: superRoot, + return AtTimestampResponse{ + CurrentL1Derived: currentL1Derived, + CurrentL1: minCurrentL1, + Data: &SuperRootResponseData{ + UnverifiedAtTimestamp: optimistic, + VerifiedRequiredL1: maxVerifiedRequiredL1, + 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 ac7c624f73474..ee1cdf67543f0 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -100,12 +100,12 @@ func TestSuperroot_AtTimestamp_Succeeds(t *testing.T) { out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) require.Len(t, out.CurrentL1Derived, 2) - require.Len(t, out.Data[0].UnverifiedAtTimestamp, 2) + require.Len(t, out.Data.UnverifiedAtTimestamp, 2) // min values require.Equal(t, uint64(2000), out.CurrentL1.Number) - require.Equal(t, uint64(1100), out.Data[0].VerifiedRequiredL1.Number) + require.Equal(t, uint64(1100), out.Data.VerifiedRequiredL1.Number) // With zero outputs, the superroot will be deterministic, just ensure it's set - _ = out.Data[0].SuperRoot + _ = out.Data.SuperRoot } func TestSuperroot_AtTimestamp_ComputesSuperRoot(t *testing.T) { @@ -142,7 +142,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.Data[0].SuperRoot) + require.Equal(t, expected, resp.Data.SuperRoot) } func TestSuperroot_AtTimestamp_ErrorOnCurrentL1(t *testing.T) { @@ -182,7 +182,7 @@ func TestSuperroot_AtTimestamp_NotFoundErrorOnVerifiedAt(t *testing.T) { api := &superrootAPI{s: s} response, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) - require.Nil(t, response.Data[0]) + require.Nil(t, response.Data) } func TestSuperroot_AtTimestamp_ErrorOnOutputRoot(t *testing.T) { @@ -222,7 +222,7 @@ func TestSuperroot_AtTimestamp_EmptyChains(t *testing.T) { out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) require.Len(t, out.CurrentL1Derived, 0) - require.Len(t, out.Data[0].UnverifiedAtTimestamp, 0) + require.Len(t, out.Data.UnverifiedAtTimestamp, 0) } // assertErr returns a generic error instance used to signal mock failures.