From a1a56a725619ce6445a9a54a4c58c29d43505946 Mon Sep 17 00:00:00 2001 From: axelKingsley Date: Tue, 4 Nov 2025 11:37:10 -0600 Subject: [PATCH 1/4] op-supernode: Superroot API Activity --- op-node/node/node.go | 11 + op-supernode/README.md | 2 + .../supernode/activity/superroot/superroot.go | 108 +++++++++ .../activity/superroot/superroot_test.go | 205 ++++++++++++++++++ .../chain_container/chain_container.go | 115 ++++++++++ .../chain_container/chain_container_test.go | 37 ++++ .../engine_controller/engine_controller.go | 116 +++++++++- .../engine_controller_test.go | 106 +++++++++ .../virtual_node/virtual_node.go | 144 ++++++++++++ .../virtual_node/virtual_node_test.go | 14 ++ op-supernode/supernode/supernode.go | 11 + 11 files changed, 867 insertions(+), 2 deletions(-) create mode 100644 op-supernode/supernode/activity/superroot/superroot.go create mode 100644 op-supernode/supernode/activity/superroot/superroot_test.go create mode 100644 op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go 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-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..5e6c4202c69 --- /dev/null +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -0,0 +1,108 @@ +package superroot + +import ( + "context" + + "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" +) + +// 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 } + +// API Response Shape not final +type derivedPair struct { + L2 eth.BlockID + L1 eth.BlockID +} +type atTimestampResponse struct { + CurrentL1s map[eth.ChainID]eth.BlockID + Verified map[eth.ChainID]derivedPair + Optimistic map[eth.ChainID]derivedPair + SuperRoot eth.Bytes32 +} + +// AtL1 computes the super-root at the given L1 block number. +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) { + currentL1s := map[eth.ChainID]eth.BlockID{} + verified := map[eth.ChainID]derivedPair{} + optimistic := map[eth.ChainID]derivedPair{} + 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 + } + currentL1s[chainID] = currentL1.ID() + } + + 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{}, err + } + verified[chainID] = derivedPair{ + L2: verifiedL2, + L1: 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{}, err + } + chainOutputs = append(chainOutputs, eth.ChainIDAndOutput{ChainID: chainID, Output: outRoot}) + // optimisticAt returns the L2 block which would be applied if verification were assumed to be successful + optimisticL2, optimisticL1, err := chain.OptimisticAt(ctx, timestamp) + if err != nil { + s.log.Warn("failed to get optimistic L1", "chain_id", chainID.String(), "err", err) + return atTimestampResponse{}, err + } + optimistic[chainID] = derivedPair{ + L2: optimisticL2, + L1: optimisticL1, + } + } + + // Build super root from collected outputs + superV1 := eth.NewSuperV1(timestamp, chainOutputs...) + superRoot := eth.SuperRoot(superV1) + + return atTimestampResponse{ + CurrentL1s: currentL1s, + Verified: verified, + Optimistic: optimistic, + 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..5a528f85c25 --- /dev/null +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -0,0 +1,205 @@ +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) BlockAtTimestamp(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 +} + +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.CurrentL1s, 2) + require.Len(t, out.Verified, 2) + require.Len(t, out.Optimistic, 2) + // 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.CurrentL1s, 0) + require.Len(t, out.Verified, 0) + require.Len(t, out.Optimistic, 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..a4d7282cf8a 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" @@ -12,6 +13,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/eth" oprpc "github.com/ethereum-optimism/optimism/op-service/rpc" "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 +27,15 @@ type ChainContainer interface { Stop(ctx context.Context) error Pause(ctx context.Context) error Resume(ctx context.Context) error + + BlockAtTimestamp(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) } type virtualNodeFactory func(cfg *opnodecfg.Config, log gethlog.Logger, initOverrides *rollupNode.InitializationOverrides, appVersion string) virtual_node.VirtualNode @@ -33,6 +44,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{} @@ -46,6 +58,9 @@ type simpleChainContainer struct { virtualNodeFactory virtualNodeFactory // Factory function to create virtual node (for testing) } +// Interface conformance assertions +var _ ChainContainer = (*simpleChainContainer)(nil) + func NewChainContainer( chainID eth.ChainID, vncfg *opnodecfg.Config, @@ -71,6 +86,18 @@ func NewChainContainer( } vncfg.SafeDBPath = c.subPath("safe_db") vncfg.RPC = cfg.RPCConfig + // 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 } @@ -158,6 +185,11 @@ func (c *simpleChainContainer) Stop(ctx context.Context) error { } } + // Close engine controller RPC resources + if c.engine != nil { + _ = c.engine.Close() + } + select { case <-c.stopped: return nil @@ -175,3 +207,86 @@ func (c *simpleChainContainer) Resume(ctx context.Context) error { c.pause.Store(false) return nil } + +// BlockAtTimestamp returns the highest L2 block with timestamp <= ts using the L2 client. +func (c *simpleChainContainer) BlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) { + if c.engine == nil { + return eth.L2BlockRef{}, engine_controller.ErrNoEngineClient + } + return c.engine.BlockAtTimestamp(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.BlockAtTimestamp(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.BlockAtTimestamp(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 +} 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..ef5534ea28e 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,123 @@ 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 { + // BlockAtTimestamp returns the L2 block ref for the block at or before the given timestamp. + BlockAtTimestamp(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") +) + +func (e *simpleEngineController) BlockAtTimestamp(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 + } + if e.log != nil { + e.log.Debug("engine_controller: computed target block number from timestamp", "timestamp", ts, "blockNumber", num) + } + 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..773452a5795 --- /dev/null +++ b/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go @@ -0,0 +1,106 @@ +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 BlockAtTimestamp 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{}, 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} + + // ts = genesis + 2*3 => block #3 + numRef, err := ec.BlockAtTimestamp(context.Background(), 1_000+2*3) + require.NoError(t, err) + require.Equal(t, uint64(3), m.lastNum) + require.Equal(t, m.ref, numRef) +} + +func TestEngineController_SentinelErrors(t *testing.T) { + t.Parallel() + ec := &simpleEngineController{l2: nil, rollup: nil} + _, err := ec.BlockAtTimestamp(context.Background(), 0) + require.ErrorIs(t, err, ErrNoEngineClient) + + ec = &simpleEngineController{l2: &mockL2{}, rollup: nil} + _, err = ec.BlockAtTimestamp(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..002f6a2cf48 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,138 @@ 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) +} + +// 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 v.log != nil { + v.log.Debug("L1AtSafeHead: start", "target_num", target.Number, "target_hash", target.Hash) + } + if inner == nil { + return eth.BlockID{}, ErrVirtualNodeNotRunning + } + db := inner.SafeDB() + if db == nil { + return eth.BlockID{}, ErrVirtualNodeNotRunning + } + // Get the latest entry to bound the search space + latestL1, latestL2, err := db.SafeHeadAtL1(ctx, math.MaxUint64-1) + if err != nil { + if v.log != nil { + v.log.Debug("L1AtSafeHead: latest lookup failed", "err", err) + } + return eth.BlockID{}, err + } + if v.log != nil { + 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 { + if v.log != nil { + v.log.Debug("L1AtSafeHead: target beyond latest", "latest_l2", latestL2.Number) + } + return eth.BlockID{}, errors.New("target not found") + } + // Restrict lower bound to rollup genesis L1 (the rollup starts after this L1) + var lo uint64 = v.cfg.Rollup.Genesis.L1.Number + hi := latestL1.Number + if v.log != nil { + v.log.Debug("L1AtSafeHead: initial bounds", "lo", lo, "hi", hi) + } + for lo < hi { + mid := (lo + hi) / 2 + if v.log != nil { + v.log.Debug("L1AtSafeHead: probe", "mid", mid, "lo", lo, "hi", hi) + } + _, midL2, err := db.SafeHeadAtL1(ctx, mid) + if err != nil { + // before first entry; treat as below target + if v.log != nil { + v.log.Debug("L1AtSafeHead: mid lookup failed, advance lo", "mid", mid, "err", err) + } + lo = mid + 1 + continue + } + if v.log != nil { + v.log.Debug("L1AtSafeHead: mid result", "mid", mid, "mid_l2_num", midL2.Number, "mid_l2_hash", midL2.Hash) + } + if midL2.Number >= target.Number { + if v.log != nil { + v.log.Debug("L1AtSafeHead: move hi", "from", hi, "to", mid) + } + hi = mid + } else { + if v.log != nil { + v.log.Debug("L1AtSafeHead: move lo", "from", lo, "to", mid+1) + } + lo = mid + 1 + } + } + // Validate match at boundary + if v.log != nil { + v.log.Debug("L1AtSafeHead: boundary", "lo", lo) + } + fL1, fL2, err := db.SafeHeadAtL1(ctx, lo) + if err != nil { + if v.log != nil { + v.log.Debug("L1AtSafeHead: boundary lookup failed", "lo", lo, "err", err) + } + return eth.BlockID{}, err + } + if v.log != nil { + v.log.Debug("L1AtSafeHead: boundary result", "l1", fL1.Number, "l2_num", fL2.Number, "l2_hash", fL2.Hash) + } + // If the exact L2 is found, return its L1; otherwise, return the earliest L1 + // at which the safe head number is >= the target (implied availability). + if fL2.Number == target.Number && fL2.Hash == target.Hash { + if v.log != nil { + v.log.Debug("L1AtSafeHead: found", "l1", fL1.Number) + } + return fL1, nil + } + if fL2.Number >= target.Number { + if v.log != nil { + v.log.Debug("L1AtSafeHead: implied at boundary", "implied_l1", fL1.Number) + } + return fL1, nil + } + if v.log != nil { + v.log.Debug("L1AtSafeHead: not found (unexpected)") + } + return eth.BlockID{}, errors.New("target not found") +} + +// 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..18a06422f40 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) @@ -195,6 +197,15 @@ func (s *Supernode) Stop(ctx context.Context) error { } } + // Stop runnable activities + for _, a := range s.activities { + if run, ok := a.(activity.RunnableActivity); ok { + if err := run.Stop(ctx); err != nil { + s.log.Error("error stopping runnable activity", "error", err) + } + } + } + for chainID, chain := range s.chains { if err := chain.Stop(ctx); err != nil { s.log.Error("error stopping chain container", "chain_id", chainID.String(), "error", err) From 97980c05bb84dcbee453e2097e8e6a476386b86e Mon Sep 17 00:00:00 2001 From: axelKingsley Date: Wed, 12 Nov 2025 16:00:34 -0600 Subject: [PATCH 2/4] Response Object Cleanup --- .../supernode/activity/superroot/superroot.go | 81 +++++++++---- .../activity/superroot/superroot_test.go | 17 +-- .../chain_container/chain_container.go | 12 +- .../engine_controller/engine_controller.go | 19 +++- .../engine_controller_test.go | 17 +-- .../virtual_node/virtual_node.go | 106 +++++------------- op-supernode/supernode/supernode.go | 9 -- 7 files changed, 127 insertions(+), 134 deletions(-) diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 5e6c4202c69..ea1b3c3c0e2 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -30,27 +30,49 @@ func (s *Superroot) RPCService() interface{} { return &superrootAPI{s: s} } type superrootAPI struct{ s *Superroot } -// API Response Shape not final -type derivedPair struct { - L2 eth.BlockID - L1 eth.BlockID +// L2WithSource is a L2 block and its source L1 block +type L2WithSource struct { + L2 eth.BlockID + 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 { - CurrentL1s map[eth.ChainID]eth.BlockID - Verified map[eth.ChainID]derivedPair - Optimistic map[eth.ChainID]derivedPair - SuperRoot eth.Bytes32 + CurrentL1Derived map[eth.ChainID]eth.BlockID + CurrentL1Verified map[string]eth.BlockID + VerifiedAtTimestamp map[eth.ChainID]L2WithRequiredL1 + OptimisticAtTimestamp map[eth.ChainID]L2WithSource + MinCurrentL1 eth.BlockID + MinVerifiedRequiredL1 eth.BlockID + SuperRoot eth.Bytes32 } -// AtL1 computes the super-root at the given L1 block number. +// 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) { - currentL1s := map[eth.ChainID]eth.BlockID{} - verified := map[eth.ChainID]derivedPair{} - optimistic := map[eth.ChainID]derivedPair{} + 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]L2WithSource{} + minCurrentL1 := eth.BlockID{} + minVerifiedRequiredL1 := eth.BlockID{} chainOutputs := make([]eth.ChainIDAndOutput, 0, len(s.chains)) // get current l1s @@ -62,9 +84,13 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest s.log.Warn("failed to get current L1", "chain_id", chainID.String(), "err", err) return atTimestampResponse{}, err } - currentL1s[chainID] = currentL1.ID() + 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) @@ -72,9 +98,12 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest s.log.Warn("failed to get verified L1", "chain_id", chainID.String(), "err", err) return atTimestampResponse{}, err } - verified[chainID] = derivedPair{ - L2: verifiedL2, - L1: verifiedL1, + 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) @@ -83,15 +112,16 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest return atTimestampResponse{}, err } chainOutputs = append(chainOutputs, eth.ChainIDAndOutput{ChainID: chainID, Output: outRoot}) - // optimisticAt returns the L2 block which would be applied if verification were assumed to be successful + // optimisticAt returns the L2 block which would apply if verification were successful at the given timestamp + // it will differ from the verified L2 block if the optimistic L2 block was invalid. optimisticL2, optimisticL1, err := chain.OptimisticAt(ctx, timestamp) if err != nil { s.log.Warn("failed to get optimistic L1", "chain_id", chainID.String(), "err", err) return atTimestampResponse{}, err } - optimistic[chainID] = derivedPair{ - L2: optimisticL2, - L1: optimisticL1, + optimistic[chainID] = L2WithSource{ + L2: optimisticL2, + SourceL1: optimisticL1, } } @@ -100,9 +130,12 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest superRoot := eth.SuperRoot(superV1) return atTimestampResponse{ - CurrentL1s: currentL1s, - Verified: verified, - Optimistic: optimistic, - SuperRoot: superRoot, + 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 index 5a528f85c25..94837d86b2c 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -30,7 +30,7 @@ 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) BlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) { +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) { @@ -90,9 +90,12 @@ func TestSuperroot_AtTimestamp_Succeeds(t *testing.T) { api := &superrootAPI{s: s} out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) - require.Len(t, out.CurrentL1s, 2) - require.Len(t, out.Verified, 2) - require.Len(t, out.Optimistic, 2) + 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 } @@ -196,9 +199,9 @@ func TestSuperroot_AtTimestamp_EmptyChains(t *testing.T) { api := &superrootAPI{s: s} out, err := api.AtTimestamp(context.Background(), 123) require.NoError(t, err) - require.Len(t, out.CurrentL1s, 0) - require.Len(t, out.Verified, 0) - require.Len(t, out.Optimistic, 0) + 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. diff --git a/op-supernode/supernode/chain_container/chain_container.go b/op-supernode/supernode/chain_container/chain_container.go index a4d7282cf8a..7dc389b0960 100644 --- a/op-supernode/supernode/chain_container/chain_container.go +++ b/op-supernode/supernode/chain_container/chain_container.go @@ -28,7 +28,7 @@ type ChainContainer interface { Pause(ctx context.Context) error Resume(ctx context.Context) error - BlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, 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) @@ -208,12 +208,12 @@ func (c *simpleChainContainer) Resume(ctx context.Context) error { return nil } -// BlockAtTimestamp returns the highest L2 block with timestamp <= ts using the L2 client. -func (c *simpleChainContainer) BlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) { +// 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.BlockAtTimestamp(ctx, ts) + return c.engine.SafeBlockAtTimestamp(ctx, ts) } // OutputRootAtL2BlockNumber computes the L2 output root for the specified L2 block number. @@ -257,7 +257,7 @@ func (c *simpleChainContainer) CurrentL1(ctx context.Context) (eth.BlockRef, err // 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.BlockAtTimestamp(ctx, ts) + 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 @@ -275,7 +275,7 @@ func (c *simpleChainContainer) VerifiedAt(ctx context.Context, ts uint64) (l2, l // 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.BlockAtTimestamp(ctx, ts) + 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 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 ef5534ea28e..1fbe41706b2 100644 --- a/op-supernode/supernode/chain_container/engine_controller/engine_controller.go +++ b/op-supernode/supernode/chain_container/engine_controller/engine_controller.go @@ -14,8 +14,9 @@ import ( // EngineController abstracts access to the L2 execution layer type EngineController interface { - // BlockAtTimestamp returns the L2 block ref for the block at or before the given timestamp. - BlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) + // 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. @@ -59,9 +60,10 @@ func NewEngineControllerFromConfig(ctx context.Context, log gethlog.Logger, vncf var ( ErrNoEngineClient = errors.New("engine client not initialized") ErrNoRollupConfig = errors.New("rollup config not available") + ErrNotFound = errors.New("not found") ) -func (e *simpleEngineController) BlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) { +func (e *simpleEngineController) SafeBlockAtTimestamp(ctx context.Context, ts uint64) (eth.L2BlockRef, error) { if e.l2 == nil { return eth.L2BlockRef{}, ErrNoEngineClient } @@ -73,9 +75,16 @@ func (e *simpleEngineController) BlockAtTimestamp(ctx context.Context, ts uint64 if err != nil { return eth.L2BlockRef{}, err } - if e.log != nil { - e.log.Debug("engine_controller: computed target block number from timestamp", "timestamp", ts, "blockNumber", num) + 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) } diff --git a/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go b/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go index 773452a5795..6545b15f7ef 100644 --- a/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go +++ b/op-supernode/supernode/chain_container/engine_controller/engine_controller_test.go @@ -12,7 +12,7 @@ import ( "github.com/stretchr/testify/require" ) -// unified mock covers both payload/output paths and BlockAtTimestamp path +// unified mock covers both payload/output paths and SafeBlockAtTimestamp path func TestOutputV0AtBlockNumber_UsesPayloadWhenAvailable(t *testing.T) { t.Parallel() @@ -64,7 +64,7 @@ type mockL2 struct { } func (m *mockL2) L2BlockRefByLabel(ctx context.Context, label eth.BlockLabel) (eth.L2BlockRef, error) { - return eth.L2BlockRef{}, nil + return eth.L2BlockRef{Number: 999}, nil } func (m *mockL2) L2BlockRefByNumber(ctx context.Context, num uint64) (eth.L2BlockRef, error) { m.lastNum = num @@ -85,22 +85,25 @@ 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} + ec := &simpleEngineController{l2: m, rollup: rcfg, log: gethlog.New()} - // ts = genesis + 2*3 => block #3 - numRef, err := ec.BlockAtTimestamp(context.Background(), 1_000+2*3) + // 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.BlockAtTimestamp(context.Background(), 0) + _, err := ec.SafeBlockAtTimestamp(context.Background(), 0) require.ErrorIs(t, err, ErrNoEngineClient) ec = &simpleEngineController{l2: &mockL2{}, rollup: nil} - _, err = ec.BlockAtTimestamp(context.Background(), 0) + _, 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 002f6a2cf48..af0cd88e637 100644 --- a/op-supernode/supernode/chain_container/virtual_node/virtual_node.go +++ b/op-supernode/supernode/chain_container/virtual_node/virtual_node.go @@ -204,15 +204,14 @@ func (v *simpleVirtualNode) SafeHeadAtL1(ctx context.Context, l1BlockNum uint64) 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 v.log != nil { - v.log.Debug("L1AtSafeHead: start", "target_num", target.Number, "target_hash", target.Hash) - } if inner == nil { return eth.BlockID{}, ErrVirtualNodeNotRunning } @@ -220,90 +219,45 @@ func (v *simpleVirtualNode) L1AtSafeHead(ctx context.Context, target eth.BlockID if db == nil { return eth.BlockID{}, ErrVirtualNodeNotRunning } - // Get the latest entry to bound the search space + // Get the latest entry to start the walkback latestL1, latestL2, err := db.SafeHeadAtL1(ctx, math.MaxUint64-1) if err != nil { - if v.log != nil { - v.log.Debug("L1AtSafeHead: latest lookup failed", "err", err) - } + v.log.Debug("L1AtSafeHead: latest lookup failed", "err", err) return eth.BlockID{}, err } - if v.log != nil { - v.log.Debug("L1AtSafeHead: latest bounds", "latest_l1", latestL1.Number, "latest_l2_num", latestL2.Number, "latest_l2_hash", latestL2.Hash) - } + 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 { - if v.log != nil { - v.log.Debug("L1AtSafeHead: target beyond latest", "latest_l2", latestL2.Number) - } - return eth.BlockID{}, errors.New("target not found") + v.log.Debug("L1AtSafeHead: target beyond latest", "latest_l2", latestL2.Number) + return eth.BlockID{}, ErrL1AtSafeHeadNotFound } - // Restrict lower bound to rollup genesis L1 (the rollup starts after this L1) - var lo uint64 = v.cfg.Rollup.Genesis.L1.Number - hi := latestL1.Number - if v.log != nil { - v.log.Debug("L1AtSafeHead: initial bounds", "lo", lo, "hi", hi) - } - for lo < hi { - mid := (lo + hi) / 2 - if v.log != nil { - v.log.Debug("L1AtSafeHead: probe", "mid", mid, "lo", lo, "hi", hi) + // 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 } - _, midL2, err := db.SafeHeadAtL1(ctx, mid) + 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 { - // before first entry; treat as below target - if v.log != nil { - v.log.Debug("L1AtSafeHead: mid lookup failed, advance lo", "mid", mid, "err", err) - } - lo = mid + 1 - continue - } - if v.log != nil { - v.log.Debug("L1AtSafeHead: mid result", "mid", mid, "mid_l2_num", midL2.Number, "mid_l2_hash", midL2.Hash) - } - if midL2.Number >= target.Number { - if v.log != nil { - v.log.Debug("L1AtSafeHead: move hi", "from", hi, "to", mid) - } - hi = mid - } else { - if v.log != nil { - v.log.Debug("L1AtSafeHead: move lo", "from", lo, "to", mid+1) - } - lo = mid + 1 - } - } - // Validate match at boundary - if v.log != nil { - v.log.Debug("L1AtSafeHead: boundary", "lo", lo) - } - fL1, fL2, err := db.SafeHeadAtL1(ctx, lo) - if err != nil { - if v.log != nil { - v.log.Debug("L1AtSafeHead: boundary lookup failed", "lo", lo, "err", err) - } - return eth.BlockID{}, err - } - if v.log != nil { - v.log.Debug("L1AtSafeHead: boundary result", "l1", fL1.Number, "l2_num", fL2.Number, "l2_hash", fL2.Hash) - } - // If the exact L2 is found, return its L1; otherwise, return the earliest L1 - // at which the safe head number is >= the target (implied availability). - if fL2.Number == target.Number && fL2.Hash == target.Hash { - if v.log != nil { - v.log.Debug("L1AtSafeHead: found", "l1", fL1.Number) + v.log.Debug("L1AtSafeHead: walkback lookup failed, stopping", "probe_l1", prev, "err", err) + break } - return fL1, nil - } - if fL2.Number >= target.Number { - if v.log != nil { - v.log.Debug("L1AtSafeHead: implied at boundary", "implied_l1", fL1.Number) + 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 } - return fL1, nil - } - if v.log != nil { - v.log.Debug("L1AtSafeHead: not found (unexpected)") + // Dropped below target; current cursor is the first that meets/exceeds + break } - return eth.BlockID{}, errors.New("target not found") + v.log.Debug("L1AtSafeHead: result", "l1", cursor) + return cursor, nil } // CurrentL1 returns the current processed L1 block based on derivation pipeline sync status. diff --git a/op-supernode/supernode/supernode.go b/op-supernode/supernode/supernode.go index 18a06422f40..6fb50868282 100644 --- a/op-supernode/supernode/supernode.go +++ b/op-supernode/supernode/supernode.go @@ -197,15 +197,6 @@ func (s *Supernode) Stop(ctx context.Context) error { } } - // Stop runnable activities - for _, a := range s.activities { - if run, ok := a.(activity.RunnableActivity); ok { - if err := run.Stop(ctx); err != nil { - s.log.Error("error stopping runnable activity", "error", err) - } - } - } - for chainID, chain := range s.chains { if err := chain.Stop(ctx); err != nil { s.log.Error("error stopping chain container", "chain_id", chainID.String(), "error", err) From ff65768b193859598d539a4bf54dcd25daeba4ce Mon Sep 17 00:00:00 2001 From: axelKingsley Date: Wed, 19 Nov 2025 13:00:08 -0600 Subject: [PATCH 3/4] Update return struct for full Optimistic Outputs ; In-Proc RPC --- op-service/rpc/handler.go | 12 ++++ .../supernode/activity/superroot/superroot.go | 25 ++++---- .../activity/superroot/superroot_test.go | 7 +++ .../chain_container/chain_container.go | 59 ++++++++++++++++++- 4 files changed, 92 insertions(+), 11 deletions(-) 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/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index ea1b3c3c0e2..2263c4eebe5 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -30,9 +30,9 @@ func (s *Superroot) RPCService() interface{} { return &superrootAPI{s: s} } type superrootAPI struct{ s *Superroot } -// L2WithSource is a L2 block and its source L1 block -type L2WithSource struct { - L2 eth.BlockID +// OutputWithSource is the full Output and its source L1 block +type OutputWithSource struct { + Output *eth.OutputResponse SourceL1 eth.BlockID } @@ -53,7 +53,7 @@ type atTimestampResponse struct { CurrentL1Derived map[eth.ChainID]eth.BlockID CurrentL1Verified map[string]eth.BlockID VerifiedAtTimestamp map[eth.ChainID]L2WithRequiredL1 - OptimisticAtTimestamp map[eth.ChainID]L2WithSource + OptimisticAtTimestamp map[eth.ChainID]OutputWithSource MinCurrentL1 eth.BlockID MinVerifiedRequiredL1 eth.BlockID SuperRoot eth.Bytes32 @@ -70,7 +70,7 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest // 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]L2WithSource{} + optimistic := map[eth.ChainID]OutputWithSource{} minCurrentL1 := eth.BlockID{} minVerifiedRequiredL1 := eth.BlockID{} chainOutputs := make([]eth.ChainIDAndOutput, 0, len(s.chains)) @@ -112,15 +112,20 @@ func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (atTimest return atTimestampResponse{}, err } chainOutputs = append(chainOutputs, eth.ChainIDAndOutput{ChainID: chainID, Output: outRoot}) - // optimisticAt returns the L2 block which would apply if verification were successful at the given timestamp - // it will differ from the verified L2 block if the optimistic L2 block was invalid. - optimisticL2, optimisticL1, err := chain.OptimisticAt(ctx, timestamp) + // 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{}, err } - optimistic[chainID] = L2WithSource{ - L2: optimisticL2, + // 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{}, err + } + optimistic[chainID] = OutputWithSource{ + Output: optimisticOut, SourceL1: optimisticL1, } } diff --git a/op-supernode/supernode/activity/superroot/superroot_test.go b/op-supernode/supernode/activity/superroot/superroot_test.go index 94837d86b2c..f85acd8572e 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -63,6 +63,13 @@ func (m *mockCC) OutputRootAtL2BlockNumber(ctx context.Context, l2BlockNum uint6 } return m.output, nil } +func (m *mockCC) OptimisticOutputAtTimestamp(ctx context.Context, ts uint64) (*eth.OutputResponse, error) { + 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) diff --git a/op-supernode/supernode/chain_container/chain_container.go b/op-supernode/supernode/chain_container/chain_container.go index 7dc389b0960..0e262ee94db 100644 --- a/op-supernode/supernode/chain_container/chain_container.go +++ b/op-supernode/supernode/chain_container/chain_container.go @@ -10,8 +10,10 @@ 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" @@ -36,6 +38,8 @@ type ChainContainer interface { VerifiedAt(ctx context.Context, ts uint64) (l2, l1 eth.BlockID, err error) OptimisticAt(ctx context.Context, ts uint64) (l2, l1 eth.BlockID, err error) OutputRootAtL2BlockNumber(ctx context.Context, l2BlockNum uint64) (eth.Bytes32, error) + // OptimisticOutputAtTimestamp 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 @@ -55,7 +59,8 @@ 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 @@ -86,6 +91,12 @@ 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) @@ -123,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 @@ -179,6 +194,11 @@ 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) @@ -290,3 +310,40 @@ func (c *simpleChainContainer) OptimisticAt(ctx context.Context, ts uint64) (l2, // 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 +} From 63bca40a35bee0253c4d52a10b0acde1cb3e7d1f Mon Sep 17 00:00:00 2001 From: axelKingsley Date: Wed, 19 Nov 2025 13:03:17 -0600 Subject: [PATCH 4/4] Use eth.NotFound Errors --- op-supernode/supernode/activity/superroot/superroot.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 2263c4eebe5..31d4a9c29fe 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -2,9 +2,11 @@ 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" ) @@ -96,7 +98,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) - return atTimestampResponse{}, err + return atTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) } verified[chainID] = L2WithRequiredL1{ L2: verifiedL2, @@ -109,20 +111,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{}, 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{}, 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{}, err + return atTimestampResponse{}, fmt.Errorf("%w: %w", ethereum.NotFound, err) } optimistic[chainID] = OutputWithSource{ Output: optimisticOut,