Commit f917b007 authored by protolambda's avatar protolambda

op-e2e: action tests: L2 engine actor

parent e81a6ff5
package actions
import (
"errors"
"fmt"
"github.com/ethereum/go-ethereum/core"
"github.com/ethereum/go-ethereum/core/beacon"
"github.com/ethereum/go-ethereum/core/types"
geth "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/ethdb"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum/go-ethereum/node"
"github.com/ethereum/go-ethereum/rpc"
"github.com/ethereum-optimism/optimism/op-node/client"
"github.com/ethereum-optimism/optimism/op-node/eth"
"github.com/ethereum-optimism/optimism/op-node/rollup"
"github.com/ethereum-optimism/optimism/op-node/testutils"
)
// L2Engine is an in-memory implementation of the Engine API,
// without support for snap-sync, and no concurrency or background processes.
type L2Engine struct {
log log.Logger
node *node.Node
eth *geth.Ethereum
rollupGenesis *rollup.Genesis
// L2 evm / chain
l2Chain *core.BlockChain
l2Database ethdb.Database
l2Cfg *core.Genesis
l2Signer types.Signer
// L2 block building data
// TODO proto - block building PR
payloadID beacon.PayloadID // ID of payload that is currently being built
failL2RPC error // mock error
}
func NewL2Engine(log log.Logger, genesis *core.Genesis, rollupGenesisL1 eth.BlockID, jwtPath string) *L2Engine {
ethCfg := &ethconfig.Config{
NetworkId: genesis.Config.ChainID.Uint64(),
Genesis: genesis,
}
nodeCfg := &node.Config{
Name: "l2-geth",
WSHost: "127.0.0.1",
WSPort: 0,
AuthAddr: "127.0.0.1",
AuthPort: 0,
WSModules: []string{"debug", "admin", "eth", "txpool", "net", "rpc", "web3", "personal"},
HTTPModules: []string{"debug", "admin", "eth", "txpool", "net", "rpc", "web3", "personal"},
JWTSecret: jwtPath,
}
n, err := node.New(nodeCfg)
if err != nil {
panic(err)
}
backend, err := geth.New(n, ethCfg)
if err != nil {
panic(err)
}
n.RegisterAPIs(tracers.APIs(backend.APIBackend))
chain := backend.BlockChain()
db := backend.ChainDb()
genesisBlock := chain.Genesis()
eng := &L2Engine{
log: log,
node: n,
eth: backend,
rollupGenesis: &rollup.Genesis{
L1: rollupGenesisL1,
L2: eth.BlockID{Hash: genesisBlock.Hash(), Number: genesisBlock.NumberU64()},
L2Time: genesis.Timestamp,
},
l2Chain: chain,
l2Database: db,
l2Cfg: genesis,
l2Signer: types.LatestSigner(genesis.Config),
}
// register the custom engine API, so we can serve engine requests while having more control
// over sequencing of individual txs.
n.RegisterAPIs([]rpc.API{
{
Namespace: "engine",
Service: (*L2EngineAPI)(eng),
Authenticated: true,
},
})
if err := n.Start(); err != nil {
panic(fmt.Errorf("failed to start L2 op-geth node: %w", err))
}
return eng
}
func (e *L2Engine) RPCClient() client.RPC {
cl, _ := e.node.Attach() // never errors
return testutils.RPCErrFaker{
RPC: cl,
ErrFn: func() error {
err := e.failL2RPC
e.failL2RPC = nil // reset back, only error once.
return err
},
}
}
// ActL2RPCFail makes the next L2 RPC request fail
func (e *L2Engine) ActL2RPCFail(t Testing) {
if e.failL2RPC != nil { // already set to fail?
t.InvalidAction("already set a mock L2 rpc error")
return
}
e.failL2RPC = errors.New("mock L2 RPC error")
}
package actions
import (
"context"
"errors"
"fmt"
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/core/beacon"
"github.com/ethereum/go-ethereum/core/rawdb"
"github.com/ethereum/go-ethereum/core/types"
"github.com/ethereum/go-ethereum/log"
"github.com/ethereum-optimism/optimism/op-node/eth"
)
// L2EngineAPI wraps an engine actor, and implements the RPC backend required to serve the engine API.
// This re-implements some of the Geth API work, but changes the API backend so we can deterministically
// build and control the L2 block contents to reach very specific edge cases as desired for testing.
type L2EngineAPI L2Engine
var (
STATUS_INVALID = &eth.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionInvalid}, PayloadID: nil}
STATUS_SYNCING = &eth.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionSyncing}, PayloadID: nil}
INVALID_TERMINAL_BLOCK = eth.PayloadStatusV1{Status: eth.ExecutionInvalid, LatestValidHash: &common.Hash{}}
)
func (ea *L2EngineAPI) startBlock(parent common.Hash, params *eth.PayloadAttributes) error {
return fmt.Errorf("todo")
}
func (ea *L2EngineAPI) endBlock() (*types.Block, error) {
return nil, fmt.Errorf("todo")
}
func (ea *L2EngineAPI) GetPayloadV1(ctx context.Context, payloadId eth.PayloadID) (*eth.ExecutionPayload, error) {
ea.log.Trace("L2Engine API request received", "method", "GetPayload", "id", payloadId)
if ea.payloadID != payloadId {
ea.log.Warn("unexpected payload ID requested for block building", "expected", ea.payloadID, "got", payloadId)
return nil, beacon.UnknownPayload
}
bl, err := ea.endBlock()
if err != nil {
ea.log.Error("failed to finish block building", "err", err)
return nil, beacon.UnknownPayload
}
return eth.BlockAsPayload(bl)
}
func (ea *L2EngineAPI) ForkchoiceUpdatedV1(ctx context.Context, state *eth.ForkchoiceState, attr *eth.PayloadAttributes) (*eth.ForkchoiceUpdatedResult, error) {
ea.log.Trace("L2Engine API request received", "method", "ForkchoiceUpdated", "head", state.HeadBlockHash, "finalized", state.FinalizedBlockHash, "safe", state.SafeBlockHash)
if state.HeadBlockHash == (common.Hash{}) {
ea.log.Warn("Forkchoice requested update to zero hash")
return STATUS_INVALID, nil
}
// Check whether we have the block yet in our database or not. If not, we'll
// need to either trigger a sync, or to reject this forkchoice update for a
// reason.
block := ea.l2Chain.GetBlockByHash(state.HeadBlockHash)
if block == nil {
// TODO: syncing not supported yet
return STATUS_SYNCING, nil
}
// Block is known locally, just sanity check that the beacon client does not
// attempt to push us back to before the merge.
if block.Difficulty().BitLen() > 0 || block.NumberU64() == 0 {
var (
td = ea.l2Chain.GetTd(state.HeadBlockHash, block.NumberU64())
ptd = ea.l2Chain.GetTd(block.ParentHash(), block.NumberU64()-1)
ttd = ea.l2Chain.Config().TerminalTotalDifficulty
)
if td == nil || (block.NumberU64() > 0 && ptd == nil) {
ea.log.Error("TDs unavailable for TTD check", "number", block.NumberU64(), "hash", state.HeadBlockHash, "td", td, "parent", block.ParentHash(), "ptd", ptd)
return STATUS_INVALID, errors.New("TDs unavailable for TDD check")
}
if td.Cmp(ttd) < 0 {
ea.log.Error("Refusing beacon update to pre-merge", "number", block.NumberU64(), "hash", state.HeadBlockHash, "diff", block.Difficulty(), "age", common.PrettyAge(time.Unix(int64(block.Time()), 0)))
return &eth.ForkchoiceUpdatedResult{PayloadStatus: INVALID_TERMINAL_BLOCK, PayloadID: nil}, nil
}
if block.NumberU64() > 0 && ptd.Cmp(ttd) >= 0 {
ea.log.Error("Parent block is already post-ttd", "number", block.NumberU64(), "hash", state.HeadBlockHash, "diff", block.Difficulty(), "age", common.PrettyAge(time.Unix(int64(block.Time()), 0)))
return &eth.ForkchoiceUpdatedResult{PayloadStatus: INVALID_TERMINAL_BLOCK, PayloadID: nil}, nil
}
}
valid := func(id *beacon.PayloadID) *eth.ForkchoiceUpdatedResult {
return &eth.ForkchoiceUpdatedResult{
PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionValid, LatestValidHash: &state.HeadBlockHash},
PayloadID: id,
}
}
if rawdb.ReadCanonicalHash(ea.l2Database, block.NumberU64()) != state.HeadBlockHash {
// Block is not canonical, set head.
if latestValid, err := ea.l2Chain.SetCanonical(block); err != nil {
return &eth.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionInvalid, LatestValidHash: &latestValid}}, err
}
} else if ea.l2Chain.CurrentBlock().Hash() == state.HeadBlockHash {
// If the specified head matches with our local head, do nothing and keep
// generating the payload. It's a special corner case that a few slots are
// missing and we are requested to generate the payload in slot.
} else if ea.l2Chain.Config().Optimism == nil { // minor L2Engine API divergence: allow proposers to reorg their own chain
panic("engine not configured as optimism engine")
}
// If the beacon client also advertised a finalized block, mark the local
// chain final and completely in PoS mode.
if state.FinalizedBlockHash != (common.Hash{}) {
// If the finalized block is not in our canonical tree, somethings wrong
finalBlock := ea.l2Chain.GetBlockByHash(state.FinalizedBlockHash)
if finalBlock == nil {
ea.log.Warn("Final block not available in database", "hash", state.FinalizedBlockHash)
return STATUS_INVALID, beacon.InvalidForkChoiceState.With(errors.New("final block not available in database"))
} else if rawdb.ReadCanonicalHash(ea.l2Database, finalBlock.NumberU64()) != state.FinalizedBlockHash {
ea.log.Warn("Final block not in canonical chain", "number", block.NumberU64(), "hash", state.HeadBlockHash)
return STATUS_INVALID, beacon.InvalidForkChoiceState.With(errors.New("final block not in canonical chain"))
}
// Set the finalized block
ea.l2Chain.SetFinalized(finalBlock)
}
// Check if the safe block hash is in our canonical tree, if not somethings wrong
if state.SafeBlockHash != (common.Hash{}) {
safeBlock := ea.l2Chain.GetBlockByHash(state.SafeBlockHash)
if safeBlock == nil {
ea.log.Warn("Safe block not available in database")
return STATUS_INVALID, beacon.InvalidForkChoiceState.With(errors.New("safe block not available in database"))
}
if rawdb.ReadCanonicalHash(ea.l2Database, safeBlock.NumberU64()) != state.SafeBlockHash {
ea.log.Warn("Safe block not in canonical chain")
return STATUS_INVALID, beacon.InvalidForkChoiceState.With(errors.New("safe block not in canonical chain"))
}
// Set the safe block
ea.l2Chain.SetSafe(safeBlock)
}
// If payload generation was requested, create a new block to be potentially
// sealed by the beacon client. The payload will be requested later, and we
// might replace it arbitrarily many times in between.
if attr != nil {
err := ea.startBlock(state.HeadBlockHash, attr)
if err != nil {
ea.log.Error("Failed to start block building", "err", err, "noTxPool", attr.NoTxPool, "txs", len(attr.Transactions), "timestamp", attr.Timestamp)
return STATUS_INVALID, beacon.InvalidPayloadAttributes.With(err)
}
return valid(&ea.payloadID), nil
}
return valid(nil), nil
}
func (ea *L2EngineAPI) NewPayloadV1(ctx context.Context, payload *eth.ExecutionPayload) (*eth.PayloadStatusV1, error) {
ea.log.Trace("L2Engine API request received", "method", "ExecutePayload", "number", payload.BlockNumber, "hash", payload.BlockHash)
txs := make([][]byte, len(payload.Transactions))
for i, tx := range payload.Transactions {
txs[i] = tx
}
block, err := beacon.ExecutableDataToBlock(beacon.ExecutableDataV1{
ParentHash: payload.ParentHash,
FeeRecipient: payload.FeeRecipient,
StateRoot: common.Hash(payload.StateRoot),
ReceiptsRoot: common.Hash(payload.ReceiptsRoot),
LogsBloom: payload.LogsBloom[:],
Random: common.Hash(payload.PrevRandao),
Number: uint64(payload.BlockNumber),
GasLimit: uint64(payload.GasLimit),
GasUsed: uint64(payload.GasUsed),
Timestamp: uint64(payload.Timestamp),
ExtraData: payload.ExtraData,
BaseFeePerGas: payload.BaseFeePerGas.ToBig(),
BlockHash: payload.BlockHash,
Transactions: txs,
})
if err != nil {
log.Debug("Invalid NewPayload params", "params", payload, "error", err)
return &eth.PayloadStatusV1{Status: eth.ExecutionInvalidBlockHash}, nil
}
// If we already have the block locally, ignore the entire execution and just
// return a fake success.
if block := ea.l2Chain.GetBlockByHash(payload.BlockHash); block != nil {
ea.log.Warn("Ignoring already known beacon payload", "number", payload.BlockNumber, "hash", payload.BlockHash, "age", common.PrettyAge(time.Unix(int64(block.Time()), 0)))
hash := block.Hash()
return &eth.PayloadStatusV1{Status: eth.ExecutionValid, LatestValidHash: &hash}, nil
}
// TODO: skipping invalid ancestor check (i.e. not remembering previously failed blocks)
parent := ea.l2Chain.GetBlock(block.ParentHash(), block.NumberU64()-1)
if parent == nil {
// TODO: hack, saying we accepted if we don't know the parent block. Might want to return critical error if we can't actually sync.
return &eth.PayloadStatusV1{Status: eth.ExecutionAccepted, LatestValidHash: nil}, nil
}
if !ea.l2Chain.HasBlockAndState(block.ParentHash(), block.NumberU64()-1) {
ea.log.Warn("State not available, ignoring new payload")
return &eth.PayloadStatusV1{Status: eth.ExecutionAccepted}, nil
}
if err := ea.l2Chain.InsertBlockWithoutSetHead(block); err != nil {
ea.log.Warn("NewPayloadV1: inserting block failed", "error", err)
// TODO not remembering the payload as invalid
return ea.invalid(err, parent.Header()), nil
}
hash := block.Hash()
return &eth.PayloadStatusV1{Status: eth.ExecutionValid, LatestValidHash: &hash}, nil
}
func (ea *L2EngineAPI) invalid(err error, latestValid *types.Header) *eth.PayloadStatusV1 {
currentHash := ea.l2Chain.CurrentBlock().Hash()
if latestValid != nil {
// Set latest valid hash to 0x0 if parent is PoW block
currentHash = common.Hash{}
if latestValid.Difficulty.BitLen() == 0 {
// Otherwise set latest valid hash to parent hash
currentHash = latestValid.Hash()
}
}
errorMsg := err.Error()
return &eth.PayloadStatusV1{Status: eth.ExecutionInvalid, LatestValidHash: &currentHash, ValidationError: &errorMsg}
}
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/stretchr/testify/require"
"github.com/ethereum/go-ethereum/log"
"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"
)
func TestL2EngineAPI(gt *testing.T) {
t := NewDefaultTesting(gt)
jwtPath := e2eutils.WriteDefaultJWT(t)
dp := e2eutils.MakeDeployParams(t, defaultRollupTestParams)
sd := e2eutils.Setup(t, dp, defaultAlloc)
log := testlog.Logger(t, log.LvlDebug)
genesisBlock := sd.L2Cfg.ToBlock()
consensus := beacon.New(ethash.NewFaker())
db := rawdb.NewMemoryDatabase()
sd.L2Cfg.MustCommit(db)
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)
// build an empty block
chainA, _ := core.GenerateChain(sd.L2Cfg.Config, genesisBlock, consensus, db, 1, func(i int, gen *core.BlockGen) {
gen.SetCoinbase(common.Address{'A'})
})
payloadA, err := eth.BlockAsPayload(chainA[0])
require.NoError(t, err)
// apply the payload
status, err := l2Cl.NewPayload(t.Ctx(), payloadA)
require.NoError(t, err)
require.Equal(t, status.Status, eth.ExecutionValid)
require.Equal(t, genesisBlock.Hash(), engine.l2Chain.CurrentBlock().Hash(), "processed payloads are not immediately canonical")
// recognize the payload as canonical
fcRes, err := l2Cl.ForkchoiceUpdate(t.Ctx(), &eth.ForkchoiceState{
HeadBlockHash: payloadA.BlockHash,
SafeBlockHash: genesisBlock.Hash(),
FinalizedBlockHash: genesisBlock.Hash(),
}, nil)
require.NoError(t, err)
require.Equal(t, fcRes.PayloadStatus.Status, eth.ExecutionValid)
require.Equal(t, payloadA.BlockHash, engine.l2Chain.CurrentBlock().Hash(), "now payload A is canonical")
// build an alternative block
chainB, _ := core.GenerateChain(sd.L2Cfg.Config, genesisBlock, consensus, db, 1, func(i int, gen *core.BlockGen) {
gen.SetCoinbase(common.Address{'B'})
})
payloadB, err := eth.BlockAsPayload(chainB[0])
require.NoError(t, err)
// apply the payload
status, err = l2Cl.NewPayload(t.Ctx(), payloadB)
require.NoError(t, err)
require.Equal(t, status.Status, eth.ExecutionValid)
require.Equal(t, payloadA.BlockHash, engine.l2Chain.CurrentBlock().Hash(), "processed payloads are not immediately canonical")
// reorg block A in favor of block B
fcRes, err = l2Cl.ForkchoiceUpdate(t.Ctx(), &eth.ForkchoiceState{
HeadBlockHash: payloadB.BlockHash,
SafeBlockHash: genesisBlock.Hash(),
FinalizedBlockHash: genesisBlock.Hash(),
}, nil)
require.NoError(t, err)
require.Equal(t, fcRes.PayloadStatus.Status, eth.ExecutionValid)
require.Equal(t, payloadB.BlockHash, engine.l2Chain.CurrentBlock().Hash(), "now payload B is canonical")
}
func TestL2EngineAPIFail(gt *testing.T) {
t := NewDefaultTesting(gt)
jwtPath := e2eutils.WriteDefaultJWT(t)
dp := e2eutils.MakeDeployParams(t, defaultRollupTestParams)
sd := e2eutils.Setup(t, dp, defaultAlloc)
log := testlog.Logger(t, log.LvlDebug)
engine := NewL2Engine(log, sd.L2Cfg, sd.RollupCfg.Genesis.L1, jwtPath)
// mock an RPC failure
engine.ActL2RPCFail(t)
// check RPC failure
l2Cl, err := sources.NewL2Client(engine.RPCClient(), log, nil, sources.L2ClientDefaultConfig(sd.RollupCfg, false))
require.NoError(t, err)
_, err = l2Cl.InfoByLabel(t.Ctx(), eth.Unsafe)
require.ErrorContains(t, err, "mock")
head, err := l2Cl.InfoByLabel(t.Ctx(), eth.Unsafe)
require.NoError(t, err)
require.Equal(gt, sd.L2Cfg.ToBlock().Hash(), head.Hash(), "expecting engine to start at genesis")
}
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