diff --git a/op-acceptance-tests/tests/sync/unsafe_only/init_test.go b/op-acceptance-tests/tests/sync/follow_l2/init_test.go similarity index 65% rename from op-acceptance-tests/tests/sync/unsafe_only/init_test.go rename to op-acceptance-tests/tests/sync/follow_l2/init_test.go index f2e144bf06376..47024bf47db28 100644 --- a/op-acceptance-tests/tests/sync/unsafe_only/init_test.go +++ b/op-acceptance-tests/tests/sync/follow_l2/init_test.go @@ -1,4 +1,4 @@ -package unsafe_only +package follow_l2 import ( "testing" @@ -8,11 +8,9 @@ import ( ) func TestMain(m *testing.M) { - presets.DoMain(m, presets.WithSingleChainTwoVerifiers(), - presets.WithExecutionLayerSyncOnVerifiers(), + presets.DoMain(m, presets.WithSingleChainTwoVerifiersFollowL2(), presets.WithReqRespSyncDisabled(), presets.WithNoDiscovery(), presets.WithCompatibleTypes(compat.SysGo), - presets.WithUnsafeOnly(), ) } diff --git a/op-acceptance-tests/tests/sync/follow_l2/sync_test.go b/op-acceptance-tests/tests/sync/follow_l2/sync_test.go new file mode 100644 index 0000000000000..11c38822e3472 --- /dev/null +++ b/op-acceptance-tests/tests/sync/follow_l2/sync_test.go @@ -0,0 +1,199 @@ +package follow_l2 + +import ( + "testing" + "time" + + "github.com/ethereum-optimism/optimism/op-devstack/devtest" + "github.com/ethereum-optimism/optimism/op-devstack/dsl" + "github.com/ethereum-optimism/optimism/op-devstack/presets" + "github.com/ethereum-optimism/optimism/op-devstack/stack" + "github.com/ethereum-optimism/optimism/op-devstack/stack/match" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" + "github.com/ethereum-optimism/optimism/op-test-sequencer/sequencer/seqtypes" + "github.com/ethereum/go-ethereum/common" +) + +func TestFollowL2_Safe_Finalized_CurrentL1(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewSingleChainTwoVerifiersWithoutCheck(t) + logger := t.Logger() + + // Takes about 2 minutes for L1 finalization + attempts := 70 + target := uint64(3) + + // L2CL is the sequencer with CL follow source, derivation disabled + // L2CLB is the verifier without follow source, derivation enabled + // L2CLC is the verifier with CL follow source, derivation disabled + // All verifiers must eventually advance unsafe, safe, finalized + checkMatchedAll := func(lvl types.SafetyLevel) { + dsl.CheckAll(t, + sys.L2CL.ReachedFn(lvl, target, attempts), + sys.L2CLB.ReachedFn(lvl, target, attempts), + sys.L2CLC.ReachedFn(lvl, target, attempts), + ) + dsl.CheckAll(t, + sys.L2CLB.MatchedFn(sys.L2CL, lvl, attempts), + sys.L2CLB.MatchedFn(sys.L2CLC, lvl, attempts), + ) + } + + checkMatchedAll(types.LocalUnsafe) + logger.Info("Unsafe head advanced due to CLP2P", "target", target) + + checkMatchedAll(types.LocalSafe) + logger.Info("Safe head followed source", "target", target) + + checkMatchedAll(types.Finalized) + logger.Info("Finalized head followed source", "target", target) + + attempts = 10 + dsl.CheckAll(t, + sys.L2CLC.CurrentL1MatchedFn(sys.L2CLB, attempts), + sys.L2CL.CurrentL1MatchedFn(sys.L2CLB, attempts), + ) + logger.Info("CurrentL1 followed source", "currentL1", sys.L2CL.SyncStatus().CurrentL1, "currentL1C", sys.L2CLC.SyncStatus().CurrentL1) +} + +func TestFollowL2_ReorgRecovery(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewSingleChainTwoVerifiersWithoutCheck(t) + require := t.Require() + logger := t.Logger() + ctx := t.Ctx() + + // L2CLB is the verifier without follow source, derivation enabled + + ts := sys.TestSequencer.Escape().ControlAPI(sys.L1Network.ChainID()) + cl := sys.L1Network.Escape().L1CLNode(match.FirstL1CL) + + // Pass the L1 genesis + sys.L1Network.WaitForBlock() + + // Stop auto advancing L1 + sys.ControlPlane.FakePoSState(cl.ID(), stack.Stop) + + startL1Block := sys.L1EL.BlockRefByLabel(eth.Unsafe) + + require.Eventually(func() bool { + // Advance single L1 block + require.NoError(ts.New(ctx, seqtypes.BuildOpts{Parent: common.Hash{}})) + require.NoError(ts.Next(ctx)) + l1head := sys.L1EL.BlockRefByLabel(eth.Unsafe) + l2Safe := sys.L2ELB.BlockRefByLabel(eth.Safe) + + logger.Info("l1 info", "l1_head", l1head, "l1_origin", l2Safe.L1Origin, "l2Safe", l2Safe) + // Wait until safe L2 block has L1 origin point after the startL1Block + return l2Safe.Number > 0 && l2Safe.L1Origin.Number > startL1Block.Number + }, 120*time.Second, 2*time.Second) + + l2BlockBeforeReorg := sys.L2ELB.BlockRefByLabel(eth.Safe) + logger.Info("Target L2 Block to reorg", "l2", l2BlockBeforeReorg, "l1_origin", l2BlockBeforeReorg.L1Origin) + + // Make sure verifier safe head is also advanced from reorgL2Block or matched + sys.L2ELB.Reached(eth.Safe, l2BlockBeforeReorg.Number, 3) + + // Reorg L1 block which safe block L1 Origin points to + l1BlockBeforeReorg := sys.L1EL.BlockRefByNumber(l2BlockBeforeReorg.L1Origin.Number) + logger.Info("Triggering L1 reorg", "l1", l1BlockBeforeReorg) + require.NoError(ts.New(ctx, seqtypes.BuildOpts{Parent: l1BlockBeforeReorg.ParentHash})) + require.NoError(ts.Next(ctx)) + + // Start advancing L1 + sys.ControlPlane.FakePoSState(cl.ID(), stack.Start) + + // Make sure L1 reorged + sys.L1EL.WaitForBlockNumber(l1BlockBeforeReorg.Number) + l1BlockAfterReorg := sys.L1EL.BlockRefByNumber(l1BlockBeforeReorg.Number) + logger.Info("Triggered L1 reorg", "l1", l1BlockAfterReorg) + require.NotEqual(l1BlockAfterReorg.Hash, l1BlockBeforeReorg.Hash) + + // Need to poll until the L2CL detects L1 Reorg and trigger L2 Reorg + // What happens: + // L2CL detects L1 Reorg and reset the pipeline. op-node example logs: "reset: detected L1 reorg" + // L2ELB detects L2 Reorg and reorgs. op-geth example logs: "Chain reorg detected" + sys.L2ELB.ReorgTriggered(l2BlockBeforeReorg, 30) + l2BlockAfterReorg := sys.L2ELB.BlockRefByNumber(l2BlockBeforeReorg.Number) + require.NotEqual(l2BlockAfterReorg.Hash, l2BlockBeforeReorg.Hash) + logger.Info("Triggered L2 reorg", "l2", l2BlockAfterReorg) + + attempts := 30 + dsl.CheckAll(t, + sys.L2CL.MatchedFn(sys.L2CLB, types.LocalUnsafe, attempts), + sys.L2CLC.MatchedFn(sys.L2CLB, types.LocalUnsafe, attempts), + sys.L2CL.MatchedFn(sys.L2CLB, types.LocalSafe, attempts), + sys.L2CLC.MatchedFn(sys.L2CLB, types.LocalSafe, attempts), + ) +} + +func TestFollowL2_WithoutCLP2P(gt *testing.T) { + t := devtest.SerialT(gt) + sys := presets.NewSingleChainTwoVerifiersWithoutCheck(t) + require := t.Require() + logger := t.Logger() + + attempts := 20 + target := uint64(3) + + // L2CLB is the verifier without follow source, derivation enabled + sys.L2CLB.Advanced(types.LocalUnsafe, target, attempts) + + // The test's primary target is the L2CLC, with follow source and derivation disabled + // Normally there should be delta between safe head between unsafe head + status := sys.L2CLC.SyncStatus() + require.NotEqual(status.LocalSafeL2, status.UnsafeL2) + + logger.Info("Disconnect CLP2P") + // L2CLC is the verifier with follow source, derivation disabled + // Disconnect CLP2P of verifier which follow source is enabled + sys.L2CLC.DisconnectPeer(sys.L2CLB) + sys.L2CLB.DisconnectPeer(sys.L2CLC) + sys.L2CLC.DisconnectPeer(sys.L2CL) + sys.L2CL.DisconnectPeer(sys.L2CLC) + + // Advance few safe blocks + sys.L2CLC.Advanced(types.LocalSafe, target, attempts) + sys.L2CLC.Matched(sys.L2CLB, types.LocalSafe, attempts) + + // Make sure the safe head reaches non-moving unsafe head + sys.L2CLC.Reached(types.LocalSafe, sys.L2CLC.UnsafeHead().BlockRef.Number, attempts) + // The only data source for L2CLC is the follow source. + // L2CLC unsafe head will only be advancing with safe head together + status = sys.L2CLC.SyncStatus() + require.Equal(status.LocalSafeL2, status.UnsafeL2) + sys.L2CLC.Advanced(types.LocalSafe, target, attempts) + + // Advance few safe blocks + sys.L2CLC.Advanced(types.LocalSafe, target, attempts) + + // Check once again that the unsafe head is moving together with safe head + status = sys.L2CLC.SyncStatus() + require.Equal(status.LocalSafeL2, status.UnsafeL2) + sys.L2CLC.Advanced(types.LocalSafe, target, attempts) + + // Recover CLP2P + logger.Info("Recover CLP2P") + sys.L2CLC.ConnectPeer(sys.L2CLB) + sys.L2CLB.ConnectPeer(sys.L2CLC) + sys.L2CLC.ConnectPeer(sys.L2CL) + sys.L2CL.ConnectPeer(sys.L2CLC) + + // Sequencer unsafe payload will arrive to the verifier, triggering EL sync and filling in the unsafe gap + dsl.CheckAll(t, + // Match with sequencer with derivation disabled + sys.L2CLC.MatchedFn(sys.L2CL, types.LocalSafe, attempts), + sys.L2CLC.MatchedFn(sys.L2CL, types.LocalUnsafe, attempts), + // Match with other verifier with derivation enabled + sys.L2CLC.MatchedFn(sys.L2CLB, types.LocalSafe, attempts), + sys.L2CLC.MatchedFn(sys.L2CLB, types.LocalUnsafe, attempts), + ) + + t.Cleanup(func() { + sys.L2CLC.ConnectPeer(sys.L2CLB) + sys.L2CLB.ConnectPeer(sys.L2CLC) + sys.L2CLC.ConnectPeer(sys.L2CL) + sys.L2CL.ConnectPeer(sys.L2CLC) + }) +} diff --git a/op-acceptance-tests/tests/sync/unsafe_only/sync_test.go b/op-acceptance-tests/tests/sync/unsafe_only/sync_test.go deleted file mode 100644 index aec7fc54e13af..0000000000000 --- a/op-acceptance-tests/tests/sync/unsafe_only/sync_test.go +++ /dev/null @@ -1,103 +0,0 @@ -package unsafe_only - -import ( - "testing" - - "github.com/ethereum-optimism/optimism/op-devstack/devtest" - "github.com/ethereum-optimism/optimism/op-devstack/presets" - "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" -) - -func TestUnsafeOnly_VerifierUnsafeGapClosed(gt *testing.T) { - t := devtest.SerialT(gt) - sys := presets.NewSingleChainTwoVerifiersWithoutCheck(t) - require := t.Require() - attempts := 10 - - sys.L2CL.AdvancedUnsafe(3, attempts) - sys.L2EL.MatchedUnsafe(sys.L2ELB, attempts) - sys.L2CL.MatchedUnsafe(sys.L2CLB, attempts) - - // Case 1: Closing the gap starting from genesis - sys.L2CLB.Stop() - sys.L2ELB.DisconnectPeerWith(sys.L2EL) - // Wipe EL to genesis - sys.L2ELB.Stop() - sys.L2ELB.Start() - // Check EL rewinded to genesis. Unsafe gap introduced - sys.L2ELB.UnsafeHead().IsGenesis() - // Verifier CL triggers EL Sync to close the gap including genesis - sys.L2CLB.Start() - sys.L2CLB.ConnectPeer(sys.L2CL) - sys.L2ELB.PeerWith(sys.L2EL) - // Gap is closed - sys.L2CLB.MatchedUnsafe(sys.L2CL, attempts) - sys.L2ELB.MatchedUnsafe(sys.L2EL, attempts) - - // Case 2: Closing the gap not starting from genesis - sys.L2CLB.DisconnectPeer(sys.L2CL) - sys.L2CL.AdvancedUnsafe(3, attempts) - sys.L2CLB.NotAdvanced(types.LocalUnsafe, 3) - // Turn back the CLP2P - sys.L2CLB.ConnectPeer(sys.L2CL) - // gap is closed again - sys.L2CLB.MatchedUnsafe(sys.L2CL, attempts) - sys.L2ELB.MatchedUnsafe(sys.L2EL, attempts) - - // Derivation did not happen - sys.L2CL.SafeHead().IsGenesis() - - // Derivation happened at the second verifier - require.Greater(sys.L2CLC.SafeHead().BlockRef.Number, uint64(0)) - - t.Cleanup(func() { - sys.L2ELB.Start() - sys.L2ELB.PeerWith(sys.L2EL) - sys.L2CLB.Start() - sys.L2CLB.ConnectPeer(sys.L2CL) - }) -} - -func TestUnsafeOnly_SequencerRestart(gt *testing.T) { - t := devtest.SerialT(gt) - sys := presets.NewSingleChainTwoVerifiersWithoutCheck(t) - require := t.Require() - - attempts := 10 - - sys.L2CL.AdvancedUnsafe(3, attempts) - sys.L2EL.MatchedUnsafe(sys.L2ELB, attempts) - sys.L2CL.MatchedUnsafe(sys.L2CLB, attempts) - - // Stop the sequencer - sys.L2CL.Stop() - sys.L2ELB.NotAdvancedUnsafe(3) - - // Restart the sequencer - sys.L2CL.Start() - // Sequencer produces blocks again - sys.L2CL.AdvancedUnsafe(3, attempts) - - // Derivation did not happen at sequencer - sys.L2CL.SafeHead().IsGenesis() - - // Stop the sequencer with API - sys.L2CL.StopSequencer() - sys.L2ELB.NotAdvancedUnsafe(3) - - // Restart the sequencer with API - sys.L2CL.StartSequencer() - // Sequencer produces blocks again - sys.L2CL.AdvancedUnsafe(3, attempts) - - // Derivation did not happen at sequencer - sys.L2CL.SafeHead().IsGenesis() - - // Derivation happened at the second verifier - safeHeadNum := sys.L2CLC.SafeHead().BlockRef.Number - require.Greater(safeHeadNum, uint64(0)) - - t.Cleanup(func() { - sys.L2CL.Start() - }) -} diff --git a/op-acceptance-tests/tests/sync_tester/sync_tester_unsafe_only_ext/init_test.go b/op-acceptance-tests/tests/sync_tester/sync_tester_unsafe_only_ext/init_test.go deleted file mode 100644 index d566ebef2678a..0000000000000 --- a/op-acceptance-tests/tests/sync_tester/sync_tester_unsafe_only_ext/init_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package sync_tester_unsafe_only_ext - -import ( - "testing" - - "github.com/ethereum-optimism/optimism/op-acceptance-tests/tests/sync_tester/sync_tester_ext_el" - bss "github.com/ethereum-optimism/optimism/op-batcher/batcher" - "github.com/ethereum-optimism/optimism/op-devstack/compat" - "github.com/ethereum-optimism/optimism/op-devstack/presets" - "github.com/ethereum-optimism/optimism/op-devstack/stack" - "github.com/ethereum-optimism/optimism/op-devstack/sysgo" - "github.com/ethereum-optimism/optimism/op-node/chaincfg" - "github.com/ethereum-optimism/optimism/op-service/eth" -) - -func TestMain(m *testing.M) { - // Target op-sepolia - networkName := "op-sepolia" - config, _ := sync_tester_ext_el.GetNetworkPreset(networkName) - chainCfg := chaincfg.ChainByName(networkName) - presets.DoMain(m, - presets.WithExternalELWithSuperchainRegistry(config), - // CL connected to sync tester EL is verifier - presets.WithExecutionLayerSyncOnVerifiers(), - // Make sync tester EL mock EL Sync - presets.WithELSyncActive(), - // Only rely on EL sync for unsafe gap filling - presets.WithReqRespSyncDisabled(), - presets.WithNoDiscovery(), - presets.WithCompatibleTypes(compat.SysGo), - presets.WithUnsafeOnly(), - stack.MakeCommon(sysgo.WithBatcherOption(func(id stack.L2BatcherID, cfg *bss.CLIConfig) { - // For stopping derivation, not to advance safe heads - cfg.Stopped = true - })), - // Sync tester EL at genesis - presets.WithSyncTesterELInitialState(eth.FCUState{ - Latest: chainCfg.Genesis.L2.Number, - Safe: chainCfg.Genesis.L2.Number, - Finalized: chainCfg.Genesis.L2.Number, - }), - ) -} diff --git a/op-acceptance-tests/tests/sync_tester/sync_tester_unsafe_only_ext/sync_test.go b/op-acceptance-tests/tests/sync_tester/sync_tester_unsafe_only_ext/sync_test.go deleted file mode 100644 index 74bee285dfb5c..0000000000000 --- a/op-acceptance-tests/tests/sync_tester/sync_tester_unsafe_only_ext/sync_test.go +++ /dev/null @@ -1,50 +0,0 @@ -package sync_tester_unsafe_only_ext - -import ( - "testing" - - "github.com/ethereum-optimism/optimism/op-devstack/devtest" - "github.com/ethereum-optimism/optimism/op-devstack/presets" - "github.com/ethereum-optimism/optimism/op-service/eth" - "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" -) - -func TestSyncTesterUnsafeOnlyReachUnsafeTip(gt *testing.T) { - t := devtest.SerialT(gt) - require := t.Require() - - sys := presets.NewMinimalExternalEL(t) - sys.L2EL.UnsafeHead().IsGenesis() - - // Check external read only EL is advancing - sys.L2ELReadOnly.Advanced(eth.Unsafe, 3) - - unsafeTip := sys.L2ELReadOnly.UnsafeHead() - unsafeTipNum := unsafeTip.BlockRef.Number - startNum := unsafeTipNum - 3 - // Trigger and finish EL Sync - for i := startNum; i <= unsafeTipNum; i++ { - sys.L2CL.SignalTarget(sys.L2ELReadOnly, i) - } - - sys.L2EL.Reached(eth.Unsafe, unsafeTipNum, 5) - require.Equal(unsafeTip.BlockRef, sys.L2EL.UnsafeHead().BlockRef) - - // Make sure the unsafe only CL can still advance unsafe - target := unsafeTipNum + 3 - sys.L2ELReadOnly.Reached(eth.Unsafe, target, 3) - for i := unsafeTipNum + 1; i <= target; i++ { - sys.L2CL.SignalTarget(sys.L2ELReadOnly, i) - } - sys.L2EL.Reached(eth.Unsafe, target, 5) - sys.L2CL.Reached(types.LocalUnsafe, target, 5) - - // Check unsafe gap is closed - target = unsafeTipNum + 9 - sys.L2ELReadOnly.Reached(eth.Unsafe, target, 6) - for i := unsafeTipNum + 6; i <= target; i++ { - sys.L2CL.SignalTarget(sys.L2ELReadOnly, i) - } - sys.L2EL.Reached(eth.Unsafe, target, 5) - sys.L2CL.Reached(types.LocalUnsafe, target, 5) -} diff --git a/op-devstack/dsl/l2_cl.go b/op-devstack/dsl/l2_cl.go index 862394fc522c4..f478410f17fe9 100644 --- a/op-devstack/dsl/l2_cl.go +++ b/op-devstack/dsl/l2_cl.go @@ -434,3 +434,19 @@ func (cl *L2CLNode) UnsafeHead() *BlockRefResult { func (cl *L2CLNode) SafeHead() *BlockRefResult { return &BlockRefResult{T: cl.t, BlockRef: cl.HeadBlockRef(types.CrossSafe)} } + +func (cl *L2CLNode) CurrentL1MatchedFn(refNode *L2CLNode, attempts int) CheckFunc { + return func() error { + return retry.Do0(cl.ctx, attempts, &retry.FixedStrategy{Dur: 1 * time.Second}, + func() error { + currentL1 := cl.SyncStatus().CurrentL1 + ref := refNode.SyncStatus().CurrentL1 + if currentL1 == ref { + cl.log.Info("CurrentL1 reached", "currentL1", currentL1) + return nil + } + cl.log.Info("Chain sync status", "currentL1", currentL1.Number, "ref", ref) + return fmt.Errorf("expected currentL1 to match") + }) + } +} diff --git a/op-devstack/dsl/l2_el.go b/op-devstack/dsl/l2_el.go index c5f3f21897abc..b226386aeed91 100644 --- a/op-devstack/dsl/l2_el.go +++ b/op-devstack/dsl/l2_el.go @@ -393,6 +393,10 @@ func (el *L2ELNode) SafeHead() *BlockRefResult { return &BlockRefResult{T: el.t, BlockRef: el.BlockRefByLabel(eth.Safe)} } +func (el *L2ELNode) FinalizedHead() *BlockRefResult { + return &BlockRefResult{T: el.t, BlockRef: el.BlockRefByLabel(eth.Finalized)} +} + type BlockRefResult struct { T devtest.T BlockRef eth.L2BlockRef diff --git a/op-devstack/presets/cl_config.go b/op-devstack/presets/cl_config.go index 41109fc23e31a..3760cb3ab9ad7 100644 --- a/op-devstack/presets/cl_config.go +++ b/op-devstack/presets/cl_config.go @@ -56,12 +56,3 @@ func WithNoDiscovery() stack.CommonOption { cfg.NoDiscovery = true }))) } - -func WithUnsafeOnly() stack.CommonOption { - return stack.MakeCommon( - sysgo.WithGlobalL2CLOption(sysgo.L2CLOptionFn( - func(_ devtest.P, id stack.L2CLNodeID, cfg *sysgo.L2CLConfig) { - cfg.SequencerUnsafeOnly = true - cfg.VerifierUnsafeOnly = true - }))) -} diff --git a/op-devstack/presets/singlechain_twoverifiers.go b/op-devstack/presets/singlechain_twoverifiers.go index 76af5ef8bb750..898af17b4bc48 100644 --- a/op-devstack/presets/singlechain_twoverifiers.go +++ b/op-devstack/presets/singlechain_twoverifiers.go @@ -14,10 +14,8 @@ type SingleChainTwoVerifiers struct { L2ELC *dsl.L2ELNode L2CLC *dsl.L2CLNode -} -func WithSingleChainTwoVerifiers() stack.CommonOption { - return stack.MakeCommon(sysgo.DefaultSingleChainTwoVerifiersSystem(&sysgo.DefaultSingleChainTwoVerifiersSystemIDs{})) + TestSequencer *dsl.TestSequencer } func NewSingleChainTwoVerifiersWithoutCheck(t devtest.T) *SingleChainTwoVerifiers { @@ -41,6 +39,11 @@ func NewSingleChainTwoVerifiersWithoutCheck(t devtest.T) *SingleChainTwoVerifier SingleChainMultiNode: *singleChainMultiNode, L2ELC: dsl.NewL2ELNode(verifierEL, orch.ControlPlane()), L2CLC: dsl.NewL2CLNode(verifierCL, orch.ControlPlane()), + TestSequencer: dsl.NewTestSequencer(system.TestSequencer(match.Assume(t, match.FirstTestSequencer))), } return preset } + +func WithSingleChainTwoVerifiersFollowL2() stack.CommonOption { + return stack.MakeCommon(sysgo.DefaultSingleChainTwoVerifiersFollowL2System(&sysgo.DefaultSingleChainTwoVerifiersSystemIDs{})) +} diff --git a/op-devstack/sysgo/l2_cl.go b/op-devstack/sysgo/l2_cl.go index f64c245953b30..c6da8e5f143e5 100644 --- a/op-devstack/sysgo/l2_cl.go +++ b/op-devstack/sysgo/l2_cl.go @@ -37,9 +37,7 @@ type L2CLConfig struct { // NoDiscovery is the flag to enable/disable discovery NoDiscovery bool - // UnsafeOnly is the flag to disable derivation - SequencerUnsafeOnly bool - VerifierUnsafeOnly bool + FollowSource string } func L2CLSequencer() L2CLOption { @@ -54,24 +52,23 @@ func L2CLIndexing() L2CLOption { }) } -func L2CLVerifierDisableUnsafeOnly() L2CLOption { +func L2CLFollowSource(source string) L2CLOption { return L2CLOptionFn(func(p devtest.P, id stack.L2CLNodeID, cfg *L2CLConfig) { - cfg.VerifierUnsafeOnly = false + cfg.FollowSource = source }) } func DefaultL2CLConfig() *L2CLConfig { return &L2CLConfig{ - SequencerSyncMode: nodeSync.CLSync, - VerifierSyncMode: nodeSync.CLSync, - SafeDBPath: "", - IsSequencer: false, - IndexingMode: false, - EnableReqRespSync: true, - UseReqRespSync: true, - NoDiscovery: false, - SequencerUnsafeOnly: false, - VerifierUnsafeOnly: false, + SequencerSyncMode: nodeSync.CLSync, + VerifierSyncMode: nodeSync.CLSync, + SafeDBPath: "", + IsSequencer: false, + IndexingMode: false, + EnableReqRespSync: true, + UseReqRespSync: true, + NoDiscovery: false, + FollowSource: "", } } @@ -119,3 +116,14 @@ func WithL2CLNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack return WithOpNode(l2CLID, l1CLID, l1ELID, l2ELID, opts...) } } + +func WithL2CLNodeFollowL2(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L1ELNodeID, l2ELID stack.L2ELNodeID, l2FollowSourceID stack.L2CLNodeID, opts ...L2CLOption) stack.Option[*Orchestrator] { + switch os.Getenv("DEVSTACK_L2CL_KIND") { + case "kona": + panic("kona does not support following") + case "supernode": + panic("supernode does not support following") + default: + return WithOpNodeFollowL2(l2CLID, l1CLID, l1ELID, l2ELID, l2FollowSourceID, opts...) + } +} diff --git a/op-devstack/sysgo/l2_cl_opnode.go b/op-devstack/sysgo/l2_cl_opnode.go index 2831a4666e289..8358c57be4c02 100644 --- a/op-devstack/sysgo/l2_cl_opnode.go +++ b/op-devstack/sysgo/l2_cl_opnode.go @@ -162,8 +162,25 @@ func (n *OpNode) Stop() { n.opNode = nil } -func WithOpNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L1ELNodeID, l2ELID stack.L2ELNodeID, opts ...L2CLOption) stack.Option[*Orchestrator] { +func WithOpNodeFollowL2(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L1ELNodeID, l2ELID stack.L2ELNodeID, l2FollowSourceID stack.L2CLNodeID, opts ...L2CLOption) stack.Option[*Orchestrator] { return stack.AfterDeploy(func(orch *Orchestrator) { + followSource := func(orch *Orchestrator) string { + p := orch.P().WithCtx(stack.ContextWithID(orch.P().Ctx(), l2CLID)) + l2CLFollowSource, ok := orch.l2CLs.Get(l2FollowSourceID) + p.Require().True(ok, "l2 CL Follow Source required") + return l2CLFollowSource.UserRPC() + }(orch) + opts = append(opts, L2CLFollowSource(followSource)) + withOpNode(l2CLID, l1CLID, l1ELID, l2ELID, opts...)(orch) + }) +} + +func WithOpNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L1ELNodeID, l2ELID stack.L2ELNodeID, opts ...L2CLOption) stack.Option[*Orchestrator] { + return stack.AfterDeploy(withOpNode(l2CLID, l1CLID, l1ELID, l2ELID, opts...)) +} + +func withOpNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L1ELNodeID, l2ELID stack.L2ELNodeID, opts ...L2CLOption) func(orch *Orchestrator) { + return func(orch *Orchestrator) { p := orch.P().WithCtx(stack.ContextWithID(orch.P().Ctx(), l2CLID)) require := p.Require() @@ -195,16 +212,12 @@ func WithOpNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L L2CLOptionBundle(opts).Apply(p, l2CLID, cfg) // apply specific options syncMode := cfg.VerifierSyncMode - unsafeOnly := false if cfg.IsSequencer { syncMode = cfg.SequencerSyncMode // Sanity check, to navigate legacy sync-mode test assumptions. // Can't enable ELSync on the sequencer or it will never start sequencing because // ELSync needs to receive gossip from the sequencer to drive the sync p.Require().NotEqual(nodeSync.ELSync, syncMode, "sequencer cannot use EL sync") - unsafeOnly = cfg.SequencerUnsafeOnly - } else { - unsafeOnly = cfg.VerifierUnsafeOnly } jwtPath, jwtSecret := orch.writeDefaultJWT() @@ -286,6 +299,9 @@ func WithOpNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L L2EngineAddr: l2EL.EngineRPC(), L2EngineJWTSecret: jwtSecret, }, + L2FollowSource: &config.L2FollowSourceConfig{ + L2RPCAddr: cfg.FollowSource, + }, Beacon: &config.L1BeaconEndpointConfig{ BeaconAddr: l1CL.beaconHTTPAddr, }, @@ -313,9 +329,8 @@ func WithOpNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L SyncModeReqResp: cfg.UseReqRespSync, SkipSyncStartCheck: false, SupportsPostFinalizationELSync: false, - UnsafeOnly: unsafeOnly, - L2FollowSourceEndpoint: "", - NeedInitialResetEngine: cfg.IsSequencer && unsafeOnly, + L2FollowSourceEndpoint: cfg.FollowSource, + NeedInitialResetEngine: cfg.IsSequencer && cfg.FollowSource != "", }, ConfigPersistence: config.DisabledConfigPersistence{}, Metrics: opmetrics.CLIConfig{}, @@ -350,5 +365,5 @@ func WithOpNode(l2CLID stack.L2CLNodeID, l1CLID stack.L1CLNodeID, l1ELID stack.L require.True(orch.l2CLs.SetIfMissing(l2CLID, l2CLNode), fmt.Sprintf("must not already exist: %s", l2CLID)) l2CLNode.Start() p.Cleanup(l2CLNode.Stop) - }) + } } diff --git a/op-devstack/sysgo/system_singlechain_twoverifiers.go b/op-devstack/sysgo/system_singlechain_twoverifiers.go index 9b47b2b0f048d..14c69f0534bd0 100644 --- a/op-devstack/sysgo/system_singlechain_twoverifiers.go +++ b/op-devstack/sysgo/system_singlechain_twoverifiers.go @@ -1,6 +1,7 @@ package sysgo import ( + "github.com/ethereum-optimism/optimism/op-chain-ops/devkeys" "github.com/ethereum-optimism/optimism/op-devstack/stack" "github.com/ethereum-optimism/optimism/op-service/eth" ) @@ -10,6 +11,8 @@ type DefaultSingleChainTwoVerifiersSystemIDs struct { L2CLC stack.L2CLNodeID L2ELC stack.L2ELNodeID + + TestSequencer stack.TestSequencerID } func NewDefaultSingleChainTwoVerifiersSystemIDs(l1ID, l2ID eth.ChainID) DefaultSingleChainTwoVerifiersSystemIDs { @@ -20,24 +23,56 @@ func NewDefaultSingleChainTwoVerifiersSystemIDs(l1ID, l2ID eth.ChainID) DefaultS } } -func DefaultSingleChainTwoVerifiersSystem(dest *DefaultSingleChainTwoVerifiersSystemIDs) stack.Option[*Orchestrator] { +func DefaultSingleChainTwoVerifiersFollowL2System(dest *DefaultSingleChainTwoVerifiersSystemIDs) stack.Option[*Orchestrator] { ids := NewDefaultSingleChainTwoVerifiersSystemIDs(DefaultL1ID, DefaultL2AID) opt := stack.Combine[*Orchestrator]() - opt.Add(DefaultSingleChainMultiNodeSystem(&dest.DefaultSingleChainMultiNodeSystemIDs)) + opt.Add(stack.BeforeDeploy(func(o *Orchestrator) { + o.P().Logger().Info("Setting up") + })) + + opt.Add(WithMnemonicKeys(devkeys.TestMnemonic)) + + opt.Add(WithDeployer(), + WithDeployerOptions( + WithLocalContractSources(), + WithCommons(ids.L1.ChainID()), + WithPrefundedL2(ids.L1.ChainID(), ids.L2.ChainID()), + ), + ) + + opt.Add(WithL1Nodes(ids.L1EL, ids.L1CL)) + + opt.Add(WithL2ELNode(ids.L2ELB)) + opt.Add(WithL2CLNode(ids.L2CLB, ids.L1CL, ids.L1EL, ids.L2ELB)) + + opt.Add(WithL2ELNode(ids.L2EL)) + opt.Add(WithL2CLNodeFollowL2(ids.L2CL, ids.L1CL, ids.L1EL, ids.L2EL, ids.L2CLB, L2CLSequencer())) opt.Add(WithL2ELNode(ids.L2ELC)) - // Specific options are applied after global options - // this means unsafeOnly is always disabled for the second verifier - opt.Add(WithL2CLNode(ids.L2CLC, ids.L1CL, ids.L1EL, ids.L2ELC, L2CLVerifierDisableUnsafeOnly())) + opt.Add(WithL2CLNodeFollowL2(ids.L2CLC, ids.L1CL, ids.L1EL, ids.L2ELC, ids.L2CLB)) + opt.Add(WithL2CLP2PConnection(ids.L2CL, ids.L2CLB)) + opt.Add(WithL2ELP2PConnection(ids.L2EL, ids.L2ELB)) opt.Add(WithL2CLP2PConnection(ids.L2CL, ids.L2CLC)) opt.Add(WithL2ELP2PConnection(ids.L2EL, ids.L2ELC)) opt.Add(WithL2CLP2PConnection(ids.L2CLB, ids.L2CLC)) opt.Add(WithL2ELP2PConnection(ids.L2ELB, ids.L2ELC)) + opt.Add(WithBatcher(ids.L2Batcher, ids.L1EL, ids.L2CL, ids.L2EL)) + opt.Add(WithProposer(ids.L2Proposer, ids.L1EL, &ids.L2CL, nil)) + + opt.Add(WithFaucets([]stack.L1ELNodeID{ids.L1EL}, []stack.L2ELNodeID{ids.L2EL})) + + opt.Add(WithTestSequencer(ids.TestSequencer, ids.L1CL, ids.L2CLB, ids.L1EL, ids.L2ELB)) + + opt.Add(WithL2Challenger(ids.L2Challenger, ids.L1EL, ids.L1CL, nil, nil, &ids.L2CL, []stack.L2ELNodeID{ + ids.L2EL, + })) + opt.Add(stack.Finally(func(orch *Orchestrator) { *dest = ids })) + return opt } diff --git a/op-devstack/sysgo/system_synctester_ext.go b/op-devstack/sysgo/system_synctester_ext.go index aa8dd829f28fa..fe784f65832a0 100644 --- a/op-devstack/sysgo/system_synctester_ext.go +++ b/op-devstack/sysgo/system_synctester_ext.go @@ -88,10 +88,11 @@ func ExternalELSystemWithEndpointAndSuperchainRegistry(dest *DefaultMinimalExter // Add SyncTesterL2ELNode as the L2EL replacement for real-world EL endpoint opt.Add(WithSyncTesterL2ELNode(ids.L2EL, ids.L2EL)) - opt.Add(WithL2CLNode(ids.L2CL, ids.L1CL, ids.L1EL, ids.L2EL)) opt.Add(WithExtL2Node(ids.L2ELReadOnly, networkPreset.L2ELEndpoint)) + opt.Add(WithL2CLNode(ids.L2CL, ids.L1CL, ids.L1EL, ids.L2EL)) + opt.Add(WithL2MetricsDashboard()) opt.Add(stack.Finally(func(orch *Orchestrator) { diff --git a/op-node/config/config.go b/op-node/config/config.go index 4aaa32ccf970a..fb53f31f28104 100644 --- a/op-node/config/config.go +++ b/op-node/config/config.go @@ -68,6 +68,9 @@ type Config struct { // Optional Tracer tracer.Tracer + // Optional + L2FollowSource L2FollowSourceEndpointSetup + Sync sync.Config // To halt when detecting the node does not support a signaled protocol version diff --git a/op-node/config/follow_source.go b/op-node/config/follow_source.go new file mode 100644 index 0000000000000..917c2ced18109 --- /dev/null +++ b/op-node/config/follow_source.go @@ -0,0 +1,50 @@ +package config + +import ( + "context" + "errors" + "time" + + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-service/client" + opmetrics "github.com/ethereum-optimism/optimism/op-service/metrics" + "github.com/ethereum-optimism/optimism/op-service/sources" + "github.com/ethereum/go-ethereum/log" +) + +type L2FollowSourceEndpointSetup interface { + // Setup a RPC client to a L2 execution engine to follow. + Setup(ctx context.Context, log log.Logger, rollupCfg *rollup.Config, metrics opmetrics.RPCMetricer) (client.RPC, *sources.L2ClientConfig, error) + Check() error +} + +type L2FollowSourceConfig struct { + L2RPCAddr string + L2RPCCallTimeout time.Duration +} + +var _ L2FollowSourceEndpointSetup = (*L2FollowSourceConfig)(nil) + +func (cfg *L2FollowSourceConfig) Check() error { + if cfg.L2RPCAddr == "" { + return errors.New("empty L2 RPC Address") + } + return nil +} + +func (cfg *L2FollowSourceConfig) Setup(ctx context.Context, log log.Logger, rollupCfg *rollup.Config, metrics opmetrics.RPCMetricer) (client.RPC, *sources.L2ClientConfig, error) { + if err := cfg.Check(); err != nil { + return nil, nil, err + } + opts := []client.RPCOption{ + client.WithDialAttempts(10), + client.WithCallTimeout(cfg.L2RPCCallTimeout), + client.WithRPCRecorder(metrics.NewRecorder("follow-source-api")), + } + l2Node, err := client.NewRPC(ctx, log, cfg.L2RPCAddr, opts...) + if err != nil { + return nil, nil, err + } + + return l2Node, sources.L2ClientDefaultConfig(rollupCfg, true), nil +} diff --git a/op-node/flags/flags.go b/op-node/flags/flags.go index 37d9b421be62e..522f4e7fa6b4f 100644 --- a/op-node/flags/flags.go +++ b/op-node/flags/flags.go @@ -217,20 +217,20 @@ var ( Value: time.Second * 10, Category: RollupCategory, } - L2UnsafeOnly = &cli.BoolFlag{ - Name: "l2.unsafe-only", - Usage: "Disable derivation", - EnvVars: prefixEnvVars("L2_UNSAFE_ONLY"), - Category: RollupCategory, - Required: false, - } L2FollowSource = &cli.StringFlag{ Name: "l2.follow.source", - Usage: "Address of L2 EL RPC HTTP endpoint to fetch safe/finalized blocks", + Usage: "Address of L2 CL RPC HTTP endpoint to follow source", EnvVars: prefixEnvVars("L2_FOLLOW_SOURCE"), Category: RollupCategory, Required: false, } + L2FollowSourceRpcTimeout = &cli.DurationFlag{ + Name: "l2.follow.source.rpc-timeout", + Usage: "L2 follow source client rpc timeout", + EnvVars: prefixEnvVars("L2_FOLLOW_SOURCE_RPC_TIMEOUT"), + Value: time.Second * 10, + Category: RollupCategory, + } VerifierL1Confs = &cli.Uint64Flag{ Name: "verifier.l1-confs", Usage: "Number of L1 blocks to keep distance from the L1 head before deriving L2 data from. Reorgs are supported, but may be slow to perform.", @@ -500,7 +500,6 @@ var optionalFlags = []cli.Flag{ L1ChainConfig, L2EngineKind, L2EngineRpcTimeout, - L2UnsafeOnly, L2FollowSource, InteropRPCAddr, InteropRPCPort, diff --git a/op-node/metrics/metrics.go b/op-node/metrics/metrics.go index 7bc8079c5408f..bc1476439361c 100644 --- a/op-node/metrics/metrics.go +++ b/op-node/metrics/metrics.go @@ -84,6 +84,8 @@ type Metrics struct { L1SourceCache *metrics.CacheMetrics L2SourceCache *metrics.CacheMetrics + L2FollowSourceCache *metrics.CacheMetrics + DerivationIdle prometheus.Gauge PipelineResets *metrics.Event @@ -186,6 +188,8 @@ func NewMetrics(procName string) *Metrics { L1SourceCache: metrics.NewCacheMetrics(factory, ns, "l1_source_cache", "L1 Source cache"), L2SourceCache: metrics.NewCacheMetrics(factory, ns, "l2_source_cache", "L2 Source cache"), + L2FollowSourceCache: metrics.NewCacheMetrics(factory, ns, "l2_follow_source_cache", "L2 Follow source cache"), + DerivationIdle: factory.NewGauge(prometheus.GaugeOpts{ Namespace: ns, Name: "derivation_idle", diff --git a/op-node/node/node.go b/op-node/node/node.go index fadc303b7275f..4ebe271566ec1 100644 --- a/op-node/node/node.go +++ b/op-node/node/node.go @@ -124,6 +124,8 @@ type OpNode struct { p2pSigner p2p.Signer // p2p gossip application messages will be signed with this signer runCfg *runcfg.RuntimeConfig // runtime configurables + l2FollowSource *sources.FollowClient // (Optional) L2 Follow source when derivation disabled + safeDB closableSafeDB rollupHalt string // when to halt the rollup, disabled if empty @@ -205,17 +207,19 @@ type InitializationOverrides struct { // so order is important to ensure that all resources are available when needed. func (n *OpNode) init(ctx context.Context, cfg *config.Config, overrides InitializationOverrides) error { n.log.Info("Initializing rollup node", "version", n.appVersion) + + var err error + safe := "enabled" - if cfg.Sync.UnsafeOnly { + if cfg.Sync.FollowSourceEnabled() { safe = cfg.Sync.L2FollowSourceEndpoint - if safe == "" { - safe = "disabled" + n.l2FollowSource, err = initFollowSource(ctx, cfg, n) + if err != nil { + return fmt.Errorf("failed to init l2 follow source: %w", err) } } n.log.Info("Safety levels", "unsafe", "enabled", "safe", safe) - var err error - n.eventSys, n.eventDrain, err = initEventSystem(n) if err != nil { return fmt.Errorf("failed to init event system: %w", err) @@ -597,7 +601,12 @@ func initL2(ctx context.Context, cfg *config.Config, node *OpNode) (*sources.Eng return nil, nil, nil, nil, fmt.Errorf("cfg.Rollup.ChainOpConfig is nil. Please see https://github.com/ethereum-optimism/optimism/releases/tag/op-node/v1.11.0: %w", err) } - l2Driver := driver.NewDriver(node.eventSys, node.eventDrain, &cfg.Driver, &cfg.Rollup, cfg.L1ChainConfig, cfg.DependencySet, l2Source, node.l1Source, + var upstreamFollowSource driver.UpstreamFollowSource + if node.cfg.Sync.FollowSourceEnabled() { + upstreamFollowSource = driver.NewL2FollowSource(node.l2FollowSource, node.l1Source) + } + + l2Driver := driver.NewDriver(node.eventSys, node.eventDrain, &cfg.Driver, &cfg.Rollup, cfg.L1ChainConfig, cfg.DependencySet, l2Source, node.l1Source, upstreamFollowSource, node.beacon, node, node, node.log, node.metrics, cfg.ConfigPersistence, safeDB, &cfg.Sync, sequencerConductor, altDA, indexingMode) // Wire up IndexingMode to engine controller for direct procedure call @@ -610,6 +619,18 @@ func initL2(ctx context.Context, cfg *config.Config, node *OpNode) (*sources.Eng return l2Source, sys, l2Driver, safeDB, nil } +func initFollowSource(ctx context.Context, cfg *config.Config, node *OpNode) (*sources.FollowClient, error) { + rpcClient, _, err := cfg.L2FollowSource.Setup(ctx, node.log, &node.cfg.Rollup, node.metrics) + if err != nil { + return nil, fmt.Errorf("failed to setup L2 follow source RPC client: %w", err) + } + l2FollowSource, err := sources.NewFollowClient(rpcClient) + if err != nil { + return nil, fmt.Errorf("failed to create follow client: %w", err) + } + return l2FollowSource, nil +} + func initRPCServer(cfg *config.Config, node *OpNode) (*oprpc.Server, error) { server := newRPCServer(&cfg.RPC, &cfg.Rollup, cfg.DependencySet, node.l2Source.L2Client, node.l2Driver, node.safeDB, diff --git a/op-node/rollup/driver/driver.go b/op-node/rollup/driver/driver.go index 63f552080ecb0..9d24c340b0e6f 100644 --- a/op-node/rollup/driver/driver.go +++ b/op-node/rollup/driver/driver.go @@ -37,6 +37,7 @@ func NewDriver( depSet derive.DependencySet, l2 L2Chain, l1 L1Chain, + upstreamFollowSource UpstreamFollowSource, l1Blobs derive.L1BlobsFetcher, altSync AltSync, network Network, @@ -127,22 +128,23 @@ func NewDriver( driverEmitter := sys.Register("driver", nil) driver := &Driver{ - StatusTracker: statusTracker, - Finalizer: finalizer, - SyncDeriver: syncDeriver, - sched: schedDeriv, - emitter: driverEmitter, - drain: drain, - stateReq: make(chan chan struct{}), - forceReset: make(chan chan struct{}, 10), - driverConfig: driverCfg, - syncConfig: syncCfg, - driverCtx: driverCtx, - driverCancel: driverCancel, - log: log, - sequencer: sequencer, - metrics: metrics, - altSync: altSync, + StatusTracker: statusTracker, + Finalizer: finalizer, + SyncDeriver: syncDeriver, + sched: schedDeriv, + emitter: driverEmitter, + drain: drain, + stateReq: make(chan chan struct{}), + forceReset: make(chan chan struct{}, 10), + driverConfig: driverCfg, + syncConfig: syncCfg, + driverCtx: driverCtx, + driverCancel: driverCancel, + log: log, + sequencer: sequencer, + metrics: metrics, + altSync: altSync, + upstreamFollowSource: upstreamFollowSource, } return driver @@ -184,6 +186,8 @@ type Driver struct { driverCtx context.Context driverCancel context.CancelFunc + + upstreamFollowSource UpstreamFollowSource } // Start starts up the state loop. @@ -272,7 +276,7 @@ func (s *Driver) eventLoop() { lastUnsafeL2 := s.SyncDeriver.Engine.UnsafeL2Head() - unsafeOnly := s.SyncDeriver.SyncCfg.UnsafeOnly + followSource := s.SyncDeriver.SyncCfg.FollowSourceEnabled() resetAltSync := func(newHead eth.L2BlockRef, derivationReady bool) { s.log.Debug( @@ -280,12 +284,27 @@ func (s *Driver) eventLoop() { "head", newHead, "lastUnsafeL2", lastUnsafeL2, "derivationReady", derivationReady, - "unsafeOnly", unsafeOnly, + "followSource", followSource, ) lastUnsafeL2 = newHead altSyncTicker.Reset(syncCheckInterval) } + // upstreamSyncTickerC drives the upstreamSyncTicker, which periodically reconciles + // the state against upstream sources when derivation is disabled (unsafeOnly). + // + // In this mode, the node does not derive from L1; instead, it uses L1 as a mandatory + // upstream anchor for its unsafe head, and imports safe/finalized state + // from an external source. Since the normal derivation pipeline is inactive, reorg + // detection must be performed here instead. + var upstreamSyncTickerC <-chan time.Time + if followSource { + upstreamSyncTickerCheckInterval := time.Duration(s.SyncDeriver.Config.BlockTime) * time.Second * 2 + upstreamSyncTicker := time.NewTicker(upstreamSyncTickerCheckInterval) + upstreamSyncTickerC = upstreamSyncTicker.C + defer upstreamSyncTicker.Stop() + } + for { if s.driverCtx.Err() != nil { // don't try to schedule/handle more work when we are closing. return @@ -299,7 +318,7 @@ func (s *Driver) eventLoop() { if lastUnsafeL2 != head { // Unsafe head changed: reset alt-sync to avoid redundant L2 requests while syncing. resetAltSync(head, derivationReady) - } else if !unsafeOnly && !derivationReady { + } else if !followSource && !derivationReady { // Derivation enabled but not yet ready: reset alt-sync while it catches up. resetAltSync(head, derivationReady) } @@ -315,6 +334,8 @@ func (s *Driver) eventLoop() { if err != nil { s.log.Warn("failed to check for unsafe L2 blocks to sync", "err", err) } + case <-upstreamSyncTickerC: + s.followUpstream() case <-s.sched.NextDelayedStep(): s.sched.AttemptStep(s.driverCtx) case <-s.sched.NextStep(): @@ -445,3 +466,85 @@ func (s *Driver) checkForGapInUnsafeQueue(ctx context.Context) error { func (s *Driver) OnUnsafeL2Payload(ctx context.Context, payload *eth.ExecutionPayloadEnvelope) { s.SyncDeriver.OnUnsafeL2Payload(ctx, payload) } + +// followUpstream reconciles the local engine state with upstream sources when +// derivation is disabled (UnsafeOnly). +// +// In this mode, the driver does not derive L2 from L1. Instead, it: +// Uses the followTracker to fetch external safe / finalized / CurrentL1, +// validates that the external state is sane (e.g. finalized is not ahead +// of safe), and then updates the engine via FollowSource. +// +// This function is intended to be called periodically by a ticker and is a +// no-op while derivation is enabled or the EL is still performing its initial +// sync. +func (s *Driver) followUpstream() { + if !s.syncConfig.FollowSourceEnabled() { + return + } + if s.SyncDeriver.Engine.IsEngineInitialELSyncing() { + // Do not interfere with initial EL Sync and wait until it is done + return + } + status, err := s.upstreamFollowSource.GetFollowStatus(s.driverCtx) + if err != nil { + s.log.Warn("Follow Upstream: Failed to fetch status", "err", err) + return + } + s.log.Info("Follow Upstream", "eSafe", status.SafeL2, "eFinalized", status.FinalizedL2, "eCurrentL1", status.CurrentL1) + if status.FinalizedL2.Number > status.SafeL2.Number { + s.log.Warn("Follow Upstream: Invalid external state, finalized is ahead of safe", "safe", status.SafeL2.Number, "finalized", status.FinalizedL2.Number) + return + } + + eSafeL1Origin, err := s.upstreamFollowSource.L1BlockRefByNumber(s.driverCtx, status.SafeL2.L1Origin.Number) + if err != nil { + s.log.Warn("Follow Upstream: Failed to look up L1 origin of external safe head", "err", err) + return + } + if eSafeL1Origin.Hash != status.SafeL2.L1Origin.Hash { + s.log.Warn( + "Follow Upstream: Invalid external safe: L1 origin of external safe head mismatch", + "actual", eSafeL1Origin, + "expected", status.SafeL2.L1Origin, + ) + return + } + + eFinalizedL1Origin, err := s.upstreamFollowSource.L1BlockRefByNumber(s.driverCtx, status.FinalizedL2.L1Origin.Number) + if err != nil { + s.log.Warn("Follow Upstream: Failed to look up L1 origin of external finalized head", "err", err) + return + } + if eFinalizedL1Origin.Hash != status.FinalizedL2.L1Origin.Hash { + s.log.Warn( + "Follow Upstream: Invalid external finalized: L1 origin of external finalized head mismatch", + "actual", eFinalizedL1Origin, + "expected", status.FinalizedL2.L1Origin, + ) + return + } + + if (status.CurrentL1 == eth.L1BlockRef{}) { + s.log.Debug("Follow Upstream: CurrentL1 not available") + } else { + eCurrentL1, err := s.upstreamFollowSource.L1BlockRefByNumber(s.driverCtx, status.CurrentL1.Number) + if err != nil { + s.log.Warn("Follow Upstream: Failed to look up external currentL1", "err", err) + return + } + if eCurrentL1.Hash != status.CurrentL1.Hash { + s.log.Warn( + "Follow Upstream: Invalid external CurrentL1: L1 head mismatch", + "actual", eCurrentL1, + "expected", status.CurrentL1, + ) + return + } + + s.log.Debug("Follow Upstream: Inject L1 Info", "currentL1", status.CurrentL1) + s.emitter.Emit(s.driverCtx, derive.DeriverL1StatusEvent{Origin: status.CurrentL1}) + } + // Only reach this point if all L1 checks passed + s.SyncDeriver.Engine.FollowSource(status.SafeL2, status.FinalizedL2) +} diff --git a/op-node/rollup/driver/follow_source.go b/op-node/rollup/driver/follow_source.go new file mode 100644 index 0000000000000..d6343eef35b59 --- /dev/null +++ b/op-node/rollup/driver/follow_source.go @@ -0,0 +1,42 @@ +package driver + +import ( + "context" + + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-service/sources" +) + +// L1FollowSource provides access to L1 block references for upstream following. +type L1FollowSource interface { + L1BlockRefByNumber(ctx context.Context, num uint64) (eth.L1BlockRef, error) +} + +// UpstreamFollowSource combines L1 and L2 follow sources. +// L2 following may be optionally disabled. +type UpstreamFollowSource interface { + L1FollowSource + GetFollowStatus(ctx context.Context) (*sources.FollowStatus, error) +} + +type L2FollowSource struct { + l2Source *sources.FollowClient + l1Source L1FollowSource +} + +var _ UpstreamFollowSource = (*L2FollowSource)(nil) + +func NewL2FollowSource(client *sources.FollowClient, l1Source L1FollowSource) *L2FollowSource { + if l1Source == nil || client == nil { + panic("NewL2FollowSource: sources must not be nil") + } + return &L2FollowSource{l2Source: client, l1Source: l1Source} +} + +func (fs *L2FollowSource) GetFollowStatus(ctx context.Context) (*sources.FollowStatus, error) { + return fs.l2Source.GetFollowStatus(ctx) +} + +func (fs *L2FollowSource) L1BlockRefByNumber(ctx context.Context, num uint64) (eth.L1BlockRef, error) { + return fs.l1Source.L1BlockRefByNumber(ctx, num) +} diff --git a/op-node/rollup/driver/sync_deriver.go b/op-node/rollup/driver/sync_deriver.go index bd5f29b0044ce..e8e38757a5ebf 100644 --- a/op-node/rollup/driver/sync_deriver.go +++ b/op-node/rollup/driver/sync_deriver.go @@ -237,7 +237,7 @@ func (s *SyncDeriver) SyncStep() { return } - if s.SyncCfg.UnsafeOnly { + if s.SyncCfg.FollowSourceEnabled() { if s.SyncCfg.NeedInitialResetEngine { // May need a single reset to trigger sequencer block building s.Engine.TryInitialResetEngineForSequencer(s.Ctx) diff --git a/op-node/rollup/engine/engine_controller.go b/op-node/rollup/engine/engine_controller.go index f97ba854f1161..f6eaa8956ce51 100644 --- a/op-node/rollup/engine/engine_controller.go +++ b/op-node/rollup/engine/engine_controller.go @@ -62,6 +62,7 @@ type ExecEngine interface { NewPayload(ctx context.Context, payload *eth.ExecutionPayload, parentBeaconBlockRoot *common.Hash) (*eth.PayloadStatusV1, error) L2BlockRefByLabel(ctx context.Context, label eth.BlockLabel) (eth.L2BlockRef, error) L2BlockRefByHash(ctx context.Context, hash common.Hash) (eth.L2BlockRef, error) + L2BlockRefByNumber(ctx context.Context, num uint64) (eth.L2BlockRef, error) } // Metrics interface for CLSync functionality @@ -1122,3 +1123,65 @@ func (e *EngineController) startPayload(ctx context.Context, fc eth.ForkchoiceSt return eth.PayloadID{}, BlockInsertTemporaryErr, eth.ForkchoiceUpdateErr(fcRes.PayloadStatus) } } + +func (e *EngineController) FollowSource(eSafeBlockRef, eFinalizedRef eth.L2BlockRef) { + e.mu.Lock() + defer e.mu.Unlock() + + followExternalRefs := func(updateUnsafe bool) { + // Assume the sanity of external safe and finalized are checked + if updateUnsafe { + // May interrupt ongoing EL Sync to update the target, or trigger EL Sync + e.tryUpdateUnsafe(e.ctx, eSafeBlockRef) + } + e.tryUpdateLocalSafe(e.ctx, eSafeBlockRef, true, eth.L1BlockRef{}) + // Directly update the Engine Controller state, bypassing finalizer + if e.finalizedHead.Number <= eFinalizedRef.Number { + e.promoteFinalized(e.ctx, eFinalizedRef) + } + } + + logger := e.log.With( + "currentUnsafe", e.unsafeHead, + "currentSafe", e.safeHead, + "externalSafe", eSafeBlockRef, + "externalFinalized", eFinalizedRef, + ) + + logger.Info("Follow Source: Process external refs") + + if e.unsafeHead.Number < eSafeBlockRef.Number { + // EL Sync target may be updated + logger.Debug("Follow Source: EL Sync: External safe ahead of current unsafe") + followExternalRefs(true) + return + } + + fetchedSafe, err := e.engine.L2BlockRefByNumber(e.ctx, eSafeBlockRef.Number) + if errors.Is(err, ethereum.NotFound) { + // We queried a block before the EngineController unsafe head number, + // but it is not found. This indicates the underlying EL is still syncing. + // We do not know if the current EL sync is targeting a chain that will + // eventually reorg out this target. So we do not interrupt EL sync; + // we only update the local safe head. + logger.Debug("Follow Source: EL Sync in progress") + followExternalRefs(false) + return + } + if err != nil { + logger.Debug("Follow Source: Failed to fetch external safe from local EL", "err", err) + return + } + + if fetchedSafe == eSafeBlockRef { + // External safe is found locally and matches. + logger.Debug("Follow Source: Consolidation") + followExternalRefs(false) + return + } + + // External safe is found locally but they differ so trigger reorg. + // Reorging may trigger EL Sync, or updating the EL Sync target. + logger.Warn("Follow Source: Reorg. May Trigger EL sync") + followExternalRefs(true) +} diff --git a/op-node/rollup/sync/config.go b/op-node/rollup/sync/config.go index 79b46a223a033..2822be0420a91 100644 --- a/op-node/rollup/sync/config.go +++ b/op-node/rollup/sync/config.go @@ -75,7 +75,10 @@ type Config struct { SupportsPostFinalizationELSync bool `json:"supports_post_finalization_elsync"` - UnsafeOnly bool `json:"unsafe_only"` L2FollowSourceEndpoint string `json:"l2_follow_source_endpoint"` NeedInitialResetEngine bool `json:"need_initial_reset_engine"` } + +func (c *Config) FollowSourceEnabled() bool { + return c.L2FollowSourceEndpoint != "" +} diff --git a/op-node/service.go b/op-node/service.go index d590750628edf..1ac43ad80f40d 100644 --- a/op-node/service.go +++ b/op-node/service.go @@ -117,6 +117,7 @@ func NewConfig(ctx cliiface.Context, log log.Logger) (*config.Config, error) { ConfigPersistence: configPersistence, SafeDBPath: ctx.String(flags.SafeDBPath.Name), Sync: *syncConfig, + L2FollowSource: NewL2FollowSourceConfig(ctx), RollupHalt: haltOption, ConductorEnabled: ctx.Bool(flags.ConductorEnabledFlag.Name), @@ -194,6 +195,15 @@ func NewL2EndpointConfig(ctx cliiface.Context, logger log.Logger) (*config.L2End }, nil } +func NewL2FollowSourceConfig(ctx cliiface.Context) *config.L2FollowSourceConfig { + l2Addr := ctx.String(flags.L2FollowSource.Name) + l2RpcTimeout := ctx.Duration(flags.L2FollowSourceRpcTimeout.Name) + return &config.L2FollowSourceConfig{ + L2RPCAddr: l2Addr, + L2RPCCallTimeout: l2RpcTimeout, + } +} + func NewConfigPersistence(ctx cliiface.Context) config.ConfigPersistence { stateFile := ctx.String(flags.RPCAdminPersistence.Name) if stateFile == "" { @@ -340,12 +350,7 @@ func NewSyncConfig(ctx cliiface.Context, log log.Logger) (*sync.Config, error) { } else if ctx.IsSet(flags.L2EngineSyncEnabled.Name) { log.Error("l2.engine-sync is deprecated and will be removed in a future release. Use --syncmode=execution-layer instead.") } - unsafeOnly := ctx.Bool(flags.L2UnsafeOnly.Name) l2FollowSourceEndpoint := ctx.String(flags.L2FollowSource.Name) - if !unsafeOnly && l2FollowSourceEndpoint != "" { - return nil, errors.New("cannot follow external safe/finalized with derivation enabled (--l2.unsafe-only=false): " + - "Either remove --l2.follow.source or set --l2.unsafe-only=true to disable derivation") - } rrSyncEnabled := ctx.Bool(flags.SyncModeReqRespFlag.Name) // p2p.sync.req-resp=false && syncmode.req-resp=true is not allowed if !ctx.Bool(flags.SyncReqRespName) && rrSyncEnabled { @@ -355,32 +360,15 @@ func NewSyncConfig(ctx cliiface.Context, log log.Logger) (*sync.Config, error) { if err != nil { return nil, err } - isSequencer := ctx.Bool(flags.SequencerEnabledFlag.Name) - if unsafeOnly && !isSequencer { - // The verifier node initially gains payloads from the sequencer via CLP2P. - // To sync to the chain tip, the verifier must close the gap between its current - // unsafe view and the sequencer's latest unsafe payloads. - // With derivation disabled, the node can only rely on RR Sync or EL Sync to close this gap. - if rrSyncEnabled { - // Allowing RR Sync technically works, but it is impractical for a verifier to - // rely solely on RR Syncing - bootstrapping would take too long. - // Since RR Sync is also being deprecated, fail early for clarity. - return nil, errors.New("derivation disabled (--l2.unsafe-only=true) and RR sync enabled (--syncmode.req-resp=true): " + - "reaching the unsafe tip would rely solely on RR sync, " + - "which is infeasible for bootstrap. Disable RR sync or enable derivation") - } - // If RR Sync is not used, EL Sync will fill in the unsafe gap. - // This path is much faster and more practical for closing the gap. - } engineKind := engine.Kind(ctx.String(flags.L2EngineKind.Name)) cfg := &sync.Config{ SyncMode: mode, SyncModeReqResp: ctx.Bool(flags.SyncModeReqRespFlag.Name), SkipSyncStartCheck: ctx.Bool(flags.SkipSyncStartCheck.Name), SupportsPostFinalizationELSync: engineKind.SupportsPostFinalizationELSync(), - UnsafeOnly: unsafeOnly, L2FollowSourceEndpoint: l2FollowSourceEndpoint, - NeedInitialResetEngine: isSequencer && unsafeOnly, + // Sequencer needs a manual initial reset when follow source + NeedInitialResetEngine: ctx.Bool(flags.SequencerEnabledFlag.Name) && l2FollowSourceEndpoint != "", } if ctx.Bool(flags.L2EngineSyncEnabled.Name) { cfg.SyncMode = sync.ELSync diff --git a/op-node/service_test.go b/op-node/service_test.go index ac24aa86165e3..7bcd0171caef0 100644 --- a/op-node/service_test.go +++ b/op-node/service_test.go @@ -1,7 +1,6 @@ package opnode import ( - "fmt" "testing" "github.com/ethereum-optimism/optimism/op-node/flags" @@ -12,7 +11,6 @@ import ( func syncConfigCliApp() *cli.App { syncConfigFlags := append([]cli.Flag{ - flags.L2UnsafeOnly, flags.SequencerEnabledFlag, flags.L2EngineSyncEnabled, flags.SyncModeFlag, @@ -38,38 +36,3 @@ func run(args []string) error { func TestNewSyncConfigDefault(t *testing.T) { require.NoError(t, run(nil)) } - -func TestNewSyncConfig_DerivationDisabled_NoRRSync(t *testing.T) { - err := run([]string{ - fmt.Sprintf("--%s=true", flags.L2UnsafeOnly.Name), - // No follow source with no derivation allowed - fmt.Sprintf("--%s=false", flags.SyncModeReqRespFlag.Name), - }) - require.NoError(t, err) -} - -func TestNewSyncConfig_FollowSourceWithDerivationDisabled(t *testing.T) { - err := run([]string{ - fmt.Sprintf("--%s=true", flags.L2UnsafeOnly.Name), - fmt.Sprintf("--%s=http://example", flags.L2FollowSource.Name), - fmt.Sprintf("--%s=false", flags.SyncModeReqRespFlag.Name), - }) - require.NoError(t, err) -} - -func TestNewSyncConfig_FollowSourceWithDerivationEnabled(t *testing.T) { - err := run([]string{ - // unsafe-only defaults in false - fmt.Sprintf("--%s=http://example", flags.L2FollowSource.Name), - }) - require.ErrorContains(t, err, "cannot follow external safe/finalized with derivation enabled") -} - -func TestNewSyncConfig_VerifierUnsafeOnlyWithRRSyncEnabled(t *testing.T) { - err := run([]string{ - // verifier mode is default - fmt.Sprintf("--%s=true", flags.L2UnsafeOnly.Name), - fmt.Sprintf("--%s=true", flags.SyncModeReqRespFlag.Name), - }) - require.ErrorContains(t, err, "reaching the unsafe tip would rely solely on RR sync") -} diff --git a/op-service/sources/follow_client.go b/op-service/sources/follow_client.go new file mode 100644 index 0000000000000..0793ad5736add --- /dev/null +++ b/op-service/sources/follow_client.go @@ -0,0 +1,36 @@ +package sources + +import ( + "context" + "fmt" + + "github.com/ethereum-optimism/optimism/op-service/client" + "github.com/ethereum-optimism/optimism/op-service/eth" +) + +type FollowClient struct { + rollupClient *RollupClient +} + +type FollowStatus struct { + SafeL2 eth.L2BlockRef + FinalizedL2 eth.L2BlockRef + CurrentL1 eth.L1BlockRef +} + +func NewFollowClient(client client.RPC) (*FollowClient, error) { + rollupClient := NewRollupClient(client) + return &FollowClient{rollupClient: rollupClient}, nil +} + +func (s *FollowClient) GetFollowStatus(ctx context.Context) (*FollowStatus, error) { + status, err := s.rollupClient.SyncStatus(ctx) + if err != nil { + return nil, fmt.Errorf("failed to fetch external syncStatus: %w", err) + } + return &FollowStatus{ + FinalizedL2: status.FinalizedL2, + SafeL2: status.SafeL2, + CurrentL1: status.CurrentL1, + }, nil +}