Skip to content
Open
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cmd/ethrex/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,15 @@ pub struct Options {
env = "ETHREX_MEMPOOL_MAX_SIZE"
)]
pub mempool_max_size: usize,
#[arg(
help = "Maximum number of pending transactions a single sender may hold in the mempool. Replacements at an existing (sender, nonce) bypass this cap.",
long = "mempool.max-pending-txs-per-account",
default_value_t = 16,
value_name = "MAX_PENDING_TXS_PER_ACCOUNT",
help_heading = "Node options",
env = "ETHREX_MEMPOOL_MAX_PENDING_TXS_PER_ACCOUNT"
)]
pub mempool_max_pending_txs_per_account: usize,
Comment on lines +186 to +194
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#[arg(
long = "http.addr",
default_value = "0.0.0.0",
Expand Down Expand Up @@ -450,6 +459,7 @@ impl Default for Options {
dev: Default::default(),
force: false,
mempool_max_size: Default::default(),
mempool_max_pending_txs_per_account: 16,
tx_broadcasting_time_interval: Default::default(),
Comment on lines 473 to 477
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

target_peers: Default::default(),
lookup_interval: Default::default(),
Expand Down
1 change: 1 addition & 0 deletions cmd/ethrex/initializers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -529,6 +529,7 @@ pub async fn init_l1(
max_blobs_per_block: opts.max_blobs_per_block,
precompute_witnesses: opts.precompute_witnesses,
precompile_cache_enabled: !opts.no_precompile_cache,
max_pending_txs_per_account: opts.mempool_max_pending_txs_per_account,
},
);

Expand Down
1 change: 1 addition & 0 deletions cmd/ethrex/l2/initializers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,7 @@ pub async fn init_l2(
max_blobs_per_block: None, // L2 doesn't support blob transactions
precompute_witnesses: opts.node_opts.precompute_witnesses,
precompile_cache_enabled: true,
max_pending_txs_per_account: opts.node_opts.mempool_max_pending_txs_per_account,
};

let blockchain = init_blockchain(store.clone(), blockchain_opts.clone());
Expand Down
21 changes: 21 additions & 0 deletions crates/blockchain/blockchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,10 @@ pub struct BlockchainOptions {
/// warmer thread and the executor. Set to false (via `--no-precompile-cache`) to
/// disable the cache for benchmarking purposes.
pub precompile_cache_enabled: bool,
/// Maximum number of pending transactions a single sender may hold in
/// the mempool. A replacement at an existing `(sender, nonce)` bypasses
/// this check.
pub max_pending_txs_per_account: usize,
}

impl Default for BlockchainOptions {
Expand All @@ -242,10 +246,14 @@ impl Default for BlockchainOptions {
max_blobs_per_block: None,
precompute_witnesses: false,
precompile_cache_enabled: true,
max_pending_txs_per_account: DEFAULT_MAX_PENDING_TXS_PER_ACCOUNT,
}
}
}

/// Default per-account pending-tx cap.
pub const DEFAULT_MAX_PENDING_TXS_PER_ACCOUNT: usize = 16;

#[derive(Debug, Clone)]
pub struct BatchBlockProcessingFailure {
pub last_valid_hash: H256,
Expand Down Expand Up @@ -2507,6 +2515,19 @@ impl Blockchain {
// If it exists check if the new tx has higher fees
let tx_to_replace_hash = self.mempool.find_tx_to_replace(sender, nonce, tx)?;

// Per-account pending-tx cap. Replacement candidates (same
// `(sender, nonce)`) bypass the cap — they don't grow the
// sender's pool footprint.
if tx_to_replace_hash.is_none() {
let count = self.mempool.count_for_sender(sender)?;
if count >= self.options.max_pending_txs_per_account {
return Err(MempoolError::MaxPendingTxsPerAccountExceeded {
count,
limit: self.options.max_pending_txs_per_account,
});
}
}
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 TOCTOU race allows cap to be exceeded under concurrent load

The count check in validate_transaction and the actual add_transaction call hold separate, non-overlapping locks. Between releasing the read lock after count_for_sender and acquiring the write lock inside add_transaction, another concurrent submission from the same sender can pass the same check. Both threads see count = 15 < 16, both pass, and both are inserted, leaving the sender with 17 pending slots instead of the intended 16. Wrapping the validate-and-add sequence in a single write-locked section — or moving the count check inside add_transaction where the write lock is already held — would close this window.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/blockchain.rs
Line: 2521-2529

Comment:
**TOCTOU race allows cap to be exceeded under concurrent load**

The count check in `validate_transaction` and the actual `add_transaction` call hold separate, non-overlapping locks. Between releasing the read lock after `count_for_sender` and acquiring the write lock inside `add_transaction`, another concurrent submission from the same sender can pass the same check. Both threads see `count = 15 < 16`, both pass, and both are inserted, leaving the sender with 17 pending slots instead of the intended 16. Wrapping the validate-and-add sequence in a single write-locked section — or moving the count check inside `add_transaction` where the write lock is already held — would close this window.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


if tx
.chain_id()
.is_some_and(|chain_id| chain_id != config.chain_id)
Expand Down
2 changes: 2 additions & 0 deletions crates/blockchain/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ pub enum MempoolError {
TxMaxInitCodeSizeError,
#[error("Transaction max data size exceeded")]
TxMaxDataSizeError,
#[error("Sender has {count} pending transactions, exceeds the per-account cap of {limit}")]
Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MaxPendingTxsPerAccountExceeded { count: usize, limit: usize },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 When count == limit the message reads "has 16 pending transactions, exceeds the per-account cap of 16", but 16 does not exceed 16. The phrasing should reflect that admitting a new transaction would exceed the cap.

Suggested change
#[error("Sender has {count} pending transactions, exceeds the per-account cap of {limit}")]
MaxPendingTxsPerAccountExceeded { count: usize, limit: usize },
#[error("Sender has {count} pending transactions; adding a new one would exceed the per-account cap of {limit}")]
MaxPendingTxsPerAccountExceeded { count: usize, limit: usize },
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/error.rs
Line: 86-87

Comment:
When `count == limit` the message reads "has 16 pending transactions, exceeds the per-account cap of 16", but 16 does not exceed 16. The phrasing should reflect that admitting a new transaction *would* exceed the cap.

```suggestion
    #[error("Sender has {count} pending transactions; adding a new one would exceed the per-account cap of {limit}")]
    MaxPendingTxsPerAccountExceeded { count: usize, limit: usize },
```

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#[error("Transaction gas limit exceeded")]
TxGasLimitExceededError,
#[error(
Expand Down
78 changes: 78 additions & 0 deletions crates/blockchain/mempool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,17 @@ impl Mempool {
Ok(contains)
}

/// Returns the number of pending transactions currently held in the
/// mempool for `sender`. Used by the per-sender slot cap at admission.
pub fn count_for_sender(&self, sender: Address) -> Result<usize, MempoolError> {
let inner = self.read()?;
let count = inner
.txs_by_sender_nonce
.range((sender, 0)..=(sender, u64::MAX))
.count();
Comment on lines +479 to +481
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is bounded, so I think it's OK for now.

Ok(count)
}

pub fn find_tx_to_replace(
&self,
sender: Address,
Expand Down Expand Up @@ -574,3 +585,70 @@ pub fn transaction_intrinsic_gas(

Ok(gas)
}

#[cfg(test)]
mod tests {
use super::*;
use ethrex_common::types::EIP1559Transaction;

fn build_tx(nonce: u64) -> Transaction {
Transaction::EIP1559Transaction(EIP1559Transaction {
nonce,
..Default::default()
})
}

fn add_tx(pool: &Mempool, sender: Address, nonce: u64) -> H256 {
let tx = build_tx(nonce);
let mtx = MempoolTransaction::new(tx, sender);
let hash = mtx.hash();
pool.add_transaction(hash, sender, mtx).unwrap();
hash
}

#[test]
fn count_for_sender_empty_pool() {
let pool = Mempool::new(64);
let sender = Address::from_low_u64_be(1);
assert_eq!(pool.count_for_sender(sender).unwrap(), 0);
}

#[test]
fn count_for_sender_one_tx() {
let pool = Mempool::new(64);
let sender = Address::from_low_u64_be(1);
add_tx(&pool, sender, 0);
assert_eq!(pool.count_for_sender(sender).unwrap(), 1);
}

#[test]
fn count_for_sender_many_nonces() {
let pool = Mempool::new(64);
let sender = Address::from_low_u64_be(1);
for nonce in 0..5 {
add_tx(&pool, sender, nonce);
}
assert_eq!(pool.count_for_sender(sender).unwrap(), 5);
}

#[test]
fn count_for_sender_isolates_senders() {
let pool = Mempool::new(64);
let a = Address::from_low_u64_be(1);
let b = Address::from_low_u64_be(2);
add_tx(&pool, a, 0);
add_tx(&pool, a, 1);
add_tx(&pool, b, 0);
assert_eq!(pool.count_for_sender(a).unwrap(), 2);
assert_eq!(pool.count_for_sender(b).unwrap(), 1);
}

#[test]
fn count_for_sender_unknown_returns_zero() {
let pool = Mempool::new(64);
let a = Address::from_low_u64_be(1);
let b = Address::from_low_u64_be(2);
add_tx(&pool, a, 0);
assert_eq!(pool.count_for_sender(b).unwrap(), 0);
}
}
6 changes: 6 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,12 @@ Node options:
[env: ETHREX_MEMPOOL_MAX_SIZE=]
[default: 10000]

--mempool.max-pending-txs-per-account <MAX_PENDING_TXS_PER_ACCOUNT>
Maximum number of pending transactions a single sender may hold in the mempool. Replacements at an existing (sender, nonce) bypass this cap.

[env: ETHREX_MEMPOOL_MAX_PENDING_TXS_PER_ACCOUNT=]
[default: 16]

--precompute-witnesses
Once synced, computes execution witnesses upon receiving newPayload messages and stores them in local storage

Expand Down
Loading