diff --git a/beacon/engine/errors.go b/beacon/engine/errors.go index 769001b9e3d7..8baf5f6f5f21 100644 --- a/beacon/engine/errors.go +++ b/beacon/engine/errors.go @@ -58,11 +58,13 @@ var ( // VALID is returned by the engine API in the following calls: // - newPayloadV1: if the payload was already known or was just validated and executed // - forkchoiceUpdateV1: if the chain accepted the reorg (might ignore if it's stale) + // - newInclusionListV1: if the inclusion list is valid and executable on current state VALID = "VALID" // INVALID is returned by the engine API in the following calls: // - newPayloadV1: if the payload failed to execute on top of the local chain // - forkchoiceUpdateV1: if the new head is unknown, pre-merge, or reorg to it fails + // - newInclusionListV1: if the inclusion list is invalid INVALID = "INVALID" // SYNCING is returned by the engine API in the following calls: diff --git a/beacon/engine/types.go b/beacon/engine/types.go index 874f3e90aff2..9819c928bd50 100644 --- a/beacon/engine/types.go +++ b/beacon/engine/types.go @@ -63,6 +63,10 @@ type ExecutableData struct { Withdrawals []*types.Withdrawal `json:"withdrawals"` BlobGasUsed *uint64 `json:"blobGasUsed"` ExcessBlobGas *uint64 `json:"excessBlobGas"` + + // ePBS + InclusionListSummary InclusionListSummaryV1 `json:"inclusionListSummary"` + InclusionListExclusions ExclusionList `json:"inclusionListExclusions"` } // JSON type overrides for executableData. @@ -277,3 +281,19 @@ type ExecutionPayloadBodyV1 struct { TransactionData []hexutil.Bytes `json:"transactions"` Withdrawals []*types.Withdrawal `json:"withdrawals"` } + +type VerifiableInclusionList struct { + ParentHash common.Hash `json:"parentHash"` + InclusionList types.InclusionList `json:"inclusionList"` +} + +type InclusionListV1 types.InclusionList + +type InclusionListSummaryV1 []*types.InclusionListEntry + +type ExclusionList []uint64 + +type InclusionListStatusV1 struct { + Status string `json:"status"` + ValidatorError error `json:"validatorError"` +} diff --git a/core/blockchain.go b/core/blockchain.go index c579123c0ec2..59b9f1b5342b 100644 --- a/core/blockchain.go +++ b/core/blockchain.go @@ -2587,3 +2587,13 @@ func (bc *BlockChain) SetTrieFlushInterval(interval time.Duration) { func (bc *BlockChain) GetTrieFlushInterval() time.Duration { return time.Duration(bc.flushInterval.Load()) } + +// VerifyInclusionList validates an inclusion list to make sure it satisfies all the condition based on a `parent` header. +func (bc *BlockChain) VerifyInclusionList(list types.InclusionList, parent *types.Header, getStateNonce func(common.Address) uint64) (bool, error) { + return verifyInclusionList(list, parent, bc.Config(), getStateNonce) +} + +// VerifyInclusionListInBlock verifies the block solely based on the inclusion list conditions based on `parent` block's data. +func (bc *BlockChain) VerifyInclusionListInBlock(summary []*types.InclusionListEntry, exclusionList []uint64, currentTxs types.Transactions, parent *types.Block) (bool, error) { + return verifyInclusionListInBlock(summary, exclusionList, parent.Body().Transactions, currentTxs, bc.Config()) +} diff --git a/core/inclusion_list.go b/core/inclusion_list.go new file mode 100644 index 000000000000..244601a4bb51 --- /dev/null +++ b/core/inclusion_list.go @@ -0,0 +1,191 @@ +package core + +import ( + "errors" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/consensus/misc/eip1559" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/log" + "github.com/ethereum/go-ethereum/params" +) + +var ( + ErrSizeMismatch = errors.New("summary and transactions length mismatch in IL") + ErrSizeExceeded = errors.New("transactions exceeds maximum limit in IL") + ErrUnsupportedTxType = errors.New("unsupported tx type in IL") + ErrInvalidTx = errors.New("invalid tx in IL") + ErrGasLimitExceeded = errors.New("gas limit exceeds maximum allowed in IL") + ErrSenderMismatch = errors.New("summary and transaction sender mismatch in IL") + ErrGasLimitMismatch = errors.New("summary and transaction gaslimit mismatch in IL") + ErrIncorrectNonce = errors.New("incorrect nonce in IL") + ErrInsufficientGasFeeCap = errors.New("insufficient gas fee cap in IL") +) + +// IL constants taken from specs here: https://github.com/potuz/consensus-specs/blob/a6c55576de059a1b2cae69848dee827f6e26e72d/specs/_features/epbs/beacon-chain.md#execution +const ( + MaxTransactionsPerInclusionList = 16 + MaxGasPerInclusionList = 2_097_152 // 2^21 +) + +// verifyInclusionList verifies the properties of the inclusion list and the +// transactions in it based on a `parent` block. +func verifyInclusionList(list types.InclusionList, parent *types.Header, config *params.ChainConfig, getStateNonce func(addr common.Address) uint64) (bool, error) { + if len(list.Summary) != len(list.Transactions) { + log.Debug("IL verification failed: summary and transactions length mismatch", "summary", len(list.Summary), "txs", len(list.Transactions)) + return false, ErrSizeMismatch + } + + if len(list.Summary) > MaxTransactionsPerInclusionList { + log.Debug("IL verification failed: exceeds maximum number of transactions", "len", len(list.Summary), "max", MaxTransactionsPerInclusionList) + return false, ErrSizeExceeded + } + + // As IL will be included in the next block, calculate the current block's base fee. + // As the current block's payload isn't revealed yet (due to ePBS), calculate + // it from parent block. + currentBaseFee := eip1559.CalcBaseFee(config, parent) + + // 1.125 * currentBaseFee + gasFeeThreshold := new(big.Float).Mul(new(big.Float).SetFloat64(1.125), new(big.Float).SetInt(currentBaseFee)) + + // Prepare the signer object + signer := types.LatestSigner(config) + + // Create a nonce cache + nonceCache := make(map[common.Address]uint64) + + // Track total gas limit + gasLimit := uint64(0) + + // Verify if the summary and transactions match. Also check if the txs + // have at least 12.5% higher `maxFeePerGas` than parent block's base fee. + for i, summary := range list.Summary { + tx := list.Transactions[i] + + // Don't allow BlobTxs + if tx.Type() == types.BlobTxType { + log.Debug("IL verification failed: received blob tx in IL") + return false, ErrUnsupportedTxType + } + + // Verify gas limit + gasLimit += tx.Gas() + + if gasLimit > MaxGasPerInclusionList { + log.Debug("IL verification failed: gas limit exceeds maximum allowed", "gaslimit", gasLimit, "max", MaxGasPerInclusionList) + return false, ErrGasLimitExceeded + } + + // Verify sender + from, err := types.Sender(signer, tx) + if err != nil { + log.Debug("IL verification failed: unable to get sender from transaction", "err", err) + return false, ErrInvalidTx + } + + if summary.Address != from { + log.Debug("IL verification failed: summary and transaction address mismatch", "summary", summary.Address, "tx", from) + return false, ErrSenderMismatch + } + + if summary.GasLimit != uint32(tx.Gas()) { + log.Debug("IL verification failed: summary and transaction gaslimit mismatch", "summary", summary.GasLimit, "tx", tx.Gas()) + return false, ErrGasLimitMismatch + } + + // Verify nonce from state + nonce := getStateNonce(from) + if cacheNonce, ok := nonceCache[from]; ok { + nonce = cacheNonce + } + + if tx.Nonce() == nonce { + nonceCache[from] = nonce + 1 + } else { + log.Debug("IL verification failed: incorrect nonce", "state nonce", nonce, "tx nonce", tx.Nonce()) + return false, ErrIncorrectNonce + } + + // Verify gas fee: tx.GasFeeCap >= gasFeeThreshold + if new(big.Float).SetInt(tx.GasFeeCap()).Cmp(gasFeeThreshold) == -1 { + log.Debug("IL verification failed: insufficient gas fee cap", "gasFeeCap", tx.GasFeeCap(), "threshold", gasFeeThreshold) + return false, ErrInsufficientGasFeeCap + } + } + + log.Debug("IL verified successfully", "len", len(list.Summary), "gas", gasLimit) + + return true, nil +} + +// verifyInclusionListInBlock verifies if a block satisfies the inclusion list summary +// or not. Note that this function doesn't validate the state transition. It can be +// considered as a filter before sending the block to state transition. This function +// assumes that basic validations are already done. It only checks the following things: +// +// 1. If the indices in the exclusion list pointing to the parent block transactions +// are present in the summary or not. +// 2. If the remaining summary entries are satisfied by the first `k` transactions +// of the current block. +func verifyInclusionListInBlock(summaryEntries types.InclusionListSummaries, exclusionList []uint64, parentTxs, currentTxs types.Transactions, config *params.ChainConfig) (bool, error) { + // We assume that summary isn't ordered + // Prepare a map of summary entries: address -> []{gas limit}. + summaries := make(map[common.Address][]uint32) + for _, summary := range summaryEntries { + if _, ok := summaries[summary.Address]; !ok { + summaries[summary.Address] = make([]uint32, 0) + } + summaries[summary.Address] = append(summaries[summary.Address], summary.GasLimit) + } + + // Prepare the signer object + signer := types.LatestSigner(config) + + exclusions := 0 + for _, index := range exclusionList { + tx := parentTxs[index] + + // Verify sender + from, err := types.Sender(signer, tx) + if err != nil { + return false, errors.New("invalid tx in parent block") + } + + if entries, ok := summaries[from]; !ok || len(entries) == 0 { + return false, errors.New("missing summary entry") + } + + summaries[from] = summaries[from][1:] + exclusions++ + } + + index := 0 + for { + if exclusions < len(summaryEntries) { + break + } + + tx := currentTxs[index] + + // Verify sender + from, err := types.Sender(signer, tx) + if err != nil { + return false, errors.New("invalid tx in current block") + } + + if entries, ok := summaries[from]; !ok || len(entries) == 0 { + return false, errors.New("missing IL in current block") + } + + if summaries[from][0] > uint32(tx.Gas()) { + return false, errors.New("invalid gas limit") + } + summaries[from] = summaries[from][1:] + exclusions++ + index++ + } + + return true, nil +} diff --git a/core/inclusion_list_test.go b/core/inclusion_list_test.go new file mode 100644 index 000000000000..d05e63c67e83 --- /dev/null +++ b/core/inclusion_list_test.go @@ -0,0 +1,120 @@ +package core + +import ( + "crypto/ecdsa" + "math/big" + "testing" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/core/types" + "github.com/ethereum/go-ethereum/crypto" + "github.com/ethereum/go-ethereum/params" + "github.com/stretchr/testify/assert" +) + +func transaction(nonce uint64, gaslimit uint64, gasPrice *big.Int, key *ecdsa.PrivateKey) *types.Transaction { + return pricedTransaction(nonce, gaslimit, gasPrice, key) +} + +func pricedTransaction(nonce uint64, gaslimit uint64, gasprice *big.Int, key *ecdsa.PrivateKey) *types.Transaction { + tx, _ := types.SignTx(types.NewTransaction(nonce, common.Address{}, big.NewInt(100), gaslimit, gasprice, nil), types.HomesteadSigner{}, key) + return tx +} + +func getTxsAndSummary(n int, startNonce uint64, getGasLimit func(n int) uint64, getGasPrice func(n int) *big.Int, key *ecdsa.PrivateKey) ([]*types.InclusionListEntry, []*types.Transaction) { + summary := make([]*types.InclusionListEntry, 0, n) + txs := make([]*types.Transaction, 0, n) + + for i := 0; i < n; i++ { + txs = append(txs, transaction(startNonce, getGasLimit(i), getGasPrice(i), key)) + summary = append(summary, &types.InclusionListEntry{Address: crypto.PubkeyToAddress(key.PublicKey), GasLimit: uint32(getGasLimit(i))}) + startNonce++ + } + + return summary, txs +} + +func getGasLimitForTest(n int) uint64 { + if n == 15 { + return 1_000_000 + } + return 100_000 +} + +func getGasPriceForTest(n int) *big.Int { + // threshold = 1.125 * 1 Gwei + if n == 0 { + return big.NewInt(1_126_000_000) + } + if n == 17 { + return big.NewInt(1_124_000_000) + } + return big.NewInt(1_125_000_000) +} + +func getStateNonceForTest(n int) func(addr common.Address) uint64 { + if n == 1 { + return func(addr common.Address) uint64 { + return 1 + } + } else if n == 2 { + return func(addr common.Address) uint64 { + return 17 + } + } + + return func(addr common.Address) uint64 { + return 0 + } +} + +func TestVerifyInclusionList(t *testing.T) { + key, _ := crypto.GenerateKey() + + // Generate dummy summary and set of txs + summary, txs := getTxsAndSummary(32, 0, getGasLimitForTest, getGasPriceForTest, key) + + // Modify a summary entry explicity for validating invalid + // sender address check + summary[16].Address = common.Address{} + + // Build a parent block such that the base fee stays the same + // for the next block. + parent := &types.Header{ + Number: big.NewInt(0), + GasLimit: 30_00_000, + GasUsed: 15_00_000, + BaseFee: big.NewInt(1_000_000_000), // 1 GWei + } + + testCases := []struct { + name string + list types.InclusionList + parent *types.Header + config *params.ChainConfig + getStateNonce func(addr common.Address) uint64 + want bool + err error + }{ + {"empty inclusion list", types.InclusionList{Summary: summary[:0], Transactions: txs[:0]}, parent, params.TestChainConfig, getStateNonceForTest(0), true, nil}, + {"unqeual size of summary and transactions - 1", types.InclusionList{Summary: summary[:1], Transactions: txs[:0]}, parent, params.TestChainConfig, getStateNonceForTest(0), false, ErrSizeMismatch}, + {"unqeual size of summary and transactions - 2", types.InclusionList{Summary: summary[:0], Transactions: txs[:1]}, parent, params.TestChainConfig, getStateNonceForTest(0), false, ErrSizeMismatch}, + {"size exceeded", types.InclusionList{Summary: summary, Transactions: txs}, parent, params.TestChainConfig, getStateNonceForTest(0), false, ErrSizeExceeded}, + {"gas limit exceeded", types.InclusionList{Summary: summary[:16], Transactions: txs[:16]}, parent, params.TestChainConfig, getStateNonceForTest(0), false, ErrGasLimitExceeded}, + {"invalid sender address", types.InclusionList{Summary: summary[16:], Transactions: txs[16:]}, parent, params.TestChainConfig, getStateNonceForTest(0), false, ErrSenderMismatch}, + {"invalid nonce - 1", types.InclusionList{Summary: summary[1:16], Transactions: txs[1:16]}, parent, params.TestChainConfig, getStateNonceForTest(0), false, ErrIncorrectNonce}, + {"invalid nonce - 2", types.InclusionList{Summary: summary[:16], Transactions: txs[:16]}, parent, params.TestChainConfig, getStateNonceForTest(1), false, ErrIncorrectNonce}, + {"less base fee", types.InclusionList{Summary: summary[17:], Transactions: txs[17:]}, parent, params.TestChainConfig, getStateNonceForTest(2), false, ErrInsufficientGasFeeCap}, + {"happy case", types.InclusionList{Summary: summary[:15], Transactions: txs[:15]}, parent, params.TestChainConfig, getStateNonceForTest(0), true, nil}, + } + + for _, tc := range testCases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + res, err := verifyInclusionList(tc.list, parent, params.TestChainConfig, tc.getStateNonce) + assert.Equal(t, res, tc.want, "result mismatch") + assert.Equal(t, err, tc.err, "error mismatch") + }) + } + +} diff --git a/core/txpool/blobpool/blobpool.go b/core/txpool/blobpool/blobpool.go index a7381ac6e798..4ad985ff9fba 100644 --- a/core/txpool/blobpool/blobpool.go +++ b/core/txpool/blobpool/blobpool.go @@ -1480,6 +1480,16 @@ func (p *BlobPool) Nonce(addr common.Address) uint64 { return p.state.GetNonce(addr) } +// StateNonce returns the next nonce of an account from the underlying state, without +// applying any transactions from the pool on top. This is only used for verification +// of inclusion list which only supports *legacy* transactions as of now. +func (p *BlobPool) StateNonce(addr common.Address) uint64 { + p.lock.Lock() + defer p.lock.Unlock() + + return 0 +} + // Stats retrieves the current pool stats, namely the number of pending and the // number of queued (non-executable) transactions. func (p *BlobPool) Stats() (int, int) { @@ -1526,3 +1536,10 @@ func (p *BlobPool) Status(hash common.Hash) txpool.TxStatus { } return txpool.TxStatusUnknown } + +// GetInclusionList returns an inclusion list from the pool containing pairs +// of transaction summary and data which are executable. Currently blob txs +// aren't supported in the inclusion list. +func (pool *BlobPool) GetInclusionList() (*types.InclusionList, error) { + return nil, txpool.ErrUnsupportedMethod +} diff --git a/core/txpool/errors.go b/core/txpool/errors.go index bc26550f78ca..e8c1bc23e673 100644 --- a/core/txpool/errors.go +++ b/core/txpool/errors.go @@ -54,4 +54,8 @@ var ( // ErrFutureReplacePending is returned if a future transaction replaces a pending // transaction. Future transactions should only be able to replace other future transactions. ErrFutureReplacePending = errors.New("future transaction tries to replace pending") + + // ErrUnsupportedMethod is returned when an unsupported method is called on + // a subpool. + ErrUnsupportedMethod = errors.New("unsupported method") ) diff --git a/core/txpool/legacypool/legacypool.go b/core/txpool/legacypool/legacypool.go index 00e326c4b87f..5315440726ed 100644 --- a/core/txpool/legacypool/legacypool.go +++ b/core/txpool/legacypool/legacypool.go @@ -448,6 +448,15 @@ func (pool *LegacyPool) Nonce(addr common.Address) uint64 { return pool.pendingNonces.get(addr) } +// StateNonce returns the next nonce of an account from the underlying state, without +// applying any transactions from the pool on top. +func (pool *LegacyPool) StateNonce(addr common.Address) uint64 { + pool.mu.RLock() + defer pool.mu.RUnlock() + + return pool.currentState.GetNonce(addr) +} + // Stats retrieves the current pool stats, namely the number of pending and the // number of queued (non-executable) transactions. func (pool *LegacyPool) Stats() (int, int) { @@ -1670,6 +1679,131 @@ func (pool *LegacyPool) demoteUnexecutables() { } } +// GetInclusionList returns an inclusion list from the pool containing pairs +// of transaction summary and data which are executable. +func (pool *LegacyPool) GetInclusionList() (*types.InclusionList, error) { + summaries := make([]*types.InclusionListEntry, 0, core.MaxTransactionsPerInclusionList) + transactions := make([]*types.Transaction, 0, core.MaxTransactionsPerInclusionList) + + // TODO: Not sure what's the best way to fetch transactions for inclusion list. + // Few possibilities are: + // 1. Prioritise locals first + // 2. Highest paying ones + // 3. Stayed in txpool for the longest time + // 4. Reorged more than twice + + // TODO: This logic is borrowed from miner. Figure out something + // to avoid duplicate code. + pending := pool.Pending(true) + + // Split the pending transactions into locals and remotes. + localTxs, remoteTxs := make(map[common.Address][]*txpool.LazyTransaction), pending + for _, account := range pool.Locals() { + if txs := remoteTxs[account]; len(txs) > 0 { + delete(remoteTxs, account) + localTxs[account] = txs + } + } + + // TODO: Confirm if in case of ePBS, `pool.currentHead` has the `slot N` block + // or parent (i.e. `slot N-1`) block. + + // As IL will be included in the next block, calculate the current block's base fee. + // As the current block's payload isn't revealed yet (due to ePBS), calculate + // it from parent block. + currentBaseFee := eip1559.CalcBaseFee(pool.chainconfig, pool.currentHead.Load()) + + // 1.125 * currentBaseFee + gasFeeThreshold := new(big.Float).Mul(new(big.Float).SetFloat64(1.125), new(big.Float).SetInt(currentBaseFee)) + + total := uint64(0) + gasLimit := uint64(0) + + // Try filling IL with locals first + if len(localTxs) > 0 { + // TODO: What baseFee to use in this function? + txs := newTransactionsByPriceAndNonce(pool.signer, localTxs, pool.currentHead.Load().BaseFee) + localSummary, localTxs := filterTxs(pool.chainconfig, txs, &total, &gasLimit, gasFeeThreshold) + + summaries = append(summaries, localSummary...) + transactions = append(transactions, localTxs...) + } + + // Check for remote txs if we have space in IL + if (total < core.MaxTransactionsPerInclusionList) && len(remoteTxs) > 0 { + // TODO: What baseFee to use in this function? + txs := newTransactionsByPriceAndNonce(pool.signer, localTxs, pool.currentHead.Load().BaseFee) + remoteSummary, remoteTxs := filterTxs(pool.chainconfig, txs, &total, &gasLimit, gasFeeThreshold) + + summaries = append(summaries, remoteSummary...) + transactions = append(transactions, remoteTxs...) + } + + return &types.InclusionList{Summary: summaries, Transactions: transactions}, nil +} + +func filterTxs(config *params.ChainConfig, txs *transactionsByPriceAndNonce, total, gasLimit *uint64, gasFeeThreshold *big.Float) ([]*types.InclusionListEntry, []*types.Transaction) { + var ( + intrinsicGas uint64 = 21_000 + + summaries = make([]*types.InclusionListEntry, 0, core.MaxTransactionsPerInclusionList) + transactions = make([]*types.Transaction, 0, core.MaxTransactionsPerInclusionList) + ) + + // Prepare the signer object + signer := types.LatestSigner(config) + + for { + ltx := txs.Peek() + if ltx == nil { + break + } + tx := ltx.Resolve() + if tx == nil { + txs.Pop() + continue + } + + // Check if we have enough space in IL + if *total+1 > core.MaxTransactionsPerInclusionList { + log.Debug("Reached max txs per inclusion list") + break + } + + // Check if we can even afford a new tx with min gas + if (core.MaxGasPerInclusionList - *gasLimit) < intrinsicGas { + log.Debug("Reached max gas per inclusion list") + break + } + + // Check if the tx is below the max gas limit allowed. + if *gasLimit+tx.Gas() > core.MaxGasPerInclusionList { + log.Debug("Reached max gas per inclusion list") + txs.Pop() + continue + } + + // Check if the tx.GasFeeCap > 1.125 * gasFeeThreshold + if new(big.Float).SetInt(tx.GasFeeCap()).Cmp(gasFeeThreshold) == -1 { + log.Debug("Gas fee cap is below threshold", "gasFeeCap", tx.GasFeeCap(), "threshold", gasFeeThreshold) + txs.Pop() + continue + } + + *total++ + *gasLimit += tx.Gas() + + from, _ := types.Sender(signer, tx) + summaries = append(summaries, &types.InclusionListEntry{ + Address: from, + GasLimit: uint32(tx.Gas()), + }) + transactions = append(transactions, tx) + } + + return summaries, transactions +} + // addressByHeartbeat is an account address tagged with its last activity timestamp. type addressByHeartbeat struct { address common.Address diff --git a/core/txpool/legacypool/ordering.go b/core/txpool/legacypool/ordering.go new file mode 100644 index 000000000000..3b6cb7586924 --- /dev/null +++ b/core/txpool/legacypool/ordering.go @@ -0,0 +1,147 @@ +// Copyright 2014 The go-ethereum Authors +// This file is part of the go-ethereum library. +// +// The go-ethereum library is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// The go-ethereum library is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public License +// along with the go-ethereum library. If not, see . + +package legacypool + +import ( + "container/heap" + "math/big" + + "github.com/ethereum/go-ethereum/common" + "github.com/ethereum/go-ethereum/common/math" + "github.com/ethereum/go-ethereum/core/txpool" + "github.com/ethereum/go-ethereum/core/types" +) + +// txWithMinerFee wraps a transaction with its gas price or effective miner gasTipCap +type txWithMinerFee struct { + tx *txpool.LazyTransaction + from common.Address + fees *big.Int +} + +// newTxWithMinerFee creates a wrapped transaction, calculating the effective +// miner gasTipCap if a base fee is provided. +// Returns error in case of a negative effective miner gasTipCap. +func newTxWithMinerFee(tx *txpool.LazyTransaction, from common.Address, baseFee *big.Int) (*txWithMinerFee, error) { + tip := new(big.Int).Set(tx.GasTipCap) + if baseFee != nil { + if tx.GasFeeCap.Cmp(baseFee) < 0 { + return nil, types.ErrGasFeeCapTooLow + } + tip = math.BigMin(tx.GasTipCap, new(big.Int).Sub(tx.GasFeeCap, baseFee)) + } + return &txWithMinerFee{ + tx: tx, + from: from, + fees: tip, + }, nil +} + +// txByPriceAndTime implements both the sort and the heap interface, making it useful +// for all at once sorting as well as individually adding and removing elements. +type txByPriceAndTime []*txWithMinerFee + +func (s txByPriceAndTime) Len() int { return len(s) } +func (s txByPriceAndTime) Less(i, j int) bool { + // If the prices are equal, use the time the transaction was first seen for + // deterministic sorting + cmp := s[i].fees.Cmp(s[j].fees) + if cmp == 0 { + return s[i].tx.Time.Before(s[j].tx.Time) + } + return cmp > 0 +} +func (s txByPriceAndTime) Swap(i, j int) { s[i], s[j] = s[j], s[i] } + +func (s *txByPriceAndTime) Push(x interface{}) { + *s = append(*s, x.(*txWithMinerFee)) +} + +func (s *txByPriceAndTime) Pop() interface{} { + old := *s + n := len(old) + x := old[n-1] + old[n-1] = nil + *s = old[0 : n-1] + return x +} + +// transactionsByPriceAndNonce represents a set of transactions that can return +// transactions in a profit-maximizing sorted order, while supporting removing +// entire batches of transactions for non-executable accounts. +type transactionsByPriceAndNonce struct { + txs map[common.Address][]*txpool.LazyTransaction // Per account nonce-sorted list of transactions + heads txByPriceAndTime // Next transaction for each unique account (price heap) + signer types.Signer // Signer for the set of transactions + baseFee *big.Int // Current base fee +} + +// newTransactionsByPriceAndNonce creates a transaction set that can retrieve +// price sorted transactions in a nonce-honouring way. +// +// Note, the input map is reowned so the caller should not interact any more with +// if after providing it to the constructor. +func newTransactionsByPriceAndNonce(signer types.Signer, txs map[common.Address][]*txpool.LazyTransaction, baseFee *big.Int) *transactionsByPriceAndNonce { + // Initialize a price and received time based heap with the head transactions + heads := make(txByPriceAndTime, 0, len(txs)) + for from, accTxs := range txs { + wrapped, err := newTxWithMinerFee(accTxs[0], from, baseFee) + if err != nil { + delete(txs, from) + continue + } + heads = append(heads, wrapped) + txs[from] = accTxs[1:] + } + heap.Init(&heads) + + // Assemble and return the transaction set + return &transactionsByPriceAndNonce{ + txs: txs, + heads: heads, + signer: signer, + baseFee: baseFee, + } +} + +// Peek returns the next transaction by price. +func (t *transactionsByPriceAndNonce) Peek() *txpool.LazyTransaction { + if len(t.heads) == 0 { + return nil + } + return t.heads[0].tx +} + +// Shift replaces the current best head with the next one from the same account. +func (t *transactionsByPriceAndNonce) Shift() { + acc := t.heads[0].from + if txs, ok := t.txs[acc]; ok && len(txs) > 0 { + if wrapped, err := newTxWithMinerFee(txs[0], acc, t.baseFee); err == nil { + t.heads[0], t.txs[acc] = wrapped, txs[1:] + heap.Fix(&t.heads, 0) + return + } + } + heap.Pop(&t.heads) +} + +// Pop removes the best transaction, *not* replacing it with the next one from +// the same account. This should be used when a transaction cannot be executed +// and hence all subsequent ones should be discarded from the same account. +func (t *transactionsByPriceAndNonce) Pop() { + heap.Pop(&t.heads) +} diff --git a/core/txpool/subpool.go b/core/txpool/subpool.go index 85312c431807..310aa2e6071a 100644 --- a/core/txpool/subpool.go +++ b/core/txpool/subpool.go @@ -106,6 +106,10 @@ type SubPool interface { // by the pool already applied on top. Nonce(addr common.Address) uint64 + // StateNonce returns the next nonce of an account from the underlying state, without + // applying any transactions from the pool on top. + StateNonce(addr common.Address) uint64 + // Stats retrieves the current pool stats, namely the number of pending and the // number of queued (non-executable) transactions. Stats() (int, int) @@ -121,6 +125,10 @@ type SubPool interface { // Locals retrieves the accounts currently considered local by the pool. Locals() []common.Address + // GetInclusionList returns an inclusion list from the pool containing pairs + // of transaction summary and data which are executable. + GetInclusionList() (*types.InclusionList, error) + // Status returns the known status (unknown/pending/queued) of a transaction // identified by their hashes. Status(hash common.Hash) TxStatus diff --git a/core/txpool/txpool.go b/core/txpool/txpool.go index e40b41405454..2cbe88eede9f 100644 --- a/core/txpool/txpool.go +++ b/core/txpool/txpool.go @@ -341,6 +341,18 @@ func (p *TxPool) Nonce(addr common.Address) uint64 { return nonce } +// StateNonce returns the next nonce of an account from the underlying state, without +// applying any transactions from the pool on top. +func (p *TxPool) StateNonce(addr common.Address) uint64 { + var nonce uint64 + for _, subpool := range p.subpools { + if next := subpool.StateNonce(addr); nonce < next { + nonce = next + } + } + return nonce +} + // Stats retrieves the current pool stats, namely the number of pending and the // number of queued (non-executable) transactions. func (p *TxPool) Stats() (int, int) { @@ -413,3 +425,16 @@ func (p *TxPool) Status(hash common.Hash) TxStatus { } return TxStatusUnknown } + +// GetInclusionList returns an inclusion list from the pool containing pairs +// of transaction summary and data which are executable. +func (p *TxPool) GetInclusionList() (*types.InclusionList, error) { + for _, subpool := range p.subpools { + list, err := subpool.GetInclusionList() + if errors.Is(err, ErrUnsupportedMethod) { + continue + } + return list, err + } + return nil, nil +} diff --git a/core/types/block.go b/core/types/block.go index 6f897121df8f..8b355dc91b29 100644 --- a/core/types/block.go +++ b/core/types/block.go @@ -196,6 +196,8 @@ type Block struct { transactions Transactions withdrawals Withdrawals + summary []*InclusionListEntry + // caches hash atomic.Value size atomic.Value diff --git a/core/types/inclusion_list.go b/core/types/inclusion_list.go new file mode 100644 index 000000000000..6aaede4eab6b --- /dev/null +++ b/core/types/inclusion_list.go @@ -0,0 +1,17 @@ +package types + +import "github.com/ethereum/go-ethereum/common" + +// InclusionList represents pairs of transaction summary and the transaction data itself +type InclusionList struct { + Summary []*InclusionListEntry `json:"summary"` + Transactions []*Transaction `json:"transactions"` +} + +// InclusionListEntry denotes a summary entry of (address, gasLimit) +type InclusionListEntry struct { + Address common.Address `json:"address"` + GasLimit uint32 `json:"gasLimit"` // TODO(manav): change to uint8 +} + +type InclusionListSummaries []*InclusionListEntry diff --git a/eth/catalyst/api.go b/eth/catalyst/api.go index f6c7ab09c7d5..8c8b1b455fa7 100644 --- a/eth/catalyst/api.go +++ b/eth/catalyst/api.go @@ -87,6 +87,9 @@ var caps = []string{ "engine_newPayloadV3", "engine_getPayloadBodiesByHashV1", "engine_getPayloadBodiesByRangeV1", + "engine_getInclusionListV1", + "engine_newInclusionListV1", + "engine_newPayloadVePBS", } type ConsensusAPI struct { @@ -156,6 +159,82 @@ func newConsensusAPIWithoutHeartbeat(eth *eth.Ethereum) *ConsensusAPI { return api } +// GetInclusionListV1 returns an inclusion list which contains summary + list of transactions +// which are valid for the current slot. +func (api *ConsensusAPI) GetInclusionListV1(parentHash common.Hash) (engine.InclusionListV1, error) { + // TODO: Add HF related checks + + log.Trace("Engine API request received", "method", "GetInclusionListV1") + if parentHash == (common.Hash{}) { + log.Warn("Inclusion list requested with zero parent hash") + return engine.InclusionListV1{}, errors.New("getInclusionListV1 called with empty parent hash") + } + + // Check if we have parent block available or not. If not, reject the + // inclusion list. Note: As the IL API's are for specific purpose, we + // won't trigger a sync here if parent block is unavailable. + parent := api.eth.BlockChain().GetBlockByHash(parentHash) + if parent == nil { + log.Warn("Inclusion list requested with unknown parent", "hash", parentHash) + return engine.InclusionListV1{}, errors.New("getInclusionListV1 called with invalid parent hash") + } + + // TODO: Get below function to return errors? + list, err := api.eth.TxPool().GetInclusionList() + if err != nil { + log.Trace("Failed to get inclusion list", "parent", parentHash, "err", err) + return engine.InclusionListV1{}, err + } + + return (engine.InclusionListV1)(*list), nil +} + +// NewInclusionListV1 validates whether an inclusion list (summary + txs) is +// correct for the current state or not. +func (api *ConsensusAPI) NewInclusionListV1(params engine.VerifiableInclusionList) (engine.InclusionListStatusV1, error) { + // TODO: Add HF related checks + + log.Trace("Engine API request received", "method", "NewInclusionListV1") + if params.ParentHash == (common.Hash{}) { + log.Warn("Inclusion list verification requested with zero parent hash") + return engine.InclusionListStatusV1{Status: engine.INVALID}, errors.New("newInclusionListV1 called with empty parent hash") + } + + // Check if we have parent block available or not. If not, reject the + // inclusion list. Note: As the IL API's are for specific purpose, we + // won't trigger a sync here if parent block is unavailable. + parent := api.eth.BlockChain().GetBlockByHash(params.ParentHash) + if parent == nil { + log.Warn("Inclusion list verification requested with unknown parent", "hash", params.ParentHash) + return engine.InclusionListStatusV1{Status: engine.INVALID}, errors.New("newInclusionListV1 called with invalid parent hash") + } + + getStateNonce := func(addr common.Address) uint64 { + return api.eth.TxPool().StateNonce(addr) + } + + var ( + valid bool + validationError error + err error + ) + valid, validationError = api.eth.BlockChain().VerifyInclusionList(params.InclusionList, parent.Header(), getStateNonce) + if !valid && validationError == nil { + validationError = errors.New("invalid inclusion list") + } + + if validationError != nil { + err = errors.New("invalid inclusion list") + } + + if err != nil { + log.Trace("Inclusion list verification failed", "validator error", validationError, "err", err) + return engine.InclusionListStatusV1{Status: engine.INVALID, ValidatorError: validationError}, err + } + + return engine.InclusionListStatusV1{Status: engine.VALID}, nil +} + // ForkchoiceUpdatedV1 has several responsibilities: // // We try to set our blockchain to the headBlock. @@ -465,6 +544,17 @@ func (api *ConsensusAPI) NewPayloadV3(params engine.ExecutableData, versionedHas return api.newPayload(params, hashes) } +// NewPayloadVePBS creates an Eth1 block, inserts it in the chain, and returns the status of the chain. +func (api *ConsensusAPI) NewPayloadVePBS(params engine.ExecutableData, versionedHashes *[]common.Hash) (engine.PayloadStatusV1, error) { + // TODO: Add HF related checks + + var hashes []common.Hash + if versionedHashes != nil { + hashes = *versionedHashes + } + return api.newPayload(params, hashes) +} + func (api *ConsensusAPI) newPayload(params engine.ExecutableData, versionedHashes []common.Hash) (engine.PayloadStatusV1, error) { // The locking here is, strictly, not required. Without these locks, this can happen: // @@ -488,6 +578,11 @@ func (api *ConsensusAPI) newPayload(params engine.ExecutableData, versionedHashe log.Warn("Invalid NewPayload params", "params", params, "error", err) return engine.PayloadStatusV1{Status: engine.INVALID}, nil } + + if (params.InclusionListSummary == nil && params.InclusionListExclusions != nil) || (params.InclusionListSummary != nil && params.InclusionListExclusions == nil) { + return engine.PayloadStatusV1{Status: engine.INVALID}, nil + } + // Stash away the last update to warn the user if the beacon client goes offline api.lastNewPayloadLock.Lock() api.lastNewPayloadUpdate = time.Now() @@ -545,6 +640,19 @@ func (api *ConsensusAPI) newPayload(params engine.ExecutableData, versionedHashe log.Warn("State not available, ignoring new payload") return engine.PayloadStatusV1{Status: engine.ACCEPTED}, nil } + + if params.InclusionListSummary != nil && params.InclusionListExclusions != nil { + valid, err := api.eth.BlockChain().VerifyInclusionListInBlock(params.InclusionListSummary, params.InclusionListExclusions, block.Body().Transactions, parent) + if !valid && err == nil { + err = errors.New("invalid inclusion list") + } + + if err != nil { + log.Trace("Failed to validate block based on inclusion list criteria", "err", err) + return api.invalid(err, parent.Header()), nil + } + } + log.Trace("Inserting block without sethead", "hash", block.Hash(), "number", block.Number) if err := api.eth.BlockChain().InsertBlockWithoutSetHead(block); err != nil { log.Warn("NewPayloadV1: inserting block failed", "error", err)