diff --git a/op-e2e/actions/action.go b/op-e2e/actions/action.go index a578c6ad55261..c4e86b2b15f7f 100644 --- a/op-e2e/actions/action.go +++ b/op-e2e/actions/action.go @@ -44,6 +44,22 @@ type defaultTesting struct { state ActionStatus } +type StatefulTesting interface { + Testing + Reset(actionCtx context.Context) + State() ActionStatus +} + +// NewDefaultTesting returns a new testing obj. +// Returns an interface, we're likely changing the behavior here as we build more action tests. +func NewDefaultTesting(tb e2eutils.TestingBase) StatefulTesting { + return &defaultTesting{ + TestingBase: tb, + ctx: context.Background(), + state: ActionOK, + } +} + // Ctx shares a context to execute an action with, the test runner may interrupt the action without stopping the test. func (st *defaultTesting) Ctx() context.Context { return st.ctx diff --git a/op-e2e/actions/l1_miner.go b/op-e2e/actions/l1_miner.go new file mode 100644 index 0000000000000..d94124a81671e --- /dev/null +++ b/op-e2e/actions/l1_miner.go @@ -0,0 +1,142 @@ +package actions + +import ( + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/misc" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/state" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" + "github.com/ethereum/go-ethereum/trie" +) + +// L1Miner wraps a L1Replica with instrumented block building ability. +type L1Miner struct { + L1Replica + + // 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 + l1GasPool *core.GasPool // track gas used of ongoing building + pendingIndices map[common.Address]uint64 // per account, how many txs from the pool were already included in the block, since the pool is lagging behind block mining. + l1Transactions []*types.Transaction // collects txs that were successfully included into current block build + l1Receipts []*types.Receipt // collect receipts of ongoing building + l1Building bool + l1TxFailed []*types.Transaction // log of failed transactions which could not be included +} + +// NewL1Miner creates a new L1Replica that can also build blocks. +func NewL1Miner(log log.Logger, genesis *core.Genesis) *L1Miner { + rep := NewL1Replica(log, genesis) + return &L1Miner{ + L1Replica: *rep, + } +} + +// ActL1StartBlock returns an action to build a new L1 block on top of the head block, +// with timeDelta added to the head block time. +func (s *L1Miner) ActL1StartBlock(timeDelta uint64) Action { + return func(t Testing) { + if s.l1Building { + t.InvalidAction("not valid if we already started building a block") + } + if timeDelta == 0 { + t.Fatalf("invalid time delta: %d", timeDelta) + } + + parent := s.l1Chain.CurrentHeader() + parentHash := parent.Hash() + statedb, err := state.New(parent.Root, state.NewDatabase(s.l1Database), nil) + if err != nil { + t.Fatalf("failed to init state db around block %s (state %s): %w", parentHash, parent.Root, err) + } + header := &types.Header{ + ParentHash: parentHash, + Coinbase: parent.Coinbase, + Difficulty: common.Big0, + Number: new(big.Int).Add(parent.Number, common.Big1), + GasLimit: parent.GasLimit, + Time: parent.Time + timeDelta, + Extra: []byte("L1 was here"), + MixDigest: common.Hash{}, // TODO: maybe randomize this (prev-randao value) + } + if s.l1Cfg.Config.IsLondon(header.Number) { + header.BaseFee = misc.CalcBaseFee(s.l1Cfg.Config, parent) + // At the transition, double the gas limit so the gas target is equal to the old gas limit. + if !s.l1Cfg.Config.IsLondon(parent.Number) { + header.GasLimit = parent.GasLimit * params.ElasticityMultiplier + } + } + + s.l1Building = true + s.l1BuildingHeader = header + s.l1BuildingState = statedb + s.l1Receipts = make([]*types.Receipt, 0) + s.l1Transactions = make([]*types.Transaction, 0) + s.pendingIndices = make(map[common.Address]uint64) + + s.l1GasPool = new(core.GasPool).AddGas(header.GasLimit) + } +} + +// ActL1IncludeTx includes the next tx from L1 tx pool from the given account +func (s *L1Miner) ActL1IncludeTx(from common.Address) Action { + return func(t Testing) { + if !s.l1Building { + t.InvalidAction("no tx inclusion when not building l1 block") + return + } + i := s.pendingIndices[from] + txs, q := s.eth.TxPool().ContentFrom(from) + if uint64(len(txs)) <= i { + t.Fatalf("no pending txs from %s, and have %d unprocessable queued txs from this account", from, len(q)) + } + tx := txs[i] + if tx.Gas() > s.l1BuildingHeader.GasLimit { + t.Fatalf("tx consumes %d gas, more than available in L1 block %d", tx.Gas(), s.l1BuildingHeader.GasLimit) + } + if tx.Gas() > uint64(*s.l1GasPool) { + t.InvalidAction("action takes too much gas: %d, only have %d", tx.Gas(), uint64(*s.l1GasPool)) + return + } + s.pendingIndices[from] = i + 1 // won't retry the tx + receipt, err := core.ApplyTransaction(s.l1Cfg.Config, s.l1Chain, &s.l1BuildingHeader.Coinbase, + s.l1GasPool, s.l1BuildingState, s.l1BuildingHeader, tx, &s.l1BuildingHeader.GasUsed, *s.l1Chain.GetVMConfig()) + if err != nil { + s.l1TxFailed = append(s.l1TxFailed, tx) + t.Fatalf("failed to apply transaction to L1 block (tx %d): %w", len(s.l1Transactions), err) + } + s.l1Receipts = append(s.l1Receipts, receipt) + s.l1Transactions = append(s.l1Transactions, tx) + } +} + +// ActL1EndBlock finishes the new L1 block, and applies it to the chain as unsafe block +func (s *L1Miner) ActL1EndBlock(t Testing) { + if !s.l1Building { + t.InvalidAction("cannot end L1 block when not building block") + return + } + + s.l1Building = false + s.l1BuildingHeader.GasUsed = s.l1BuildingHeader.GasLimit - uint64(*s.l1GasPool) + s.l1BuildingHeader.Root = s.l1BuildingState.IntermediateRoot(s.l1Cfg.Config.IsEIP158(s.l1BuildingHeader.Number)) + block := types.NewBlock(s.l1BuildingHeader, s.l1Transactions, nil, s.l1Receipts, trie.NewStackTrie(nil)) + + // Write state changes to db + root, err := s.l1BuildingState.Commit(s.l1Cfg.Config.IsEIP158(s.l1BuildingHeader.Number)) + if err != nil { + t.Fatalf("l1 state write error: %v", err) + } + if err := s.l1BuildingState.Database().TrieDB().Commit(root, false, nil); err != nil { + t.Fatalf("l1 trie write error: %v", err) + } + + _, err = s.l1Chain.InsertChain(types.Blocks{block}) + if err != nil { + t.Fatalf("failed to insert block into l1 chain") + } +} diff --git a/op-e2e/actions/l1_miner_test.go b/op-e2e/actions/l1_miner_test.go new file mode 100644 index 0000000000000..8979d0ead7dc1 --- /dev/null +++ b/op-e2e/actions/l1_miner_test.go @@ -0,0 +1,59 @@ +package actions + +import ( + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils" + "github.com/ethereum-optimism/optimism/op-node/testlog" +) + +func TestL1Miner_BuildBlock(gt *testing.T) { + t := NewDefaultTesting(gt) + dp := e2eutils.MakeDeployParams(t, defaultRollupTestParams) + sd := e2eutils.Setup(t, dp, defaultAlloc) + log := testlog.Logger(t, log.LvlDebug) + miner := NewL1Miner(log, sd.L1Cfg) + + cl := miner.EthClient() + signer := types.LatestSigner(sd.L1Cfg.Config) + + // send a tx to the miner + tx := types.MustSignNewTx(dp.Secrets.Alice, signer, &types.DynamicFeeTx{ + ChainID: sd.L1Cfg.Config.ChainID, + Nonce: 0, + GasTipCap: big.NewInt(2 * params.GWei), + GasFeeCap: new(big.Int).Add(miner.l1Chain.CurrentBlock().BaseFee(), big.NewInt(2*params.GWei)), + Gas: params.TxGas, + To: &dp.Addresses.Bob, + Value: e2eutils.Ether(2), + }) + require.NoError(gt, cl.SendTransaction(t.Ctx(), tx)) + + // make an empty block, even though a tx may be waiting + miner.ActL1StartBlock(10)(t) + miner.ActL1EndBlock(t) + bl := miner.l1Chain.CurrentBlock() + require.Equal(t, uint64(1), bl.NumberU64()) + require.Zero(gt, bl.Transactions().Len()) + + // now include the tx when we want it to + miner.ActL1StartBlock(10)(t) + miner.ActL1IncludeTx(dp.Addresses.Alice)(t) + miner.ActL1EndBlock(t) + bl = miner.l1Chain.CurrentBlock() + require.Equal(t, uint64(2), bl.NumberU64()) + require.Equal(t, 1, bl.Transactions().Len()) + require.Equal(t, tx.Hash(), bl.Transactions()[0].Hash()) + + // now make a replica that syncs these two blocks from the miner + replica := NewL1Replica(log, sd.L1Cfg) + replica.ActL1Sync(miner.CanonL1Chain())(t) + replica.ActL1Sync(miner.CanonL1Chain())(t) + require.Equal(t, replica.l1Chain.CurrentBlock().Hash(), miner.l1Chain.CurrentBlock().Hash()) +} diff --git a/op-e2e/actions/l1_replica.go b/op-e2e/actions/l1_replica.go new file mode 100644 index 0000000000000..8097f55886f6b --- /dev/null +++ b/op-e2e/actions/l1_replica.go @@ -0,0 +1,188 @@ +package actions + +import ( + "errors" + "fmt" + + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/eth" + "github.com/ethereum/go-ethereum/eth/ethconfig" + "github.com/ethereum/go-ethereum/eth/tracers" + "github.com/ethereum/go-ethereum/ethclient" + "github.com/ethereum/go-ethereum/ethdb" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/node" + "github.com/ethereum/go-ethereum/p2p" + "github.com/stretchr/testify/require" + + "github.com/ethereum-optimism/optimism/op-node/client" + "github.com/ethereum-optimism/optimism/op-node/testutils" +) + +// L1CanonSrc is used to sync L1 from another node. +// The other node always has the canonical chain. +// May be nil if there is nothing to sync from +type L1CanonSrc func(num uint64) *types.Block + +// L1Replica is an instrumented in-memory L1 geth node that: +// - can sync from the given canonical L1 blocks source +// - can rewind the chain back (for reorgs) +// - can provide an RPC with mock errors +type L1Replica struct { + log log.Logger + + node *node.Node + eth *eth.Ethereum + + // L1 evm / chain + l1Chain *core.BlockChain + l1Database ethdb.Database + l1Cfg *core.Genesis + l1Signer types.Signer + + failL1RPC error // mock error +} + +// NewL1Replica constructs a L1Replica starting at the given genesis. +func NewL1Replica(log log.Logger, genesis *core.Genesis) *L1Replica { + ethCfg := ðconfig.Config{ + NetworkId: genesis.Config.ChainID.Uint64(), + Genesis: genesis, + RollupDisableTxPoolGossip: true, + } + nodeCfg := &node.Config{ + Name: "l1-geth", + WSHost: "127.0.0.1", + WSPort: 0, + WSModules: []string{"debug", "admin", "eth", "txpool", "net", "rpc", "web3", "personal"}, + HTTPModules: []string{"debug", "admin", "eth", "txpool", "net", "rpc", "web3", "personal"}, + DataDir: "", // in-memory + P2P: p2p.Config{ + NoDiscovery: true, + NoDial: true, + }, + } + n, err := node.New(nodeCfg) + if err != nil { + panic(err) + } + + backend, err := eth.New(n, ethCfg) + if err != nil { + panic(err) + } + + n.RegisterAPIs(tracers.APIs(backend.APIBackend)) + + if err := n.Start(); err != nil { + panic(fmt.Errorf("failed to start L1 geth node: %w", err)) + } + return &L1Replica{ + log: log, + node: n, + eth: backend, + l1Chain: backend.BlockChain(), + l1Database: backend.ChainDb(), + l1Cfg: genesis, + l1Signer: types.LatestSigner(genesis.Config), + failL1RPC: nil, + } +} + +// 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) + } +} + +// ActL1Sync processes the next canonical L1 block, +// or rewinds one block if the canonical block cannot be applied to the head. +func (s *L1Replica) ActL1Sync(canonL1 func(num uint64) *types.Block) Action { + return func(t Testing) { + selfHead := s.l1Chain.CurrentHeader() + n := selfHead.Number.Uint64() + expected := canonL1(n) + if expected == nil || selfHead.Hash() != expected.Hash() { + s.ActL1RewindToParent(t) + return + } + next := canonL1(n + 1) + if next == nil { + t.InvalidAction("already fully synced to head %s (%d), n+1 is not there", selfHead.Hash(), n) + return + } + if next.ParentHash() != selfHead.Hash() { + // canonical chain must be set up wrong - with actions one by one it is not supposed to reorg during a single sync step. + t.Fatalf("canonical L1 source reorged unexpectedly from %s (num %d) to next block %s (parent %s)", n, selfHead.Hash(), next.Hash(), next.ParentHash()) + } + _, err := s.l1Chain.InsertChain([]*types.Block{next}) + require.NoError(t, err, "L1 replica could not sync next canonical L1 block %s (%d)", next.Hash(), next.NumberU64()) + } +} + +func (s *L1Replica) CanonL1Chain() func(num uint64) *types.Block { + return s.l1Chain.GetBlockByNumber +} + +// ActL1RPCFail makes the next L1 RPC request to this node fail +func (s *L1Replica) ActL1RPCFail(t Testing) { + if s.failL1RPC != nil { // already set to fail? + t.InvalidAction("already have a mock l1 rpc fail set") + } + s.failL1RPC = errors.New("mock L1 RPC error") +} + +func (s *L1Replica) EthClient() *ethclient.Client { + cl, _ := s.node.Attach() // never errors + return ethclient.NewClient(cl) +} + +func (s *L1Replica) RPCClient() client.RPC { + cl, _ := s.node.Attach() // never errors + return testutils.RPCErrFaker{ + RPC: cl, + ErrFn: func() error { + err := s.failL1RPC + s.failL1RPC = nil // reset back, only error once. + return err + }, + } +} + +// ActL1FinalizeNext finalizes the next block, which must be marked as safe before doing so (see ActL1SafeNext). +func (s *L1Replica) ActL1FinalizeNext(t Testing) { + safe := s.l1Chain.CurrentSafeBlock() + finalizedNum := s.l1Chain.CurrentFinalizedBlock().NumberU64() + if safe.NumberU64() <= finalizedNum { + t.InvalidAction("need to move forward safe block before moving finalized block") + return + } + next := s.l1Chain.GetBlockByNumber(finalizedNum + 1) + if next == nil { + t.Fatalf("expected next block after finalized L1 block %d, safe head is ahead", finalizedNum) + } + s.l1Chain.SetFinalized(next) +} + +// ActL1SafeNext marks the next unsafe block as safe. +func (s *L1Replica) ActL1SafeNext(t Testing) { + safe := s.l1Chain.CurrentSafeBlock() + next := s.l1Chain.GetBlockByNumber(safe.NumberU64() + 1) + if next == nil { + t.InvalidAction("if head of chain is marked as safe then there's no next block") + return + } + s.l1Chain.SetSafe(next) +} diff --git a/op-e2e/actions/l1_replica_test.go b/op-e2e/actions/l1_replica_test.go new file mode 100644 index 0000000000000..5b88aa7499109 --- /dev/null +++ b/op-e2e/actions/l1_replica_test.go @@ -0,0 +1,102 @@ +package actions + +import ( + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/beacon" + "github.com/ethereum/go-ethereum/consensus/ethash" + "github.com/ethereum/go-ethereum/core" + "github.com/ethereum/go-ethereum/core/rawdb" + "github.com/ethereum/go-ethereum/core/types" + "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/eth" + "github.com/ethereum-optimism/optimism/op-node/sources" + "github.com/ethereum-optimism/optimism/op-node/testlog" +) + +var defaultRollupTestParams = &e2eutils.TestParams{ + MaxSequencerDrift: 40, + SequencerWindowSize: 120, + ChannelTimeout: 120, +} + +var defaultAlloc = &e2eutils.AllocParams{PrefundTestUsers: true} + +// Test if we can mock an RPC failure +func TestL1Replica_ActL1RPCFail(gt *testing.T) { + t := NewDefaultTesting(gt) + dp := e2eutils.MakeDeployParams(t, defaultRollupTestParams) + sd := e2eutils.Setup(t, dp, defaultAlloc) + log := testlog.Logger(t, log.LvlDebug) + replica := NewL1Replica(log, sd.L1Cfg) + // mock an RPC failure + replica.ActL1RPCFail(t) + // check RPC failure + l1Cl, err := sources.NewL1Client(replica.RPCClient(), log, nil, sources.L1ClientDefaultConfig(sd.RollupCfg, false)) + require.NoError(t, err) + _, err = l1Cl.InfoByLabel(t.Ctx(), eth.Unsafe) + require.ErrorContains(t, err, "mock") + head, err := l1Cl.InfoByLabel(t.Ctx(), eth.Unsafe) + require.NoError(t, err) + require.Equal(gt, sd.L1Cfg.ToBlock().Hash(), head.Hash(), "expecting replica to start at genesis") +} + +// Test if we can make the replica sync an artificial L1 chain, rewind it, and reorg it +func TestL1Replica_ActL1Sync(gt *testing.T) { + t := NewDefaultTesting(gt) + dp := e2eutils.MakeDeployParams(t, defaultRollupTestParams) + sd := e2eutils.Setup(t, dp, defaultAlloc) + log := testlog.Logger(t, log.LvlDebug) + genesisBlock := sd.L1Cfg.ToBlock() + consensus := beacon.New(ethash.NewFaker()) + db := rawdb.NewMemoryDatabase() + sd.L1Cfg.MustCommit(db) + + chainA, _ := core.GenerateChain(sd.L1Cfg.Config, genesisBlock, consensus, db, 10, func(n int, g *core.BlockGen) { + g.SetCoinbase(common.Address{'A'}) + }) + chainA = append(append([]*types.Block{}, genesisBlock), chainA...) + chainB, _ := core.GenerateChain(sd.L1Cfg.Config, chainA[3], consensus, db, 10, func(n int, g *core.BlockGen) { + g.SetCoinbase(common.Address{'B'}) + }) + chainB = append(append([]*types.Block{}, chainA[:4]...), chainB...) + require.NotEqual(t, chainA[9], chainB[9], "need different chains") + canonL1 := func(blocks []*types.Block) func(num uint64) *types.Block { + return func(num uint64) *types.Block { + if num >= uint64(len(blocks)) { + return nil + } + return blocks[num] + } + } + + // Enough setup, create the test actor and run the actual actions + replica1 := NewL1Replica(log, sd.L1Cfg) + syncFromA := replica1.ActL1Sync(canonL1(chainA)) + // sync canonical chain A + for replica1.l1Chain.CurrentBlock().NumberU64()+1 < uint64(len(chainA)) { + syncFromA(t) + } + require.Equal(t, replica1.l1Chain.CurrentBlock().Hash(), chainA[len(chainA)-1].Hash(), "sync replica1 to head of chain A") + replica1.ActL1RewindToParent(t) + require.Equal(t, replica1.l1Chain.CurrentBlock().Hash(), chainA[len(chainA)-2].Hash(), "rewind replica1 to parent of chain A") + + // sync new canonical chain B + syncFromB := replica1.ActL1Sync(canonL1(chainB)) + for replica1.l1Chain.CurrentBlock().NumberU64()+1 < uint64(len(chainB)) { + syncFromB(t) + } + require.Equal(t, replica1.l1Chain.CurrentBlock().Hash(), chainB[len(chainB)-1].Hash(), "sync replica1 to head of chain B") + + // Adding and syncing a new replica + replica2 := NewL1Replica(log, sd.L1Cfg) + syncFromOther := replica2.ActL1Sync(replica1.CanonL1Chain()) + for replica2.l1Chain.CurrentBlock().NumberU64()+1 < uint64(len(chainB)) { + syncFromOther(t) + } + require.Equal(t, replica2.l1Chain.CurrentBlock().Hash(), chainB[len(chainB)-1].Hash(), "sync replica2 to head of chain B") +} diff --git a/op-e2e/go.mod b/op-e2e/go.mod index 6f95482a06e2c..78d31a8de85f6 100644 --- a/op-e2e/go.mod +++ b/op-e2e/go.mod @@ -128,6 +128,7 @@ require ( github.com/spacemonkeygo/spacelog v0.0.0-20180420211403-2296661a0572 // indirect github.com/spaolacci/murmur3 v1.1.0 // indirect github.com/status-im/keycard-go v0.0.0-20211109104530-b0e0482ba91d // indirect + github.com/stretchr/objx v0.4.0 // indirect github.com/syndtr/goleveldb v1.0.1-0.20220614013038-64ee5596c38a // indirect github.com/tklauser/go-sysconf v0.3.10 // indirect github.com/tklauser/numcpus v0.5.0 // indirect