diff --git a/op-node/node/node.go b/op-node/node/node.go index dd8c42cd18b..c81085a4268 100644 --- a/op-node/node/node.go +++ b/op-node/node/node.go @@ -992,3 +992,14 @@ func (n *OpNode) getP2PNodeIfEnabled() *p2p.NodeP2P { defer n.p2pMu.Unlock() return n.p2pNode } + +func (n *OpNode) SafeDB() SafeDBReader { + return n.safeDB +} + +func (n *OpNode) SyncStatus() *eth.SyncStatus { + if n.l2Driver == nil || n.l2Driver.StatusTracker == nil { + return ð.SyncStatus{} + } + return n.l2Driver.StatusTracker.SyncStatus() +} diff --git a/op-service/rpc/handler.go b/op-service/rpc/handler.go index 4b8e3caa4b5..ef455d216c2 100644 --- a/op-service/rpc/handler.go +++ b/op-service/rpc/handler.go @@ -93,6 +93,18 @@ func (b *Handler) ServeHTTP(writer http.ResponseWriter, request *http.Request) { b.outer.ServeHTTP(writer, request) } +// DialInProc creates a new in-process RPC client connected to the root RPC server. +// Useful for components that need to call RPC methods on the embedded server without going over the network. +func (b *Handler) DialInProc() (*rpc.Client, error) { + b.rpcRoutesLock.Lock() + defer b.rpcRoutesLock.Unlock() + server, ok := b.rpcRoutes[rootRoute] + if !ok || server == nil { + return nil, fmt.Errorf("root RPC server not available") + } + return rpc.DialInProc(server), nil +} + // AddAPI adds a backend to the given RPC namespace, on the default RPC route of the server. func (b *Handler) AddAPI(api rpc.API) error { return b.AddAPIToRPC(rootRoute, api) diff --git a/op-supernode/README.md b/op-supernode/README.md index 8479ed013ad..7ecfafebdf9 100644 --- a/op-supernode/README.md +++ b/op-supernode/README.md @@ -75,6 +75,8 @@ Components which expose Start/Stop are given a goroutine to work during `op-supe - `Heartbeat` - RPC: `heartbeat_check` produces a random-hex sign of life when called. - Runtime: emits a simple heartbeat message to the logs to show liveness. +- `SuperRoot` + - RPC: `superroot_atTimestamp` produces a SuperRoot from Verified L2 blocks, and includes sync/derivation information for Proofs. ### Quickstart Build: diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go new file mode 100644 index 00000000000..31d4a9c29fe --- /dev/null +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -0,0 +1,148 @@ +package superroot + +import ( + "context" + "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" + gethlog "github.com/ethereum/go-ethereum/log" +) + +// Superroot satisfies the RPC Activity interface +// it provides the superroot at a given timestamp for all chains +// along with the current L1s and the verified and optimistic L1:L2 pairs +type Superroot struct { + log gethlog.Logger + chains map[eth.ChainID]cc.ChainContainer +} + +func New(log gethlog.Logger, chains map[eth.ChainID]cc.ChainContainer) *Superroot { + return &Superroot{ + log: log, + chains: chains, + } +} + +func (s *Superroot) ActivityName() string { return "superroot" } + +func (s *Superroot) RPCNamespace() string { return "superroot" } +func (s *Superroot) RPCService() interface{} { return &superrootAPI{s: s} } + +type superrootAPI struct{ s *Superroot } + +// OutputWithSource is the full Output and its source L1 block +type OutputWithSource struct { + Output *eth.OutputResponse + SourceL1 eth.BlockID +} + +// L2WithRequiredL1 is a verified L2 block and the minimum L1 block at which the verification is possible +type L2WithRequiredL1 struct { + L2 eth.BlockID + MinRequiredL1 eth.BlockID +} + +// atTimestampResponse is the response superroot_atTimestamp +// it contains: +// - CurrentL1Derived: the current L1 block that each chain has derived up to (without any verification) +// - CurrentL1Verified: the current L1 block that each verifier has processed up to +// - VerifiedAtTimestamp: the L2 blocks which are fully verified at the given timestamp, and the minimum L1 block at which verification is possible +// - OptimisticAtTimestamp: the L2 blocks which would be applied if verification were assumed to be successful, and their L1 sources +// - SuperRoot: the superroot at the given timestamp using verified L2 blocks +type atTimestampResponse struct { + CurrentL1Derived map[eth.ChainID]eth.BlockID + CurrentL1Verified map[string]eth.BlockID + VerifiedAtTimestamp map[eth.ChainID]L2WithRequiredL1 + OptimisticAtTimestamp map[eth.ChainID]OutputWithSource + MinCurrentL1 eth.BlockID + MinVerifiedRequiredL1 eth.BlockID + SuperRoot eth.Bytes32 +} + +// AtTimestamp computes the super-root at the given timestamp, plus additional information about the current L1s, verified L2s, and optimistic L2s +func (api *superrootAPI) AtTimestamp(ctx context.Context, timestamp uint64) (atTimestampResponse, error) { + return api.s.atTimestamp(ctx, timestamp) +} + +func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimestampResponse, error) { + currentL1Derived := map[eth.ChainID]eth.BlockID{} + // there are no Verification Activities yet, so there is no call to make to collect their CurrentL1 + // this will be replaced with a call to the Verification Activities when they are implemented + currentL1Verified := map[string]eth.BlockID{} + verified := map[eth.ChainID]L2WithRequiredL1{} + optimistic := map[eth.ChainID]OutputWithSource{} + minCurrentL1 := eth.BlockID{} + minVerifiedRequiredL1 := 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 + // but does not guarantee verifiers have processed this L1 block yet. This field is likely unhelpful, but I await feedback to confirm + for chainID, chain := range s.chains { + currentL1, err := chain.CurrentL1(ctx) + if err != nil { + s.log.Warn("failed to get current L1", "chain_id", chainID.String(), "err", err) + return atTimestampResponse{}, err + } + currentL1Derived[chainID] = currentL1.ID() + if currentL1.ID().Number < minCurrentL1.Number || minCurrentL1 == (eth.BlockID{}) { + minCurrentL1 = currentL1.ID() + } + } + + // collect verified and optimistic L2 and L1 blocks at the given timestamp + for chainID, chain := range s.chains { + // verifiedAt returns the L2 block which is fully verified at the given timestamp, and the minimum L1 block at which verification is possible + verifiedL2, verifiedL1, err := chain.VerifiedAt(ctx, timestamp) + if err != nil { + s.log.Warn("failed to get verified L1", "chain_id", chainID.String(), "err", err) + return atTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) + } + verified[chainID] = L2WithRequiredL1{ + L2: verifiedL2, + MinRequiredL1: verifiedL1, + } + if verifiedL1.Number < minVerifiedRequiredL1.Number || minVerifiedRequiredL1 == (eth.BlockID{}) { + minVerifiedRequiredL1 = 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) + } + 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) + } + // 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) + } + optimistic[chainID] = OutputWithSource{ + Output: optimisticOut, + SourceL1: optimisticL1, + } + } + + // Build super root from collected outputs + superV1 := eth.NewSuperV1(timestamp, chainOutputs...) + superRoot := eth.SuperRoot(superV1) + + return atTimestampResponse{ + CurrentL1Derived: currentL1Derived, + CurrentL1Verified: currentL1Verified, + VerifiedAtTimestamp: verified, + OptimisticAtTimestamp: optimistic, + MinCurrentL1: minCurrentL1, + MinVerifiedRequiredL1: minVerifiedRequiredL1, + SuperRoot: superRoot, + }, nil +} diff --git a/op-supernode/supernode/activity/superroot/superroot_test.go b/op-supernode/supernode/activity/superroot/superroot_test.go new file mode 100644 index 00000000000..f85acd8572e --- /dev/null +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -0,0 +1,215 @@ +package superroot + +import ( + "context" + "fmt" + "testing" + + "github.com/ethereum-optimism/optimism/op-service/eth" + cc "github.com/ethereum-optimism/optimism/op-supernode/supernode/chain_container" + gethlog "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +type mockCC struct { + verL2 eth.BlockID + verL1 eth.BlockID + optL2 eth.BlockID + optL1 eth.BlockID + output eth.Bytes32 + currentL1 eth.BlockRef + + currentL1Err error + verifiedErr error + outputErr error + optimisticErr error +} + +func (m *mockCC) Start(ctx context.Context) error { return nil } +func (m *mockCC) Stop(ctx context.Context) error { return nil } +func (m *mockCC) Pause(ctx context.Context) error { return nil } +func (m *mockCC) Resume(ctx context.Context) error { return nil } + +func (m *mockCC) SafeBlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) { + return eth.L2BlockRef{}, nil +} +func (m *mockCC) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (eth.BlockID, eth.BlockID, error) { + return eth.BlockID{}, eth.BlockID{}, nil +} +func (m *mockCC) L1AtSafeHead(ctx context.Context, l2 eth.BlockID) (eth.BlockID, error) { + return eth.BlockID{}, nil +} +func (m *mockCC) CurrentL1(ctx context.Context) (eth.BlockRef, error) { + if m.currentL1Err != nil { + return eth.BlockRef{}, m.currentL1Err + } + return m.currentL1, nil +} +func (m *mockCC) VerifiedAt(ctx context.Context, ts uint64) (eth.BlockID, eth.BlockID, error) { + if m.verifiedErr != nil { + return eth.BlockID{}, eth.BlockID{}, m.verifiedErr + } + return m.verL2, m.verL1, nil +} +func (m *mockCC) OptimisticAt(ctx context.Context, ts uint64) (eth.BlockID, eth.BlockID, error) { + if m.optimisticErr != nil { + return eth.BlockID{}, eth.BlockID{}, m.optimisticErr + } + return m.optL2, m.optL1, nil +} +func (m *mockCC) OutputRootAtL2BlockNumber(ctx context.Context, l2BlockNum uint64) (eth.Bytes32, error) { + if m.outputErr != nil { + return eth.Bytes32{}, m.outputErr + } + return m.output, nil +} +func (m *mockCC) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputResponse, error) { + if m.optimisticErr != nil { + return nil, m.optimisticErr + } + // Return minimal output response; tests only assert presence/count + return ð.OutputResponse{}, nil +} + +var _ cc.ChainContainer = (*mockCC)(nil) + +func TestSuperroot_AtTimestamp_Succeeds(t *testing.T) { + t.Parallel() + chains := map[eth.ChainID]cc.ChainContainer{ + eth.ChainIDFromUInt64(10): &mockCC{ + verL2: eth.BlockID{Number: 100}, + verL1: eth.BlockID{Number: 1000}, + optL2: eth.BlockID{Number: 100}, + optL1: eth.BlockID{Number: 1000}, + output: eth.Bytes32{}, + currentL1: eth.BlockRef{Number: 2000}, + }, + eth.ChainIDFromUInt64(420): &mockCC{ + verL2: eth.BlockID{Number: 200}, + verL1: eth.BlockID{Number: 1100}, + optL2: eth.BlockID{Number: 200}, + optL1: eth.BlockID{Number: 1100}, + output: eth.Bytes32{}, + currentL1: eth.BlockRef{Number: 2100}, + }, + } + s := New(gethlog.New(), chains) + api := &superrootAPI{s: s} + out, err := api.AtTimestamp(context.Background(), 123) + require.NoError(t, err) + require.Len(t, out.CurrentL1Derived, 2) + require.Len(t, out.VerifiedAtTimestamp, 2) + require.Len(t, out.OptimisticAtTimestamp, 2) + // min values + require.Equal(t, uint64(2000), out.MinCurrentL1.Number) + require.Equal(t, uint64(1000), out.MinVerifiedRequiredL1.Number) + // With zero outputs, the superroot will be deterministic, just ensure it's set + _ = out.SuperRoot +} + +func TestSuperroot_AtTimestamp_ComputesSuperRoot(t *testing.T) { + t.Parallel() + out1 := eth.Bytes32{1} + out2 := eth.Bytes32{2} + chains := map[eth.ChainID]cc.ChainContainer{ + eth.ChainIDFromUInt64(10): &mockCC{ + verL2: eth.BlockID{Number: 100}, + verL1: eth.BlockID{Number: 1000}, + optL2: eth.BlockID{Number: 100}, + optL1: eth.BlockID{Number: 1000}, + output: out1, + currentL1: eth.BlockRef{Number: 2000}, + }, + eth.ChainIDFromUInt64(420): &mockCC{ + verL2: eth.BlockID{Number: 200}, + verL1: eth.BlockID{Number: 1100}, + optL2: eth.BlockID{Number: 200}, + optL1: eth.BlockID{Number: 1100}, + output: out2, + currentL1: eth.BlockRef{Number: 2100}, + }, + } + ts := uint64(123) + s := New(gethlog.New(), chains) + api := &superrootAPI{s: s} + resp, err := api.AtTimestamp(context.Background(), ts) + require.NoError(t, err) + + // Compute expected super root + chainOutputs := []eth.ChainIDAndOutput{ + {ChainID: eth.ChainIDFromUInt64(10), Output: out1}, + {ChainID: eth.ChainIDFromUInt64(420), Output: out2}, + } + expected := eth.SuperRoot(eth.NewSuperV1(ts, chainOutputs...)) + require.Equal(t, expected, resp.SuperRoot) +} + +func TestSuperroot_AtTimestamp_ErrorOnCurrentL1(t *testing.T) { + t.Parallel() + chains := map[eth.ChainID]cc.ChainContainer{ + eth.ChainIDFromUInt64(10): &mockCC{ + currentL1Err: assertErr(), + }, + } + s := New(gethlog.New(), chains) + api := &superrootAPI{s: s} + _, err := api.AtTimestamp(context.Background(), 123) + require.Error(t, err) +} + +func TestSuperroot_AtTimestamp_ErrorOnVerifiedAt(t *testing.T) { + t.Parallel() + chains := map[eth.ChainID]cc.ChainContainer{ + eth.ChainIDFromUInt64(10): &mockCC{ + verifiedErr: assertErr(), + }, + } + s := New(gethlog.New(), chains) + api := &superrootAPI{s: s} + _, err := api.AtTimestamp(context.Background(), 123) + require.Error(t, err) +} + +func TestSuperroot_AtTimestamp_ErrorOnOutputRoot(t *testing.T) { + t.Parallel() + chains := map[eth.ChainID]cc.ChainContainer{ + eth.ChainIDFromUInt64(10): &mockCC{ + verL2: eth.BlockID{Number: 100}, + outputErr: assertErr(), + }, + } + s := New(gethlog.New(), chains) + api := &superrootAPI{s: s} + _, err := api.AtTimestamp(context.Background(), 123) + require.Error(t, err) +} + +func TestSuperroot_AtTimestamp_ErrorOnOptimisticAt(t *testing.T) { + t.Parallel() + chains := map[eth.ChainID]cc.ChainContainer{ + eth.ChainIDFromUInt64(10): &mockCC{ + verL2: eth.BlockID{Number: 100}, + output: eth.Bytes32{1}, + optimisticErr: assertErr(), + }, + } + s := New(gethlog.New(), chains) + api := &superrootAPI{s: s} + _, err := api.AtTimestamp(context.Background(), 123) + require.Error(t, err) +} + +func TestSuperroot_AtTimestamp_EmptyChains(t *testing.T) { + t.Parallel() + chains := map[eth.ChainID]cc.ChainContainer{} + s := New(gethlog.New(), chains) + api := &superrootAPI{s: s} + out, err := api.AtTimestamp(context.Background(), 123) + require.NoError(t, err) + require.Len(t, out.CurrentL1Derived, 0) + require.Len(t, out.VerifiedAtTimestamp, 0) + require.Len(t, out.OptimisticAtTimestamp, 0) +} + +// assertErr returns a generic error instance used to signal mock failures. +func assertErr() error { return fmt.Errorf("mock error") } diff --git a/op-supernode/supernode/chain_container/chain_container.go b/op-supernode/supernode/chain_container/chain_container.go index 24aab37b4b7..0e262ee94db 100644 --- a/op-supernode/supernode/chain_container/chain_container.go +++ b/op-supernode/supernode/chain_container/chain_container.go @@ -2,6 +2,7 @@ package chain_container import ( "context" + "fmt" "net/http" "path/filepath" "sync/atomic" @@ -9,9 +10,12 @@ import ( opnodecfg "github.com/ethereum-optimism/optimism/op-node/config" rollupNode "github.com/ethereum-optimism/optimism/op-node/node" + "github.com/ethereum-optimism/optimism/op-service/client" "github.com/ethereum-optimism/optimism/op-service/eth" oprpc "github.com/ethereum-optimism/optimism/op-service/rpc" + "github.com/ethereum-optimism/optimism/op-service/sources" "github.com/ethereum-optimism/optimism/op-supernode/config" + "github.com/ethereum-optimism/optimism/op-supernode/supernode/chain_container/engine_controller" "github.com/ethereum-optimism/optimism/op-supernode/supernode/chain_container/virtual_node" gethlog "github.com/ethereum/go-ethereum/log" "github.com/prometheus/client_golang/prometheus" @@ -25,6 +29,17 @@ type ChainContainer interface { Stop(ctx context.Context) error Pause(ctx context.Context) error Resume(ctx context.Context) error + + SafeBlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) + SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (l1 eth.BlockID, l2 eth.BlockID, err error) + // L1AtSafeHead returns the earliest L1 block at which the given L2 block became safe. + L1AtSafeHead(ctx context.Context, l2 eth.BlockID) (eth.BlockID, error) + CurrentL1(ctx context.Context) (eth.BlockRef, error) + VerifiedAt(ctx context.Context, ts uint64) (l2, l1 eth.BlockID, err error) + OptimisticAt(ctx context.Context, ts uint64) (l2, l1 eth.BlockID, err error) + OutputRootAtL2BlockNumber(ctx context.Context, l2BlockNum uint64) (eth.Bytes32, error) + // OptimisticOutputAtTimestamp returns the full Output at the optimistic L2 block for the given timestamp. + OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputResponse, error) } type virtualNodeFactory func(cfg *opnodecfg.Config, log gethlog.Logger, initOverrides *rollupNode.InitializationOverrides, appVersion string) virtual_node.VirtualNode @@ -33,6 +48,7 @@ type simpleChainContainer struct { vn virtual_node.VirtualNode vncfg *opnodecfg.Config cfg config.CLIConfig + engine engine_controller.EngineController pause atomic.Bool stop atomic.Bool stopped chan struct{} @@ -43,9 +59,13 @@ type simpleChainContainer struct { setHandler func(chainID string, h http.Handler) // Set the RPC handler on the router for the chain setMetricsHandler func(chainID string, h http.Handler) // Set the metrics handler on the router for the chain appVersion string - virtualNodeFactory virtualNodeFactory // Factory function to create virtual node (for testing) + virtualNodeFactory virtualNodeFactory // Factory function to create virtual node (for testing) + rollupClient *sources.RollupClient // In-proc rollup RPC client bound to rpcHandler } +// Interface conformance assertions +var _ ChainContainer = (*simpleChainContainer)(nil) + func NewChainContainer( chainID eth.ChainID, vncfg *opnodecfg.Config, @@ -71,6 +91,24 @@ func NewChainContainer( } vncfg.SafeDBPath = c.subPath("safe_db") vncfg.RPC = cfg.RPCConfig + // Attach in-proc rollup client if an initial handler is provided + if c.rpcHandler != nil { + if err := c.attachInProcRollupClient(); err != nil { + log.Warn("failed to attach in-proc rollup client (initial)", "err", err) + } + } + // Initialize engine controller (separate connection, not an op-node override) with a short setup timeout + if vncfg.L2 != nil { + setupCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // Provide contextual logger to engine controller + engLog := log.New("chain_id", chainID.String(), "component", "engine_controller") + if eng, err := engine_controller.NewEngineControllerFromConfig(setupCtx, engLog, vncfg); err != nil { + log.Error("failed to setup engine controller", "err", err) + } else { + c.engine = eng + } + } return c } @@ -96,6 +134,10 @@ func (c *simpleChainContainer) Start(ctx context.Context) error { } c.initOverload.RPCHandler = h c.rpcHandler = h + // attach in-proc rollup client for this handler + if err := c.attachInProcRollupClient(); err != nil { + c.log.Warn("failed to attach in-proc rollup client", "err", err) + } // Disable per-VN metrics server and provide metrics registry hook c.vncfg.Metrics.Enabled = false @@ -152,12 +194,22 @@ func (c *simpleChainContainer) Stop(ctx context.Context) error { stopCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() + // Close in-proc rollup RPC resources + if c.rollupClient != nil { + c.rollupClient.Close() + } + if c.vn != nil { if err := c.vn.Stop(stopCtx); err != nil { c.log.Error("error stopping virtual node", "error", err) } } + // Close engine controller RPC resources + if c.engine != nil { + _ = c.engine.Close() + } + select { case <-c.stopped: return nil @@ -175,3 +227,123 @@ func (c *simpleChainContainer) Resume(ctx context.Context) error { c.pause.Store(false) return nil } + +// SafeBlockAtTimestamp returns the highest SAFE L2 block with timestamp <= ts using the L2 client. +func (c *simpleChainContainer) SafeBlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) { + if c.engine == nil { + return eth.L2BlockRef{}, engine_controller.ErrNoEngineClient + } + return c.engine.SafeBlockAtTimestamp(ctx, ts) +} + +// OutputRootAtL2BlockNumber computes the L2 output root for the specified L2 block number. +func (c *simpleChainContainer) OutputRootAtL2BlockNumber(ctx context.Context, l2BlockNum uint64) (eth.Bytes32, error) { + if c.engine == nil { + return eth.Bytes32{}, engine_controller.ErrNoEngineClient + } + out, err := c.engine.OutputV0AtBlockNumber(ctx, l2BlockNum) + if err != nil { + return eth.Bytes32{}, err + } + return eth.OutputRoot(out), nil +} + +// SafeHeadAtL1 queries the embedded op-node RPC handler for the SafeDB mapping at/preceding the given L1 block number. +func (c *simpleChainContainer) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (eth.BlockID, eth.BlockID, error) { + if c.vn == nil { + return eth.BlockID{}, eth.BlockID{}, fmt.Errorf("virtual node not initialized") + } + return c.vn.SafeHeadAtL1(ctx, l1BlockNum) +} + +// L1AtSafeHead delegates to the virtual node to resolve the earliest L1 at which the L2 became safe. +func (c *simpleChainContainer) L1AtSafeHead(ctx context.Context, l2 eth.BlockID) (eth.BlockID, error) { + if c.vn == nil { + return eth.BlockID{}, fmt.Errorf("virtual node not initialized") + } + return c.vn.L1AtSafeHead(ctx, l2) +} + +// CurrentL1 returns the most recent processed L1 block reference based on the derivation pipeline sync status. +func (c *simpleChainContainer) CurrentL1(ctx context.Context) (eth.BlockRef, error) { + if c.vn == nil { + if c.log != nil { + c.log.Warn("CurrentL1: virtual node not initialized") + } + return eth.BlockRef{}, nil + } + return c.vn.CurrentL1(ctx) +} + +// VerifiedAt returns the verified L2 and L1 blocks for the given L2 timestamp. +func (c *simpleChainContainer) VerifiedAt(ctx context.Context, ts uint64) (l2, l1 eth.BlockID, err error) { + l2Block, err := c.SafeBlockAtTimestamp(ctx, ts) + if err != nil { + c.log.Error("error determining l2 block at given timestamp", "error", err) + return eth.BlockID{}, eth.BlockID{}, err + } + l1Block, err := c.L1AtSafeHead(ctx, l2Block.ID()) + if err != nil { + c.log.Error("error determining l1 block number at which l2 block became safe", "error", err) + return eth.BlockID{}, eth.BlockID{}, err + } + + // if there were Verification Activities, we would check if the data could be *verified* at this L1, or would use its L1 block number + // but there are currently no verification activities, so we just return the l2 and l1 blocks + return l2Block.ID(), l1Block, nil +} + +// OptimisticAt returns the optimistic (pre-verified) L2 and L1 blocks for the given L2 timestamp. +func (c *simpleChainContainer) OptimisticAt(ctx context.Context, ts uint64) (l2, l1 eth.BlockID, err error) { + l2Block, err := c.SafeBlockAtTimestamp(ctx, ts) + if err != nil { + c.log.Error("error determining l2 block at given timestamp", "error", err) + return eth.BlockID{}, eth.BlockID{}, err + } + l1Block, err := c.L1AtSafeHead(ctx, l2Block.ID()) + if err != nil { + c.log.Error("error determining l1 block number at which l2 block became safe", "error", err) + return eth.BlockID{}, eth.BlockID{}, err + } + + // if there were Verification Activities, we could check if there was a pre-verified block which was added to the denylist + // but there are currently no verification activities, so we just return the l2 and l1 blocks + return l2Block.ID(), l1Block, nil +} + +// OptimisticOutputAtTimestamp returns the full Output for the optimistic L2 block at the given timestamp. +// For now this simply calls the op-node's normal OutputAtBlock for the block number computed from the timestamp. +func (c *simpleChainContainer) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputResponse, error) { + if c.rollupClient == nil { + return nil, fmt.Errorf("rollup client not initialized") + } + // Determine the optimistic L2 block at timestamp (currently same as safe block at ts) + l2Block, err := c.SafeBlockAtTimestamp(ctx, ts) + if err != nil { + return nil, fmt.Errorf("failed to resolve L2 block at timestamp: %w", err) + } + // Call the standard OutputAtBlock RPC + out, err := c.rollupClient.OutputAtBlock(ctx, l2Block.Number) + if err != nil { + return nil, fmt.Errorf("failed to get output at block %d: %w", l2Block.Number, err) + } + return out, nil +} + +// attachInProcRollupClient creates a new in-proc rollup RPC client bound to the current rpcHandler. +// It will close any existing client before replacing it. +func (c *simpleChainContainer) attachInProcRollupClient() error { + if c.rpcHandler == nil { + return fmt.Errorf("rpc handler not initialized") + } + inproc, err := c.rpcHandler.DialInProc() + if err != nil { + return err + } + // Close previous rollup client if present + if c.rollupClient != nil { + c.rollupClient.Close() + } + c.rollupClient = sources.NewRollupClient(client.NewBaseRPCClient(inproc)) + return nil +} diff --git a/op-supernode/supernode/chain_container/chain_container_test.go b/op-supernode/supernode/chain_container/chain_container_test.go index ba7da6006ce..193610d7385 100644 --- a/op-supernode/supernode/chain_container/chain_container_test.go +++ b/op-supernode/supernode/chain_container/chain_container_test.go @@ -31,6 +31,14 @@ type mockVirtualNode struct { stopFunc func(ctx context.Context) error blockOnStart bool startSignal chan struct{} + // latest safe mock behavior + latestSafe eth.BlockID + latestErr error + + // safe head mapping mock behavior + safeHeadL1 eth.BlockID + safeHeadL2 eth.BlockID + safeHeadErr error } func newMockVirtualNode() *mockVirtualNode { @@ -73,6 +81,33 @@ func (m *mockVirtualNode) Stop(ctx context.Context) error { return m.stopErr } +// SafeTimestamp implements virtual_node.VirtualNode SafeTimestamp +func (m *mockVirtualNode) LatestSafe(ctx context.Context) (eth.BlockID, error) { + return m.latestSafe, m.latestErr +} + +// SafeHeadAtL1 implements virtual_node.VirtualNode SafeHeadAtL1 +func (m *mockVirtualNode) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (eth.BlockID, eth.BlockID, error) { + return m.safeHeadL1, m.safeHeadL2, m.safeHeadErr +} + +// L1AtSafeHead implements virtual_node.VirtualNode L1AtSafeHead +func (m *mockVirtualNode) L1AtSafeHead(ctx context.Context, target eth.BlockID) (eth.BlockID, error) { + return m.safeHeadL1, m.safeHeadErr +} + +// LastL1 implements virtual_node.VirtualNode LastL1 +func (m *mockVirtualNode) LastL1(ctx context.Context) (eth.BlockID, error) { + return m.safeHeadL1, m.safeHeadErr +} + +// CurrentL1 implements virtual_node.VirtualNode CurrentL1 +func (m *mockVirtualNode) CurrentL1(ctx context.Context) (eth.BlockRef, error) { + return eth.BlockRef{Hash: m.safeHeadL1.Hash, Number: m.safeHeadL1.Number}, m.safeHeadErr +} + +// SafeDB is not required by VirtualNode in these tests + // Test helpers func createTestVNConfig() *opnodecfg.Config { return &opnodecfg.Config{ @@ -595,3 +630,5 @@ func TestChainContainer_VirtualNodeIntegration(t *testing.T) { }, 1*time.Second, 10*time.Millisecond) }) } + +// Output root helper tests removed with simplified interface diff --git a/op-supernode/supernode/chain_container/engine_controller/engine_controller.go b/op-supernode/supernode/chain_container/engine_controller/engine_controller.go index 2dc77e163c1..1fbe41706b2 100644 --- a/op-supernode/supernode/chain_container/engine_controller/engine_controller.go +++ b/op-supernode/supernode/chain_container/engine_controller/engine_controller.go @@ -1,11 +1,132 @@ package engine_controller +import ( + "context" + "errors" + + opnodecfg "github.com/ethereum-optimism/optimism/op-node/config" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/eth" + opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics" + "github.com/ethereum-optimism/optimism/op-service/sources" + gethlog "github.com/ethereum/go-ethereum/log" +) + +// EngineController abstracts access to the L2 execution layer type EngineController interface { + // SafeBlockAtTimestamp returns the L2 block ref for the block at or before the given timestamp, + // clamped to the current SAFE head. + SafeBlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) + // OutputV0AtBlockNumber returns the output preimage for the given L2 block number. + OutputV0AtBlockNumber(ctx context.Context, num uint64) (*eth.OutputV0, error) + // Close releases any underlying RPC resources. + Close() error +} + +// l2Provider captures the subset of the engine client we rely on. +type l2Provider interface { + L2BlockRefByLabel(ctx context.Context, label eth.BlockLabel) (eth.L2BlockRef, error) + L2BlockRefByNumber(ctx context.Context, num uint64) (eth.L2BlockRef, error) + OutputV0AtBlockNumber(ctx context.Context, blockNum uint64) (*eth.OutputV0, error) + PayloadByNumber(ctx context.Context, number uint64) (*eth.ExecutionPayloadEnvelope, error) + Close() } type simpleEngineController struct { + l2 l2Provider + rollup *rollup.Config + log gethlog.Logger +} + +// NewEngineControllerWithL2 wraps an existing L2 provider. +func NewEngineControllerWithL2(l2 l2Provider) EngineController { + return &simpleEngineController{l2: l2, log: gethlog.New()} +} + +// NewEngineControllerFromConfig builds an engine client from the op-node L2 endpoint config. +// This creates a separate connection (not passed as an override to op-node). +func NewEngineControllerFromConfig(ctx context.Context, log gethlog.Logger, vncfg *opnodecfg.Config) (EngineController, error) { + rpc, engCfg, err := vncfg.L2.Setup(ctx, log, &vncfg.Rollup, &opmetrics.NoopRPCMetrics{}) + if err != nil { + return nil, err + } + eng, err := sources.NewEngineClient(rpc, log, nil, engCfg) + if err != nil { + return nil, err + } + return &simpleEngineController{l2: eng, rollup: &vncfg.Rollup, log: log}, nil +} + +var ( + ErrNoEngineClient = errors.New("engine client not initialized") + ErrNoRollupConfig = errors.New("rollup config not available") + ErrNotFound = errors.New("not found") +) + +func (e *simpleEngineController) SafeBlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) { + if e.l2 == nil { + return eth.L2BlockRef{}, ErrNoEngineClient + } + if e.rollup == nil { + return eth.L2BlockRef{}, ErrNoRollupConfig + } + // Compute the target block directly from rollup config + num, err := e.rollup.TargetBlockNumber(ts) + if err != nil { + return eth.L2BlockRef{}, err + } + safeHead, err := e.l2.L2BlockRefByLabel(ctx, eth.Safe) + if err != nil { + return eth.L2BlockRef{}, err + } + if num > safeHead.Number { + e.log.Warn("engine_controller: target block number exceeds safe head", "targetBlockNumber", num, "safeHead", safeHead.Number) + return eth.L2BlockRef{}, ErrNotFound + } + e.log.Debug("engine_controller: computed safe block number from timestamp", + "timestamp", ts, "targetBlockNumber", num, "safeHead", safeHead.Number, "safeHeadErr", err) + return e.l2.L2BlockRefByNumber(ctx, num) } -func NewEngineController() EngineController { - return &simpleEngineController{} +func (e *simpleEngineController) OutputV0AtBlockNumber(ctx context.Context, num uint64) (*eth.OutputV0, error) { + if e.l2 == nil { + return nil, ErrNoEngineClient + } + // Prefer payload WithdrawalsRoot to avoid eth_getProof requirement on compatible nodes + env, err := e.l2.PayloadByNumber(ctx, num) + if e.log != nil { + if err != nil { + e.log.Debug("engine_controller: payload fetch failed, will try fallback if needed", "blockNumber", num, "err", err) + } else if env == nil || env.ExecutionPayload == nil { + e.log.Debug("engine_controller: payload missing, will try fallback", "blockNumber", num) + } else if env.ExecutionPayload.WithdrawalsRoot == nil { + e.log.Debug("engine_controller: payload has no withdrawals root (pre-Isthmus?), will try fallback", "blockNumber", num) + } else { + e.log.Debug("engine_controller: payload contains withdrawals root; using payload-based OutputV0", "blockNumber", num) + } + } + if err == nil && env != nil && env.ExecutionPayload != nil && env.ExecutionPayload.WithdrawalsRoot != nil { + p := env.ExecutionPayload + out := ð.OutputV0{ + StateRoot: p.StateRoot, + MessagePasserStorageRoot: eth.Bytes32(*p.WithdrawalsRoot), + BlockHash: p.BlockHash, + } + return out, nil + } + // Fallback to proof-based method if payload does not include WithdrawalsRoot + if e.log != nil { + e.log.Debug("engine_controller: falling back to proof-based OutputV0", "blockNumber", num) + } + return e.l2.OutputV0AtBlockNumber(ctx, num) } + +func (e *simpleEngineController) Close() error { + if e.l2 != nil { + e.l2.Close() + } + return nil +} + +// Interface conformance assertion +var _ EngineController = (*simpleEngineController)(nil) diff --git a/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go b/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go new file mode 100644 index 00000000000..6545b15f7ef --- /dev/null +++ b/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go @@ -0,0 +1,109 @@ +package engine_controller + +import ( + "context" + "math/big" + "testing" + + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum/go-ethereum/common" + gethlog "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +// unified mock covers both payload/output paths and SafeBlockAtTimestamp path + +func TestOutputV0AtBlockNumber_UsesPayloadWhenAvailable(t *testing.T) { + t.Parallel() + l2 := &mockL2{ + ref: eth.L2BlockRef{Number: 100, Time: 123}, + payload: ð.ExecutionPayloadEnvelope{ExecutionPayload: ð.ExecutionPayload{ + StateRoot: eth.Bytes32{0xaa}, + WithdrawalsRoot: func() *common.Hash { h := common.Hash{}; h[0] = 0xbb; return &h }(), + BlockHash: func() common.Hash { h := common.Hash{}; h[0] = 0xcc; return h }(), + }}, + } + ec := &simpleEngineController{l2: l2, rollup: &rollup.Config{}, log: gethlog.New()} + out, err := ec.OutputV0AtBlockNumber(context.Background(), 100) + require.NoError(t, err) + require.NotNil(t, out) + require.Equal(t, 1, l2.payloadCalls) + require.Equal(t, 0, l2.outputCalls) // no fallback +} + +func TestOutputV0AtBlockNumber_FallsBackWithoutWithdrawalsRoot(t *testing.T) { + t.Parallel() + l2 := &mockL2{ + ref: eth.L2BlockRef{Number: 100, Time: 123}, + // payload without withdrawals root forces fallback + payload: ð.ExecutionPayloadEnvelope{ExecutionPayload: ð.ExecutionPayload{}}, + output: ð.OutputV0{StateRoot: eth.Bytes32{0x01}, MessagePasserStorageRoot: eth.Bytes32{0x02}, BlockHash: func() common.Hash { var h common.Hash; h[0] = 0x03; return h }()}, + } + ec := &simpleEngineController{l2: l2, rollup: &rollup.Config{}, log: gethlog.New()} + out, err := ec.OutputV0AtBlockNumber(context.Background(), 100) + require.NoError(t, err) + require.NotNil(t, out) + require.Equal(t, 1, l2.payloadCalls) + require.Equal(t, 1, l2.outputCalls) +} + +type mockL2 struct { + // Block ref path + lastNum uint64 + ref eth.L2BlockRef + refErr error + + // Output/payload path + payload *eth.ExecutionPayloadEnvelope + payloadErr error + output *eth.OutputV0 + outputErr error + payloadCalls int + outputCalls int +} + +func (m *mockL2) L2BlockRefByLabel(ctx context.Context, label eth.BlockLabel) (eth.L2BlockRef, error) { + return eth.L2BlockRef{Number: 999}, nil +} +func (m *mockL2) L2BlockRefByNumber(ctx context.Context, num uint64) (eth.L2BlockRef, error) { + m.lastNum = num + return m.ref, m.refErr +} +func (m *mockL2) OutputV0AtBlockNumber(ctx context.Context, blockNum uint64) (*eth.OutputV0, error) { + m.outputCalls++ + return m.output, m.outputErr +} +func (m *mockL2) PayloadByNumber(ctx context.Context, number uint64) (*eth.ExecutionPayloadEnvelope, error) { + m.payloadCalls++ + return m.payload, m.payloadErr +} +func (m *mockL2) Close() { +} + +func TestEngineController_TargetBlockNumber(t *testing.T) { + t.Parallel() + rcfg := &rollup.Config{Genesis: rollup.Genesis{L2: eth.BlockID{Number: 0}, L2Time: 1_000}, BlockTime: 2, L2ChainID: big.NewInt(420)} + m := &mockL2{ref: eth.L2BlockRef{Number: 0, Time: 0}} + ec := &simpleEngineController{l2: m, rollup: rcfg, log: gethlog.New()} + + // ts = genesis + 2*3 => block #3, with safe head above target + numRef, err := ec.SafeBlockAtTimestamp(context.Background(), 1_000+2*3) + require.NoError(t, err) + require.Equal(t, uint64(3), m.lastNum) + require.Equal(t, m.ref, numRef) + // ts = genesis + 2*1000 => block #1000, with safe head now below target + _, err = ec.SafeBlockAtTimestamp(context.Background(), 1_000+2*1000) + require.ErrorIs(t, err, ErrNotFound) +} + +func TestEngineController_SentinelErrors(t *testing.T) { + t.Parallel() + ec := &simpleEngineController{l2: nil, rollup: nil} + _, err := ec.SafeBlockAtTimestamp(context.Background(), 0) + require.ErrorIs(t, err, ErrNoEngineClient) + + ec = &simpleEngineController{l2: &mockL2{}, rollup: nil} + _, err = ec.SafeBlockAtTimestamp(context.Background(), 0) + require.ErrorIs(t, err, ErrNoRollupConfig) +} diff --git a/op-supernode/supernode/chain_container/virtual_node/virtual_node.go b/op-supernode/supernode/chain_container/virtual_node/virtual_node.go index b9797c4e030..af0cd88e637 100644 --- a/op-supernode/supernode/chain_container/virtual_node/virtual_node.go +++ b/op-supernode/supernode/chain_container/virtual_node/virtual_node.go @@ -3,11 +3,13 @@ package virtual_node import ( "context" "errors" + "math" "sync" opnodecfg "github.com/ethereum-optimism/optimism/op-node/config" opmetrics "github.com/ethereum-optimism/optimism/op-node/metrics" rollupNode "github.com/ethereum-optimism/optimism/op-node/node" + "github.com/ethereum-optimism/optimism/op-service/eth" gethlog "github.com/ethereum/go-ethereum/log" "github.com/google/uuid" ) @@ -31,11 +33,18 @@ var ( type VirtualNode interface { Start(ctx context.Context) error Stop(ctx context.Context) error + + SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (eth.BlockID, eth.BlockID, error) + // L1AtSafeHead returns the earliest L1 block at which the given L2 block became safe. + L1AtSafeHead(ctx context.Context, target eth.BlockID) (eth.BlockID, error) + CurrentL1(ctx context.Context) (eth.BlockRef, error) } type innerNode interface { Start(ctx context.Context) error Stop(ctx context.Context) error + SafeDB() rollupNode.SafeDBReader + SyncStatus() *eth.SyncStatus } type innerNodeFactory func(ctx context.Context, cfg *opnodecfg.Config, log gethlog.Logger, appVersion string, m *opmetrics.Metrics, initOverload *rollupNode.InitializationOverrides) (innerNode, error) @@ -179,3 +188,92 @@ func (v *simpleVirtualNode) State() VNState { defer v.mu.Unlock() return v.state } + +// SafeHeadAtL1 returns the recorded mapping of L1 block -> L2 safe head at or before the given L1 block number. +func (v *simpleVirtualNode) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) (eth.BlockID, eth.BlockID, error) { + v.mu.Lock() + inner := v.inner + v.mu.Unlock() + if inner == nil { + return eth.BlockID{}, eth.BlockID{}, ErrVirtualNodeNotRunning + } + db := inner.SafeDB() + if db == nil { + return eth.BlockID{}, eth.BlockID{}, ErrVirtualNodeNotRunning + } + return db.SafeHeadAtL1(ctx, l1BlockNum) +} + +var ErrL1AtSafeHeadNotFound = errors.New("l1 at safe head not found") + +// L1AtSafeHead finds the earliest L1 block at which the provided L2 block became safe, +// using the monotonicity of SafeDB (L2 safe head number is non-decreasing over L1). +func (v *simpleVirtualNode) L1AtSafeHead(ctx context.Context, target eth.BlockID) (eth.BlockID, error) { + v.mu.Lock() + inner := v.inner + v.mu.Unlock() + if inner == nil { + return eth.BlockID{}, ErrVirtualNodeNotRunning + } + db := inner.SafeDB() + if db == nil { + return eth.BlockID{}, ErrVirtualNodeNotRunning + } + // Get the latest entry to start the walkback + latestL1, latestL2, err := db.SafeHeadAtL1(ctx, math.MaxUint64-1) + if err != nil { + v.log.Debug("L1AtSafeHead: latest lookup failed", "err", err) + return eth.BlockID{}, err + } + v.log.Debug("L1AtSafeHead: latest bounds", "latest_l1", latestL1.Number, "latest_l2_num", latestL2.Number, "latest_l2_hash", latestL2.Hash) + if latestL2.Number < target.Number { + v.log.Debug("L1AtSafeHead: target beyond latest", "latest_l2", latestL2.Number) + return eth.BlockID{}, ErrL1AtSafeHeadNotFound + } + // Walk back until the cursor would drop below the target + cursor := latestL1 + genesisL1 := v.cfg.Rollup.Genesis.L1.Number + for { + if cursor.Number <= 0 || cursor.Number <= genesisL1 { + // if we made it all the way back to genesis, it is likely the SafeDB is not stable enough for use + // safer to simply return an error for now. + v.log.Warn("L1AtSafeHead: reached genesis bound", "genesis_l1", genesisL1, "earliest_l1", cursor.Number) + return eth.BlockID{}, ErrL1AtSafeHeadNotFound + } + prev := cursor.Number - 1 + v.log.Debug("L1AtSafeHead: checking previous l1 block", "l1_num", prev) + l1Prev, l2Prev, err := db.SafeHeadAtL1(ctx, prev) + if err != nil { + v.log.Debug("L1AtSafeHead: walkback lookup failed, stopping", "probe_l1", prev, "err", err) + break + } + v.log.Debug("L1AtSafeHead: walkback result", "l1_prev", l1Prev.Number, "l2_prev_num", l2Prev.Number, "l2_prev_hash", l2Prev.Hash) + if l2Prev.Number >= target.Number { + // Still meets or exceeds target; continue walking back + cursor = l1Prev + continue + } + // Dropped below target; current cursor is the first that meets/exceeds + break + } + v.log.Debug("L1AtSafeHead: result", "l1", cursor) + return cursor, nil +} + +// CurrentL1 returns the current processed L1 block based on derivation pipeline sync status. +func (v *simpleVirtualNode) CurrentL1(ctx context.Context) (eth.BlockRef, error) { + v.mu.Lock() + inner := v.inner + v.mu.Unlock() + if inner == nil { + return eth.BlockRef{}, ErrVirtualNodeNotRunning + } + st := inner.SyncStatus() + // Map L1 block ref into generic block ref + return eth.BlockRef{ + Hash: st.CurrentL1.Hash, + Number: st.CurrentL1.Number, + ParentHash: st.CurrentL1.ParentHash, + Time: st.CurrentL1.Time, + }, nil +} diff --git a/op-supernode/supernode/chain_container/virtual_node/virtual_node_test.go b/op-supernode/supernode/chain_container/virtual_node/virtual_node_test.go index 5b6e89feec8..1e57d197f9e 100644 --- a/op-supernode/supernode/chain_container/virtual_node/virtual_node_test.go +++ b/op-supernode/supernode/chain_container/virtual_node/virtual_node_test.go @@ -12,6 +12,7 @@ import ( opmetrics "github.com/ethereum-optimism/optimism/op-node/metrics" rollupNode "github.com/ethereum-optimism/optimism/op-node/node" "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/eth" gethlog "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" ) @@ -24,6 +25,9 @@ type mockInnerNode struct { stopErr error startFunc func(ctx context.Context) started bool + safeTs uint64 + haveSafe bool + db rollupNode.SafeDBReader } func newMockInnerNode() *mockInnerNode { @@ -51,6 +55,16 @@ func (m *mockInnerNode) Stop(ctx context.Context) error { return m.stopErr } +// SafeL2Timestamp implements the innerNode interface method used by VirtualNode for safety checks +func (m *mockInnerNode) SafeL2Timestamp() (uint64, bool) { + return m.safeTs, m.haveSafe +} + +// SafeDB implements innerNode interface method used by VirtualNode +func (m *mockInnerNode) SafeDB() rollupNode.SafeDBReader { return m.db } + +func (m *mockInnerNode) SyncStatus() *eth.SyncStatus { return ð.SyncStatus{} } + // Test helpers func createTestConfig() *opnodecfg.Config { return &opnodecfg.Config{ diff --git a/op-supernode/supernode/supernode.go b/op-supernode/supernode/supernode.go index ab2f4a3f9a6..6fb50868282 100644 --- a/op-supernode/supernode/supernode.go +++ b/op-supernode/supernode/supernode.go @@ -17,6 +17,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/sources" "github.com/ethereum-optimism/optimism/op-supernode/supernode/activity" "github.com/ethereum-optimism/optimism/op-supernode/supernode/activity/heartbeat" + "github.com/ethereum-optimism/optimism/op-supernode/supernode/activity/superroot" cc "github.com/ethereum-optimism/optimism/op-supernode/supernode/chain_container" "github.com/ethereum-optimism/optimism/op-supernode/supernode/resources" gethlog "github.com/ethereum/go-ethereum/log" @@ -84,6 +85,7 @@ func New(ctx context.Context, log gethlog.Logger, version string, requestStop co // Initialize activities s.activities = []activity.Activity{ heartbeat.New(log.New("activity", "heartbeat"), 10*time.Second), + superroot.New(log.New("activity", "superroot"), s.chains), } addr := net.JoinHostPort(cfg.RPCConfig.ListenAddr, strconv.Itoa(cfg.RPCConfig.ListenPort)) s.httpServer = httputil.NewHTTPServer(addr, s.rpcRouter)