diff --git a/op-acceptance-tests/acceptance-tests.yaml b/op-acceptance-tests/acceptance-tests.yaml index 27b19a1ca7a3b..cc53f79f22b29 100644 --- a/op-acceptance-tests/acceptance-tests.yaml +++ b/op-acceptance-tests/acceptance-tests.yaml @@ -149,3 +149,17 @@ gates: tests: - package: github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/custom_gas_token timeout: 10m + + - id: supernode + description: "Supernode tests - tests for the op-supernode multi-chain consensus layer." + tests: + - package: github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/supernode + timeout: 10m + + - id: supernode-interop + inherits: + - supernode + description: "Supernode interop tests - tests for supernode's cross-chain message verification." + tests: + - package: github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/supernode/interop + timeout: 15m diff --git a/op-acceptance-tests/tests/supernode/advance_multiple_test.go b/op-acceptance-tests/tests/supernode/advance_multiple_test.go index 2e8c30c9ae60a..da51f33e4921e 100644 --- a/op-acceptance-tests/tests/supernode/advance_multiple_test.go +++ b/op-acceptance-tests/tests/supernode/advance_multiple_test.go @@ -10,22 +10,18 @@ import ( "github.com/stretchr/testify/require" ) -// TestCLAdvanceMultiple verifies two L2 chains advance when using a shared CL +// TestTwoChainProgress confirms that two L2 chains advance when using a shared CL // it confirms: -// - the two L2 chains are on different chains +// - the two L2 chains are different // - the two CLs are using the same supernode -// - the two CLs are advancing -func TestCLAdvanceMultiple(gt *testing.T) { +// - the two CLs are advancing unsafe and safe heads +func TestTwoChainProgress(gt *testing.T) { t := devtest.ParallelT(gt) sys := presets.NewTwoL2(t) blockTime := sys.L2A.Escape().RollupConfig().BlockTime waitTime := time.Duration(blockTime+1) * time.Second - // Check L2A advances - numA := sys.L2ACL.SyncStatus().UnsafeL2.Number - numB := sys.L2BCL.SyncStatus().UnsafeL2.Number - // Check that the two CLs are on different chains require.NotEqual(t, sys.L2ACL.ChainID(), sys.L2BCL.ChainID()) @@ -36,11 +32,47 @@ func TestCLAdvanceMultiple(gt *testing.T) { require.NoError(t, err) require.Equal(t, uA.Scheme, uB.Scheme) require.Equal(t, uA.Host, uB.Host) + require.Equal(t, uA.Port(), uB.Port()) + + // Record initial sync status + statusA := sys.L2ACL.SyncStatus() + statusB := sys.L2BCL.SyncStatus() + + t.Logger().Info("initial sync status", + "chainA_unsafe", statusA.UnsafeL2.Number, + "chainA_safe", statusA.SafeL2.Number, + "chainB_unsafe", statusB.UnsafeL2.Number, + "chainB_safe", statusB.SafeL2.Number, + ) + + // unsafe heads should advance + t.Require().Eventually(func() bool { + newStatusA := sys.L2ACL.SyncStatus() + newStatusB := sys.L2BCL.SyncStatus() + return newStatusA.UnsafeL2.Number > statusA.UnsafeL2.Number && + newStatusB.UnsafeL2.Number > statusB.UnsafeL2.Number + }, 30*time.Second, waitTime, "chains should advance unsafe heads") + + // safe heads should advance + t.Require().Eventually(func() bool { + newStatusA := sys.L2ACL.SyncStatus() + newStatusB := sys.L2BCL.SyncStatus() + t.Logger().Info("waiting for safe head progression", + "chainA_safe", newStatusA.SafeL2.Number, + "chainB_safe", newStatusB.SafeL2.Number, + ) + return newStatusA.SafeL2.Number > statusA.SafeL2.Number && + newStatusB.SafeL2.Number > statusB.SafeL2.Number + }, 60*time.Second, waitTime, "chains should advance safe heads") - require.Eventually(t, func() bool { - newA := sys.L2ACL.SyncStatus().UnsafeL2.Number - newB := sys.L2BCL.SyncStatus().UnsafeL2.Number - return newA > numA && newB > numB - }, 30*time.Second, waitTime) + // Log final status + finalStatusA := sys.L2ACL.SyncStatus() + finalStatusB := sys.L2BCL.SyncStatus() + t.Logger().Info("final sync status", + "chainA_unsafe", finalStatusA.UnsafeL2.Number, + "chainA_safe", finalStatusA.SafeL2.Number, + "chainB_unsafe", finalStatusB.UnsafeL2.Number, + "chainB_safe", finalStatusB.SafeL2.Number, + ) } diff --git a/op-acceptance-tests/tests/supernode/interop/activation/activation_after_genesis_test.go b/op-acceptance-tests/tests/supernode/interop/activation/activation_after_genesis_test.go new file mode 100644 index 0000000000000..4abc6fd1da5d5 --- /dev/null +++ b/op-acceptance-tests/tests/supernode/interop/activation/activation_after_genesis_test.go @@ -0,0 +1,79 @@ +package activation + +import ( + "testing" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// TestSupernodeInteropActivationAfterGenesis tests behavior when interop is activated +// AFTER genesis. This verifies that VerifiedAt (via superroot_atTimestamp) returns +// verified data for timestamps both before and after the activation boundary. +func TestSupernodeInteropActivationAfterGenesis(gt *testing.T) { + t := devtest.ParallelT(gt) + sys := presets.NewTwoL2SupernodeInterop(t, InteropActivationDelay) + + genesisTime := sys.GenesisTime + activationTime := sys.InteropActivationTime + blockTime := sys.L2A.Escape().RollupConfig().BlockTime + + // Select timestamps before and after activation + // Pre-activation: one block after genesis (before interop is active) + // Post-activation: one block after activation (after interop is active) + preActivationTs := genesisTime + blockTime + postActivationTs := activationTime + blockTime + + t.Logger().Info("testing interop activation boundary", + "genesis_time", genesisTime, + "activation_time", activationTime, + "pre_activation_ts", preActivationTs, + "post_activation_ts", postActivationTs, + "block_time", blockTime, + ) + + ctx := t.Ctx() + snClient := sys.SuperNodeClient() + + // Wait for both timestamps to be verified via SuperRootAtTimestamp + // Pre-activation timestamps are auto-verified (interop wasn't active yet) + // Post-activation timestamps require interop verification + var preActivationResp, postActivationResp eth.SuperRootAtTimestampResponse + t.Require().Eventually(func() bool { + var err error + + // Check pre-activation timestamp + preActivationResp, err = snClient.SuperRootAtTimestamp(ctx, preActivationTs) + if err != nil { + t.Logger().Debug("superroot_atTimestamp error for pre-activation", "timestamp", preActivationTs, "err", err) + return false + } + preVerified := preActivationResp.Data != nil + + // Check post-activation timestamp + postActivationResp, err = snClient.SuperRootAtTimestamp(ctx, postActivationTs) + if err != nil { + t.Logger().Debug("superroot_atTimestamp error for post-activation", "timestamp", postActivationTs, "err", err) + return false + } + postVerified := postActivationResp.Data != nil + + t.Logger().Debug("waiting for both timestamps to be verified", + "pre_activation_ts", preActivationTs, + "pre_verified", preVerified, + "post_activation_ts", postActivationTs, + "post_verified", postVerified, + ) + + return preVerified && postVerified + }, 90*time.Second, time.Second, "both pre and post activation timestamps should be verified") + + t.Logger().Info("activation boundary test complete", + "pre_activation_ts", preActivationTs, + "pre_activation_super_root", preActivationResp.Data.SuperRoot, + "post_activation_ts", postActivationTs, + "post_activation_super_root", postActivationResp.Data.SuperRoot, + ) +} diff --git a/op-acceptance-tests/tests/supernode/interop/activation/init_test.go b/op-acceptance-tests/tests/supernode/interop/activation/init_test.go new file mode 100644 index 0000000000000..c9611c295c460 --- /dev/null +++ b/op-acceptance-tests/tests/supernode/interop/activation/init_test.go @@ -0,0 +1,21 @@ +package activation + +import ( + "os" + "testing" + + "github.com/ethereum-optimism/optimism/op-devstack/presets" +) + +// InteropActivationDelay is the delay in seconds from genesis to interop activation. +// This is set to 20 seconds to allow several blocks to be produced before interop kicks in. +const InteropActivationDelay = uint64(20) + +// TestMain creates a two-L2 setup with a shared supernode that has interop enabled +// AFTER genesis (delayed by InteropActivationDelay seconds). +// This allows testing that safety proceeds normally before interop activation. +func TestMain(m *testing.M) { + // Set the L2CL kind to supernode for all tests in this package + _ = os.Setenv("DEVSTACK_L2CL_KIND", "supernode") + presets.DoMain(m, presets.WithTwoL2SupernodeInterop(InteropActivationDelay)) +} diff --git a/op-acceptance-tests/tests/supernode/interop/activation_at_genesis_test.go b/op-acceptance-tests/tests/supernode/interop/activation_at_genesis_test.go new file mode 100644 index 0000000000000..2407128ac20f1 --- /dev/null +++ b/op-acceptance-tests/tests/supernode/interop/activation_at_genesis_test.go @@ -0,0 +1,58 @@ +package interop + +import ( + "testing" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// TestSupernodeInteropActivationAtGenesis tests behavior when interop is activated +// at genesis time (timestamp 0 offset). This verifies the first few timestamps are +// processed correctly with interop verification from the very beginning. +// Also verifies that VerifiedAt (via superroot_atTimestamp) works correctly. +func TestSupernodeInteropActivationAtGenesis(gt *testing.T) { + t := devtest.ParallelT(gt) + sys := presets.NewTwoL2SupernodeInterop(t, 0) + + genesisTime := sys.L2A.Escape().RollupConfig().Genesis.L2Time + blockTime := sys.L2A.Escape().RollupConfig().BlockTime + + t.Logger().Info("testing interop activation at genesis", + "genesis_time", genesisTime, + "block_time", blockTime, + ) + + // Create a SuperNodeClient to call superroot_atTimestamp (which uses VerifiedAt internally) + ctx := t.Ctx() + snClient := sys.SuperNodeClient() + + // The first timestamp to be verified should be genesis + blockTime + // (genesis block doesn't have L1 data recorded in safeDB yet) + targetTimestamp := genesisTime + blockTime + t.Logger().Info("checking VerifiedAt for first block after genesis", "timestamp", targetTimestamp) + + // Wait for interop to verify the first block after genesis + var genesisResp eth.SuperRootAtTimestampResponse + t.Require().Eventually(func() bool { + var err error + genesisResp, err = snClient.SuperRootAtTimestamp(ctx, targetTimestamp) + if err != nil { + t.Logger().Warn("superroot_atTimestamp error, retrying", "timestamp", targetTimestamp, "err", err) + return false + } + if genesisResp.Data == nil { + t.Logger().Debug("waiting for interop to verify first block", "timestamp", targetTimestamp) + return false + } + return true + }, 60*time.Second, time.Second, "VerifiedAt should return data for first block after genesis (interop-verified)") + + t.Logger().Info("genesis activation verified", + "timestamp", targetTimestamp, + "verified_required_l1", genesisResp.Data.VerifiedRequiredL1, + "super_root", genesisResp.Data.SuperRoot, + ) +} diff --git a/op-acceptance-tests/tests/supernode/interop/cross_message_test.go b/op-acceptance-tests/tests/supernode/interop/cross_message_test.go new file mode 100644 index 0000000000000..973d1537a4b6a --- /dev/null +++ b/op-acceptance-tests/tests/supernode/interop/cross_message_test.go @@ -0,0 +1,101 @@ +package interop + +import ( + "math/rand" + "testing" + "time" + + "github.com/ethereum/go-ethereum/common" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-service/bigs" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/testutils" + "github.com/ethereum-optimism/optimism/op-service/txintent" +) + +// TestSupernodeInteropBidirectionalMessages tests sending messages in both directions +// (A->B and B->A) to verify the supernode handles bidirectional interop correctly. +// All messages are valid, and no interruptions to the chains are expected. +func TestSupernodeInteropBidirectionalMessages(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewTwoL2SupernodeInterop(t, 0) + + // Create funded EOAs on both chains + alice := sys.FunderA.NewFundedEOA(eth.OneEther) + bob := sys.FunderB.NewFundedEOA(eth.OneEther) + + // Deploy event loggers on both chains + eventLoggerA := alice.DeployEventLogger() + eventLoggerB := bob.DeployEventLogger() + + // Sync chains + sys.L2B.CatchUpTo(sys.L2A) + sys.L2A.CatchUpTo(sys.L2B) + + rng := rand.New(rand.NewSource(54321)) + + // Send A -> B message + initTriggerAtoB := randomInitTrigger(rng, eventLoggerA, 2, 10) + initTxAtoB, initReceiptAtoB := alice.SendInitMessage(initTriggerAtoB) + sys.L2B.WaitForBlock() + _, execReceiptAtoB := bob.SendExecMessage(initTxAtoB, 0) + + t.Logger().Info("A->B message sent", + "init_block", initReceiptAtoB.BlockNumber, + "exec_block", execReceiptAtoB.BlockNumber, + ) + + // Send B -> A message + initTriggerBtoA := randomInitTrigger(rng, eventLoggerB, 2, 10) + initTxBtoA, initReceiptBtoA := bob.SendInitMessage(initTriggerBtoA) + sys.L2A.WaitForBlock() + _, execReceiptBtoA := alice.SendExecMessage(initTxBtoA, 0) + + t.Logger().Info("B->A message sent", + "init_block", initReceiptBtoA.BlockNumber, + "exec_block", execReceiptBtoA.BlockNumber, + ) + + // Wait for all messages to become safe + blockTime := sys.L2A.Escape().RollupConfig().BlockTime + timeout := time.Duration(blockTime*25+60) * time.Second + + t.Require().Eventually(func() bool { + statusA := sys.L2ACL.SyncStatus() + statusB := sys.L2BCL.SyncStatus() + + // All blocks should be safe + return statusA.SafeL2.Number > bigs.Uint64Strict(initReceiptAtoB.BlockNumber) && + statusA.SafeL2.Number > bigs.Uint64Strict(execReceiptBtoA.BlockNumber) && + statusB.SafeL2.Number > bigs.Uint64Strict(execReceiptAtoB.BlockNumber) && + statusB.SafeL2.Number > bigs.Uint64Strict(initReceiptBtoA.BlockNumber) + }, timeout, time.Second, "bidirectional messages should become safe") + + t.Logger().Info("bidirectional messages processed successfully") +} + +// randomInitTrigger creates a random init trigger for testing. +func randomInitTrigger(rng *rand.Rand, eventLoggerAddress common.Address, topicCount, dataLen int) *txintent.InitTrigger { + if topicCount > 4 { + topicCount = 4 // Max 4 topics in EVM logs + } + if topicCount < 1 { + topicCount = 1 + } + if dataLen < 1 { + dataLen = 1 + } + + topics := make([][32]byte, topicCount) + for i := range topics { + copy(topics[i][:], testutils.RandomData(rng, 32)) + } + + return &txintent.InitTrigger{ + Emitter: eventLoggerAddress, + Topics: topics, + OpaqueData: testutils.RandomData(rng, dataLen), + } +} diff --git a/op-acceptance-tests/tests/supernode/interop/init_test.go b/op-acceptance-tests/tests/supernode/interop/init_test.go new file mode 100644 index 0000000000000..22e7f02af12c2 --- /dev/null +++ b/op-acceptance-tests/tests/supernode/interop/init_test.go @@ -0,0 +1,16 @@ +package interop + +import ( + "os" + "testing" + + "github.com/ethereum-optimism/optimism/op-devstack/presets" +) + +// TestMain creates a two-L2 setup with a shared supernode that has interop enabled. +// This allows testing of cross-chain message verification at each timestamp. +func TestMain(m *testing.M) { + // Set the L2CL kind to supernode for all tests in this package + _ = os.Setenv("DEVSTACK_L2CL_KIND", "supernode") + presets.DoMain(m, presets.WithTwoL2SupernodeInterop(0)) +} diff --git a/op-acceptance-tests/tests/supernode/interop/timestamp_progression_test.go b/op-acceptance-tests/tests/supernode/interop/timestamp_progression_test.go new file mode 100644 index 0000000000000..6864e477da1fa --- /dev/null +++ b/op-acceptance-tests/tests/supernode/interop/timestamp_progression_test.go @@ -0,0 +1,216 @@ +package interop + +import ( + "testing" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/presets" +) + +// TestSupernodeInteropVerifiedAt tests that the VerifiedAt endpoint returns +// correct data after the interop activity has processed timestamps. +func TestSupernodeInteropVerifiedAt(gt *testing.T) { + t := devtest.ParallelT(gt) + sys := presets.NewTwoL2SupernodeInterop(t, 0) + + blockTime := sys.L2A.Escape().RollupConfig().BlockTime + genesisTime := sys.L2A.Escape().RollupConfig().Genesis.L2Time + ctx := t.Ctx() + snClient := sys.SuperNodeClient() + + // Query for a timestamp that should be verified + // Use genesis time + one block time to ensure we're past the first block + targetTimestamp := genesisTime + blockTime + + t.Logger().Info("querying verified at timestamp", + "genesis_time", genesisTime, + "target_timestamp", targetTimestamp, + ) + + // Wait for the interop activity to verify the target timestamp + t.Require().Eventually(func() bool { + resp, err := snClient.SuperRootAtTimestamp(ctx, targetTimestamp) + if err != nil { + return false + } + return resp.Data != nil + }, 60*time.Second, time.Second, "interop should verify target timestamp") + + // Log the final state + resp, err := snClient.SuperRootAtTimestamp(ctx, targetTimestamp) + t.Require().NoError(err) + t.Logger().Info("verified at test complete", + "target_timestamp", targetTimestamp, + "super_root", resp.Data.SuperRoot, + ) +} + +// TestSupernodeInteropChainLag tests that interop verification is based on SAFE heads, +// not unsafe heads. When chain B's batcher is stopped (but CL keeps running): +// - Chain B's unsafe head continues to advance +// - Chain B's safe head is frozen (no batches submitted to L1) +// - Timestamps should NOT be verified until safe heads catch up +// +// This proves the supernode waits for all chains' safe heads before verifying. +func TestSupernodeInteropChainLag(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewTwoL2SupernodeInterop(t, 0) + + blockTime := sys.L2A.Escape().RollupConfig().BlockTime + ctx := t.Ctx() + snClient := sys.SuperNodeClient() + + // Let both chains advance initially and wait for verification to catch up + t.Require().Eventually(func() bool { + statusA := sys.L2ACL.SyncStatus() + statusB := sys.L2BCL.SyncStatus() + return statusA.SafeL2.Number > 2 && statusB.SafeL2.Number > 2 + }, 60*time.Second, time.Second, "both chains should advance initially") + + // Record the current state - this is the "baseline" verified timestamp + statusA := sys.L2ACL.SyncStatus() + statusB := sys.L2BCL.SyncStatus() + baselineTimestamp := statusA.SafeL2.Time + + // Wait for baseline timestamp to be verified before proceeding + t.Require().Eventually(func() bool { + resp, err := snClient.SuperRootAtTimestamp(ctx, baselineTimestamp) + if err != nil { + return false + } + return resp.Data != nil + }, 60*time.Second, time.Second, "baseline timestamp should be verified before stopping batcher") + + t.Logger().Info("initial state before lag test", + "chainA_safe", statusA.SafeL2.Number, + "chainA_safe_time", statusA.SafeL2.Time, + "chainB_safe", statusB.SafeL2.Number, + "chainB_safe_time", statusB.SafeL2.Time, + "chainB_unsafe", statusB.UnsafeL2.Number, + "baseline_timestamp", baselineTimestamp, + ) + + // Stop Chain B's batcher to halt safe head progression + sys.L2BatcherB.Stop() + t.Logger().Info("stopped chain B batcher (CL still running)") + + // Wait for in-flight batches to settle - the safe head should stabilize + // We wait until the safe head doesn't change for 10 seconds + // or up to 30 seconds to fail the test + var bStoppedSafeNum uint64 + var bStoppedSafeTime uint64 + lastSafe := sys.L2BCL.SyncStatus().SafeL2.Number + stableFor := 0 + start := time.Now() + for stableFor < 10 { + time.Sleep(time.Second) + current := sys.L2BCL.SyncStatus() + if current.SafeL2.Number == lastSafe { + stableFor++ + } else { + stableFor = 0 + lastSafe = current.SafeL2.Number + } + bStoppedSafeNum = current.SafeL2.Number + bStoppedSafeTime = current.SafeL2.Time + if time.Since(start) > 30*time.Second { + t.Logger().Error("safe head did not stabilize after 30 seconds") + t.FailNow() + } + } + + bStoppedStatus := sys.L2BCL.SyncStatus() + t.Logger().Info("chain B batcher stopped (safe head stabilized)", + "chainB_safe", bStoppedSafeNum, + "chainB_safe_time", bStoppedSafeTime, + "chainB_unsafe", bStoppedStatus.UnsafeL2.Number, + ) + + // Watch safe/unsafe and verified for 30 seconds + // First wait for chain A to advance past chain B's frozen safe head + start = time.Now() + var aheadTimestamp uint64 + for { + if time.Since(start) > 30*time.Second { + break + } + + time.Sleep(time.Second) + + // Check the state + newStatusA := sys.L2ACL.SyncStatus() + newStatusB := sys.L2BCL.SyncStatus() + + t.Logger().Info("state", + "chainA_safe", newStatusA.SafeL2.Number, + "chainA_safe_time", newStatusA.SafeL2.Time, + "chainB_safe", newStatusB.SafeL2.Number, + "chainB_safe_time", newStatusB.SafeL2.Time, + "chainB_unsafe", newStatusB.UnsafeL2.Number, + ) + + // KEY ASSERTION 1: Chain B's unsafe head should have advanced (CL is still running) + t.Require().Greater(newStatusB.UnsafeL2.Number, bStoppedSafeNum, + "chain B unsafe head should advance even with batcher stopped") + + // KEY ASSERTION 2: Chain B's safe head should be frozen (no batches) + t.Require().Equal(bStoppedSafeNum, newStatusB.SafeL2.Number, + "chain B safe head should be frozen with batcher stopped") + + // Use chain A's ahead timestamp for verification check + aheadTimestamp = newStatusA.SafeL2.Time + + // KEY ASSERTION 3: The timestamp should NOT be verified + // Even though chain B's unsafe head is past this timestamp, + // verification requires SAFE heads on all chains + resp, err := snClient.SuperRootAtTimestamp(ctx, aheadTimestamp) + t.Require().NoError(err, "SuperRootAtTimestamp should not error") + t.Require().Nil(resp.Data, + "timestamp should NOT be verified - chain B unsafe is ahead but safe is behind") + + t.Logger().Info("confirmed: timestamp not verified despite chain B unsafe being ahead", + "ahead_timestamp", aheadTimestamp, + "chainB_unsafe", newStatusB.UnsafeL2.Number, + "chainB_safe", newStatusB.SafeL2.Number, + ) + } + + // Resume the batcher + sys.L2BatcherB.Start() + t.Logger().Info("resumed chain B batcher") + + // Wait for chain B's SAFE head to catch up + timeout := time.Duration(blockTime*20+60) * time.Second + t.Require().Eventually(func() bool { + currentB := sys.L2BCL.SyncStatus() + return currentB.SafeL2.Time >= aheadTimestamp + }, timeout, time.Second, "chain B safe head should catch up after batcher resumes") + + // KEY ASSERTION 4: Now that safe heads are caught up, timestamp should be verified + t.Require().Eventually(func() bool { + resp, err := snClient.SuperRootAtTimestamp(ctx, aheadTimestamp) + if err != nil { + t.Logger().Warn("SuperRootAtTimestamp error while waiting for verification", "err", err) + return false + } + return resp.Data != nil + }, 60*time.Second, time.Second, "ahead timestamp should be verified after chain B safe catches up") + + t.Logger().Info("confirmed: ahead timestamp is now verified after chain B safe caught up", + "ahead_timestamp", aheadTimestamp, + ) + + // Both chains should continue advancing together + finalStatusA := sys.L2ACL.SyncStatus() + finalStatusB := sys.L2BCL.SyncStatus() + + t.Logger().Info("final state after recovery", + "chainA_safe", finalStatusA.SafeL2.Number, + "chainB_safe", finalStatusB.SafeL2.Number, + "chainB_unsafe", finalStatusB.UnsafeL2.Number, + ) + + t.Require().Greater(finalStatusA.SafeL2.Number, statusA.SafeL2.Number, "chain A should have advanced") + t.Require().Greater(finalStatusB.SafeL2.Number, statusB.SafeL2.Number, "chain B should have advanced") +} diff --git a/op-devstack/dsl/supernode.go b/op-devstack/dsl/supernode.go new file mode 100644 index 0000000000000..c506ee3c64dea --- /dev/null +++ b/op-devstack/dsl/supernode.go @@ -0,0 +1,50 @@ +package dsl + +import ( + "context" + + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-service/apis" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +// Supernode wraps a stack.Supernode interface for DSL operations +type Supernode struct { + commonImpl + inner stack.Supernode +} + +// NewSupernode creates a new Supernode DSL wrapper +func NewSupernode(inner stack.Supernode) *Supernode { + return &Supernode{ + commonImpl: commonFromT(inner.T()), + inner: inner, + } +} + +func (s *Supernode) ID() stack.SupernodeID { + return s.inner.ID() +} + +func (s *Supernode) String() string { + return s.inner.ID().String() +} + +// Escape returns the underlying stack.Supernode +func (s *Supernode) Escape() stack.Supernode { + return s.inner +} + +// QueryAPI returns the supernode's query API +func (s *Supernode) QueryAPI() apis.SupernodeQueryAPI { + return s.inner.QueryAPI() +} + +// SuperRootAtTimestamp fetches the super-root at the given timestamp +func (s *Supernode) SuperRootAtTimestamp(timestamp uint64) eth.SuperRootAtTimestampResponse { + ctx, cancel := context.WithTimeout(s.ctx, DefaultTimeout) + defer cancel() + resp, err := s.inner.QueryAPI().SuperRootAtTimestamp(ctx, timestamp) + s.require.NoError(err, "failed to get super-root at timestamp %d", timestamp) + return resp +} diff --git a/op-devstack/presets/twol2.go b/op-devstack/presets/twol2.go index bcbd29edd7a9e..abdb9babb65bc 100644 --- a/op-devstack/presets/twol2.go +++ b/op-devstack/presets/twol2.go @@ -1,6 +1,8 @@ package presets import ( + "time" + "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" @@ -10,6 +12,7 @@ import ( "github.com/ethereum-optimism/optimism/op-devstack/stack" "github.com/ethereum-optimism/optimism/op-devstack/stack/match" "github.com/ethereum-optimism/optimism/op-devstack/sysgo" + "github.com/ethereum-optimism/optimism/op-service/apis" ) // TwoL2 represents a two-L2 setup without interop considerations. @@ -36,6 +39,13 @@ func WithTwoL2Supernode() stack.CommonOption { return stack.MakeCommon(sysgo.DefaultSupernodeTwoL2System(&sysgo.DefaultTwoL2SystemIDs{})) } +// WithTwoL2SupernodeInterop specifies a two-L2 system using a shared supernode with interop enabled. +// Use delaySeconds=0 for interop at genesis, or a positive value to test the transition from +// normal safety to interop-verified safety. +func WithTwoL2SupernodeInterop(delaySeconds uint64) stack.CommonOption { + return stack.MakeCommon(sysgo.DefaultSupernodeInteropTwoL2System(&sysgo.DefaultTwoL2SystemIDs{}, delaySeconds)) +} + func NewTwoL2(t devtest.T) *TwoL2 { system := shim.NewSystem(t) orch := Orchestrator() @@ -61,3 +71,107 @@ func NewTwoL2(t devtest.T) *TwoL2 { L2BCL: dsl.NewL2CLNode(l2bCL, orch.ControlPlane()), } } + +// TwoL2SupernodeInterop represents a two-L2 setup with a shared supernode that has interop enabled. +// This allows testing of cross-chain message verification at each timestamp. +// Use delaySeconds=0 for interop at genesis, or a positive value to test the transition. +type TwoL2SupernodeInterop struct { + TwoL2 + + // Supernode provides access to the shared supernode for interop operations + Supernode *dsl.Supernode + + // L2ELA and L2ELB provide access to the EL nodes for transaction submission + L2ELA *dsl.L2ELNode + L2ELB *dsl.L2ELNode + + // L2BatcherA and L2BatcherB provide access to the batchers for pausing/resuming + L2BatcherA *dsl.L2Batcher + L2BatcherB *dsl.L2Batcher + + // Faucets for funding test accounts + FaucetA *dsl.Faucet + FaucetB *dsl.Faucet + + // Wallet for test account management + Wallet *dsl.HDWallet + + // Funders for creating funded EOAs + FunderA *dsl.Funder + FunderB *dsl.Funder + + // GenesisTime is the genesis timestamp of the L2 chains + GenesisTime uint64 + + // InteropActivationTime is the timestamp when interop becomes active + InteropActivationTime uint64 + + // DelaySeconds is the delay from genesis to interop activation + DelaySeconds uint64 + + // system holds the underlying system for advanced operations + system stack.ExtensibleSystem +} + +// AdvanceTime advances the time-travel clock if enabled. +func (s *TwoL2SupernodeInterop) AdvanceTime(amount time.Duration) { + ttSys, ok := s.system.(stack.TimeTravelSystem) + s.T.Require().True(ok, "attempting to advance time on incompatible system") + ttSys.AdvanceTime(amount) +} + +// SuperNodeClient returns an API for calling supernode-specific RPC methods +// like superroot_atTimestamp. +func (s *TwoL2SupernodeInterop) SuperNodeClient() apis.SupernodeQueryAPI { + return s.Supernode.QueryAPI() +} + +// NewTwoL2SupernodeInterop creates a TwoL2SupernodeInterop preset for acceptance tests. +// Use delaySeconds=0 for interop at genesis, or a positive value to test the transition. +// The delaySeconds must match what was passed to WithTwoL2SupernodeInterop in TestMain. +func NewTwoL2SupernodeInterop(t devtest.T, delaySeconds uint64) *TwoL2SupernodeInterop { + system := shim.NewSystem(t) + orch := Orchestrator() + orch.Hydrate(system) + + l1Net := system.L1Network(match.FirstL1Network) + l2a := system.L2Network(match.Assume(t, match.L2ChainA)) + l2b := system.L2Network(match.Assume(t, match.L2ChainB)) + l2aCL := l2a.L2CLNode(match.Assume(t, match.WithSequencerActive(t.Ctx()))) + l2bCL := l2b.L2CLNode(match.Assume(t, match.WithSequencerActive(t.Ctx()))) + + require.NotEqual(t, l2a.ChainID(), l2b.ChainID()) + + // Get genesis time from the DSL wrapper + l2aNet := dsl.NewL2Network(l2a, orch.ControlPlane()) + genesisTime := l2aNet.Escape().RollupConfig().Genesis.L2Time + + out := &TwoL2SupernodeInterop{ + TwoL2: TwoL2{ + Log: t.Logger(), + T: t, + ControlPlane: orch.ControlPlane(), + L1Network: dsl.NewL1Network(l1Net), + L1EL: dsl.NewL1ELNode(l1Net.L1ELNode(match.Assume(t, match.FirstL1EL))), + L2A: l2aNet, + L2B: dsl.NewL2Network(l2b, orch.ControlPlane()), + L2ACL: dsl.NewL2CLNode(l2aCL, orch.ControlPlane()), + L2BCL: dsl.NewL2CLNode(l2bCL, orch.ControlPlane()), + }, + Supernode: dsl.NewSupernode(system.Supernode(match.Assume(t, match.FirstSupernode))), + L2ELA: dsl.NewL2ELNode(l2a.L2ELNode(match.Assume(t, match.FirstL2EL)), orch.ControlPlane()), + L2ELB: dsl.NewL2ELNode(l2b.L2ELNode(match.Assume(t, match.FirstL2EL)), orch.ControlPlane()), + L2BatcherA: dsl.NewL2Batcher(l2a.L2Batcher(match.Assume(t, match.FirstL2Batcher))), + L2BatcherB: dsl.NewL2Batcher(l2b.L2Batcher(match.Assume(t, match.FirstL2Batcher))), + FaucetA: dsl.NewFaucet(l2a.Faucet(match.Assume(t, match.FirstFaucet))), + FaucetB: dsl.NewFaucet(l2b.Faucet(match.Assume(t, match.FirstFaucet))), + Wallet: dsl.NewRandomHDWallet(t, 30), + GenesisTime: genesisTime, + InteropActivationTime: genesisTime + delaySeconds, + DelaySeconds: delaySeconds, + system: system, + } + out.FunderA = dsl.NewFunder(out.Wallet, out.FaucetA, out.L2ELA) + out.FunderB = dsl.NewFunder(out.Wallet, out.FaucetB, out.L2ELB) + return out +} diff --git a/op-devstack/sysgo/l2_cl_supernode.go b/op-devstack/sysgo/l2_cl_supernode.go index 586d4500c4167..7590692381378 100644 --- a/op-devstack/sysgo/l2_cl_supernode.go +++ b/op-devstack/sysgo/l2_cl_supernode.go @@ -196,162 +196,243 @@ type L2CLs struct { ELID stack.L2ELNodeID } +// SupernodeConfig holds configuration options for the shared supernode. +type SupernodeConfig struct { + // InteropActivationTimestamp enables the interop activity at the given timestamp. + // Set to 0 to disable interop (default). + InteropActivationTimestamp uint64 +} + +// SupernodeOption is a functional option for configuring the supernode. +type SupernodeOption func(*SupernodeConfig) + +// WithSupernodeInterop enables the interop activity with the given activation timestamp. +func WithSupernodeInterop(activationTimestamp uint64) SupernodeOption { + return func(cfg *SupernodeConfig) { + cfg.InteropActivationTimestamp = activationTimestamp + } +} + +// WithSharedSupernodeCLsInterop starts one supernode for N L2 chains with interop enabled at genesis. +// The interop activation timestamp is computed from the first chain's genesis time. +func WithSharedSupernodeCLsInterop(supernodeID stack.SupernodeID, cls []L2CLs, l1CLID stack.L1CLNodeID, l1ELID stack.L1ELNodeID) stack.Option[*Orchestrator] { + return stack.AfterDeploy(func(orch *Orchestrator) { + // Get genesis timestamp from first chain + if len(cls) == 0 { + orch.P().Require().Fail("no chains provided") + return + } + l2Net, ok := orch.l2Nets.Get(cls[0].CLID.ChainID()) + if !ok { + orch.P().Require().Fail("l2 network not found") + return + } + genesisTime := l2Net.rollupCfg.Genesis.L2Time + orch.P().Logger().Info("enabling supernode interop at genesis", "activation_timestamp", genesisTime) + + // Call the main implementation with interop enabled + withSharedSupernodeCLsImpl(orch, supernodeID, cls, l1CLID, l1ELID, WithSupernodeInterop(genesisTime)) + }) +} + +// WithSharedSupernodeCLsInteropDelayed starts one supernode for N L2 chains with interop enabled +// at a specified offset from genesis. This allows testing the transition from non-interop to interop mode. +func WithSharedSupernodeCLsInteropDelayed(supernodeID stack.SupernodeID, cls []L2CLs, l1CLID stack.L1CLNodeID, l1ELID stack.L1ELNodeID, delaySeconds uint64) stack.Option[*Orchestrator] { + return stack.AfterDeploy(func(orch *Orchestrator) { + // Get genesis timestamp from first chain + if len(cls) == 0 { + orch.P().Require().Fail("no chains provided") + return + } + l2Net, ok := orch.l2Nets.Get(cls[0].CLID.ChainID()) + if !ok { + orch.P().Require().Fail("l2 network not found") + return + } + genesisTime := l2Net.rollupCfg.Genesis.L2Time + activationTime := genesisTime + delaySeconds + orch.P().Logger().Info("enabling supernode interop with delay", + "genesis_time", genesisTime, + "activation_timestamp", activationTime, + "delay_seconds", delaySeconds, + ) + + // Call the main implementation with interop enabled at delayed timestamp + withSharedSupernodeCLsImpl(orch, supernodeID, cls, l1CLID, l1ELID, WithSupernodeInterop(activationTime)) + }) +} + // WithSharedSupernodeCLs starts one supernode for N L2 chains and registers thin L2CL wrappers. -func WithSharedSupernodeCLs(supernodeID stack.SupernodeID, cls []L2CLs, l1CLID stack.L1CLNodeID, l1ELID stack.L1ELNodeID) stack.Option[*Orchestrator] { +func WithSharedSupernodeCLs(supernodeID stack.SupernodeID, cls []L2CLs, l1CLID stack.L1CLNodeID, l1ELID stack.L1ELNodeID, opts ...SupernodeOption) stack.Option[*Orchestrator] { return stack.AfterDeploy(func(orch *Orchestrator) { - p := orch.P() - require := p.Require() + withSharedSupernodeCLsImpl(orch, supernodeID, cls, l1CLID, l1ELID, opts...) + }) +} - l1EL, ok := orch.l1ELs.Get(l1ELID) - require.True(ok, "l1 EL node required") - l1CL, ok := orch.l1CLs.Get(l1CLID) - require.True(ok, "l1 CL node required") +// withSharedSupernodeCLsImpl is the implementation for starting a shared supernode. +func withSharedSupernodeCLsImpl(orch *Orchestrator, supernodeID stack.SupernodeID, cls []L2CLs, l1CLID stack.L1CLNodeID, l1ELID stack.L1ELNodeID, opts ...SupernodeOption) { + p := orch.P() + require := p.Require() - // Get L1 network to access L1 chain config - l1Net, ok := orch.l1Nets.Get(l1ELID.ChainID()) - require.True(ok, "l1 network required") + // Apply options + snOpts := &SupernodeConfig{} + for _, opt := range opts { + opt(snOpts) + } - _, jwtSecret := orch.writeDefaultJWT() + l1EL, ok := orch.l1ELs.Get(l1ELID) + require.True(ok, "l1 EL node required") + l1CL, ok := orch.l1CLs.Get(l1CLID) + require.True(ok, "l1 CL node required") - logger := p.Logger() + // Get L1 network to access L1 chain config + l1Net, ok := orch.l1Nets.Get(l1ELID.ChainID()) + require.True(ok, "l1 network required") - // Build per-chain op-node configs - makeNodeCfg := func(l2Net *L2Network, l2ChainID eth.ChainID, l2EL L2ELNode, isSequencer bool) *config.Config { - interopCfg := &interop.Config{} - l2EngineAddr := l2EL.EngineRPC() - var depSet depset.DependencySet - if cluster, ok := orch.ClusterForL2(l2ChainID); ok { - depSet = cluster.DepSet() - } - return &config.Config{ - L1: &config.L1EndpointConfig{ - L1NodeAddr: l1EL.UserRPC(), - L1TrustRPC: false, - L1RPCKind: sources.RPCKindDebugGeth, - RateLimit: 0, - BatchSize: 20, - HttpPollInterval: time.Millisecond * 100, - MaxConcurrency: 10, - CacheSize: 0, - }, - L1ChainConfig: l1Net.genesis.Config, - L2: &config.L2EndpointConfig{ - L2EngineAddr: l2EngineAddr, - L2EngineJWTSecret: jwtSecret, - }, - DependencySet: depSet, - Beacon: &config.L1BeaconEndpointConfig{BeaconAddr: l1CL.beaconHTTPAddr}, - Driver: driver.Config{SequencerEnabled: isSequencer, SequencerConfDepth: 2}, - Rollup: *l2Net.rollupCfg, - RPC: oprpc.CLIConfig{ListenAddr: "127.0.0.1", ListenPort: 0, EnableAdmin: true}, - InteropConfig: interopCfg, - P2P: nil, - L1EpochPollInterval: 2 * time.Second, - RuntimeConfigReloadInterval: 0, - Sync: nodeSync.Config{SyncMode: nodeSync.CLSync}, - ConfigPersistence: config.DisabledConfigPersistence{}, - Metrics: opmetrics.CLIConfig{}, - Pprof: oppprof.CLIConfig{}, - AltDA: altda.CLIConfig{}, - IgnoreMissingPectraBlobSchedule: false, - ExperimentalOPStackAPI: true, - } - } + _, jwtSecret := orch.writeDefaultJWT() - // Gather VN configs and chain IDs - vnCfgs := make(map[eth.ChainID]*config.Config) - chainIDs := make([]uint64, 0, len(cls)) - els := make([]*stack.L2ELNodeID, 0, len(cls)) - for i := range cls { - a := cls[i] - l2Net, ok := orch.l2Nets.Get(a.CLID.ChainID()) - require.True(ok, "l2 network required") - l2ELNode, ok := orch.l2ELs.Get(a.ELID) - require.True(ok, "l2 EL node required") - l2ChainID := a.CLID.ChainID() - cfg := makeNodeCfg(l2Net, l2ChainID, l2ELNode, true) - require.NoError(cfg.Check(), "invalid op-node config for chain %s", a.CLID.ChainID()) - id := eth.EvilChainIDToUInt64(a.CLID.ChainID()) - chainIDs = append(chainIDs, id) - vnCfgs[eth.ChainIDFromUInt64(id)] = cfg - els = append(els, &cls[i].ELID) - } + logger := p.Logger() - // Start shared supernode with all chains - snCfg := &snconfig.CLIConfig{ - Chains: chainIDs, - DataDir: p.TempDir(), - L1NodeAddr: l1EL.UserRPC(), - L1BeaconAddr: l1CL.beaconHTTPAddr, - RPCConfig: oprpc.CLIConfig{ListenAddr: "127.0.0.1", ListenPort: 0, EnableAdmin: true}, + // Build per-chain op-node configs + makeNodeCfg := func(l2Net *L2Network, l2ChainID eth.ChainID, l2EL L2ELNode, isSequencer bool) *config.Config { + interopCfg := &interop.Config{} + l2EngineAddr := l2EL.EngineRPC() + var depSet depset.DependencySet + if cluster, ok := orch.ClusterForL2(l2ChainID); ok { + depSet = cluster.DepSet() } - ctx, cancel := context.WithCancel(p.Ctx()) - exitFn := func(err error) { p.Require().NoError(err, "supernode critical error") } - sn, err := supernode.New(ctx, logger, "devstack", exitFn, snCfg, vnCfgs) - require.NoError(err) - go func() { _ = sn.Start(ctx) }() - // Resolve bound address - addr, err := sn.WaitRPCAddr(ctx) - require.NoError(err, "failed waiting for supernode RPC addr") - base := "http://" + addr - p.Cleanup(func() { - stopCtx, c := context.WithTimeout(context.Background(), 5*time.Second) - _ = sn.Stop(stopCtx) - c() - cancel() - }) - // Wait for per-chain RPC routes to serve optimism_rollupConfig and register proxies - waitReady := func(u string) { - deadline := time.Now().Add(15 * time.Second) - for { - if time.Now().After(deadline) { - require.FailNow(fmt.Sprintf("timed out waiting for RPC to be ready at %s", u)) - } - rpcCl, err := client.NewRPC(p.Ctx(), logger, u, client.WithLazyDial()) - if err == nil { - var v any - if callErr := rpcCl.CallContext(p.Ctx(), &v, "optimism_rollupConfig"); callErr == nil { - rpcCl.Close() - break - } + return &config.Config{ + L1: &config.L1EndpointConfig{ + L1NodeAddr: l1EL.UserRPC(), + L1TrustRPC: false, + L1RPCKind: sources.RPCKindDebugGeth, + RateLimit: 0, + BatchSize: 20, + HttpPollInterval: time.Millisecond * 100, + MaxConcurrency: 10, + CacheSize: 0, + }, + L1ChainConfig: l1Net.genesis.Config, + L2: &config.L2EndpointConfig{ + L2EngineAddr: l2EngineAddr, + L2EngineJWTSecret: jwtSecret, + }, + DependencySet: depSet, + Beacon: &config.L1BeaconEndpointConfig{BeaconAddr: l1CL.beaconHTTPAddr}, + Driver: driver.Config{SequencerEnabled: isSequencer, SequencerConfDepth: 2}, + Rollup: *l2Net.rollupCfg, + RPC: oprpc.CLIConfig{ListenAddr: "127.0.0.1", ListenPort: 0, EnableAdmin: true}, + InteropConfig: interopCfg, + P2P: nil, + L1EpochPollInterval: 2 * time.Second, + RuntimeConfigReloadInterval: 0, + Sync: nodeSync.Config{SyncMode: nodeSync.CLSync}, + ConfigPersistence: config.DisabledConfigPersistence{}, + Metrics: opmetrics.CLIConfig{}, + Pprof: oppprof.CLIConfig{}, + AltDA: altda.CLIConfig{}, + IgnoreMissingPectraBlobSchedule: false, + ExperimentalOPStackAPI: true, + } + } + + // Gather VN configs and chain IDs + vnCfgs := make(map[eth.ChainID]*config.Config) + chainIDs := make([]uint64, 0, len(cls)) + els := make([]*stack.L2ELNodeID, 0, len(cls)) + for i := range cls { + a := cls[i] + l2Net, ok := orch.l2Nets.Get(a.CLID.ChainID()) + require.True(ok, "l2 network required") + l2ELNode, ok := orch.l2ELs.Get(a.ELID) + require.True(ok, "l2 EL node required") + l2ChainID := a.CLID.ChainID() + cfg := makeNodeCfg(l2Net, l2ChainID, l2ELNode, true) + require.NoError(cfg.Check(), "invalid op-node config for chain %s", a.CLID.ChainID()) + id := eth.EvilChainIDToUInt64(a.CLID.ChainID()) + chainIDs = append(chainIDs, id) + vnCfgs[eth.ChainIDFromUInt64(id)] = cfg + els = append(els, &cls[i].ELID) + } + + // Start shared supernode with all chains + snCfg := &snconfig.CLIConfig{ + Chains: chainIDs, + DataDir: p.TempDir(), + L1NodeAddr: l1EL.UserRPC(), + L1BeaconAddr: l1CL.beaconHTTPAddr, + RPCConfig: oprpc.CLIConfig{ListenAddr: "127.0.0.1", ListenPort: 0, EnableAdmin: true}, + InteropActivationTimestamp: snOpts.InteropActivationTimestamp, + } + if snOpts.InteropActivationTimestamp > 0 { + logger.Info("supernode interop enabled", "activation_timestamp", snOpts.InteropActivationTimestamp) + } + ctx, cancel := context.WithCancel(p.Ctx()) + exitFn := func(err error) { p.Require().NoError(err, "supernode critical error") } + sn, err := supernode.New(ctx, logger, "devstack", exitFn, snCfg, vnCfgs) + require.NoError(err) + go func() { _ = sn.Start(ctx) }() + // Resolve bound address + addr, err := sn.WaitRPCAddr(ctx) + require.NoError(err, "failed waiting for supernode RPC addr") + base := "http://" + addr + p.Cleanup(func() { + stopCtx, c := context.WithTimeout(context.Background(), 5*time.Second) + _ = sn.Stop(stopCtx) + c() + cancel() + }) + // Wait for per-chain RPC routes to serve optimism_rollupConfig and register proxies + waitReady := func(u string) { + deadline := time.Now().Add(15 * time.Second) + for { + if time.Now().After(deadline) { + require.FailNow(fmt.Sprintf("timed out waiting for RPC to be ready at %s", u)) + } + rpcCl, err := client.NewRPC(p.Ctx(), logger, u, client.WithLazyDial()) + if err == nil { + var v any + if callErr := rpcCl.CallContext(p.Ctx(), &v, "optimism_rollupConfig"); callErr == nil { rpcCl.Close() + break } - time.Sleep(200 * time.Millisecond) - } - } - for i := range cls { - a := cls[i] - // Multi-chain router exposes per-chain namespace paths - rpc := base + "/" + strconv.FormatUint(eth.EvilChainIDToUInt64(a.CLID.ChainID()), 10) - waitReady(rpc) - proxy := &SuperNodeProxy{ - id: a.CLID, - p: p, - logger: logger, - userRPC: rpc, - interopEndpoint: rpc, - interopJwtSecret: jwtSecret, - el: &cls[i].ELID, + rpcCl.Close() } - require.True(orch.l2CLs.SetIfMissing(a.CLID, proxy), fmt.Sprintf("must not already exist: %s", a.CLID)) + time.Sleep(200 * time.Millisecond) } - - supernode := &SuperNode{ - id: supernodeID, - sn: sn, - cancel: cancel, - userRPC: base, - interopEndpoint: base, - interopJwtSecret: jwtSecret, + } + for i := range cls { + a := cls[i] + // Multi-chain router exposes per-chain namespace paths + rpc := base + "/" + strconv.FormatUint(eth.EvilChainIDToUInt64(a.CLID.ChainID()), 10) + waitReady(rpc) + proxy := &SuperNodeProxy{ + id: a.CLID, p: p, logger: logger, - els: els, - chains: idsFromCLs(cls), - l1UserRPC: l1EL.UserRPC(), - l1BeaconAddr: l1CL.beaconHTTPAddr, + userRPC: rpc, + interopEndpoint: rpc, + interopJwtSecret: jwtSecret, + el: &cls[i].ELID, } - orch.supernodes.Set(supernodeID, supernode) - }) + require.True(orch.l2CLs.SetIfMissing(a.CLID, proxy), fmt.Sprintf("must not already exist: %s", a.CLID)) + } + + supernode := &SuperNode{ + id: supernodeID, + sn: sn, + cancel: cancel, + userRPC: base, + interopEndpoint: base, + interopJwtSecret: jwtSecret, + p: p, + logger: logger, + els: els, + chains: idsFromCLs(cls), + l1UserRPC: l1EL.UserRPC(), + l1BeaconAddr: l1CL.beaconHTTPAddr, + } + orch.supernodes.Set(supernodeID, supernode) } func idsFromCLs(cls []L2CLs) []eth.ChainID { diff --git a/op-devstack/sysgo/system.go b/op-devstack/sysgo/system.go index 50186d73e878d..bafccf824f5c1 100644 --- a/op-devstack/sysgo/system.go +++ b/op-devstack/sysgo/system.go @@ -219,6 +219,60 @@ func DefaultSupernodeTwoL2System(dest *DefaultTwoL2SystemIDs) stack.Option[*Orch return opt } +// DefaultSupernodeInteropTwoL2System runs two L2 chains with a shared supernode that has +// interop verification enabled. Use delaySeconds=0 for interop at genesis, or a positive value +// to test the transition from normal safety to interop-verified safety. +func DefaultSupernodeInteropTwoL2System(dest *DefaultTwoL2SystemIDs, delaySeconds uint64) stack.Option[*Orchestrator] { + ids := NewDefaultTwoL2SystemIDs(DefaultL1ID, DefaultL2AID, DefaultL2BID) + opt := stack.Combine[*Orchestrator]() + opt.Add(stack.BeforeDeploy(func(o *Orchestrator) { + if delaySeconds == 0 { + o.P().Logger().Info("Setting up (supernode with interop)") + } else { + o.P().Logger().Info("Setting up (supernode with delayed interop)", "delay_seconds", delaySeconds) + } + })) + + opt.Add(WithMnemonicKeys(devkeys.TestMnemonic)) + + opt.Add(WithDeployer(), + WithDeployerOptions( + WithLocalContractSources(), + WithCommons(ids.L1.ChainID()), + WithPrefundedL2(ids.L1.ChainID(), ids.L2A.ChainID()), + WithPrefundedL2(ids.L1.ChainID(), ids.L2B.ChainID()), + WithInteropAtGenesis(), // Enable interop contracts + ), + ) + + opt.Add(WithL1Nodes(ids.L1EL, ids.L1CL)) + + opt.Add(WithL2ELNode(ids.L2AEL)) + opt.Add(WithL2ELNode(ids.L2BEL)) + + // Shared supernode for both L2 chains with interop enabled + cls := []L2CLs{{CLID: ids.L2ACL, ELID: ids.L2AEL}, {CLID: ids.L2BCL, ELID: ids.L2BEL}} + if delaySeconds == 0 { + opt.Add(WithSharedSupernodeCLsInterop(ids.Supernode, cls, ids.L1CL, ids.L1EL)) + } else { + opt.Add(WithSharedSupernodeCLsInteropDelayed(ids.Supernode, cls, ids.L1CL, ids.L1EL, delaySeconds)) + } + + opt.Add(WithBatcher(ids.L2ABatcher, ids.L1EL, ids.L2ACL, ids.L2AEL)) + opt.Add(WithProposer(ids.L2AProposer, ids.L1EL, &ids.L2ACL, nil)) + + opt.Add(WithBatcher(ids.L2BBatcher, ids.L1EL, ids.L2BCL, ids.L2BEL)) + opt.Add(WithProposer(ids.L2BProposer, ids.L1EL, &ids.L2BCL, nil)) + + opt.Add(WithFaucets([]stack.L1ELNodeID{ids.L1EL}, []stack.L2ELNodeID{ids.L2AEL, ids.L2BEL})) + + opt.Add(stack.Finally(func(orch *Orchestrator) { + *dest = ids + })) + + return opt +} + type DefaultMinimalSystemWithSyncTesterIDs struct { DefaultMinimalSystemIDs diff --git a/op-service/eth/superroot_at_timestamp.go b/op-service/eth/superroot_at_timestamp.go index 0bc86dda04ce2..fc90ef36a7671 100644 --- a/op-service/eth/superroot_at_timestamp.go +++ b/op-service/eth/superroot_at_timestamp.go @@ -1,5 +1,7 @@ package eth +import "encoding/json" + // OutputWithRequiredL1 is the full Output and its source L1 block type OutputWithRequiredL1 struct { Output *OutputResponse `json:"output"` @@ -18,6 +20,35 @@ type SuperRootResponseData struct { SuperRoot Bytes32 `json:"super_root"` } +// superRootResponseDataMarshalling is the JSON marshalling helper for SuperRootResponseData +type superRootResponseDataMarshalling struct { + VerifiedRequiredL1 BlockID `json:"verified_required_l1"` + Super json.RawMessage `json:"super"` + SuperRoot Bytes32 `json:"super_root"` +} + +// UnmarshalJSON implements custom JSON unmarshaling for SuperRootResponseData +func (d *SuperRootResponseData) UnmarshalJSON(input []byte) error { + var dec superRootResponseDataMarshalling + if err := json.Unmarshal(input, &dec); err != nil { + return err + } + d.VerifiedRequiredL1 = dec.VerifiedRequiredL1 + d.SuperRoot = dec.SuperRoot + + // Unmarshal the Super field - currently only SuperV1 is supported + if len(dec.Super) > 0 { + var superV1 SuperV1 + if err := json.Unmarshal(dec.Super, &superV1); err != nil { + return err + } + d.Super = &superV1 + } else { + d.Super = nil + } + return nil +} + // AtTimestampResponse is the response superroot_atTimestamp type SuperRootAtTimestampResponse struct { // CurrentL1 is the highest L1 block that has been fully derived and verified by all chains. diff --git a/op-supernode/cmd/main.go b/op-supernode/cmd/main.go index 91c2be0c0fa53..b50acd422c2e4 100644 --- a/op-supernode/cmd/main.go +++ b/op-supernode/cmd/main.go @@ -21,6 +21,7 @@ import ( "github.com/ethereum-optimism/optimism/op-supernode/config" "github.com/ethereum-optimism/optimism/op-supernode/flags" "github.com/ethereum-optimism/optimism/op-supernode/supernode" + "github.com/ethereum-optimism/optimism/op-supernode/supernode/activity/interop" "github.com/ethereum/go-ethereum/log" ) @@ -78,6 +79,11 @@ func main() { return nil, fmt.Errorf("failed to create virtual node configs: %w", err) } + // Populate config with interop activation timestamp from CLI context if set + if cliCtx != nil && cliCtx.IsSet(interop.InteropActivationTimestampFlag.Name) { + cfg.InteropActivationTimestamp = cliCtx.Uint64(interop.InteropActivationTimestampFlag.Name) + } + // Create the supernode, supplying the logger, version, and close function // as well as the config and virtual node configs for each chain ctx := cliCtx.Context diff --git a/op-supernode/supernode/activity/interop/interop.go b/op-supernode/supernode/activity/interop/interop.go index d366c900f9a86..e54b66bae1c3e 100644 --- a/op-supernode/supernode/activity/interop/interop.go +++ b/op-supernode/supernode/activity/interop/interop.go @@ -198,12 +198,12 @@ func (i *Interop) progressInterop() (Result, error) { // The next timestamp to process is the previous timestamp + 1. // if the database is not initialized, we use the activation timestamp instead. lastTimestamp, initialized := i.verifiedDB.LastTimestamp() - i.log.Info("last timestamp", "lastTimestamp", lastTimestamp, "initialized", initialized) - i.log.Info("activation timestamp", "activationTimestamp", i.activationTimestamp) var ts uint64 if !initialized { + i.log.Info("initializing interop activity with activation timestamp", "activationTimestamp", i.activationTimestamp) ts = i.activationTimestamp } else { + i.log.Info("attempting to progress interop to next timestamp", "lastTimestamp", lastTimestamp, "timestamp", lastTimestamp+1) ts = lastTimestamp + 1 } @@ -330,6 +330,14 @@ func (i *Interop) CurrentL1() eth.BlockID { } // VerifiedAtTimestamp returns whether the data is verified at the given timestamp. +// For timestamps before the activation timestamp, this returns true since interop +// wasn't active yet and verification proceeds automatically. +// For timestamps at or after the activation timestamp, this checks the verifiedDB. func (i *Interop) VerifiedAtTimestamp(ts uint64) (bool, error) { + // Timestamps before the activation timestamp are considered verified + // because interop wasn't active yet + if ts < i.activationTimestamp { + return true, nil + } return i.verifiedDB.Has(ts) } diff --git a/op-supernode/supernode/activity/superroot/superroot.go b/op-supernode/supernode/activity/superroot/superroot.go index 054c119bd5169..dace9b15b8647 100644 --- a/op-supernode/supernode/activity/superroot/superroot.go +++ b/op-supernode/supernode/activity/superroot/superroot.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum-optimism/optimism/op-service/eth" cc "github.com/ethereum-optimism/optimism/op-supernode/supernode/chain_container" "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common/hexutil" gethlog "github.com/ethereum/go-ethereum/log" ) @@ -35,8 +36,8 @@ func (s *Superroot) RPCService() interface{} { return &superrootAPI{s: s} } type superrootAPI struct{ s *Superroot } // 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) (eth.SuperRootAtTimestampResponse, error) { - return api.s.atTimestamp(ctx, timestamp) +func (api *superrootAPI) AtTimestamp(ctx context.Context, timestamp hexutil.Uint64) (eth.SuperRootAtTimestampResponse, error) { + return api.s.atTimestamp(ctx, uint64(timestamp)) } func (s *Superroot) atTimestamp(ctx context.Context, timestamp uint64) (eth.SuperRootAtTimestampResponse, error) { diff --git a/op-supernode/supernode/activity/superroot/superroot_test.go b/op-supernode/supernode/activity/superroot/superroot_test.go index 2c4eb61efcae9..042e2e3c483a4 100644 --- a/op-supernode/supernode/activity/superroot/superroot_test.go +++ b/op-supernode/supernode/activity/superroot/superroot_test.go @@ -9,6 +9,7 @@ import ( "github.com/ethereum-optimism/optimism/op-supernode/supernode/activity" cc "github.com/ethereum-optimism/optimism/op-supernode/supernode/chain_container" "github.com/ethereum/go-ethereum" + "github.com/ethereum/go-ethereum/common/hexutil" gethlog "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" ) @@ -156,7 +157,7 @@ func TestSuperroot_AtTimestamp_ComputesSuperRoot(t *testing.T) { ts := uint64(123) s := New(gethlog.New(), chains) api := &superrootAPI{s: s} - resp, err := api.AtTimestamp(context.Background(), ts) + resp, err := api.AtTimestamp(context.Background(), hexutil.Uint64(ts)) require.NoError(t, err) // Compute expected super root diff --git a/op-supernode/supernode/supernode.go b/op-supernode/supernode/supernode.go index af81f935d8f87..91df00ca376e3 100644 --- a/op-supernode/supernode/supernode.go +++ b/op-supernode/supernode/supernode.go @@ -90,10 +90,10 @@ func New(ctx context.Context, log gethlog.Logger, version string, requestStop co superroot.New(log.New("activity", "superroot"), s.chains), } + log.Info("initializing interop activity? %v", cfg.RawCtx.IsSet(interop.InteropActivationTimestampFlag.Name)) // Initialize interop activity if the activation timestamp is set - if cfg.RawCtx.IsSet(interop.InteropActivationTimestampFlag.Name) { - interopActivationTimestamp := cfg.RawCtx.Uint64(interop.InteropActivationTimestampFlag.Name) - interopActivity := interop.New(log.New("activity", "interop"), interopActivationTimestamp, s.chains, cfg.DataDir) + if cfg.InteropActivationTimestamp > 0 { + interopActivity := interop.New(log.New("activity", "interop"), cfg.InteropActivationTimestamp, s.chains, cfg.DataDir) s.activities = append(s.activities, interopActivity) for _, chain := range s.chains { chain.RegisterVerifier(interopActivity) @@ -267,7 +267,11 @@ func (s *Supernode) initL1Client(ctx context.Context, cfg *config.CLIConfig) err s.log.Info("initializing shared L1 client", "l1_addr", cfg.L1NodeAddr) // Create L1 RPC client with basic configuration - l1RPC, err := client.NewRPC(ctx, s.log, cfg.L1NodeAddr, client.WithDialAttempts(10)) + // Enable HTTP polling for L1 heads to support HTTP-only L1 connections (e.g., in tests) + l1RPC, err := client.NewRPC(ctx, s.log, cfg.L1NodeAddr, + client.WithDialAttempts(10), + client.WithHttpPollInterval(time.Second*2), // Poll every 2 seconds for HTTP connections + ) if err != nil { return fmt.Errorf("failed to dial L1 address (%s): %w", cfg.L1NodeAddr, err) }