diff --git a/op-e2e/actions/altda_test.go b/op-e2e/actions/altda_test.go index 14889edabc88..ab18ff77e098 100644 --- a/op-e2e/actions/altda_test.go +++ b/op-e2e/actions/altda_test.go @@ -81,7 +81,7 @@ func NewL2AltDA(t Testing, params ...AltDAParam) *L2AltDA { daMgr := altda.NewAltDAWithStorage(log, altDACfg, storage, &altda.NoopMetrics{}) - sequencer := NewL2Sequencer(t, log, l1F, miner.BlobStore(), daMgr, engCl, sd.RollupCfg, 0) + sequencer := NewL2Sequencer(t, log, l1F, miner.BlobStore(), daMgr, engCl, sd.RollupCfg, 0, nil) miner.ActL1SetFeeRecipient(common.Address{'A'}) sequencer.ActL2PipelineFull(t) @@ -139,7 +139,7 @@ func (a *L2AltDA) NewVerifier(t Testing) *L2Verifier { daMgr := altda.NewAltDAWithStorage(a.log, a.altDACfg, a.storage, &altda.NoopMetrics{}) - verifier := NewL2Verifier(t, a.log, l1F, a.miner.BlobStore(), daMgr, engCl, a.sd.RollupCfg, &sync.Config{}, safedb.Disabled) + verifier := NewL2Verifier(t, a.log, l1F, a.miner.BlobStore(), daMgr, engCl, a.sd.RollupCfg, &sync.Config{}, safedb.Disabled, nil) return verifier } diff --git a/op-e2e/actions/batch_queue_test.go b/op-e2e/actions/batch_queue_test.go index 40ff0a2da2af..69c9957d134b 100644 --- a/op-e2e/actions/batch_queue_test.go +++ b/op-e2e/actions/batch_queue_test.go @@ -88,7 +88,8 @@ func TestDeriveChainFromNearL1Genesis(gt *testing.T) { // This is the same situation as if op-node restarted at this point. l2Cl, err := sources.NewEngineClient(seqEngine.RPCClient(), logger, nil, sources.EngineClientDefaultConfig(sd.RollupCfg)) require.NoError(gt, err) - verifier := NewL2Verifier(t, logger, sequencer.l1, miner.BlobStore(), altda.Disabled, l2Cl, sequencer.rollupCfg, sequencer.syncCfg, safedb.Disabled) + verifier := NewL2Verifier(t, logger, sequencer.l1, miner.BlobStore(), altda.Disabled, + l2Cl, sequencer.rollupCfg, sequencer.syncCfg, safedb.Disabled, nil) verifier.ActL2PipelineFull(t) // Should not get stuck in a reset loop forever require.EqualValues(gt, l2BlockNum, seqEngine.l2Chain.CurrentSafeBlock().Number.Uint64()) require.EqualValues(gt, l2BlockNum, seqEngine.l2Chain.CurrentFinalBlock().Number.Uint64()) diff --git a/op-e2e/actions/interop_test.go b/op-e2e/actions/interop_test.go new file mode 100644 index 000000000000..8890f86a0067 --- /dev/null +++ b/op-e2e/actions/interop_test.go @@ -0,0 +1,123 @@ +package actions + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils" + "github.com/ethereum-optimism/optimism/op-node/rollup/interop" + "github.com/ethereum-optimism/optimism/op-node/rollup/sync" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum-optimism/optimism/op-service/testutils" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" +) + +var _ interop.InteropBackend = (*testutils.MockInteropBackend)(nil) + +func TestInteropVerifier(gt *testing.T) { + t := NewDefaultTesting(gt) + dp := e2eutils.MakeDeployParams(t, defaultRollupTestParams) + sd := e2eutils.Setup(t, dp, defaultAlloc) + // Temporary work-around: interop needs to be active, for cross-safety to not be instant. + // The state genesis in this test is pre-interop however. + sd.RollupCfg.InteropTime = new(uint64) + logger := testlog.Logger(t, log.LevelDebug) + seqMockBackend := &testutils.MockInteropBackend{} + l1Miner, seqEng, seq := setupSequencerTest(t, sd, logger, + WithVerifierOpts(WithInteropBackend(seqMockBackend))) + + batcher := NewL2Batcher(logger, sd.RollupCfg, DefaultBatcherCfg(dp), + seq.RollupClient(), l1Miner.EthClient(), seqEng.EthClient(), seqEng.EngineClient(t, sd.RollupCfg)) + + verMockBackend := &testutils.MockInteropBackend{} + _, ver := setupVerifier(t, sd, logger, + l1Miner.L1Client(t, sd.RollupCfg), l1Miner.BlobStore(), &sync.Config{}, + WithInteropBackend(verMockBackend)) + + seq.ActL2PipelineFull(t) + ver.ActL2PipelineFull(t) + + l2ChainID := types.ChainIDFromBig(sd.RollupCfg.L2ChainID) + seqMockBackend.ExpectCheckBlock(l2ChainID, 1, types.Unsafe, nil) + // create an unsafe L2 block + seq.ActL2StartBlock(t) + seq.ActL2EndBlock(t) + seq.ActL2PipelineFull(t) + seqMockBackend.AssertExpectations(t) + status := seq.SyncStatus() + require.Equal(t, uint64(1), status.UnsafeL2.Number) + require.Equal(t, uint64(0), status.CrossUnsafeL2.Number) + require.Equal(t, uint64(0), status.LocalSafeL2.Number) + require.Equal(t, uint64(0), status.SafeL2.Number) + + // promote it to cross-unsafe in the backend + // and see if the node picks up on it + seqMockBackend.ExpectCheckBlock(l2ChainID, 1, types.CrossUnsafe, nil) + seq.ActInteropBackendCheck(t) + seq.ActL2PipelineFull(t) + seqMockBackend.AssertExpectations(t) + status = seq.SyncStatus() + require.Equal(t, uint64(1), status.UnsafeL2.Number) + require.Equal(t, uint64(1), status.CrossUnsafeL2.Number, "cross unsafe now") + require.Equal(t, uint64(0), status.LocalSafeL2.Number) + require.Equal(t, uint64(0), status.SafeL2.Number) + + // submit all new L2 blocks + batcher.ActSubmitAll(t) + // new L1 block with L2 batch + l1Miner.ActL1StartBlock(12)(t) + l1Miner.ActL1IncludeTx(sd.RollupCfg.Genesis.SystemConfig.BatcherAddr)(t) + l1Miner.ActL1EndBlock(t) + + // Sync the L1 block, to verify the L2 block as local-safe. + seqMockBackend.ExpectCheckBlock(l2ChainID, 1, types.CrossUnsafe, nil) // not cross-safe yet + seq.ActL1HeadSignal(t) + seq.ActL2PipelineFull(t) + seqMockBackend.AssertExpectations(t) + + status = seq.SyncStatus() + require.Equal(t, uint64(1), status.UnsafeL2.Number) + require.Equal(t, uint64(1), status.CrossUnsafeL2.Number) + require.Equal(t, uint64(1), status.LocalSafeL2.Number, "local safe changed") + require.Equal(t, uint64(0), status.SafeL2.Number) + + // Now mark it as cross-safe + seqMockBackend.ExpectCheckBlock(l2ChainID, 1, types.CrossSafe, nil) + seq.ActInteropBackendCheck(t) + seq.ActL2PipelineFull(t) + seqMockBackend.AssertExpectations(t) + + status = seq.SyncStatus() + require.Equal(t, uint64(1), status.UnsafeL2.Number) + require.Equal(t, uint64(1), status.CrossUnsafeL2.Number) + require.Equal(t, uint64(1), status.LocalSafeL2.Number) + require.Equal(t, uint64(1), status.SafeL2.Number, "cross-safe reached") + require.Equal(t, uint64(0), status.FinalizedL2.Number) + + // The verifier might not see the L2 block that was just derived from L1 as cross-verified yet. + verMockBackend.ExpectCheckBlock(l2ChainID, 1, types.Unsafe, nil) // for the local unsafe check + verMockBackend.ExpectCheckBlock(l2ChainID, 1, types.Unsafe, nil) // for the local safe check + ver.ActL1HeadSignal(t) + ver.ActL2PipelineFull(t) + verMockBackend.AssertExpectations(t) + status = ver.SyncStatus() + require.Equal(t, uint64(1), status.UnsafeL2.Number, "synced the block") + require.Equal(t, uint64(0), status.CrossUnsafeL2.Number, "not cross-verified yet") + require.Equal(t, uint64(1), status.LocalSafeL2.Number, "derived from L1, thus local-safe") + require.Equal(t, uint64(0), status.SafeL2.Number, "not yet cross-safe") + require.Equal(t, uint64(0), status.FinalizedL2.Number) + + // signal that L1 finalized; the cross-safe block we have should get finalized too + l1Miner.ActL1SafeNext(t) + l1Miner.ActL1FinalizeNext(t) + seq.ActL1SafeSignal(t) + seq.ActL1FinalizedSignal(t) + seq.ActL2PipelineFull(t) + seqMockBackend.AssertExpectations(t) + + status = seq.SyncStatus() + require.Equal(t, uint64(1), status.FinalizedL2.Number, "finalized the block") +} diff --git a/op-e2e/actions/l2_sequencer.go b/op-e2e/actions/l2_sequencer.go index 7211537bc50c..a4626749b18a 100644 --- a/op-e2e/actions/l2_sequencer.go +++ b/op-e2e/actions/l2_sequencer.go @@ -19,6 +19,7 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup/driver" "github.com/ethereum-optimism/optimism/op-node/rollup/engine" "github.com/ethereum-optimism/optimism/op-node/rollup/event" + "github.com/ethereum-optimism/optimism/op-node/rollup/interop" "github.com/ethereum-optimism/optimism/op-node/rollup/sequencing" "github.com/ethereum-optimism/optimism/op-node/rollup/sync" "github.com/ethereum-optimism/optimism/op-service/eth" @@ -50,8 +51,9 @@ type L2Sequencer struct { } func NewL2Sequencer(t Testing, log log.Logger, l1 derive.L1Fetcher, blobSrc derive.L1BlobsFetcher, - altDASrc driver.AltDAIface, eng L2API, cfg *rollup.Config, seqConfDepth uint64) *L2Sequencer { - ver := NewL2Verifier(t, log, l1, blobSrc, altDASrc, eng, cfg, &sync.Config{}, safedb.Disabled) + altDASrc driver.AltDAIface, eng L2API, cfg *rollup.Config, seqConfDepth uint64, + interopBackend interop.InteropBackend) *L2Sequencer { + ver := NewL2Verifier(t, log, l1, blobSrc, altDASrc, eng, cfg, &sync.Config{}, safedb.Disabled, interopBackend) attrBuilder := derive.NewFetchingAttributesBuilder(cfg, l1, eng) seqConfDepthL1 := confdepth.NewConfDepth(seqConfDepth, ver.syncStatus.L1Head, l1) l1OriginSelector := &MockL1OriginSelector{ diff --git a/op-e2e/actions/l2_sequencer_test.go b/op-e2e/actions/l2_sequencer_test.go index 6b8ec800408e..87cf95eaa425 100644 --- a/op-e2e/actions/l2_sequencer_test.go +++ b/op-e2e/actions/l2_sequencer_test.go @@ -37,8 +37,30 @@ func EngineWithP2P() EngineOption { } } -func setupSequencerTest(t Testing, sd *e2eutils.SetupData, log log.Logger) (*L1Miner, *L2Engine, *L2Sequencer) { +type sequencerCfg struct { + verifierCfg +} + +func defaultSequencerConfig() *sequencerCfg { + return &sequencerCfg{verifierCfg: *defaultVerifierCfg()} +} + +type SequencerOpt func(opts *sequencerCfg) + +func WithVerifierOpts(opts ...VerifierOpt) SequencerOpt { + return func(cfg *sequencerCfg) { + for _, opt := range opts { + opt(&cfg.verifierCfg) + } + } +} + +func setupSequencerTest(t Testing, sd *e2eutils.SetupData, log log.Logger, opts ...SequencerOpt) (*L1Miner, *L2Engine, *L2Sequencer) { jwtPath := e2eutils.WriteDefaultJWT(t) + cfg := defaultSequencerConfig() + for _, opt := range opts { + opt(cfg) + } miner := NewL1Miner(t, log.New("role", "l1-miner"), sd.L1Cfg) @@ -48,7 +70,7 @@ func setupSequencerTest(t Testing, sd *e2eutils.SetupData, log log.Logger) (*L1M l2Cl, err := sources.NewEngineClient(engine.RPCClient(), log, nil, sources.EngineClientDefaultConfig(sd.RollupCfg)) require.NoError(t, err) - sequencer := NewL2Sequencer(t, log.New("role", "sequencer"), l1F, miner.BlobStore(), altda.Disabled, l2Cl, sd.RollupCfg, 0) + sequencer := NewL2Sequencer(t, log.New("role", "sequencer"), l1F, miner.BlobStore(), altda.Disabled, l2Cl, sd.RollupCfg, 0, cfg.interopBackend) return miner, engine, sequencer } diff --git a/op-e2e/actions/l2_verifier.go b/op-e2e/actions/l2_verifier.go index 5cc09c76c4e4..03de1a674f1c 100644 --- a/op-e2e/actions/l2_verifier.go +++ b/op-e2e/actions/l2_verifier.go @@ -23,6 +23,7 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup/engine" "github.com/ethereum-optimism/optimism/op-node/rollup/event" "github.com/ethereum-optimism/optimism/op-node/rollup/finality" + "github.com/ethereum-optimism/optimism/op-node/rollup/interop" "github.com/ethereum-optimism/optimism/op-node/rollup/status" "github.com/ethereum-optimism/optimism/op-node/rollup/sync" "github.com/ethereum-optimism/optimism/op-service/client" @@ -84,7 +85,10 @@ type safeDB interface { node.SafeDBReader } -func NewL2Verifier(t Testing, log log.Logger, l1 derive.L1Fetcher, blobsSrc derive.L1BlobsFetcher, altDASrc driver.AltDAIface, eng L2API, cfg *rollup.Config, syncCfg *sync.Config, safeHeadListener safeDB) *L2Verifier { +func NewL2Verifier(t Testing, log log.Logger, l1 derive.L1Fetcher, + blobsSrc derive.L1BlobsFetcher, altDASrc driver.AltDAIface, + eng L2API, cfg *rollup.Config, syncCfg *sync.Config, safeHeadListener safeDB, + interopBackend interop.InteropBackend) *L2Verifier { ctx, cancel := context.WithCancel(context.Background()) t.Cleanup(cancel) @@ -104,6 +108,10 @@ func NewL2Verifier(t Testing, log log.Logger, l1 derive.L1Fetcher, blobsSrc deri }, } + if interopBackend != nil { + sys.Register("interop", interop.NewInteropDeriver(log, cfg, ctx, interopBackend, eng), opts) + } + metrics := &testutils.TestDerivationMetrics{} ec := engine.NewEngineController(eng, log, metrics, cfg, syncCfg, sys.Register("engine-controller", nil, opts)) @@ -316,6 +324,13 @@ func (s *L2Verifier) ActL1FinalizedSignal(t Testing) { require.Equal(t, finalized, s.syncStatus.SyncStatus().FinalizedL1) } +func (s *L2Verifier) ActInteropBackendCheck(t Testing) { + s.synchronousEvents.Emit(engine.CrossUpdateRequestEvent{ + CrossUnsafe: true, + CrossSafe: true, + }) +} + func (s *L2Verifier) OnEvent(ev event.Event) bool { switch x := ev.(type) { case rollup.L1TemporaryErrorEvent: diff --git a/op-e2e/actions/l2_verifier_test.go b/op-e2e/actions/l2_verifier_test.go index 328801214e04..f8f751ca6b7a 100644 --- a/op-e2e/actions/l2_verifier_test.go +++ b/op-e2e/actions/l2_verifier_test.go @@ -3,20 +3,22 @@ package actions import ( "testing" - altda "github.com/ethereum-optimism/optimism/op-alt-da" - "github.com/ethereum-optimism/optimism/op-node/node/safedb" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/log" "github.com/stretchr/testify/require" + altda "github.com/ethereum-optimism/optimism/op-alt-da" "github.com/ethereum-optimism/optimism/op-e2e/e2eutils" + "github.com/ethereum-optimism/optimism/op-node/node/safedb" "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-node/rollup/interop" "github.com/ethereum-optimism/optimism/op-node/rollup/sync" "github.com/ethereum-optimism/optimism/op-service/testlog" ) type verifierCfg struct { safeHeadListener safeDB + interopBackend interop.InteropBackend } type VerifierOpt func(opts *verifierCfg) @@ -27,13 +29,20 @@ func WithSafeHeadListener(l safeDB) VerifierOpt { } } +func WithInteropBackend(b interop.InteropBackend) VerifierOpt { + return func(opts *verifierCfg) { + opts.interopBackend = b + } +} + func defaultVerifierCfg() *verifierCfg { return &verifierCfg{ safeHeadListener: safedb.Disabled, } } -func setupVerifier(t Testing, sd *e2eutils.SetupData, log log.Logger, l1F derive.L1Fetcher, blobSrc derive.L1BlobsFetcher, syncCfg *sync.Config, opts ...VerifierOpt) (*L2Engine, *L2Verifier) { +func setupVerifier(t Testing, sd *e2eutils.SetupData, log log.Logger, + l1F derive.L1Fetcher, blobSrc derive.L1BlobsFetcher, syncCfg *sync.Config, opts ...VerifierOpt) (*L2Engine, *L2Verifier) { cfg := defaultVerifierCfg() for _, opt := range opts { opt(cfg) @@ -41,7 +50,7 @@ func setupVerifier(t Testing, sd *e2eutils.SetupData, log log.Logger, l1F derive jwtPath := e2eutils.WriteDefaultJWT(t) engine := NewL2Engine(t, log.New("role", "verifier-engine"), sd.L2Cfg, sd.RollupCfg.Genesis.L1, jwtPath, EngineWithP2P()) engCl := engine.EngineClient(t, sd.RollupCfg) - verifier := NewL2Verifier(t, log.New("role", "verifier"), l1F, blobSrc, altda.Disabled, engCl, sd.RollupCfg, syncCfg, cfg.safeHeadListener) + verifier := NewL2Verifier(t, log.New("role", "verifier"), l1F, blobSrc, altda.Disabled, engCl, sd.RollupCfg, syncCfg, cfg.safeHeadListener, cfg.interopBackend) return engine, verifier } diff --git a/op-e2e/actions/reorg_test.go b/op-e2e/actions/reorg_test.go index 4036d9fb131c..271b69cb1b29 100644 --- a/op-e2e/actions/reorg_test.go +++ b/op-e2e/actions/reorg_test.go @@ -613,7 +613,7 @@ func RestartOpGeth(gt *testing.T, deltaTimeOffset *hexutil.Uint64) { engRpc := &rpcWrapper{seqEng.RPCClient()} l2Cl, err := sources.NewEngineClient(engRpc, log, nil, sources.EngineClientDefaultConfig(sd.RollupCfg)) require.NoError(t, err) - sequencer := NewL2Sequencer(t, log, l1F, miner.BlobStore(), altda.Disabled, l2Cl, sd.RollupCfg, 0) + sequencer := NewL2Sequencer(t, log, l1F, miner.BlobStore(), altda.Disabled, l2Cl, sd.RollupCfg, 0, nil) batcher := NewL2Batcher(log, sd.RollupCfg, DefaultBatcherCfg(dp), sequencer.RollupClient(), miner.EthClient(), seqEng.EthClient(), seqEng.EngineClient(t, sd.RollupCfg)) @@ -701,7 +701,7 @@ func ConflictingL2Blocks(gt *testing.T, deltaTimeOffset *hexutil.Uint64) { require.NoError(t, err) l1F, err := sources.NewL1Client(miner.RPCClient(), log, nil, sources.L1ClientDefaultConfig(sd.RollupCfg, false, sources.RPCKindStandard)) require.NoError(t, err) - altSequencer := NewL2Sequencer(t, log, l1F, miner.BlobStore(), altda.Disabled, altSeqEngCl, sd.RollupCfg, 0) + altSequencer := NewL2Sequencer(t, log, l1F, miner.BlobStore(), altda.Disabled, altSeqEngCl, sd.RollupCfg, 0, nil) altBatcher := NewL2Batcher(log, sd.RollupCfg, DefaultBatcherCfg(dp), altSequencer.RollupClient(), miner.EthClient(), altSeqEng.EthClient(), altSeqEng.EngineClient(t, sd.RollupCfg)) diff --git a/op-e2e/actions/sync_test.go b/op-e2e/actions/sync_test.go index 78297a5a8135..5f78739631cc 100644 --- a/op-e2e/actions/sync_test.go +++ b/op-e2e/actions/sync_test.go @@ -819,7 +819,7 @@ func TestELSyncTransitionsToCLSyncAfterNodeRestart(gt *testing.T) { PrepareELSyncedNode(t, miner, sequencer, seqEng, verifier, verEng, seqEngCl, batcher, dp) // Create a new verifier which is essentially a new op-node with the sync mode of ELSync and default geth engine kind. - verifier = NewL2Verifier(t, captureLog, miner.L1Client(t, sd.RollupCfg), miner.BlobStore(), altda.Disabled, verifier.eng, sd.RollupCfg, &sync.Config{SyncMode: sync.ELSync}, defaultVerifierCfg().safeHeadListener) + verifier = NewL2Verifier(t, captureLog, miner.L1Client(t, sd.RollupCfg), miner.BlobStore(), altda.Disabled, verifier.eng, sd.RollupCfg, &sync.Config{SyncMode: sync.ELSync}, defaultVerifierCfg().safeHeadListener, nil) // Build another 10 L1 blocks on the sequencer for i := 0; i < 10; i++ { @@ -861,7 +861,7 @@ func TestForcedELSyncCLAfterNodeRestart(gt *testing.T) { PrepareELSyncedNode(t, miner, sequencer, seqEng, verifier, verEng, seqEngCl, batcher, dp) // Create a new verifier which is essentially a new op-node with the sync mode of ELSync and erigon engine kind. - verifier2 := NewL2Verifier(t, captureLog, miner.L1Client(t, sd.RollupCfg), miner.BlobStore(), altda.Disabled, verifier.eng, sd.RollupCfg, &sync.Config{SyncMode: sync.ELSync, SupportsPostFinalizationELSync: true}, defaultVerifierCfg().safeHeadListener) + verifier2 := NewL2Verifier(t, captureLog, miner.L1Client(t, sd.RollupCfg), miner.BlobStore(), altda.Disabled, verifier.eng, sd.RollupCfg, &sync.Config{SyncMode: sync.ELSync, SupportsPostFinalizationELSync: true}, defaultVerifierCfg().safeHeadListener, nil) // Build another 10 L1 blocks on the sequencer for i := 0; i < 10; i++ { @@ -1039,12 +1039,19 @@ func TestSpanBatchAtomicity_Consolidation(gt *testing.T) { verifier.l2PipelineIdle = false for !verifier.l2PipelineIdle { // wait for next pending block - verifier.ActL2EventsUntil(t, event.Any( - event.Is[engine2.PendingSafeUpdateEvent], event.Is[derive.DeriverIdleEvent]), 1000, false) + verifier.ActL2EventsUntil(t, func(ev event.Event) bool { + if event.Is[engine2.SafeDerivedEvent](ev) { // safe updates should only happen once the pending-safe reaches the target. + t.Fatal("unexpected next safe update") + } + return event.Any(event.Is[engine2.PendingSafeUpdateEvent], event.Is[derive.DeriverIdleEvent])(ev) + }, 1000, false) if verifier.L2PendingSafe().Number < targetHeadNumber { // If the span batch is not fully processed, the safe head must not advance. require.Equal(t, verifier.L2Safe().Number, uint64(0)) } else { + // Make sure we do the post-processing of what safety updates might happen + // after the pending-safe event, before the next pending-safe event. + verifier.ActL2EventsUntil(t, event.Is[engine2.PendingSafeUpdateEvent], 100, true) // Once the span batch is fully processed, the safe head must advance to the end of span batch. require.Equal(t, verifier.L2Safe().Number, targetHeadNumber) require.Equal(t, verifier.L2Safe(), verifier.L2PendingSafe()) @@ -1088,12 +1095,19 @@ func TestSpanBatchAtomicity_ForceAdvance(gt *testing.T) { verifier.l2PipelineIdle = false for !verifier.l2PipelineIdle { // wait for next pending block - verifier.ActL2EventsUntil(t, event.Any( - event.Is[engine2.PendingSafeUpdateEvent], event.Is[derive.DeriverIdleEvent]), 1000, false) + verifier.ActL2EventsUntil(t, func(ev event.Event) bool { + if event.Is[engine2.SafeDerivedEvent](ev) { // safe updates should only happen once the pending-safe reaches the target. + t.Fatal("unexpected next safe update") + } + return event.Any(event.Is[engine2.PendingSafeUpdateEvent], event.Is[derive.DeriverIdleEvent])(ev) + }, 1000, false) if verifier.L2PendingSafe().Number < targetHeadNumber { // If the span batch is not fully processed, the safe head must not advance. require.Equal(t, verifier.L2Safe().Number, uint64(0)) } else { + // Make sure we do the post-processing of what safety updates might happen + // after the pending-safe event, before the next pending-safe event. + verifier.ActL2EventsUntil(t, event.Is[engine2.PendingSafeUpdateEvent], 100, true) // Once the span batch is fully processed, the safe head must advance to the end of span batch. require.Equal(t, verifier.L2Safe().Number, targetHeadNumber) require.Equal(t, verifier.L2Safe(), verifier.L2PendingSafe()) diff --git a/op-node/flags/flags.go b/op-node/flags/flags.go index b3af25d66143..dcb1597ce6e5 100644 --- a/op-node/flags/flags.go +++ b/op-node/flags/flags.go @@ -73,6 +73,13 @@ var ( EnvVars: prefixEnvVars("L1_BEACON"), Category: RollupCategory, } + SupervisorAddr = &cli.StringFlag{ + Name: "supervisor", + Usage: "RPC address of interop supervisor service for cross-chain safety verification." + + "Applies only to Interop-enabled networks.", + Hidden: true, // hidden for now during early testing. + EnvVars: prefixEnvVars("SUPERVISOR"), + } /* Optional Flags */ BeaconHeader = &cli.StringFlag{ Name: "l1.beacon-header", @@ -374,6 +381,7 @@ var requiredFlags = []cli.Flag{ } var optionalFlags = []cli.Flag{ + SupervisorAddr, BeaconAddr, BeaconHeader, BeaconFallbackAddrs, diff --git a/op-node/node/client.go b/op-node/node/client.go index a1c4754616c5..caa290ddbbe8 100644 --- a/op-node/node/client.go +++ b/op-node/node/client.go @@ -230,3 +230,29 @@ func parseHTTPHeader(headerStr string) (http.Header, error) { h.Add(s[0], s[1]) return h, nil } + +type SupervisorEndpointSetup interface { + SupervisorClient(ctx context.Context, log log.Logger) (*sources.SupervisorClient, error) + Check() error +} + +type SupervisorEndpointConfig struct { + SupervisorAddr string +} + +var _ SupervisorEndpointSetup = (*SupervisorEndpointConfig)(nil) + +func (cfg *SupervisorEndpointConfig) Check() error { + if cfg.SupervisorAddr == "" { + return errors.New("supervisor RPC address is not set") + } + return nil +} + +func (cfg *SupervisorEndpointConfig) SupervisorClient(ctx context.Context, log log.Logger) (*sources.SupervisorClient, error) { + cl, err := client.NewRPC(ctx, log, cfg.SupervisorAddr) + if err != nil { + return nil, fmt.Errorf("failed to dial supervisor RPC: %w", err) + } + return sources.NewSupervisorClient(cl), nil +} diff --git a/op-node/node/config.go b/op-node/node/config.go index d52da967f14a..a78b55853aa2 100644 --- a/op-node/node/config.go +++ b/op-node/node/config.go @@ -23,6 +23,8 @@ type Config struct { Beacon L1BeaconEndpointSetup + Supervisor SupervisorEndpointSetup + Driver driver.Config Rollup rollup.Config @@ -130,12 +132,20 @@ func (cfg *Config) Check() error { } if cfg.Rollup.EcotoneTime != nil { if cfg.Beacon == nil { - return fmt.Errorf("the Ecotone upgrade is scheduled but no L1 Beacon API endpoint is configured") + return fmt.Errorf("the Ecotone upgrade is scheduled (timestamp = %d) but no L1 Beacon API endpoint is configured", *cfg.Rollup.EcotoneTime) } if err := cfg.Beacon.Check(); err != nil { return fmt.Errorf("misconfigured L1 Beacon API endpoint: %w", err) } } + if cfg.Rollup.InteropTime != nil { + if cfg.Supervisor == nil { + return fmt.Errorf("the Interop upgrade is scheduled (timestamp = %d) but no supervisor RPC endpoint is configured", *cfg.Rollup.InteropTime) + } + if err := cfg.Supervisor.Check(); err != nil { + return fmt.Errorf("misconfigured supervisor RPC endpoint: %w", err) + } + } if err := cfg.Rollup.Check(); err != nil { return fmt.Errorf("rollup config error: %w", err) } diff --git a/op-node/node/node.go b/op-node/node/node.go index c60121f7d168..13142bdf3343 100644 --- a/op-node/node/node.go +++ b/op-node/node/node.go @@ -70,6 +70,8 @@ type OpNode struct { beacon *sources.L1BeaconClient + supervisor *sources.SupervisorClient + // some resources cannot be stopped directly, like the p2p gossipsub router (not our design), // and depend on this ctx to be closed. resourcesCtx context.Context @@ -379,6 +381,14 @@ func (n *OpNode) initL2(ctx context.Context, cfg *Config) error { return err } + if cfg.Rollup.InteropTime != nil { + cl, err := cfg.Supervisor.SupervisorClient(ctx, n.log) + if err != nil { + return fmt.Errorf("failed to setup supervisor RPC client: %w", err) + } + n.supervisor = cl + } + var sequencerConductor conductor.SequencerConductor = &conductor.NoOpConductor{} if cfg.ConductorEnabled { sequencerConductor = NewConductorClient(cfg, n.log, n.metrics) @@ -400,7 +410,8 @@ func (n *OpNode) initL2(ctx context.Context, cfg *Config) error { } else { n.safeDB = safedb.Disabled } - n.l2Driver = driver.NewDriver(&cfg.Driver, &cfg.Rollup, n.l2Source, n.l1Source, n.beacon, n, n, n.log, n.metrics, cfg.ConfigPersistence, n.safeDB, &cfg.Sync, sequencerConductor, altDA) + n.l2Driver = driver.NewDriver(&cfg.Driver, &cfg.Rollup, n.l2Source, n.l1Source, + n.supervisor, n.beacon, n, n, n.log, n.metrics, cfg.ConfigPersistence, n.safeDB, &cfg.Sync, sequencerConductor, altDA) return nil } @@ -684,6 +695,11 @@ func (n *OpNode) Stop(ctx context.Context) error { n.l2Source.Close() } + // close the supervisor RPC client + if n.supervisor != nil { + n.supervisor.Close() + } + // close L1 data source if n.l1Source != nil { n.l1Source.Close() diff --git a/op-node/rollup/driver/driver.go b/op-node/rollup/driver/driver.go index 3a3272387821..8c4c975c87cf 100644 --- a/op-node/rollup/driver/driver.go +++ b/op-node/rollup/driver/driver.go @@ -17,6 +17,7 @@ import ( "github.com/ethereum-optimism/optimism/op-node/rollup/engine" "github.com/ethereum-optimism/optimism/op-node/rollup/event" "github.com/ethereum-optimism/optimism/op-node/rollup/finality" + "github.com/ethereum-optimism/optimism/op-node/rollup/interop" "github.com/ethereum-optimism/optimism/op-node/rollup/sequencing" "github.com/ethereum-optimism/optimism/op-node/rollup/status" "github.com/ethereum-optimism/optimism/op-node/rollup/sync" @@ -155,6 +156,7 @@ func NewDriver( cfg *rollup.Config, l2 L2Chain, l1 L1Chain, + supervisor interop.InteropBackend, // may be nil pre-interop. l1Blobs derive.L1BlobsFetcher, altSync AltSync, network Network, @@ -181,6 +183,14 @@ func NewDriver( opts := event.DefaultRegisterOpts() + // If interop is scheduled we start the driver. + // It will then be ready to pick up verification work + // as soon as we reach the upgrade time (if the upgrade is not already active). + if cfg.InteropTime != nil { + interopDeriver := interop.NewInteropDeriver(log, cfg, driverCtx, supervisor, l2) + sys.Register("interop", interopDeriver, opts) + } + statusTracker := status.NewStatusTracker(log, metrics) sys.Register("status", statusTracker, opts) diff --git a/op-node/rollup/driver/state.go b/op-node/rollup/driver/state.go index f851291bcb9c..73374dbf8055 100644 --- a/op-node/rollup/driver/state.go +++ b/op-node/rollup/driver/state.go @@ -465,6 +465,12 @@ func (s *SyncDeriver) SyncStep() { // Upon the pending-safe signal the attributes deriver can then ask the pipeline // to generate new attributes, if no attributes are known already. s.Emitter.Emit(engine.PendingSafeRequestEvent{}) + + // If interop is configured, we have to run the engine events, + // to ensure cross-L2 safety is continuously verified against the interop-backend. + if s.Config.InteropTime != nil { + s.Emitter.Emit(engine.CrossUpdateRequestEvent{}) + } } // ResetDerivationPipeline forces a reset of the derivation pipeline. diff --git a/op-node/rollup/engine/engine_controller.go b/op-node/rollup/engine/engine_controller.go index d8db9cad949c..f2266382a206 100644 --- a/op-node/rollup/engine/engine_controller.go +++ b/op-node/rollup/engine/engine_controller.go @@ -56,12 +56,27 @@ type EngineController struct { emitter event.Emitter // Block Head State - unsafeHead eth.L2BlockRef - pendingSafeHead eth.L2BlockRef // L2 block processed from the middle of a span batch, but not marked as the safe block yet. - safeHead eth.L2BlockRef - finalizedHead eth.L2BlockRef + unsafeHead eth.L2BlockRef + // Cross-verified unsafeHead, always equal to unsafeHead pre-interop + crossUnsafeHead eth.L2BlockRef + // Pending localSafeHead + // L2 block processed from the middle of a span batch, + // but not marked as the safe block yet. + pendingSafeHead eth.L2BlockRef + // Derived from L1, and known to be a completed span-batch, + // but not cross-verified yet. + localSafeHead eth.L2BlockRef + // Derived from L1 and cross-verified to have cross-safe dependencies. + safeHead eth.L2BlockRef + // Derived from finalized L1 data, + // and cross-verified to only have finalized dependencies. + finalizedHead eth.L2BlockRef + // The unsafe head to roll back to, + // after the pendingSafeHead fails to become safe. + // This is changing in the Holocene fork. backupUnsafeHead eth.L2BlockRef - needFCUCall bool + + needFCUCall bool // Track when the rollup node changes the forkchoice to restore previous // known unsafe chain. e.g. Unsafe Reorg caused by Invalid span batch. // This update does not retry except engine returns non-input error @@ -96,10 +111,18 @@ func (e *EngineController) UnsafeL2Head() eth.L2BlockRef { return e.unsafeHead } +func (e *EngineController) CrossUnsafeL2Head() eth.L2BlockRef { + return e.crossUnsafeHead +} + func (e *EngineController) PendingSafeL2Head() eth.L2BlockRef { return e.pendingSafeHead } +func (e *EngineController) LocalSafeL2Head() eth.L2BlockRef { + return e.localSafeHead +} + func (e *EngineController) SafeL2Head() eth.L2BlockRef { return e.safeHead } @@ -131,14 +154,20 @@ func (e *EngineController) SetPendingSafeL2Head(r eth.L2BlockRef) { e.pendingSafeHead = r } -// SetSafeHead implements LocalEngineControl. +// SetLocalSafeHead sets the local-safe head. +func (e *EngineController) SetLocalSafeHead(r eth.L2BlockRef) { + e.metrics.RecordL2Ref("l2_local_safe", r) + e.localSafeHead = r +} + +// SetSafeHead sets the cross-safe head. func (e *EngineController) SetSafeHead(r eth.L2BlockRef) { e.metrics.RecordL2Ref("l2_safe", r) e.safeHead = r e.needFCUCall = true } -// SetUnsafeHead implements LocalEngineControl. +// SetUnsafeHead sets the local-unsafe head. func (e *EngineController) SetUnsafeHead(r eth.L2BlockRef) { e.metrics.RecordL2Ref("l2_unsafe", r) e.unsafeHead = r @@ -146,6 +175,12 @@ func (e *EngineController) SetUnsafeHead(r eth.L2BlockRef) { e.chainSpec.CheckForkActivation(e.log, r) } +// SetCrossUnsafeHead the cross-unsafe head. +func (e *EngineController) SetCrossUnsafeHead(r eth.L2BlockRef) { + e.metrics.RecordL2Ref("l2_cross_unsafe", r) + e.crossUnsafeHead = r +} + // SetBackupUnsafeL2Head implements LocalEngineControl. func (e *EngineController) SetBackupUnsafeL2Head(r eth.L2BlockRef, triggerReorg bool) { e.metrics.RecordL2Ref("l2_backup_unsafe", r) @@ -310,7 +345,11 @@ func (e *EngineController) InsertUnsafePayload(ctx context.Context, envelope *et if e.syncStatus == syncStatusFinishedELButNotFinalized { fc.SafeBlockHash = envelope.ExecutionPayload.BlockHash fc.FinalizedBlockHash = envelope.ExecutionPayload.BlockHash + e.SetUnsafeHead(ref) // ensure that the unsafe head stays ahead of safe/finalized labels. + e.emitter.Emit(UnsafeUpdateEvent{Ref: ref}) + e.SetLocalSafeHead(ref) e.SetSafeHead(ref) + e.emitter.Emit(CrossSafeUpdateEvent{LocalSafe: ref, CrossSafe: ref}) e.SetFinalizedHead(ref) } logFn := e.logSyncProgressMaybe() @@ -336,6 +375,7 @@ func (e *EngineController) InsertUnsafePayload(ctx context.Context, envelope *et } e.SetUnsafeHead(ref) e.needFCUCall = false + e.emitter.Emit(UnsafeUpdateEvent{Ref: ref}) if e.syncStatus == syncStatusFinishedELButNotFinalized { e.log.Info("Finished EL sync", "sync_duration", e.clock.Since(e.elStart), "finalized_block", ref.ID().String()) diff --git a/op-node/rollup/engine/events.go b/op-node/rollup/engine/events.go index 325118825fce..b5e010280ebc 100644 --- a/op-node/rollup/engine/events.go +++ b/op-node/rollup/engine/events.go @@ -40,6 +40,55 @@ func (ev ForkchoiceUpdateEvent) String() string { return "forkchoice-update" } +// PromoteUnsafeEvent signals that the given block may now become a canonical unsafe block. +// This is pre-forkchoice update; the change may not be reflected yet in the EL. +// Note that the legacy pre-event-refactor code-path (processing P2P blocks) does fire this, +// but manually, duplicate with the newer events processing code-path. +// See EngineController.InsertUnsafePayload. +type PromoteUnsafeEvent struct { + Ref eth.L2BlockRef +} + +func (ev PromoteUnsafeEvent) String() string { + return "promote-unsafe" +} + +// RequestCrossUnsafeEvent signals that a CrossUnsafeUpdateEvent is needed. +type RequestCrossUnsafeEvent struct{} + +func (ev RequestCrossUnsafeEvent) String() string { + return "request-cross-unsafe" +} + +// UnsafeUpdateEvent signals that the given block is now considered safe. +// This is pre-forkchoice update; the change may not be reflected yet in the EL. +type UnsafeUpdateEvent struct { + Ref eth.L2BlockRef +} + +func (ev UnsafeUpdateEvent) String() string { + return "unsafe-update" +} + +// PromoteCrossUnsafeEvent signals that the given block may be promoted to cross-unsafe. +type PromoteCrossUnsafeEvent struct { + Ref eth.L2BlockRef +} + +func (ev PromoteCrossUnsafeEvent) String() string { + return "promote-cross-unsafe" +} + +// CrossUnsafeUpdateEvent signals that the given block is now considered cross-unsafe. +type CrossUnsafeUpdateEvent struct { + CrossUnsafe eth.L2BlockRef + LocalUnsafe eth.L2BlockRef +} + +func (ev CrossUnsafeUpdateEvent) String() string { + return "cross-unsafe-update" +} + type PendingSafeUpdateEvent struct { PendingSafe eth.L2BlockRef Unsafe eth.L2BlockRef // tip, added to the signal, to determine if there are existing blocks to consolidate @@ -60,7 +109,54 @@ func (ev PromotePendingSafeEvent) String() string { return "promote-pending-safe" } -// SafeDerivedEvent signals that a block was determined to be safe, and derived from the given L1 block +// PromoteLocalSafeEvent signals that a block can be promoted to local-safe. +type PromoteLocalSafeEvent struct { + Ref eth.L2BlockRef + DerivedFrom eth.L1BlockRef +} + +func (ev PromoteLocalSafeEvent) String() string { + return "promote-local-safe" +} + +// RequestCrossSafeEvent signals that a CrossSafeUpdate is needed. +type RequestCrossSafeEvent struct{} + +func (ev RequestCrossSafeEvent) String() string { + return "request-cross-safe-update" +} + +type CrossSafeUpdateEvent struct { + CrossSafe eth.L2BlockRef + LocalSafe eth.L2BlockRef +} + +func (ev CrossSafeUpdateEvent) String() string { + return "cross-safe-update" +} + +// LocalSafeUpdateEvent signals that a block is now considered to be local-safe. +type LocalSafeUpdateEvent struct { + Ref eth.L2BlockRef + DerivedFrom eth.L1BlockRef +} + +func (ev LocalSafeUpdateEvent) String() string { + return "local-safe-update" +} + +// PromoteSafeEvent signals that a block can be promoted to cross-safe. +type PromoteSafeEvent struct { + Ref eth.L2BlockRef + DerivedFrom eth.L1BlockRef +} + +func (ev PromoteSafeEvent) String() string { + return "promote-safe" +} + +// SafeDerivedEvent signals that a block was determined to be safe, and derived from the given L1 block. +// This is signaled upon successful processing of PromoteSafeEvent. type SafeDerivedEvent struct { Safe eth.L2BlockRef DerivedFrom eth.L1BlockRef @@ -133,6 +229,16 @@ func (ev PromoteFinalizedEvent) String() string { return "promote-finalized" } +// CrossUpdateRequestEvent triggers update events to be emitted, repeating the current state. +type CrossUpdateRequestEvent struct { + CrossUnsafe bool + CrossSafe bool +} + +func (ev CrossUpdateRequestEvent) String() string { + return "cross-update-request" +} + type EngDeriver struct { metrics Metrics @@ -234,6 +340,36 @@ func (d *EngDeriver) OnEvent(ev event.Event) bool { "safeHead", x.Safe, "unsafe", x.Unsafe, "safe_timestamp", x.Safe.Time, "unsafe_timestamp", x.Unsafe.Time) d.emitter.Emit(EngineResetConfirmedEvent(x)) + case PromoteUnsafeEvent: + // Backup unsafeHead when new block is not built on original unsafe head. + if d.ec.unsafeHead.Number >= x.Ref.Number { + d.ec.SetBackupUnsafeL2Head(d.ec.unsafeHead, false) + } + d.ec.SetUnsafeHead(x.Ref) + d.emitter.Emit(UnsafeUpdateEvent(x)) + case UnsafeUpdateEvent: + // pre-interop everything that is local-unsafe is also immediately cross-unsafe. + if !d.cfg.IsInterop(x.Ref.Time) { + d.emitter.Emit(PromoteCrossUnsafeEvent(x)) + } + // Try to apply the forkchoice changes + d.emitter.Emit(TryUpdateEngineEvent{}) + case PromoteCrossUnsafeEvent: + d.ec.SetCrossUnsafeHead(x.Ref) + d.emitter.Emit(CrossUnsafeUpdateEvent{ + CrossUnsafe: x.Ref, + LocalUnsafe: d.ec.UnsafeL2Head(), + }) + case RequestCrossUnsafeEvent: + d.emitter.Emit(CrossUnsafeUpdateEvent{ + CrossUnsafe: d.ec.CrossUnsafeL2Head(), + LocalUnsafe: d.ec.UnsafeL2Head(), + }) + case RequestCrossSafeEvent: + d.emitter.Emit(CrossSafeUpdateEvent{ + CrossSafe: d.ec.SafeL2Head(), + LocalSafe: d.ec.LocalSafeL2Head(), + }) case PendingSafeRequestEvent: d.emitter.Emit(PendingSafeUpdateEvent{ PendingSafe: d.ec.PendingSafeL2Head(), @@ -249,12 +385,30 @@ func (d *EngDeriver) OnEvent(ev event.Event) bool { Unsafe: d.ec.UnsafeL2Head(), }) } - if x.Safe && x.Ref.Number > d.ec.SafeL2Head().Number { - d.ec.SetSafeHead(x.Ref) - d.emitter.Emit(SafeDerivedEvent{Safe: x.Ref, DerivedFrom: x.DerivedFrom}) - // Try to apply the forkchoice changes - d.emitter.Emit(TryUpdateEngineEvent{}) + if x.Safe && x.Ref.Number > d.ec.LocalSafeL2Head().Number { + d.emitter.Emit(PromoteLocalSafeEvent{ + Ref: x.Ref, + DerivedFrom: x.DerivedFrom, + }) + } + case PromoteLocalSafeEvent: + d.ec.SetLocalSafeHead(x.Ref) + d.emitter.Emit(LocalSafeUpdateEvent(x)) + case LocalSafeUpdateEvent: + // pre-interop everything that is local-safe is also immediately cross-safe. + if !d.cfg.IsInterop(x.Ref.Time) { + d.emitter.Emit(PromoteSafeEvent(x)) } + case PromoteSafeEvent: + d.ec.SetSafeHead(x.Ref) + // Finalizer can pick up this safe cross-block now + d.emitter.Emit(SafeDerivedEvent{Safe: x.Ref, DerivedFrom: x.DerivedFrom}) + d.emitter.Emit(CrossSafeUpdateEvent{ + CrossSafe: d.ec.SafeL2Head(), + LocalSafe: d.ec.LocalSafeL2Head(), + }) + // Try to apply the forkchoice changes + d.emitter.Emit(TryUpdateEngineEvent{}) case PromoteFinalizedEvent: if x.Ref.Number < d.ec.Finalized().Number { d.log.Error("Cannot rewind finality,", "ref", x.Ref, "finalized", d.ec.Finalized()) @@ -267,6 +421,19 @@ func (d *EngDeriver) OnEvent(ev event.Event) bool { d.ec.SetFinalizedHead(x.Ref) // Try to apply the forkchoice changes d.emitter.Emit(TryUpdateEngineEvent{}) + case CrossUpdateRequestEvent: + if x.CrossUnsafe { + d.emitter.Emit(CrossUnsafeUpdateEvent{ + CrossUnsafe: d.ec.CrossUnsafeL2Head(), + LocalUnsafe: d.ec.UnsafeL2Head(), + }) + } + if x.CrossSafe { + d.emitter.Emit(CrossSafeUpdateEvent{ + CrossSafe: d.ec.SafeL2Head(), + LocalSafe: d.ec.LocalSafeL2Head(), + }) + } case BuildStartEvent: d.onBuildStart(x) case BuildStartedEvent: @@ -295,6 +462,8 @@ type ResetEngineControl interface { SetUnsafeHead(eth.L2BlockRef) SetSafeHead(eth.L2BlockRef) SetFinalizedHead(eth.L2BlockRef) + SetLocalSafeHead(ref eth.L2BlockRef) + SetCrossUnsafeHead(ref eth.L2BlockRef) SetBackupUnsafeL2Head(block eth.L2BlockRef, triggerReorg bool) SetPendingSafeL2Head(eth.L2BlockRef) } @@ -302,8 +471,10 @@ type ResetEngineControl interface { // ForceEngineReset is not to be used. The op-program needs it for now, until event processing is adopted there. func ForceEngineReset(ec ResetEngineControl, x ForceEngineResetEvent) { ec.SetUnsafeHead(x.Unsafe) - ec.SetSafeHead(x.Safe) + ec.SetLocalSafeHead(x.Safe) ec.SetPendingSafeL2Head(x.Safe) ec.SetFinalizedHead(x.Finalized) + ec.SetSafeHead(x.Safe) + ec.SetCrossUnsafeHead(x.Safe) ec.SetBackupUnsafeL2Head(eth.L2BlockRef{}, false) } diff --git a/op-node/rollup/engine/payload_success.go b/op-node/rollup/engine/payload_success.go index cdd2ee2d030b..7bb4a38307e1 100644 --- a/op-node/rollup/engine/payload_success.go +++ b/op-node/rollup/engine/payload_success.go @@ -19,23 +19,14 @@ func (ev PayloadSuccessEvent) String() string { } func (eq *EngDeriver) onPayloadSuccess(ev PayloadSuccessEvent) { - - // Backup unsafeHead when new block is not built on original unsafe head. - if eq.ec.unsafeHead.Number >= ev.Ref.Number { - eq.ec.SetBackupUnsafeL2Head(eq.ec.unsafeHead, false) - } - eq.ec.SetUnsafeHead(ev.Ref) + eq.emitter.Emit(PromoteUnsafeEvent{Ref: ev.Ref}) // If derived from L1, then it can be considered (pending) safe if ev.DerivedFrom != (eth.L1BlockRef{}) { - if ev.IsLastInSpan { - eq.ec.SetSafeHead(ev.Ref) - eq.emitter.Emit(SafeDerivedEvent{Safe: ev.Ref, DerivedFrom: ev.DerivedFrom}) - } - eq.ec.SetPendingSafeL2Head(ev.Ref) - eq.emitter.Emit(PendingSafeUpdateEvent{ - PendingSafe: eq.ec.PendingSafeL2Head(), - Unsafe: eq.ec.UnsafeL2Head(), + eq.emitter.Emit(PromotePendingSafeEvent{ + Ref: ev.Ref, + Safe: ev.IsLastInSpan, + DerivedFrom: ev.DerivedFrom, }) } diff --git a/op-node/rollup/interop/interop.go b/op-node/rollup/interop/interop.go new file mode 100644 index 000000000000..c6c170478f21 --- /dev/null +++ b/op-node/rollup/interop/interop.go @@ -0,0 +1,161 @@ +package interop + +import ( + "context" + "sync" + "time" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-node/rollup/engine" + "github.com/ethereum-optimism/optimism/op-node/rollup/event" + "github.com/ethereum-optimism/optimism/op-service/eth" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" +) + +const checkBlockTimeout = time.Second * 10 + +type InteropBackend interface { + CheckBlock(ctx context.Context, + chainID types.ChainID, blockHash common.Hash, blockNumber uint64) (types.SafetyLevel, error) +} + +type L2Source interface { + L2BlockRefByNumber(context.Context, uint64) (eth.L2BlockRef, error) +} + +// InteropDeriver watches for update events (either real changes to block safety, +// or updates published upon request), checks if there is some local data to cross-verify, +// and then checks with the interop-backend, to try to promote to cross-verified safety. +type InteropDeriver struct { + log log.Logger + cfg *rollup.Config + + // we cache the chainID, + // to not continuously convert from the type in the rollup-config to this type. + chainID types.ChainID + + driverCtx context.Context + + // L2 blockhash -> derived from L1 block ref. + // Added to when a block is local-safe. + // Removed from when it is promoted to cross-safe. + derivedFrom map[common.Hash]eth.L1BlockRef + + backend InteropBackend + l2 L2Source + + emitter event.Emitter + + mu sync.Mutex +} + +var _ event.Deriver = (*InteropDeriver)(nil) +var _ event.AttachEmitter = (*InteropDeriver)(nil) + +func NewInteropDeriver(log log.Logger, cfg *rollup.Config, + driverCtx context.Context, backend InteropBackend, l2 L2Source) *InteropDeriver { + return &InteropDeriver{ + log: log, + cfg: cfg, + chainID: types.ChainIDFromBig(cfg.L2ChainID), + driverCtx: driverCtx, + derivedFrom: make(map[common.Hash]eth.L1BlockRef), + backend: backend, + l2: l2, + } +} + +func (d *InteropDeriver) AttachEmitter(em event.Emitter) { + d.emitter = em +} + +func (d *InteropDeriver) OnEvent(ev event.Event) bool { + d.mu.Lock() + defer d.mu.Unlock() + + switch x := ev.(type) { + case engine.UnsafeUpdateEvent: + d.emitter.Emit(engine.RequestCrossUnsafeEvent{}) + case engine.CrossUnsafeUpdateEvent: + if x.CrossUnsafe.Number >= x.LocalUnsafe.Number { + break // nothing left to promote + } + // Pre-interop the engine itself handles promotion to cross-unsafe. + // Check if the next block (still unsafe) can be promoted to cross-unsafe. + if !d.cfg.IsInterop(d.cfg.TimestampForBlock(x.CrossUnsafe.Number + 1)) { + return false + } + ctx, cancel := context.WithTimeout(d.driverCtx, checkBlockTimeout) + defer cancel() + candidate, err := d.l2.L2BlockRefByNumber(ctx, x.CrossUnsafe.Number+1) + if err != nil { + d.log.Warn("Failed to fetch next cross-unsafe candidate", "err", err) + break + } + blockSafety, err := d.backend.CheckBlock(ctx, d.chainID, candidate.Hash, candidate.Number) + if err != nil { + d.log.Warn("Failed to check interop safety of unsafe block", "err", err) + break + } + switch blockSafety { + case types.CrossUnsafe, types.CrossSafe, types.CrossFinalized: + // Hold off on promoting higher than cross-unsafe, + // this will happen once we verify it to be local-safe first. + d.emitter.Emit(engine.PromoteCrossUnsafeEvent{Ref: candidate}) + } + case engine.LocalSafeUpdateEvent: + d.derivedFrom[x.Ref.Hash] = x.DerivedFrom + d.emitter.Emit(engine.RequestCrossSafeEvent{}) + case engine.CrossSafeUpdateEvent: + if x.CrossSafe.Number >= x.LocalSafe.Number { + break // nothing left to promote + } + // Pre-interop the engine itself handles promotion to cross-safe. + // Check if the next block (not yet cross-safe) can be promoted to cross-safe. + if !d.cfg.IsInterop(d.cfg.TimestampForBlock(x.CrossSafe.Number + 1)) { + return false + } + ctx, cancel := context.WithTimeout(d.driverCtx, checkBlockTimeout) + defer cancel() + candidate, err := d.l2.L2BlockRefByNumber(ctx, x.CrossSafe.Number+1) + if err != nil { + d.log.Warn("Failed to fetch next cross-safe candidate", "err", err) + break + } + blockSafety, err := d.backend.CheckBlock(ctx, d.chainID, candidate.Hash, candidate.Number) + if err != nil { + d.log.Warn("Failed to check interop safety of local-safe block", "err", err) + break + } + derivedFrom, ok := d.derivedFrom[candidate.Hash] + if !ok { + break + } + switch blockSafety { + case types.CrossSafe: + // TODO(#11673): once we have interop reorg support, we need to clean stale blocks also. + delete(d.derivedFrom, candidate.Hash) + d.emitter.Emit(engine.PromoteSafeEvent{ + Ref: candidate, + DerivedFrom: derivedFrom, + }) + case types.Finalized: + // TODO(#11673): once we have interop reorg support, we need to clean stale blocks also. + delete(d.derivedFrom, candidate.Hash) + d.emitter.Emit(engine.PromoteSafeEvent{ + Ref: candidate, + DerivedFrom: derivedFrom, + }) + d.emitter.Emit(engine.PromoteFinalizedEvent{ + Ref: candidate, + }) + } + // no reorg support yet; the safe L2 head will finalize eventually, no exceptions + default: + return false + } + return true +} diff --git a/op-node/rollup/interop/interop_test.go b/op-node/rollup/interop/interop_test.go new file mode 100644 index 000000000000..62b71140770e --- /dev/null +++ b/op-node/rollup/interop/interop_test.go @@ -0,0 +1,136 @@ +package interop + +import ( + "context" + "math/big" + "math/rand" // nosemgrep + "testing" + + "github.com/stretchr/testify/require" + + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-node/rollup/engine" + "github.com/ethereum-optimism/optimism/op-service/testlog" + "github.com/ethereum-optimism/optimism/op-service/testutils" + supervisortypes "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" +) + +func TestInteropDeriver(t *testing.T) { + logger := testlog.Logger(t, log.LevelInfo) + l2Source := &testutils.MockL2Client{} + emitter := &testutils.MockEmitter{} + interopBackend := &testutils.MockInteropBackend{} + cfg := &rollup.Config{ + InteropTime: new(uint64), + L2ChainID: big.NewInt(42), + } + chainID := supervisortypes.ChainIDFromBig(cfg.L2ChainID) + interopDeriver := NewInteropDeriver(logger, cfg, context.Background(), interopBackend, l2Source) + interopDeriver.AttachEmitter(emitter) + rng := rand.New(rand.NewSource(123)) + + t.Run("unsafe blocks trigger cross-unsafe check attempts", func(t *testing.T) { + emitter.ExpectOnce(engine.RequestCrossUnsafeEvent{}) + interopDeriver.OnEvent(engine.UnsafeUpdateEvent{ + Ref: testutils.RandomL2BlockRef(rng), + }) + emitter.AssertExpectations(t) + }) + t.Run("establish cross-unsafe", func(t *testing.T) { + crossUnsafe := testutils.RandomL2BlockRef(rng) + firstLocalUnsafe := testutils.NextRandomL2Ref(rng, 2, crossUnsafe, crossUnsafe.L1Origin) + lastLocalUnsafe := testutils.NextRandomL2Ref(rng, 2, firstLocalUnsafe, firstLocalUnsafe.L1Origin) + interopBackend.ExpectCheckBlock( + chainID, firstLocalUnsafe.Number, supervisortypes.CrossUnsafe, nil) + emitter.ExpectOnce(engine.PromoteCrossUnsafeEvent{ + Ref: firstLocalUnsafe, + }) + l2Source.ExpectL2BlockRefByNumber(firstLocalUnsafe.Number, firstLocalUnsafe, nil) + interopDeriver.OnEvent(engine.CrossUnsafeUpdateEvent{ + CrossUnsafe: crossUnsafe, + LocalUnsafe: lastLocalUnsafe, + }) + interopBackend.AssertExpectations(t) + emitter.AssertExpectations(t) + l2Source.AssertExpectations(t) + }) + t.Run("deny cross-unsafe", func(t *testing.T) { + crossUnsafe := testutils.RandomL2BlockRef(rng) + firstLocalUnsafe := testutils.NextRandomL2Ref(rng, 2, crossUnsafe, crossUnsafe.L1Origin) + lastLocalUnsafe := testutils.NextRandomL2Ref(rng, 2, firstLocalUnsafe, firstLocalUnsafe.L1Origin) + interopBackend.ExpectCheckBlock( + chainID, firstLocalUnsafe.Number, supervisortypes.Unsafe, nil) + l2Source.ExpectL2BlockRefByNumber(firstLocalUnsafe.Number, firstLocalUnsafe, nil) + interopDeriver.OnEvent(engine.CrossUnsafeUpdateEvent{ + CrossUnsafe: crossUnsafe, + LocalUnsafe: lastLocalUnsafe, + }) + interopBackend.AssertExpectations(t) + // no cross-unsafe promote event is expected + emitter.AssertExpectations(t) + l2Source.AssertExpectations(t) + }) + t.Run("register local-safe", func(t *testing.T) { + derivedFrom := testutils.RandomBlockRef(rng) + localSafe := testutils.RandomL2BlockRef(rng) + emitter.ExpectOnce(engine.RequestCrossSafeEvent{}) + interopDeriver.OnEvent(engine.LocalSafeUpdateEvent{ + Ref: localSafe, + DerivedFrom: derivedFrom, + }) + require.Contains(t, interopDeriver.derivedFrom, localSafe.Hash) + require.Equal(t, derivedFrom, interopDeriver.derivedFrom[localSafe.Hash]) + emitter.AssertExpectations(t) + }) + t.Run("establish cross-safe", func(t *testing.T) { + derivedFrom := testutils.RandomBlockRef(rng) + crossSafe := testutils.RandomL2BlockRef(rng) + firstLocalSafe := testutils.NextRandomL2Ref(rng, 2, crossSafe, crossSafe.L1Origin) + lastLocalSafe := testutils.NextRandomL2Ref(rng, 2, firstLocalSafe, firstLocalSafe.L1Origin) + emitter.ExpectOnce(engine.RequestCrossSafeEvent{}) + // The local safe block must be known, for the derived-from mapping to work + interopDeriver.OnEvent(engine.LocalSafeUpdateEvent{ + Ref: firstLocalSafe, + DerivedFrom: derivedFrom, + }) + interopBackend.ExpectCheckBlock( + chainID, firstLocalSafe.Number, supervisortypes.CrossSafe, nil) + emitter.ExpectOnce(engine.PromoteSafeEvent{ + Ref: firstLocalSafe, + DerivedFrom: derivedFrom, + }) + l2Source.ExpectL2BlockRefByNumber(firstLocalSafe.Number, firstLocalSafe, nil) + interopDeriver.OnEvent(engine.CrossSafeUpdateEvent{ + CrossSafe: crossSafe, + LocalSafe: lastLocalSafe, + }) + interopBackend.AssertExpectations(t) + emitter.AssertExpectations(t) + l2Source.AssertExpectations(t) + }) + t.Run("deny cross-safe", func(t *testing.T) { + derivedFrom := testutils.RandomBlockRef(rng) + crossSafe := testutils.RandomL2BlockRef(rng) + firstLocalSafe := testutils.NextRandomL2Ref(rng, 2, crossSafe, crossSafe.L1Origin) + lastLocalSafe := testutils.NextRandomL2Ref(rng, 2, firstLocalSafe, firstLocalSafe.L1Origin) + emitter.ExpectOnce(engine.RequestCrossSafeEvent{}) + // The local safe block must be known, for the derived-from mapping to work + interopDeriver.OnEvent(engine.LocalSafeUpdateEvent{ + Ref: firstLocalSafe, + DerivedFrom: derivedFrom, + }) + interopBackend.ExpectCheckBlock( + chainID, firstLocalSafe.Number, supervisortypes.Safe, nil) + l2Source.ExpectL2BlockRefByNumber(firstLocalSafe.Number, firstLocalSafe, nil) + interopDeriver.OnEvent(engine.CrossSafeUpdateEvent{ + CrossSafe: crossSafe, + LocalSafe: lastLocalSafe, + }) + interopBackend.AssertExpectations(t) + // no cross-safe promote event is expected + emitter.AssertExpectations(t) + l2Source.AssertExpectations(t) + }) +} diff --git a/op-node/rollup/status/status.go b/op-node/rollup/status/status.go index b14f93843f72..65121b1294aa 100644 --- a/op-node/rollup/status/status.go +++ b/op-node/rollup/status/status.go @@ -69,6 +69,14 @@ func (st *StatusTracker) OnEvent(ev event.Event) bool { case engine.PendingSafeUpdateEvent: st.data.UnsafeL2 = x.Unsafe st.data.PendingSafeL2 = x.PendingSafe + case engine.CrossUnsafeUpdateEvent: + st.data.CrossUnsafeL2 = x.CrossUnsafe + st.data.UnsafeL2 = x.LocalUnsafe + case engine.LocalSafeUpdateEvent: + st.data.LocalSafeL2 = x.Ref + case engine.CrossSafeUpdateEvent: + st.data.SafeL2 = x.CrossSafe + st.data.LocalSafeL2 = x.LocalSafe case derive.DeriverL1StatusEvent: st.data.CurrentL1 = x.Origin case L1UnsafeEvent: diff --git a/op-node/service.go b/op-node/service.go index 723c6febb321..b24e2a638335 100644 --- a/op-node/service.go +++ b/op-node/service.go @@ -82,11 +82,12 @@ func NewConfig(ctx *cli.Context, log log.Logger) (*node.Config, error) { } cfg := &node.Config{ - L1: l1Endpoint, - L2: l2Endpoint, - Rollup: *rollupConfig, - Driver: *driverConfig, - Beacon: NewBeaconEndpointConfig(ctx), + L1: l1Endpoint, + L2: l2Endpoint, + Rollup: *rollupConfig, + Driver: *driverConfig, + Beacon: NewBeaconEndpointConfig(ctx), + Supervisor: NewSupervisorEndpointConfig(ctx), RPC: node.RPCConfig{ ListenAddr: ctx.String(flags.RPCListenAddr.Name), ListenPort: ctx.Int(flags.RPCListenPort.Name), @@ -129,6 +130,12 @@ func NewConfig(ctx *cli.Context, log log.Logger) (*node.Config, error) { return cfg, nil } +func NewSupervisorEndpointConfig(ctx *cli.Context) node.SupervisorEndpointSetup { + return &node.SupervisorEndpointConfig{ + SupervisorAddr: ctx.String(flags.SupervisorAddr.Name), + } +} + func NewBeaconEndpointConfig(ctx *cli.Context) node.L1BeaconEndpointSetup { return &node.L1BeaconEndpointConfig{ BeaconAddr: ctx.String(flags.BeaconAddr.Name), diff --git a/op-service/eth/sync_status.go b/op-service/eth/sync_status.go index e6dae130de7f..f9db1f672b82 100644 --- a/op-service/eth/sync_status.go +++ b/op-service/eth/sync_status.go @@ -22,13 +22,20 @@ type SyncStatus struct { // pointing to block data that has not been submitted to L1 yet. // The sequencer is building this, and verifiers may also be ahead of the // SafeL2 block if they sync blocks via p2p or other offchain sources. + // This is considered to only be local-unsafe post-interop, see CrossUnsafe for cross-L2 guarantees. UnsafeL2 L2BlockRef `json:"unsafe_l2"` // SafeL2 points to the L2 block that was derived from the L1 chain. // This point may still reorg if the L1 chain reorgs. + // This is considered to be cross-safe post-interop, see LocalSafe to ignore cross-L2 guarantees. SafeL2 L2BlockRef `json:"safe_l2"` // FinalizedL2 points to the L2 block that was derived fully from // finalized L1 information, thus irreversible. FinalizedL2 L2BlockRef `json:"finalized_l2"` // PendingSafeL2 points to the L2 block processed from the batch, but not consolidated to the safe block yet. PendingSafeL2 L2BlockRef `json:"pending_safe_l2"` + // CrossUnsafeL2 is an unsafe L2 block, that has been verified to match cross-L2 dependencies. + // Pre-interop every unsafe L2 block is also cross-unsafe. + CrossUnsafeL2 L2BlockRef `json:"cross_unsafe_l2"` + // LocalSafeL2 is an L2 block derived from L1, not yet verified to have valid cross-L2 dependencies. + LocalSafeL2 L2BlockRef `json:"local_safe_l2"` } diff --git a/op-service/sources/supervisor_client.go b/op-service/sources/supervisor_client.go new file mode 100644 index 000000000000..a13ea4782471 --- /dev/null +++ b/op-service/sources/supervisor_client.go @@ -0,0 +1,37 @@ +package sources + +import ( + "context" + "fmt" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/hexutil" + + "github.com/ethereum-optimism/optimism/op-service/client" + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" +) + +type SupervisorClient struct { + client client.RPC +} + +func NewSupervisorClient(client client.RPC) *SupervisorClient { + return &SupervisorClient{ + client: client, + } +} + +func (cl *SupervisorClient) CheckBlock(ctx context.Context, + chainID types.ChainID, blockHash common.Hash, blockNumber uint64) (types.SafetyLevel, error) { + var result types.SafetyLevel + err := cl.client.CallContext(ctx, &result, "interop_checkBlock", + (*hexutil.U256)(&chainID), blockHash, hexutil.Uint64(blockNumber)) + if err != nil { + return types.Unsafe, fmt.Errorf("failed to check Block %s:%d (chain %s): %w", blockHash, blockNumber, chainID, err) + } + return result, nil +} + +func (cl *SupervisorClient) Close() { + cl.client.Close() +} diff --git a/op-service/testutils/mock_interop_backend.go b/op-service/testutils/mock_interop_backend.go new file mode 100644 index 000000000000..970627ff750f --- /dev/null +++ b/op-service/testutils/mock_interop_backend.go @@ -0,0 +1,28 @@ +package testutils + +import ( + "context" + + "github.com/stretchr/testify/mock" + + "github.com/ethereum/go-ethereum/common" + + "github.com/ethereum-optimism/optimism/op-supervisor/supervisor/types" +) + +type MockInteropBackend struct { + Mock mock.Mock +} + +func (m *MockInteropBackend) ExpectCheckBlock(chainID types.ChainID, blockNumber uint64, safety types.SafetyLevel, err error) { + m.Mock.On("CheckBlock", chainID, blockNumber).Once().Return(safety, &err) +} + +func (m *MockInteropBackend) CheckBlock(ctx context.Context, chainID types.ChainID, blockHash common.Hash, blockNumber uint64) (types.SafetyLevel, error) { + result := m.Mock.MethodCalled("CheckBlock", chainID, blockNumber) + return result.Get(0).(types.SafetyLevel), *result.Get(1).(*error) +} + +func (m *MockInteropBackend) AssertExpectations(t mock.TestingT) { + m.Mock.AssertExpectations(t) +}