Commit 4cfcd895 authored by protolambda's avatar protolambda Committed by GitHub

op-e2e: Action testing L1 replica + miner code (combined by mergify) (#3601)

* op-e2e: action testing L1 replica actor

* op-e2e: action testing L1 miner (#3607)

* op-e2e: remove unused empty actors file
parent a5ddb5a0
......@@ -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
......
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")
}
}
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())
}
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 := &ethconfig.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)
}
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")
}
......@@ -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
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment