Skip to content

Commit e8fd471

Browse files
Client/TxPool: add tx validation (#1852)
* client/txpool: add gas price bump check * txpool: add more validation logic * client: update txpool checks * client: fix some tests * client: fix txpool tests * client: add txpool tests * txpool: add signed check * client: add more txpool tests * client: lint * client/txpool: balance test * client: fix miner tests * client: fix txpool hash messages to show hex values * client: fix dangling promises in txpool tests * client: fix sendRawTransaction tests * client/txpool: track tx count * client/txpool: increase coverage tests: improve error messages * client/txpool: update tests * client: add local txpool test for eth_sendRawTransaction * txpool: add FeeMarket gas logic txpool: add basefee checks * client: address review * client/txpool: fix tests * client/txpool: increase coverage * client/txpool: fix broadcast * client/test/miner: address review
1 parent 64a8b13 commit e8fd471

File tree

8 files changed

+668
-64
lines changed

8 files changed

+668
-64
lines changed

packages/client/lib/rpc/modules/eth.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -915,7 +915,15 @@ export class Eth {
915915

916916
// Add the tx to own tx pool
917917
const { txPool } = this.service as FullEthereumService
918-
txPool.add(tx)
918+
919+
try {
920+
await txPool.add(tx, true)
921+
} catch (error: any) {
922+
throw {
923+
code: INVALID_PARAMS,
924+
message: error.message ?? error.toString(),
925+
}
926+
}
919927

920928
const peerPool = this.service.pool
921929
if (

packages/client/lib/service/txpool.ts

Lines changed: 162 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,26 @@
11
import Heap from 'qheap'
22
import {
3+
AccessListEIP2930Transaction,
34
Capability,
45
FeeMarketEIP1559Transaction,
56
Transaction,
67
TypedTransaction,
78
} from '@ethereumjs/tx'
8-
import { Address, BN } from 'ethereumjs-util'
9+
import { Address, BN, bufferToHex } from 'ethereumjs-util'
910
import { Config } from '../config'
1011
import { Peer } from '../net/peer'
1112
import type VM from '@ethereumjs/vm'
1213
import type { FullEthereumService } from './fullethereumservice'
1314
import type { PeerPool } from '../net/peerpool'
1415
import type { Block } from '@ethereumjs/block'
1516

17+
// Configuration constants
18+
const MIN_GAS_PRICE_BUMP_PERCENT = 10
19+
const MIN_GAS_PRICE = new BN(1000000000) // 1 GWei
20+
const TX_MAX_DATA_SIZE = 128 * 1024 // 128KB
21+
const MAX_POOL_SIZE = 5000
22+
const MAX_TXS_PER_ACCOUNT = 100
23+
1624
export interface TxPoolOptions {
1725
/* Config */
1826
config: Config
@@ -41,6 +49,11 @@ type UnprefixedAddress = string
4149
type UnprefixedHash = string
4250
type PeerId = string
4351

52+
type GasPrice = {
53+
tip: BN
54+
maxFee: BN
55+
}
56+
4457
/**
4558
* @module service
4659
*/
@@ -73,6 +86,11 @@ export class TxPool {
7386
*/
7487
public pool: Map<UnprefixedAddress, TxPoolObject[]>
7588

89+
/**
90+
* The number of txs currently in the pool
91+
*/
92+
public txsInPool: number
93+
7694
/**
7795
* Map for handled tx hashes
7896
* (have been added to the pool at some point)
@@ -114,7 +132,7 @@ export class TxPool {
114132
/**
115133
* Rebroadcast full txs and new blocks to a fraction
116134
* of peers by doing
117-
* `min(1, floor(NUM_PEERS/NUM_PEERS_REBROADCAST_QUOTIENT))`
135+
* `max(1, floor(NUM_PEERS/NUM_PEERS_REBROADCAST_QUOTIENT))`
118136
*/
119137
public NUM_PEERS_REBROADCAST_QUOTIENT = 4
120138

@@ -133,6 +151,7 @@ export class TxPool {
133151
this.vm = this.service.execution.vm
134152

135153
this.pool = new Map<UnprefixedAddress, TxPoolObject[]>()
154+
this.txsInPool = 0
136155
this.handled = new Map<UnprefixedHash, HandledObject>()
137156
this.knownByPeer = new Map<PeerId, SentObject[]>()
138157

@@ -178,27 +197,140 @@ export class TxPool {
178197
}
179198
}
180199

200+
/**
201+
* Returns the GasPrice object to provide information of the tx' gas prices
202+
* @param tx Tx to use
203+
* @returns Gas price (both tip and max fee)
204+
*/
205+
private txGasPrice(tx: TypedTransaction): GasPrice {
206+
switch (tx.type) {
207+
case 0:
208+
return {
209+
maxFee: (tx as Transaction).gasPrice,
210+
tip: (tx as Transaction).gasPrice,
211+
}
212+
case 1:
213+
return {
214+
maxFee: (tx as AccessListEIP2930Transaction).gasPrice,
215+
tip: (tx as AccessListEIP2930Transaction).gasPrice,
216+
}
217+
case 2:
218+
return {
219+
maxFee: (tx as FeeMarketEIP1559Transaction).maxFeePerGas,
220+
tip: (tx as FeeMarketEIP1559Transaction).maxPriorityFeePerGas,
221+
}
222+
default:
223+
throw new Error(`tx of type ${tx.type} unknown`)
224+
}
225+
}
226+
227+
private validateTxGasBump(existingTx: TypedTransaction, addedTx: TypedTransaction) {
228+
const existingTxGasPrice = this.txGasPrice(existingTx)
229+
const newGasPrice = this.txGasPrice(addedTx)
230+
const minTipCap = existingTxGasPrice.tip.add(
231+
existingTxGasPrice.tip.muln(MIN_GAS_PRICE_BUMP_PERCENT).divn(100)
232+
)
233+
const minFeeCap = existingTxGasPrice.maxFee.add(
234+
existingTxGasPrice.maxFee.muln(MIN_GAS_PRICE_BUMP_PERCENT).divn(100)
235+
)
236+
if (newGasPrice.tip.lt(minTipCap) || newGasPrice.maxFee.lt(minFeeCap)) {
237+
throw new Error('replacement gas too low')
238+
}
239+
}
240+
241+
/**
242+
* Validates a transaction against the pool and other constraints
243+
* @param tx The tx to validate
244+
*/
245+
private async validate(tx: TypedTransaction, isLocalTransaction: boolean = false) {
246+
if (!tx.isSigned()) {
247+
throw new Error('Attempting to add tx to txpool which is not signed')
248+
}
249+
if (tx.data.length > TX_MAX_DATA_SIZE) {
250+
throw new Error(
251+
`Tx is too large (${tx.data.length} bytes) and exceeds the max data size of ${TX_MAX_DATA_SIZE} bytes`
252+
)
253+
}
254+
const currentGasPrice = this.txGasPrice(tx)
255+
// This is the tip which the miner receives: miner does not want
256+
// to mine underpriced txs where miner gets almost no fees
257+
const currentTip = currentGasPrice.tip
258+
if (!isLocalTransaction) {
259+
const txsInPool = this.txsInPool
260+
if (txsInPool >= MAX_POOL_SIZE) {
261+
throw new Error('Cannot add tx: pool is full')
262+
}
263+
// Local txs are not checked against MIN_GAS_PRICE
264+
if (currentTip.lt(MIN_GAS_PRICE)) {
265+
throw new Error(`Tx does not pay the minimum gas price of ${MIN_GAS_PRICE}`)
266+
}
267+
}
268+
const senderAddress = tx.getSenderAddress()
269+
const sender: UnprefixedAddress = senderAddress.toString().slice(2)
270+
const inPool = this.pool.get(sender)
271+
if (inPool) {
272+
if (!isLocalTransaction && inPool.length >= MAX_TXS_PER_ACCOUNT) {
273+
throw new Error(
274+
`Cannot add tx for ${senderAddress}: already have max amount of txs for this account`
275+
)
276+
}
277+
// Replace pooled txs with the same nonce
278+
const existingTxn = inPool.find((poolObj) => poolObj.tx.nonce.eq(tx.nonce))
279+
if (existingTxn) {
280+
if (existingTxn.tx.hash().equals(tx.hash())) {
281+
throw new Error(`${bufferToHex(tx.hash())}: this transaction is already in the TxPool`)
282+
}
283+
this.validateTxGasBump(existingTxn.tx, tx)
284+
}
285+
}
286+
const block = await this.service.chain.getLatestHeader()
287+
if (block.baseFeePerGas) {
288+
if (currentGasPrice.maxFee.lt(block.baseFeePerGas)) {
289+
throw new Error(
290+
`Tx cannot pay basefee of ${block.baseFeePerGas}, have ${currentGasPrice.maxFee}.`
291+
)
292+
}
293+
}
294+
if (tx.gasLimit.gt(block.gasLimit)) {
295+
throw new Error(`Tx gaslimit of ${tx.gasLimit} exceeds block gas limit of ${block.gasLimit}`)
296+
}
297+
const account = await this.vm.stateManager.getAccount(senderAddress)
298+
if (account.nonce.gt(tx.nonce)) {
299+
throw new Error(
300+
`0x${sender} tries to send a tx with nonce ${tx.nonce}, but account has nonce ${account.nonce} (tx nonce too low)`
301+
)
302+
}
303+
const minimumBalance = tx.value.add(currentGasPrice.maxFee.mul(tx.gasLimit))
304+
if (account.balance.lt(minimumBalance)) {
305+
throw new Error(
306+
`0x${sender} does not have enough balance to cover transaction costs, need ${minimumBalance}, but have ${account.balance}`
307+
)
308+
}
309+
}
310+
181311
/**
182312
* Adds a tx to the pool.
183313
*
184314
* If there is a tx in the pool with the same address and
185-
* nonce it will be replaced by the new tx.
315+
* nonce it will be replaced by the new tx, if it has a sufficent gas bump.
316+
* This also verifies certain constraints, if these are not met, tx will not be added to the pool.
186317
* @param tx Transaction
318+
* @param isLocalTransaction Boolean which is true if this is a local transaction (this drops some constraint checks)
187319
*/
188-
add(tx: TypedTransaction) {
189-
const sender: UnprefixedAddress = tx.getSenderAddress().toString().slice(2)
190-
const inPool = this.pool.get(sender)
191-
let add: TxPoolObject[] = []
320+
async add(tx: TypedTransaction, isLocalTransaction: boolean = false) {
321+
await this.validate(tx, isLocalTransaction)
322+
const address: UnprefixedAddress = tx.getSenderAddress().toString().slice(2)
323+
let add: TxPoolObject[] = this.pool.get(address) ?? []
324+
const inPool = this.pool.get(address)
192325
if (inPool) {
193326
// Replace pooled txs with the same nonce
194327
add = inPool.filter((poolObj) => !poolObj.tx.nonce.eq(tx.nonce))
195328
}
196-
const address: UnprefixedAddress = tx.getSenderAddress().toString().slice(2)
197329
const hash: UnprefixedHash = tx.hash().toString('hex')
198330
const added = Date.now()
199331
add.push({ tx, added, hash })
200332
this.pool.set(address, add)
201-
333+
this.txsInPool++
202334
this.handled.set(hash, { address, added })
203335
}
204336

@@ -236,6 +368,7 @@ export class TxPool {
236368
return
237369
}
238370
const newPoolObjects = this.pool.get(address)!.filter((poolObj) => poolObj.hash !== txHash)
371+
this.txsInPool--
239372
if (newPoolObjects.length === 0) {
240373
// List of txs for address is now empty, can delete
241374
this.pool.delete(address)
@@ -342,12 +475,18 @@ export class TxPool {
342475

343476
const newTxHashes = []
344477
for (const tx of txs) {
345-
this.add(tx)
346-
newTxHashes.push(tx.hash())
478+
try {
479+
await this.add(tx)
480+
newTxHashes.push(tx.hash())
481+
} catch (error: any) {
482+
this.config.logger.debug(
483+
`Error adding tx to TxPool: ${error.message} (tx hash: ${bufferToHex(tx.hash())})`
484+
)
485+
}
347486
}
348487
const peers = peerPool.peers
349488
const numPeers = peers.length
350-
const sendFull = Math.min(1, Math.floor(numPeers / this.NUM_PEERS_REBROADCAST_QUOTIENT))
489+
const sendFull = Math.max(1, Math.floor(numPeers / this.NUM_PEERS_REBROADCAST_QUOTIENT))
351490
this.sendTransactions(txs, peers.slice(0, sendFull))
352491
await this.sendNewTxHashes(newTxHashes, peers.slice(sendFull))
353492
}
@@ -398,7 +537,13 @@ export class TxPool {
398537

399538
const newTxHashes = []
400539
for (const tx of txs) {
401-
this.add(tx)
540+
try {
541+
await this.add(tx)
542+
} catch (error: any) {
543+
this.config.logger.debug(
544+
`Error adding tx to TxPool: ${error.message} (tx hash: ${bufferToHex(tx.hash())})`
545+
)
546+
}
402547
newTxHashes.push(tx.hash())
403548
}
404549
await this.sendNewTxHashes(newTxHashes, peerPool.peers)
@@ -456,7 +601,7 @@ export class TxPool {
456601
* @param baseFee Provide a baseFee to subtract from the legacy
457602
* gasPrice to determine the leftover priority tip.
458603
*/
459-
private txGasPrice(tx: TypedTransaction, baseFee?: BN) {
604+
private normalizedGasPrice(tx: TypedTransaction, baseFee?: BN) {
460605
const supports1559 = tx.supports(Capability.EIP1559FeeMarket)
461606
if (baseFee) {
462607
if (supports1559) {
@@ -509,7 +654,7 @@ export class TxPool {
509654
if (baseFee) {
510655
// If any tx has an insiffucient gasPrice,
511656
// remove all txs after that since they cannot be executed
512-
const found = txsSortedByNonce.findIndex((tx) => this.txGasPrice(tx).lt(baseFee))
657+
const found = txsSortedByNonce.findIndex((tx) => this.normalizedGasPrice(tx).lt(baseFee))
513658
if (found > -1) {
514659
txsSortedByNonce = txsSortedByNonce.slice(0, found)
515660
}
@@ -519,7 +664,7 @@ export class TxPool {
519664
// Initialize a price based heap with the head transactions
520665
const byPrice = new Heap<TypedTransaction>({
521666
comparBefore: (a: TypedTransaction, b: TypedTransaction) =>
522-
this.txGasPrice(b, baseFee).sub(this.txGasPrice(a, baseFee)).ltn(0),
667+
this.normalizedGasPrice(b, baseFee).sub(this.normalizedGasPrice(a, baseFee)).ltn(0),
523668
})
524669
byNonce.forEach((txs, address) => {
525670
byPrice.insert(txs[0])
@@ -566,10 +711,7 @@ export class TxPool {
566711
}
567712

568713
_logPoolStats() {
569-
let count = 0
570-
this.pool.forEach((poolObjects) => {
571-
count += poolObjects.length
572-
})
714+
const count = this.txsInPool
573715
this.config.logger.info(
574716
`TxPool Statistics txs=${count} senders=${this.pool.size} peers=${this.service.pool.peers.length}`
575717
)

packages/client/test/integration/fullethereumservice.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ tape('[Integration:FullEthereumService]', async (t) => {
7171
const txData =
7272
'0x02f90108018001018402625a0094cccccccccccccccccccccccccccccccccccccccc830186a0b8441a8451e600000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000f85bf859940000000000000000000000000000000000000101f842a00000000000000000000000000000000000000000000000000000000000000000a000000000000000000000000000000000000000000000000000000000000060a701a0afb6e247b1c490e284053c87ab5f6b59e219d51f743f7a4d83e400782bc7e4b9a0479a268e0e0acd4de3f1e28e4fac2a6b32a4195e8dfa9d19147abe8807aa6f64'
7373
const tx = FeeMarketEIP1559Transaction.fromSerializedTx(toBuffer(txData))
74-
service.txPool.add(tx)
74+
await service.txPool.add(tx)
7575
const [_, txs] = await peer.eth!.getPooledTransactions({ hashes: [tx.hash()] })
7676
t.equal(
7777
txs[0].hash().toString('hex'),

0 commit comments

Comments
 (0)