Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
13 changes: 12 additions & 1 deletion cmd/ethrex/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ use std::{

use clap::{ArgAction, Parser as ClapParser, Subcommand as ClapSubcommand};
use ethrex_blockchain::{
BlockchainOptions, BlockchainType, L2Config,
BlockchainOptions, BlockchainType, DEFAULT_GAP_ADMIT_OCCUPANCY_THRESHOLD, L2Config,
error::{ChainError, InvalidBlockError},
};
use ethrex_common::types::{Block, DEFAULT_BUILDER_GAS_CEIL, Genesis, validate_block_body};
Expand Down Expand Up @@ -183,6 +183,16 @@ pub struct Options {
env = "ETHREX_MEMPOOL_MAX_SIZE"
)]
pub mempool_max_size: usize,
#[arg(
help = "Mempool occupancy percentage (0-100) at or above which incoming transactions with a nonce gap relative to the sender's on-chain nonce are rejected. Setting to 100 disables the check.",
long = "mempool.gap-admit-occupancy-threshold",
default_value_t = DEFAULT_GAP_ADMIT_OCCUPANCY_THRESHOLD,
value_name = "PERCENTAGE",
value_parser = clap::value_parser!(u8).range(0..=100),
help_heading = "Node options",
env = "ETHREX_MEMPOOL_GAP_ADMIT_OCCUPANCY_THRESHOLD"
)]
pub mempool_gap_admit_occupancy_threshold: u8,
#[arg(
long = "http.addr",
default_value = "0.0.0.0",
Expand Down Expand Up @@ -450,6 +460,7 @@ impl Default for Options {
dev: Default::default(),
force: false,
mempool_max_size: Default::default(),
mempool_gap_admit_occupancy_threshold: DEFAULT_GAP_ADMIT_OCCUPANCY_THRESHOLD,
tx_broadcasting_time_interval: Default::default(),
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,
gap_admit_occupancy_threshold: opts.mempool_gap_admit_occupancy_threshold,
},
);

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,
gap_admit_occupancy_threshold: opts.node_opts.mempool_gap_admit_occupancy_threshold,
};

let blockchain = init_blockchain(store.clone(), blockchain_opts.clone());
Expand Down
31 changes: 29 additions & 2 deletions crates/blockchain/blockchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ use ethrex_common::types::BlobsBundle;

const MAX_PAYLOADS: usize = 10;
const MAX_MEMPOOL_SIZE_DEFAULT: usize = 10_000;
/// Default mempool occupancy percentage (0-100) at which gapped-nonce
/// transaction admission is denied. Set to 100 to disable the check.
pub const DEFAULT_GAP_ADMIT_OCCUPANCY_THRESHOLD: u8 = 90;

/// Background thread for dropping large tree structures off the critical path.
/// Accepts any `Send` value and drops it on a dedicated thread, avoiding
Expand Down Expand Up @@ -231,6 +234,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,
/// Mempool occupancy percentage (0-100) at or above which incoming
/// transactions with a nonce gap relative to the sender's on-chain nonce
/// are rejected. Setting to 100 disables the check.
pub gap_admit_occupancy_threshold: u8,
}

