diff --git a/op-e2e/actions/l1_miner.go b/op-e2e/actions/l1_miner.go index d94124a81671e..64a69e82479b4 100644 --- a/op-e2e/actions/l1_miner.go +++ b/op-e2e/actions/l1_miner.go @@ -17,6 +17,9 @@ import ( type L1Miner struct { L1Replica + // L1 block building preferences + prefCoinbase common.Address + // L1 block building data l1BuildingHeader *types.Header // block header that we add txs to for block building l1BuildingState *state.StateDB // state used for block building @@ -55,7 +58,7 @@ func (s *L1Miner) ActL1StartBlock(timeDelta uint64) Action { } header := &types.Header{ ParentHash: parentHash, - Coinbase: parent.Coinbase, + Coinbase: s.prefCoinbase, Difficulty: common.Big0, Number: new(big.Int).Add(parent.Number, common.Big1), GasLimit: parent.GasLimit, @@ -114,6 +117,13 @@ func (s *L1Miner) ActL1IncludeTx(from common.Address) Action { } } +func (s *L1Miner) ActL1SetFeeRecipient(coinbase common.Address) { + s.prefCoinbase = coinbase + if s.l1Building { + s.l1BuildingHeader.Coinbase = coinbase + } +} + // ActL1EndBlock finishes the new L1 block, and applies it to the chain as unsafe block func (s *L1Miner) ActL1EndBlock(t Testing) { if !s.l1Building { diff --git a/op-e2e/actions/l1_replica.go b/op-e2e/actions/l1_replica.go index 8097f55886f6b..eb87199284d8b 100644 --- a/op-e2e/actions/l1_replica.go +++ b/op-e2e/actions/l1_replica.go @@ -92,18 +92,27 @@ func NewL1Replica(log log.Logger, genesis *core.Genesis) *L1Replica { // ActL1RewindToParent rewinds the L1 chain to parent block of head func (s *L1Replica) ActL1RewindToParent(t Testing) { - head := s.l1Chain.CurrentHeader().Number.Uint64() - if head == 0 { - t.InvalidAction("cannot rewind L1 past genesis") - return - } - finalized := s.l1Chain.CurrentFinalizedBlock() - if finalized != nil && head <= finalized.NumberU64() { - t.InvalidAction("cannot rewind head of chain past finalized block %d", finalized.NumberU64()) - return - } - if err := s.l1Chain.SetHead(head - 1); err != nil { - t.Fatalf("failed to rewind L1 chain to nr %d: %v", head-1, err) + s.ActL1RewindDepth(1)(t) +} + +func (s *L1Replica) ActL1RewindDepth(depth uint64) Action { + return func(t Testing) { + if depth == 0 { + return + } + head := s.l1Chain.CurrentHeader().Number.Uint64() + if head < depth { + t.InvalidAction("cannot rewind L1 past genesis (current: %d, rewind depth: %d)", head, depth) + return + } + finalized := s.l1Chain.CurrentFinalizedBlock() + if finalized != nil && head < finalized.NumberU64()+depth { + t.InvalidAction("cannot rewind head of chain past finalized block %d with rewind depth %d", finalized.NumberU64(), depth) + return + } + if err := s.l1Chain.SetHead(head - depth); err != nil { + t.Fatalf("failed to rewind L1 chain to nr %d: %v", head-depth, err) + } } } diff --git a/op-e2e/actions/l2_verifier.go b/op-e2e/actions/l2_verifier.go new file mode 100644 index 0000000000000..ee9f6031c357f --- /dev/null +++ b/op-e2e/actions/l2_verifier.go @@ -0,0 +1,102 @@ +package actions + +import ( + "errors" + "io" + + "github.com/ethereum/go-ethereum/log" + + "github.com/ethereum-optimism/optimism/op-node/eth" + "github.com/ethereum-optimism/optimism/op-node/rollup" + "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-node/testutils" +) + +// L2Verifier is an actor that functions like a rollup node, +// without the full P2P/API/Node stack, but just the derivation state, and simplified driver. +type L2Verifier struct { + log log.Logger + + eng derive.Engine + + // L2 rollup + derivation *derive.DerivationPipeline + + l1Head eth.L1BlockRef + l1Safe eth.L1BlockRef + l1Finalized eth.L1BlockRef + + l2PipelineIdle bool + l2Building bool + + rollupCfg *rollup.Config +} + +func NewL2Verifier(log log.Logger, l1 derive.L1Fetcher, eng derive.Engine, cfg *rollup.Config) *L2Verifier { + pipeline := derive.NewDerivationPipeline(log, cfg, l1, eng, &testutils.TestDerivationMetrics{}) + pipeline.Reset() + return &L2Verifier{ + log: log, + eng: eng, + derivation: pipeline, + l2PipelineIdle: true, + l2Building: false, + rollupCfg: cfg, + } +} + +func (s *L2Verifier) SyncStatus() *eth.SyncStatus { + return ð.SyncStatus{ + CurrentL1: s.derivation.Progress().Origin, + HeadL1: s.l1Head, + SafeL1: s.l1Safe, + FinalizedL1: s.l1Finalized, + UnsafeL2: s.derivation.UnsafeL2Head(), + SafeL2: s.derivation.SafeL2Head(), + FinalizedL2: s.derivation.Finalized(), + } +} + +// TODO: actions to change L1 head/safe/finalized state. Depends on driver refactor work. + +// ActL2PipelineStep runs one iteration of the L2 derivation pipeline +func (s *L2Verifier) ActL2PipelineStep(t Testing) { + if s.l2Building { + t.InvalidAction("cannot derive new data while building L2 block") + return + } + + s.l2PipelineIdle = false + err := s.derivation.Step(t.Ctx()) + if err == io.EOF { + s.l2PipelineIdle = true + return + } else if err != nil && errors.Is(err, derive.NotEnoughData) { + return + } else if err != nil && errors.Is(err, derive.ErrReset) { + s.log.Warn("Derivation pipeline is reset", "err", err) + s.derivation.Reset() + return + } else if err != nil && errors.Is(err, derive.ErrTemporary) { + s.log.Warn("Derivation process temporary error", "err", err) + return + } else if err != nil && errors.Is(err, derive.ErrCritical) { + t.Fatalf("derivation failed critically: %v", err) + } else { + return + } +} + +func (s *L2Verifier) ActL2PipelineFull(t Testing) { + s.l2PipelineIdle = false + for !s.l2PipelineIdle { + s.ActL2PipelineStep(t) + } +} + +// ActL2UnsafeGossipReceive creates an action that can receive an unsafe execution payload, like gossipsub +func (s *L2Verifier) ActL2UnsafeGossipReceive(payload *eth.ExecutionPayload) Action { + return func(t Testing) { + s.derivation.AddUnsafePayload(payload) + } +} diff --git a/op-e2e/actions/l2_verifier_test.go b/op-e2e/actions/l2_verifier_test.go new file mode 100644 index 0000000000000..86c0dce171c55 --- /dev/null +++ b/op-e2e/actions/l2_verifier_test.go @@ -0,0 +1,93 @@ +package actions + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils" + "github.com/ethereum-optimism/optimism/op-node/sources" + "github.com/ethereum-optimism/optimism/op-node/testlog" +) + +func setupVerifierTest(t Testing, sd *e2eutils.SetupData, log log.Logger) (*L1Miner, *L2Engine, *L2Verifier) { + jwtPath := e2eutils.WriteDefaultJWT(t) + + miner := NewL1Miner(log, sd.L1Cfg) + + l1F, err := sources.NewL1Client(miner.RPCClient(), log, nil, sources.L1ClientDefaultConfig(sd.RollupCfg, false)) + require.NoError(t, err) + engine := NewL2Engine(log, sd.L2Cfg, sd.RollupCfg.Genesis.L1, jwtPath) + l2Cl, err := sources.NewEngineClient(engine.RPCClient(), log, nil, sources.EngineClientDefaultConfig(sd.RollupCfg)) + require.NoError(t, err) + + verifier := NewL2Verifier(log, l1F, l2Cl, sd.RollupCfg) + return miner, engine, verifier +} + +func TestL2Verifier_SequenceWindow(gt *testing.T) { + t := NewDefaultTesting(gt) + p := &e2eutils.TestParams{ + MaxSequencerDrift: 10, + SequencerWindowSize: 24, + ChannelTimeout: 10, + } + dp := e2eutils.MakeDeployParams(t, p) + sd := e2eutils.Setup(t, dp, defaultAlloc) + log := testlog.Logger(t, log.LvlDebug) + miner, engine, verifier := setupVerifierTest(t, sd, log) + miner.ActL1SetFeeRecipient(common.Address{'A'}) + + // Make two sequence windows worth of empty L1 blocks. After we pass the first sequence window, the L2 chain should get blocks + for miner.l1Chain.CurrentBlock().NumberU64() < sd.RollupCfg.SeqWindowSize*2 { + miner.ActL1StartBlock(10)(t) + miner.ActL1EndBlock(t) + + verifier.ActL2PipelineFull(t) + + l1Head := miner.l1Chain.CurrentBlock().NumberU64() + expectedL1Origin := uint64(0) + // as soon as we complete the sequence window, we force-adopt the L1 origin + if l1Head >= sd.RollupCfg.SeqWindowSize { + expectedL1Origin = l1Head - sd.RollupCfg.SeqWindowSize + 1 + } + require.Equal(t, expectedL1Origin, verifier.SyncStatus().SafeL2.L1Origin.Number, "L1 origin is forced in, given enough L1 blocks pass by") + require.LessOrEqual(t, miner.l1Chain.GetBlockByNumber(expectedL1Origin).Time(), engine.l2Chain.CurrentBlock().Time(), "L2 time higher than L1 origin time") + } + tip2N := verifier.SyncStatus() + + // Do a deep L1 reorg as deep as a sequence window, this should affect the safe L2 chain + miner.ActL1RewindDepth(sd.RollupCfg.SeqWindowSize)(t) + + // Without new L1 block, the L1 appears to not be synced, and the node shouldn't reorg + verifier.ActL2PipelineFull(t) + require.Equal(t, tip2N.SafeL2, verifier.SyncStatus().SafeL2, "still the same after verifier work") + + // Make a new empty L1 block with different data than there was before. + miner.ActL1SetFeeRecipient(common.Address{'B'}) + miner.ActL1StartBlock(10)(t) + miner.ActL1EndBlock(t) + reorgL1Block := miner.l1Chain.CurrentBlock() + + // Still no reorg, we need more L1 blocks first, before the reorged L1 block is forced in by sequence window + verifier.ActL2PipelineFull(t) + require.Equal(t, tip2N.SafeL2, verifier.SyncStatus().SafeL2) + + for miner.l1Chain.CurrentBlock().NumberU64() < sd.RollupCfg.SeqWindowSize*2 { + miner.ActL1StartBlock(10)(t) + miner.ActL1EndBlock(t) + } + + // workaround: in L1Traversal we only recognize the reorg once we see origin N+1, we don't reorg to shorter L1 chains + miner.ActL1StartBlock(10)(t) + miner.ActL1EndBlock(t) + + // Now it will reorg + verifier.ActL2PipelineFull(t) + + // due to workaround we synced one more L1 block, so we need to compare against the parent of that + got := miner.l1Chain.GetBlockByHash(miner.l1Chain.GetBlockByHash(verifier.SyncStatus().SafeL2.L1Origin.Hash).ParentHash()) + require.Equal(t, reorgL1Block.Hash(), got.Hash(), "must have reorged L2 chain to the new L1 chain") +}