diff --git a/op-e2e/actions/l2_batcher_test.go b/op-e2e/actions/l2_batcher_test.go index 91df669ed6823..eb3f21e7a8535 100644 --- a/op-e2e/actions/l2_batcher_test.go +++ b/op-e2e/actions/l2_batcher_test.go @@ -447,7 +447,7 @@ func TestBigL2Txs(gt *testing.T) { require.NoError(t, err) gas, err := core.IntrinsicGas(data, nil, false, true, true, false) require.NoError(t, err) - if gas > engine.l2GasPool.Gas() { + if gas > engine.engineApi.RemainingBlockGas() { break } tx := types.MustSignNewTx(dp.Secrets.Alice, signer, &types.DynamicFeeTx{ diff --git a/op-e2e/actions/l2_engine.go b/op-e2e/actions/l2_engine.go index a0c9de6950c9d..f5c2b422f594e 100644 --- a/op-e2e/actions/l2_engine.go +++ b/op-e2e/actions/l2_engine.go @@ -3,12 +3,12 @@ package actions import ( "errors" + "github.com/ethereum-optimism/optimism/op-e2e/e2eutils" + "github.com/ethereum-optimism/optimism/op-program/l2/engineapi" "github.com/stretchr/testify/require" - "github.com/ethereum/go-ethereum/beacon/engine" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/core" - "github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/types" geth "github.com/ethereum/go-ethereum/eth" "github.com/ethereum/go-ethereum/eth/ethconfig" @@ -38,22 +38,10 @@ type L2Engine struct { rollupGenesis *rollup.Genesis // L2 evm / chain - l2Chain *core.BlockChain - l2Database ethdb.Database - l2Cfg *core.Genesis - l2Signer types.Signer - - // L2 block building data - l2BuildingHeader *types.Header // block header that we add txs to for block building - l2BuildingState *state.StateDB // state used for block building - l2GasPool *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. - l2Transactions []*types.Transaction // collects txs that were successfully included into current block build - l2Receipts []*types.Receipt // collect receipts of ongoing building - l2ForceEmpty bool // when no additional txs may be processed (i.e. when sequencer drift runs out) - l2TxFailed []*types.Transaction // log of failed transactions which could not be included - - payloadID engine.PayloadID // ID of payload that is currently being built + l2Chain *core.BlockChain + l2Signer types.Signer + + engineApi *engineapi.L2EngineAPI failL2RPC error // mock error } @@ -61,6 +49,38 @@ type L2Engine struct { type EngineOption func(ethCfg *ethconfig.Config, nodeCfg *node.Config) error func NewL2Engine(t Testing, log log.Logger, genesis *core.Genesis, rollupGenesisL1 eth.BlockID, jwtPath string, options ...EngineOption) *L2Engine { + n, ethBackend, apiBackend := newBackend(t, genesis, jwtPath, options) + engineApi := engineapi.NewL2EngineAPI(log, apiBackend) + chain := ethBackend.BlockChain() + genesisBlock := chain.Genesis() + eng := &L2Engine{ + log: log, + node: n, + eth: ethBackend, + rollupGenesis: &rollup.Genesis{ + L1: rollupGenesisL1, + L2: eth.BlockID{Hash: genesisBlock.Hash(), Number: genesisBlock.NumberU64()}, + L2Time: genesis.Timestamp, + }, + l2Chain: chain, + l2Signer: types.LatestSigner(genesis.Config), + engineApi: engineApi, + } + // 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: eng.engineApi, + Authenticated: true, + }, + }) + require.NoError(t, n.Start(), "failed to start L2 op-geth node") + + return eng +} + +func newBackend(t e2eutils.TestingBase, genesis *core.Genesis, jwtPath string, options []EngineOption) (*node.Node, *geth.Ethereum, *engineApiBackend) { ethCfg := ðconfig.Config{ NetworkId: genesis.Config.ChainID.Uint64(), Genesis: genesis, @@ -89,33 +109,26 @@ func NewL2Engine(t Testing, log log.Logger, genesis *core.Genesis, rollupGenesis 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), + apiBackend := &engineApiBackend{ + BlockChain: chain, + db: db, + genesis: genesis, } - // 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, - }, - }) - require.NoError(t, n.Start(), "failed to start L2 op-geth node") + return n, backend, apiBackend +} - return eng +type engineApiBackend struct { + *core.BlockChain + db ethdb.Database + genesis *core.Genesis +} + +func (e *engineApiBackend) Database() ethdb.Database { + return e.db +} + +func (e *engineApiBackend) Genesis() *core.Genesis { + return e.genesis } func (s *L2Engine) EthClient() *ethclient.Client { @@ -158,39 +171,25 @@ func (e *L2Engine) ActL2RPCFail(t Testing) { // ActL2IncludeTx includes the next transaction from the given address in the block that is being built func (e *L2Engine) ActL2IncludeTx(from common.Address) Action { return func(t Testing) { - if e.l2BuildingHeader == nil { - t.InvalidAction("not currently building a block, cannot include tx from queue") - return - } - if e.l2ForceEmpty { + if e.engineApi.ForcedEmpty() { e.log.Info("Skipping including a transaction because e.L2ForceEmpty is true") - // t.InvalidAction("cannot include any sequencer txs") return } - i := e.pendingIndices[from] + i := e.engineApi.PendingIndices(from) txs, q := e.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() > e.l2BuildingHeader.GasLimit { - t.Fatalf("tx consumes %d gas, more than available in L2 block %d", tx.Gas(), e.l2BuildingHeader.GasLimit) - } - if tx.Gas() > uint64(*e.l2GasPool) { - t.InvalidAction("action takes too much gas: %d, only have %d", tx.Gas(), uint64(*e.l2GasPool)) - return - } - e.pendingIndices[from] = i + 1 // won't retry the tx - e.l2BuildingState.SetTxContext(tx.Hash(), len(e.l2Transactions)) - receipt, err := core.ApplyTransaction(e.l2Cfg.Config, e.l2Chain, &e.l2BuildingHeader.Coinbase, - e.l2GasPool, e.l2BuildingState, e.l2BuildingHeader, tx, &e.l2BuildingHeader.GasUsed, *e.l2Chain.GetVMConfig()) - if err != nil { - e.l2TxFailed = append(e.l2TxFailed, tx) - t.Fatalf("failed to apply transaction to L2 block (tx %d): %v", len(e.l2Transactions), err) + err := e.engineApi.IncludeTx(tx, from) + if errors.Is(err, engineapi.ErrNotBuildingBlock) { + t.InvalidAction(err.Error()) + } else if errors.Is(err, engineapi.ErrUsesTooMuchGas) { + t.InvalidAction("included tx uses too much gas: %v", err) + } else if err != nil { + t.Fatalf("include tx: %v", err) } - e.l2Receipts = append(e.l2Receipts, receipt) - e.l2Transactions = append(e.l2Transactions, tx) } } diff --git a/op-e2e/actions/l2_engine_test.go b/op-e2e/actions/l2_engine_test.go index 5418d10b11e18..535a74e46b97c 100644 --- a/op-e2e/actions/l2_engine_test.go +++ b/op-e2e/actions/l2_engine_test.go @@ -4,6 +4,8 @@ import ( "math/big" "testing" + "github.com/ethereum-optimism/optimism/op-program/l2/engineapi" + "github.com/ethereum-optimism/optimism/op-program/l2/engineapi/test" "github.com/ethereum/go-ethereum/common" "github.com/ethereum/go-ethereum/consensus/beacon" "github.com/ethereum/go-ethereum/consensus/ethash" @@ -187,3 +189,15 @@ func TestL2EngineAPIFail(gt *testing.T) { require.NoError(t, err) require.Equal(gt, sd.L2Cfg.ToBlock().Hash(), head.Hash(), "expecting engine to start at genesis") } + +func TestEngineAPITests(t *testing.T) { + test.RunEngineAPITests(t, func() engineapi.EngineBackend { + jwtPath := e2eutils.WriteDefaultJWT(t) + dp := e2eutils.MakeDeployParams(t, defaultRollupTestParams) + sd := e2eutils.Setup(t, dp, defaultAlloc) + n, _, apiBackend := newBackend(t, sd.L2Cfg, jwtPath, nil) + err := n.Start() + require.NoError(t, err) + return apiBackend + }) +} diff --git a/op-e2e/actions/l2_sequencer_test.go b/op-e2e/actions/l2_sequencer_test.go index 5a62617d730d6..449ef463eb9b9 100644 --- a/op-e2e/actions/l2_sequencer_test.go +++ b/op-e2e/actions/l2_sequencer_test.go @@ -98,7 +98,7 @@ func TestL2Sequencer_SequencerDrift(gt *testing.T) { // We passed the sequencer drift: we can still keep the old origin, but can't include any txs sequencer.ActL2KeepL1Origin(t) sequencer.ActL2StartBlock(t) - require.True(t, engine.l2ForceEmpty, "engine should not be allowed to include anything after sequencer drift is surpassed") + require.True(t, engine.engineApi.ForcedEmpty(), "engine should not be allowed to include anything after sequencer drift is surpassed") } // TestL2Sequencer_SequencerOnlyReorg regression-tests a Goerli halt where the sequencer diff --git a/op-e2e/actions/l2_engine_api.go b/op-program/l2/engineapi/l2_engine_api.go similarity index 63% rename from op-e2e/actions/l2_engine_api.go rename to op-program/l2/engineapi/l2_engine_api.go index 1555f775edeab..77c42ba9da169 100644 --- a/op-e2e/actions/l2_engine_api.go +++ b/op-program/l2/engineapi/l2_engine_api.go @@ -1,4 +1,4 @@ -package actions +package engineapi import ( "context" @@ -11,26 +11,74 @@ import ( "github.com/ethereum/go-ethereum/beacon/engine" "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus" "github.com/ethereum/go-ethereum/consensus/misc" "github.com/ethereum/go-ethereum/core" - "github.com/ethereum/go-ethereum/core/rawdb" "github.com/ethereum/go-ethereum/core/state" "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/core/vm" "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" "github.com/ethereum/go-ethereum/trie" "github.com/ethereum-optimism/optimism/op-node/eth" ) +type EngineBackend interface { + CurrentBlock() *types.Header + CurrentSafeBlock() *types.Header + CurrentFinalBlock() *types.Header + GetHeaderByHash(hash common.Hash) *types.Header + GetBlockByHash(hash common.Hash) *types.Block + GetBlock(hash common.Hash, number uint64) *types.Block + // GetHeader returns the header corresponding to the hash/number argument pair. + GetHeader(common.Hash, uint64) *types.Header + HasBlockAndState(hash common.Hash, number uint64) bool + GetCanonicalHash(n uint64) common.Hash + + GetVMConfig() *vm.Config + Config() *params.ChainConfig + // Engine retrieves the chain's consensus engine. + Engine() consensus.Engine + + StateAt(root common.Hash) (*state.StateDB, error) + + InsertBlockWithoutSetHead(block *types.Block) error + SetCanonical(head *types.Block) (common.Hash, error) + SetFinalized(header *types.Header) + SetSafe(header *types.Header) +} + // 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 +type L2EngineAPI struct { + log log.Logger + backend EngineBackend + + // L2 block building data + l2BuildingHeader *types.Header // block header that we add txs to for block building + l2BuildingState *state.StateDB // state used for block building + l2GasPool *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. + l2Transactions []*types.Transaction // collects txs that were successfully included into current block build + l2Receipts []*types.Receipt // collect receipts of ongoing building + l2ForceEmpty bool // when no additional txs may be processed (i.e. when sequencer drift runs out) + l2TxFailed []*types.Transaction // log of failed transactions which could not be included + + payloadID engine.PayloadID // ID of payload that is currently being built +} + +func NewL2EngineAPI(log log.Logger, backend EngineBackend) *L2EngineAPI { + return &L2EngineAPI{ + log: log, + backend: backend, + } +} var ( - STATUS_INVALID = ð.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionInvalid}, PayloadID: nil} - STATUS_SYNCING = ð.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionSyncing}, PayloadID: nil} - INVALID_TERMINAL_BLOCK = eth.PayloadStatusV1{Status: eth.ExecutionInvalid, LatestValidHash: &common.Hash{}} + STATUS_INVALID = ð.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionInvalid}, PayloadID: nil} + STATUS_SYNCING = ð.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionSyncing}, PayloadID: nil} ) // computePayloadId computes a pseudo-random payloadid, based on the parameters. @@ -53,16 +101,64 @@ func computePayloadId(headBlockHash common.Hash, params *eth.PayloadAttributes) return out } +func (ea *L2EngineAPI) RemainingBlockGas() uint64 { + return ea.l2GasPool.Gas() +} + +func (ea *L2EngineAPI) ForcedEmpty() bool { + return ea.l2ForceEmpty +} + +func (ea *L2EngineAPI) PendingIndices(from common.Address) uint64 { + return ea.pendingIndices[from] +} + +var ( + ErrNotBuildingBlock = errors.New("not currently building a block, cannot include tx from queue") + ErrExceedsGasLimit = errors.New("tx gas exceeds block gas limit") + ErrUsesTooMuchGas = errors.New("action takes too much gas") +) + +func (ea *L2EngineAPI) IncludeTx(tx *types.Transaction, from common.Address) error { + if ea.l2BuildingHeader == nil { + return ErrNotBuildingBlock + } + if ea.l2ForceEmpty { + ea.log.Info("Skipping including a transaction because e.L2ForceEmpty is true") + // t.InvalidAction("cannot include any sequencer txs") + return nil + } + + if tx.Gas() > ea.l2BuildingHeader.GasLimit { + return fmt.Errorf("%w tx gas: %d, block gas limit: %d", ErrExceedsGasLimit, tx.Gas(), ea.l2BuildingHeader.GasLimit) + } + if tx.Gas() > uint64(*ea.l2GasPool) { + return fmt.Errorf("%w: %d, only have %d", ErrUsesTooMuchGas, tx.Gas(), uint64(*ea.l2GasPool)) + } + + ea.pendingIndices[from] = ea.pendingIndices[from] + 1 // won't retry the tx + ea.l2BuildingState.SetTxContext(tx.Hash(), len(ea.l2Transactions)) + receipt, err := core.ApplyTransaction(ea.backend.Config(), ea.backend, &ea.l2BuildingHeader.Coinbase, + ea.l2GasPool, ea.l2BuildingState, ea.l2BuildingHeader, tx, &ea.l2BuildingHeader.GasUsed, *ea.backend.GetVMConfig()) + if err != nil { + ea.l2TxFailed = append(ea.l2TxFailed, tx) + return fmt.Errorf("invalid L2 block (tx %d): %w", len(ea.l2Transactions), err) + } + ea.l2Receipts = append(ea.l2Receipts, receipt) + ea.l2Transactions = append(ea.l2Transactions, tx) + return nil +} + func (ea *L2EngineAPI) startBlock(parent common.Hash, params *eth.PayloadAttributes) error { if ea.l2BuildingHeader != nil { ea.log.Warn("started building new block without ending previous block", "previous", ea.l2BuildingHeader, "prev_payload_id", ea.payloadID) } - parentHeader := ea.l2Chain.GetHeaderByHash(parent) + parentHeader := ea.backend.GetHeaderByHash(parent) if parentHeader == nil { return fmt.Errorf("uknown parent block: %s", parent) } - statedb, err := state.New(parentHeader.Root, state.NewDatabase(ea.l2Database), nil) + statedb, err := ea.backend.StateAt(parentHeader.Root) if err != nil { return fmt.Errorf("failed to init state db around block %s (state %s): %w", parent, parentHeader.Root, err) } @@ -78,7 +174,7 @@ func (ea *L2EngineAPI) startBlock(parent common.Hash, params *eth.PayloadAttribu MixDigest: common.Hash(params.PrevRandao), } - header.BaseFee = misc.CalcBaseFee(ea.l2Cfg.Config, parentHeader) + header.BaseFee = misc.CalcBaseFee(ea.backend.Config(), parentHeader) ea.l2BuildingHeader = header ea.l2BuildingState = statedb @@ -96,8 +192,8 @@ func (ea *L2EngineAPI) startBlock(parent common.Hash, params *eth.PayloadAttribu return fmt.Errorf("transaction %d is not valid: %w", i, err) } ea.l2BuildingState.SetTxContext(tx.Hash(), i) - receipt, err := core.ApplyTransaction(ea.l2Cfg.Config, ea.l2Chain, &ea.l2BuildingHeader.Coinbase, - ea.l2GasPool, ea.l2BuildingState, ea.l2BuildingHeader, &tx, &ea.l2BuildingHeader.GasUsed, *ea.l2Chain.GetVMConfig()) + receipt, err := core.ApplyTransaction(ea.backend.Config(), ea.backend, &ea.l2BuildingHeader.Coinbase, + ea.l2GasPool, ea.l2BuildingState, ea.l2BuildingHeader, &tx, &ea.l2BuildingHeader.GasUsed, *ea.backend.GetVMConfig()) if err != nil { ea.l2TxFailed = append(ea.l2TxFailed, &tx) return fmt.Errorf("failed to apply deposit transaction to L2 block (tx %d): %w", i, err) @@ -116,17 +212,8 @@ func (ea *L2EngineAPI) endBlock() (*types.Block, error) { ea.l2BuildingHeader = nil header.GasUsed = header.GasLimit - uint64(*ea.l2GasPool) - header.Root = ea.l2BuildingState.IntermediateRoot(ea.l2Cfg.Config.IsEIP158(header.Number)) + header.Root = ea.l2BuildingState.IntermediateRoot(ea.backend.Config().IsEIP158(header.Number)) block := types.NewBlock(header, ea.l2Transactions, nil, ea.l2Receipts, trie.NewStackTrie(nil)) - - // Write state changes to db - root, err := ea.l2BuildingState.Commit(ea.l2Cfg.Config.IsEIP158(header.Number)) - if err != nil { - return nil, fmt.Errorf("l2 state write error: %w", err) - } - if err := ea.l2BuildingState.Database().TrieDB().Commit(root, false); err != nil { - return nil, fmt.Errorf("l2 trie write error: %w", err) - } return block, nil } @@ -153,31 +240,16 @@ func (ea *L2EngineAPI) ForkchoiceUpdatedV1(ctx context.Context, state *eth.Forkc // 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) + block := ea.backend.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 ð.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 ð.ForkchoiceUpdatedResult{PayloadStatus: INVALID_TERMINAL_BLOCK, PayloadID: nil}, nil - } + // Note: Differs from op-geth implementation as pre-merge blocks are never supported here + if block.Difficulty().BitLen() > 0 { + return STATUS_INVALID, errors.New("pre-merge blocks not supported") } valid := func(id *engine.PayloadID) *eth.ForkchoiceUpdatedResult { return ð.ForkchoiceUpdatedResult{ @@ -185,16 +257,16 @@ func (ea *L2EngineAPI) ForkchoiceUpdatedV1(ctx context.Context, state *eth.Forkc PayloadID: id, } } - if rawdb.ReadCanonicalHash(ea.l2Database, block.NumberU64()) != state.HeadBlockHash { + if ea.backend.GetCanonicalHash(block.NumberU64()) != state.HeadBlockHash { // Block is not canonical, set head. - if latestValid, err := ea.l2Chain.SetCanonical(block); err != nil { + if latestValid, err := ea.backend.SetCanonical(block); err != nil { return ð.ForkchoiceUpdatedResult{PayloadStatus: eth.PayloadStatusV1{Status: eth.ExecutionInvalid, LatestValidHash: &latestValid}}, err } - } else if ea.l2Chain.CurrentBlock().Hash() == state.HeadBlockHash { + } else if ea.backend.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 + } else if ea.backend.Config().Optimism == nil { // minor L2Engine API divergence: allow proposers to reorg their own chain panic("engine not configured as optimism engine") } @@ -202,30 +274,30 @@ func (ea *L2EngineAPI) ForkchoiceUpdatedV1(ctx context.Context, state *eth.Forkc // chain final and completely in PoS mode. if state.FinalizedBlockHash != (common.Hash{}) { // If the finalized block is not in our canonical tree, somethings wrong - finalHeader := ea.l2Chain.GetHeaderByHash(state.FinalizedBlockHash) + finalHeader := ea.backend.GetHeaderByHash(state.FinalizedBlockHash) if finalHeader == nil { ea.log.Warn("Final block not available in database", "hash", state.FinalizedBlockHash) return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("final block not available in database")) - } else if rawdb.ReadCanonicalHash(ea.l2Database, finalHeader.Number.Uint64()) != state.FinalizedBlockHash { + } else if ea.backend.GetCanonicalHash(finalHeader.Number.Uint64()) != state.FinalizedBlockHash { ea.log.Warn("Final block not in canonical chain", "number", block.NumberU64(), "hash", state.HeadBlockHash) return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("final block not in canonical chain")) } // Set the finalized block - ea.l2Chain.SetFinalized(finalHeader) + ea.backend.SetFinalized(finalHeader) } // Check if the safe block hash is in our canonical tree, if not somethings wrong if state.SafeBlockHash != (common.Hash{}) { - safeHeader := ea.l2Chain.GetHeaderByHash(state.SafeBlockHash) + safeHeader := ea.backend.GetHeaderByHash(state.SafeBlockHash) if safeHeader == nil { ea.log.Warn("Safe block not available in database") return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("safe block not available in database")) } - if rawdb.ReadCanonicalHash(ea.l2Database, safeHeader.Number.Uint64()) != state.SafeBlockHash { + if ea.backend.GetCanonicalHash(safeHeader.Number.Uint64()) != state.SafeBlockHash { ea.log.Warn("Safe block not in canonical chain") return STATUS_INVALID, engine.InvalidForkChoiceState.With(errors.New("safe block not in canonical chain")) } // Set the safe block - ea.l2Chain.SetSafe(safeHeader) + ea.backend.SetSafe(safeHeader) } // 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 @@ -270,7 +342,7 @@ func (ea *L2EngineAPI) NewPayloadV1(ctx context.Context, payload *eth.ExecutionP } // 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 { + if block := ea.backend.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 ð.PayloadStatusV1{Status: eth.ExecutionValid, LatestValidHash: &hash}, nil @@ -278,16 +350,23 @@ func (ea *L2EngineAPI) NewPayloadV1(ctx context.Context, payload *eth.ExecutionP // TODO: skipping invalid ancestor check (i.e. not remembering previously failed blocks) - parent := ea.l2Chain.GetBlock(block.ParentHash(), block.NumberU64()-1) + parent := ea.backend.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 ð.PayloadStatusV1{Status: eth.ExecutionAccepted, LatestValidHash: nil}, nil } - if !ea.l2Chain.HasBlockAndState(block.ParentHash(), block.NumberU64()-1) { + + if block.Time() <= parent.Time() { + log.Warn("Invalid timestamp", "parent", block.Time(), "block", block.Time()) + return ea.invalid(errors.New("invalid timestamp"), parent.Header()), nil + } + + if !ea.backend.HasBlockAndState(block.ParentHash(), block.NumberU64()-1) { ea.log.Warn("State not available, ignoring new payload") return ð.PayloadStatusV1{Status: eth.ExecutionAccepted}, nil } - if err := ea.l2Chain.InsertBlockWithoutSetHead(block); err != nil { + log.Trace("Inserting block without sethead", "hash", block.Hash(), "number", block.Number) + if err := ea.backend.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 @@ -297,7 +376,7 @@ func (ea *L2EngineAPI) NewPayloadV1(ctx context.Context, payload *eth.ExecutionP } func (ea *L2EngineAPI) invalid(err error, latestValid *types.Header) *eth.PayloadStatusV1 { - currentHash := ea.l2Chain.CurrentBlock().Hash() + currentHash := ea.backend.CurrentBlock().Hash() if latestValid != nil { // Set latest valid hash to 0x0 if parent is PoW block currentHash = common.Hash{} diff --git a/op-program/l2/engineapi/test/l2_engine_api_tests.go b/op-program/l2/engineapi/test/l2_engine_api_tests.go new file mode 100644 index 0000000000000..2d910cde5ad0e --- /dev/null +++ b/op-program/l2/engineapi/test/l2_engine_api_tests.go @@ -0,0 +1,299 @@ +package test + +import ( + "context" + "testing" + + "github.com/ethereum-optimism/optimism/op-node/eth" + "github.com/ethereum-optimism/optimism/op-node/rollup/derive" + "github.com/ethereum-optimism/optimism/op-node/testlog" + "github.com/ethereum-optimism/optimism/op-program/l2/engineapi" + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" + "github.com/stretchr/testify/require" +) + +var gasLimit = eth.Uint64Quantity(30_000_000) +var feeRecipient = common.Address{} + +func RunEngineAPITests(t *testing.T, createBackend func() engineapi.EngineBackend) { + t.Run("CreateBlock", func(t *testing.T) { + api := newTestHelper(t, createBackend) + + block := api.addBlock() + api.assert.Equal(block.BlockHash, api.headHash(), "should create and import new block") + }) + + t.Run("IncludeRequiredTransactions", func(t *testing.T) { + api := newTestHelper(t, createBackend) + genesis := api.backend.CurrentBlock() + + txData, err := derive.L1InfoDeposit(1, eth.HeaderBlockInfo(genesis), eth.SystemConfig{}, true) + api.assert.NoError(err) + tx := types.NewTx(txData) + block := api.addBlock(tx) + api.assert.Equal(block.BlockHash, api.headHash(), "should create and import new block") + imported := api.backend.GetBlockByHash(block.BlockHash) + api.assert.Len(imported.Transactions(), 1, "should include transaction") + }) + + t.Run("IgnoreUpdateHeadToOlderBlock", func(t *testing.T) { + api := newTestHelper(t, createBackend) + genesisHash := api.headHash() + api.addBlock() + block := api.addBlock() + api.assert.Equal(block.BlockHash, api.headHash(), "should have extended chain") + + api.forkChoiceUpdated(genesisHash, genesisHash, genesisHash) + api.assert.Equal(block.BlockHash, api.headHash(), "should not have reset chain head") + }) + + t.Run("AllowBuildingOnOlderBlock", func(t *testing.T) { + api := newTestHelper(t, createBackend) + genesis := api.backend.CurrentBlock() + api.addBlock() + block := api.addBlock() + api.assert.Equal(block.BlockHash, api.headHash(), "should have extended chain") + + payloadID := api.startBlockBuilding(genesis, eth.Uint64Quantity(genesis.Time+3)) + api.assert.Equal(block.BlockHash, api.headHash(), "should not reset chain head when building starts") + + payload := api.getPayload(payloadID) + api.assert.Equal(genesis.Hash(), payload.ParentHash, "should have old block as parent") + + api.newPayload(payload) + api.forkChoiceUpdated(payload.BlockHash, genesis.Hash(), genesis.Hash()) + api.assert.Equal(payload.BlockHash, api.headHash(), "should reorg to block built on old parent") + }) + + t.Run("RejectInvalidBlockHash", func(t *testing.T) { + api := newTestHelper(t, createBackend) + + // Invalid because BlockHash won't be correct (among many other reasons) + block := ð.ExecutionPayload{} + r, err := api.engine.NewPayloadV1(api.ctx, block) + api.assert.NoError(err) + api.assert.Equal(eth.ExecutionInvalidBlockHash, r.Status) + }) + + t.Run("RejectBlockWithInvalidStateTransition", func(t *testing.T) { + api := newTestHelper(t, createBackend) + genesis := api.backend.CurrentBlock() + + // Build a valid block + payloadID := api.startBlockBuilding(genesis, eth.Uint64Quantity(genesis.Time+2)) + newBlock := api.getPayload(payloadID) + + // But then make it invalid by changing the state root + newBlock.StateRoot = eth.Bytes32(genesis.TxHash) + updateBlockHash(newBlock) + + r, err := api.engine.NewPayloadV1(api.ctx, newBlock) + api.assert.NoError(err) + api.assert.Equal(eth.ExecutionInvalid, r.Status) + }) + + t.Run("RejectBlockWithSameTimeAsParent", func(t *testing.T) { + api := newTestHelper(t, createBackend) + genesis := api.backend.CurrentBlock() + + payloadID := api.startBlockBuilding(genesis, eth.Uint64Quantity(genesis.Time)) + newBlock := api.getPayload(payloadID) + + r, err := api.engine.NewPayloadV1(api.ctx, newBlock) + api.assert.NoError(err) + api.assert.Equal(eth.ExecutionInvalid, r.Status) + }) + + t.Run("RejectBlockWithTimeBeforeParent", func(t *testing.T) { + api := newTestHelper(t, createBackend) + genesis := api.backend.CurrentBlock() + + payloadID := api.startBlockBuilding(genesis, eth.Uint64Quantity(genesis.Time-1)) + newBlock := api.getPayload(payloadID) + + r, err := api.engine.NewPayloadV1(api.ctx, newBlock) + api.assert.NoError(err) + api.assert.Equal(eth.ExecutionInvalid, r.Status) + }) + + t.Run("UpdateSafeAndFinalizedHead", func(t *testing.T) { + api := newTestHelper(t, createBackend) + + finalized := api.addBlock() + safe := api.addBlock() + head := api.addBlock() + + api.forkChoiceUpdated(head.BlockHash, safe.BlockHash, finalized.BlockHash) + api.assert.Equal(head.BlockHash, api.headHash(), "should update head block") + api.assert.Equal(safe.BlockHash, api.safeHash(), "should update safe block") + api.assert.Equal(finalized.BlockHash, api.finalHash(), "should update finalized block") + }) + + t.Run("RejectSafeHeadWhenNotAncestor", func(t *testing.T) { + api := newTestHelper(t, createBackend) + genesis := api.backend.CurrentBlock() + + api.addBlock() + chainA2 := api.addBlock() + chainA3 := api.addBlock() + + chainB1 := api.addBlockWithParent(genesis, eth.Uint64Quantity(genesis.Time+3)) + + result, err := api.engine.ForkchoiceUpdatedV1(api.ctx, ð.ForkchoiceState{ + HeadBlockHash: chainA3.BlockHash, + SafeBlockHash: chainB1.BlockHash, + FinalizedBlockHash: chainA2.BlockHash, + }, nil) + api.assert.ErrorContains(err, "Invalid forkchoice state", "should return error from forkChoiceUpdated") + api.assert.Equal(eth.ExecutionInvalid, result.PayloadStatus.Status, "forkChoiceUpdated should return invalid") + api.assert.Nil(result.PayloadID, "should not provide payload ID when invalid") + }) + + t.Run("RejectFinalizedHeadWhenNotAncestor", func(t *testing.T) { + api := newTestHelper(t, createBackend) + genesis := api.backend.CurrentBlock() + + api.addBlock() + chainA2 := api.addBlock() + chainA3 := api.addBlock() + + chainB1 := api.addBlockWithParent(genesis, eth.Uint64Quantity(genesis.Time+3)) + + result, err := api.engine.ForkchoiceUpdatedV1(api.ctx, ð.ForkchoiceState{ + HeadBlockHash: chainA3.BlockHash, + SafeBlockHash: chainA2.BlockHash, + FinalizedBlockHash: chainB1.BlockHash, + }, nil) + api.assert.ErrorContains(err, "Invalid forkchoice state", "should return error from forkChoiceUpdated") + api.assert.Equal(eth.ExecutionInvalid, result.PayloadStatus.Status, "forkChoiceUpdated should return invalid") + api.assert.Nil(result.PayloadID, "should not provide payload ID when invalid") + }) +} + +// Updates the block hash to the expected value based on the other fields in the payload +func updateBlockHash(newBlock *eth.ExecutionPayload) { + // And fix up the block hash + newHash, _ := newBlock.CheckBlockHash() + newBlock.BlockHash = newHash +} + +type testHelper struct { + t *testing.T + ctx context.Context + engine *engineapi.L2EngineAPI + backend engineapi.EngineBackend + assert *require.Assertions +} + +func newTestHelper(t *testing.T, createBackend func() engineapi.EngineBackend) *testHelper { + logger := testlog.Logger(t, log.LvlDebug) + ctx := context.Background() + backend := createBackend() + api := engineapi.NewL2EngineAPI(logger, backend) + test := &testHelper{ + t: t, + ctx: ctx, + engine: api, + backend: backend, + assert: require.New(t), + } + return test +} + +func (h *testHelper) headHash() common.Hash { + return h.backend.CurrentBlock().Hash() +} + +func (h *testHelper) safeHash() common.Hash { + return h.backend.CurrentSafeBlock().Hash() +} + +func (h *testHelper) finalHash() common.Hash { + return h.backend.CurrentFinalBlock().Hash() +} + +func (h *testHelper) Log(args ...any) { + h.t.Log(args...) +} + +func (h *testHelper) addBlock(txs ...*types.Transaction) *eth.ExecutionPayload { + head := h.backend.CurrentBlock() + return h.addBlockWithParent(head, eth.Uint64Quantity(head.Time+2), txs...) +} + +func (h *testHelper) addBlockWithParent(head *types.Header, timestamp eth.Uint64Quantity, txs ...*types.Transaction) *eth.ExecutionPayload { + prevHead := h.backend.CurrentBlock() + id := h.startBlockBuilding(head, timestamp, txs...) + + block := h.getPayload(id) + h.assert.Equal(timestamp, block.Timestamp, "should create block with correct timestamp") + h.assert.Equal(head.Hash(), block.ParentHash, "should have correct parent") + h.assert.Len(block.Transactions, len(txs)) + + h.newPayload(block) + + // Should not have changed the chain head yet + h.assert.Equal(prevHead, h.backend.CurrentBlock()) + + h.forkChoiceUpdated(block.BlockHash, head.Hash(), head.Hash()) + h.assert.Equal(block.BlockHash, h.backend.CurrentBlock().Hash()) + return block +} + +func (h *testHelper) forkChoiceUpdated(head common.Hash, safe common.Hash, finalized common.Hash) { + h.Log("forkChoiceUpdated", "head", head, "safe", safe, "finalized", finalized) + result, err := h.engine.ForkchoiceUpdatedV1(h.ctx, ð.ForkchoiceState{ + HeadBlockHash: head, + SafeBlockHash: safe, + FinalizedBlockHash: finalized, + }, nil) + h.assert.NoError(err) + h.assert.Equal(eth.ExecutionValid, result.PayloadStatus.Status, "forkChoiceUpdated should return valid") + h.assert.Nil(result.PayloadStatus.ValidationError, "should not have validation error when valid") + h.assert.Nil(result.PayloadID, "should not provide payload ID when block building not requested") +} + +func (h *testHelper) startBlockBuilding(head *types.Header, newBlockTimestamp eth.Uint64Quantity, txs ...*types.Transaction) *eth.PayloadID { + h.Log("Start block building", "head", head.Hash(), "timestamp", newBlockTimestamp) + var txData []eth.Data + for _, tx := range txs { + rlp, err := tx.MarshalBinary() + h.assert.NoError(err, "Failed to marshall tx %v", tx) + txData = append(txData, rlp) + } + result, err := h.engine.ForkchoiceUpdatedV1(h.ctx, ð.ForkchoiceState{ + HeadBlockHash: head.Hash(), + SafeBlockHash: head.Hash(), + FinalizedBlockHash: head.Hash(), + }, ð.PayloadAttributes{ + Timestamp: newBlockTimestamp, + PrevRandao: eth.Bytes32(head.MixDigest), + SuggestedFeeRecipient: feeRecipient, + Transactions: txData, + NoTxPool: true, + GasLimit: &gasLimit, + }) + h.assert.NoError(err) + h.assert.Equal(eth.ExecutionValid, result.PayloadStatus.Status) + id := result.PayloadID + h.assert.NotNil(id) + return id +} + +func (h *testHelper) getPayload(id *eth.PayloadID) *eth.ExecutionPayload { + h.Log("getPayload", "id", id) + block, err := h.engine.GetPayloadV1(h.ctx, *id) + h.assert.NoError(err) + h.assert.NotNil(block) + return block +} + +func (h *testHelper) newPayload(block *eth.ExecutionPayload) { + h.Log("newPayload", "hash", block.BlockHash) + r, err := h.engine.NewPayloadV1(h.ctx, block) + h.assert.NoError(err) + h.assert.Equal(eth.ExecutionValid, r.Status) + h.assert.Nil(r.ValidationError) +}