impl Default for BlockchainOptions {
Expand All @@ -242,6 +249,7 @@ impl Default for BlockchainOptions {
max_blobs_per_block: None,
precompute_witnesses: false,
precompile_cache_enabled: true,
gap_admit_occupancy_threshold: DEFAULT_GAP_ADMIT_OCCUPANCY_THRESHOLD,
}
}
}
Expand Down Expand Up @@ -2486,7 +2494,7 @@ impl Blockchain {

let maybe_sender_acc_info = self.storage.get_account_info(header_no, sender).await?;

if let Some(sender_acc_info) = maybe_sender_acc_info {
let sender_acc_nonce = if let Some(sender_acc_info) = &maybe_sender_acc_info {
if nonce < sender_acc_info.nonce || nonce == u64::MAX {
return Err(MempoolError::NonceTooLow);
}
Expand All @@ -2498,10 +2506,11 @@ impl Blockchain {
if tx_cost > sender_acc_info.balance {
return Err(MempoolError::NotEnoughBalance);
}
sender_acc_info.nonce
} else {
// An account that is not in the database cannot possibly have enough balance to cover the transaction cost
return Err(MempoolError::NotEnoughBalance);
}
};

// Check the nonce of pendings TXs in the mempool from the same sender
// If it exists check if the new tx has higher fees
Expand All @@ -2514,6 +2523,24 @@ impl Blockchain {
return Err(MempoolError::InvalidChainId(config.chain_id));
}

// When the mempool is heavily occupied, reject incoming transactions
// whose nonce is not contiguous with the sender's on-chain nonce. This
// prevents a flood of gapped-nonce spam txs from pinning pool budget
// that productive txs could use. Replacements (same nonce as a tx
// already in the pool) bypass this rule since they are not gapped.
let threshold = self.options.gap_admit_occupancy_threshold;
if tx_to_replace_hash.is_none()
&& nonce != sender_acc_nonce
&& self.mempool.is_heavily_occupied(threshold)?
{
let occupancy_pct = (self.mempool.occupancy_ratio()? * 100.0).round() as u8;
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.

let nonce_gap = nonce.saturating_sub(sender_acc_nonce);
return Err(MempoolError::GapAdmissionDeniedUnderPressure {
occupancy_pct,
nonce_gap,
});
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 TOCTOU between admission check and error-message occupancy

is_heavily_occupied acquires and releases the read lock internally (via occupancy_ratio), then the if-body calls occupancy_ratio a second time under a fresh lock. Between those two lock acquisitions the pool can drain or fill, so the occupancy_pct printed in the error can be lower than the threshold — e.g. "Mempool 88% full; rejecting gapped-nonce tx" when the pool was actually at 91% when the check fired. Operators using this message to diagnose admission behaviour will see inconsistent numbers. Consider capturing the ratio from inside is_heavily_occupied (return it alongside the bool, or compute both from a single lock hold) so that the same snapshot drives both the decision and the diagnostic.

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

Comment:
**TOCTOU between admission check and error-message occupancy**

`is_heavily_occupied` acquires and releases the read lock internally (via `occupancy_ratio`), then the `if`-body calls `occupancy_ratio` a second time under a fresh lock. Between those two lock acquisitions the pool can drain or fill, so the `occupancy_pct` printed in the error can be lower than the threshold — e.g. "Mempool 88% full; rejecting gapped-nonce tx" when the pool was actually at 91% when the check fired. Operators using this message to diagnose admission behaviour will see inconsistent numbers. Consider capturing the ratio from inside `is_heavily_occupied` (return it alongside the bool, or compute both from a single lock hold) so that the same snapshot drives both the decision and the diagnostic.

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.

}

Ok(tx_to_replace_hash)
}

Expand Down
2 changes: 2 additions & 0 deletions crates/blockchain/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ pub enum MempoolError {
InvalidTxSender(#[from] ethrex_crypto::CryptoError),
#[error("Attempted to replace a pooled transaction with an underpriced transaction")]
UnderpricedReplacement,
#[error("Mempool {occupancy_pct}% full; rejecting gapped-nonce tx (nonce gap = {nonce_gap})")]
GapAdmissionDeniedUnderPressure { occupancy_pct: u8, nonce_gap: u64 },
}

#[derive(Debug)]
Expand Down
100 changes: 100 additions & 0 deletions crates/blockchain/mempool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,32 @@ impl Mempool {
Ok((txs_size as u64, blobs_size as u64))
}

/// Returns the current occupancy of the transaction pool as a fraction of
/// its configured maximum size, in the range `[0.0, 1.0]`.
///
/// Returns `0.0` when the pool has unlimited capacity (`max_mempool_size == 0`)
/// to avoid a division by zero and to signal that pressure-gated admission
/// rules should treat the pool as empty in that configuration.
pub fn occupancy_ratio(&self) -> Result<f64, MempoolError> {
let inner = self.read()?;
if inner.max_mempool_size == 0 {
return Ok(0.0);
}
let ratio = inner.transaction_pool.len() as f64 / inner.max_mempool_size as f64;
Ok(ratio.min(1.0))
}

/// Returns true when the transaction pool occupancy is at or above the
/// given threshold percentage (0-100). A threshold of 100 effectively
/// disables the check, since occupancy can never exceed 100%.
pub fn is_heavily_occupied(&self, threshold_pct: u8) -> Result<bool, MempoolError> {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is_heavily_occupied is exposed as a public API and has dedicated unit tests, but it's not called from anywhere outside its own tests. The admission gate at blockchain.rs:2537-2546 deliberately uses occupancy_pct() directly + inline >= threshold comparison (per the TOCTOU comment), which is the right call.

So this method's only consumer is its own tests. Two options:

  1. Drop it. Keep occupancy_pct() and let callers do the comparison inline (matching what the admission gate already does). One fewer method to maintain.
  2. Use it. Replace let occupancy_pct = self.mempool.occupancy_pct()?; if occupancy_pct >= threshold { ... } with if self.mempool.is_heavily_occupied(threshold)? { ... } — but then you lose the occupancy_pct value for the error message and would need to re-read (the TOCTOU concern the comment cites).

Leaning toward (1) — the inline comparison is cheaper and the percentage value is needed for the error message regardless. Not blocking.

if threshold_pct >= 100 {
return Ok(false);
}
let ratio = self.occupancy_ratio()?;
Ok(ratio * 100.0 >= threshold_pct as f64)
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.

}
Comment on lines +405 to +417
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 is_heavily_occupied(0) always returns true even for unlimited pools

When max_mempool_size == 0 (unlimited), occupancy_ratio() returns 0.0 — intentionally, per the doc-comment, to signal that pressure-gated rules should treat the pool as empty. However, is_heavily_occupied(0) evaluates 0.0 * 100.0 >= 0.0, which is true, so all gapped transactions are rejected even when the pool has no capacity limit. The "treat as empty" contract documented on occupancy_ratio is silently broken for the edge case of threshold=0 combined with an unlimited pool. Adding a fast-path if inner.max_mempool_size == 0 { return Ok(false); } at the top of is_heavily_occupied would make the unlimited-pool guarantee explicit and consistent with the occupancy_ratio doc-comment.

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

Comment:
**`is_heavily_occupied(0)` always returns `true` even for unlimited pools**

When `max_mempool_size == 0` (unlimited), `occupancy_ratio()` returns `0.0` — intentionally, per the doc-comment, to signal that pressure-gated rules should treat the pool as empty. However, `is_heavily_occupied(0)` evaluates `0.0 * 100.0 >= 0.0`, which is `true`, so all gapped transactions are rejected even when the pool has no capacity limit. The "treat as empty" contract documented on `occupancy_ratio` is silently broken for the edge case of threshold=0 combined with an unlimited pool. Adding a fast-path `if inner.max_mempool_size == 0 { return Ok(false); }` at the top of `is_heavily_occupied` would make the unlimited-pool guarantee explicit and consistent with the `occupancy_ratio` doc-comment.

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.


/// Returns all transactions currently in the pool
pub fn content(&self) -> Result<Vec<Transaction>, MempoolError> {
let pooled_transactions = &self.read()?.transaction_pool;
Expand Down Expand Up @@ -574,3 +600,77 @@ pub fn transaction_intrinsic_gas(

Ok(gas)
}

#[cfg(test)]
mod tests {
use super::*;
use ethrex_common::types::{EIP1559Transaction, Transaction, TxKind};
use ethrex_common::{Address, Bytes};

fn dummy_mempool_tx(sender: Address, nonce: u64) -> MempoolTransaction {
let tx = Transaction::EIP1559Transaction(EIP1559Transaction {
nonce,
max_priority_fee_per_gas: 0,
max_fee_per_gas: 0,
gas_limit: 21_000,
to: TxKind::Call(Address::from_low_u64_be(1)),
value: U256::zero(),
data: Bytes::default(),
access_list: Default::default(),
..Default::default()
});
MempoolTransaction::new(tx, sender)
}

fn fill_mempool(mempool: &Mempool, count: usize) {
for i in 0..count {
let sender = Address::from_low_u64_be(i as u64 + 1);
let hash = H256::from_low_u64_be(i as u64 + 1);
mempool
.add_transaction(hash, sender, dummy_mempool_tx(sender, 0))
.expect("Failed to add transaction");
}
}

#[test]
fn occupancy_ratio_empty_pool() {
let mempool = Mempool::new(100);
assert_eq!(mempool.occupancy_ratio().unwrap(), 0.0);
}

#[test]
fn occupancy_ratio_half_full_pool() {
let mempool = Mempool::new(100);
fill_mempool(&mempool, 50);
assert!((mempool.occupancy_ratio().unwrap() - 0.5).abs() < f64::EPSILON);
}

#[test]
fn occupancy_ratio_full_pool() {
let mempool = Mempool::new(100);
fill_mempool(&mempool, 100);
assert_eq!(mempool.occupancy_ratio().unwrap(), 1.0);
}

#[test]
fn is_heavily_occupied_disabled_at_threshold_100() {
let mempool = Mempool::new(100);
fill_mempool(&mempool, 100);
// Threshold of 100 disables the check.
assert!(!mempool.is_heavily_occupied(100).unwrap());
}

#[test]
fn is_heavily_occupied_below_threshold() {
let mempool = Mempool::new(100);
fill_mempool(&mempool, 50);
assert!(!mempool.is_heavily_occupied(90).unwrap());
}

#[test]
fn is_heavily_occupied_at_or_above_threshold() {
let mempool = Mempool::new(100);
fill_mempool(&mempool, 91);
assert!(mempool.is_heavily_occupied(90).unwrap());
}
}
12 changes: 12 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.gap-admit-occupancy-threshold <PERCENTAGE>
Mempool occupancy percentage (0-100) at or above which incoming transactions with a nonce gap relative to the sender's on-chain nonce are rejected. Setting to 100 disables the check.

[env: ETHREX_MEMPOOL_GAP_ADMIT_OCCUPANCY_THRESHOLD=]
[default: 90]

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

Expand Down Expand Up @@ -317,6 +323,12 @@ Node options:
[env: ETHREX_MEMPOOL_MAX_SIZE=]
[default: 10000]

--mempool.gap-admit-occupancy-threshold <PERCENTAGE>
Mempool occupancy percentage (0-100) at or above which incoming transactions with a nonce gap relative to the sender's on-chain nonce are rejected. Setting to 100 disables the check.

[env: ETHREX_MEMPOOL_GAP_ADMIT_OCCUPANCY_THRESHOLD=]
[default: 90]

P2P options:
--bootnodes <BOOTNODE_LIST>...
Comma separated enode URLs for P2P discovery bootstrap.
Expand Down
Loading
Loading