Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
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
35 changes: 33 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,28 @@ 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.
//
// Read occupancy once and reuse it for both the gate check and the
// error message — calling `is_heavily_occupied` + `occupancy_pct`
// takes the read lock twice, allowing TOCTOU drift where the reported
// occupancy differs from the value the gate fired on.
let threshold = self.options.gap_admit_occupancy_threshold;
if tx_to_replace_hash.is_none() && nonce != sender_acc_nonce && threshold < 100 {
let occupancy_pct = self.mempool.occupancy_pct()?;
if occupancy_pct >= threshold {
let nonce_gap = nonce.saturating_sub(sender_acc_nonce);
return Err(MempoolError::GapAdmissionDeniedUnderPressure {
occupancy_pct,
nonce_gap,
});
}
}

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
123 changes: 123 additions & 0 deletions crates/blockchain/mempool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,45 @@ impl Mempool {
Ok((txs_size as u64, blobs_size as u64))
}

/// Returns the current occupancy of the transaction pool as an integer
/// percentage of its configured maximum size, in the range `[0, 100]`.
///
/// Returns `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.
///
/// The computation uses integer arithmetic (`len * 100 / max`) so threshold
/// boundary comparisons are deterministic — float rounding from a prior
/// `f64`-based implementation could misclassify near-boundary cases.
pub fn occupancy_pct(&self) -> Result<u8, MempoolError> {
let inner = self.read()?;
if inner.max_mempool_size == 0 {
return Ok(0);
}
let pct = inner.transaction_pool.len().saturating_mul(100) / inner.max_mempool_size;
Ok(pct.min(100) as u8)
}

/// Returns `true` when the transaction pool occupancy is at or above the
/// given threshold percentage (`0..=100`). A threshold of 100 disables
/// the check entirely (occupancy can never exceed 100%); a threshold of
/// 0 on an unlimited pool (`max_mempool_size == 0`) also returns
/// `false`, treating "no cap" as "never under pressure" so gapped
/// admission isn't blanket-rejected when capacity is unbounded.
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 inner = self.read()?;
if inner.max_mempool_size == 0 {
return Ok(false);
}
let pool_len = inner.transaction_pool.len();
let max = inner.max_mempool_size;
// `pool_len * 100 >= threshold_pct * max`, in integer arithmetic.
Ok(pool_len.saturating_mul(100) >= max.saturating_mul(threshold_pct as usize))
}
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 +613,87 @@ 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_pct_empty_pool() {
let mempool = Mempool::new(100);
assert_eq!(mempool.occupancy_pct().unwrap(), 0);
}

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

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

#[test]
fn is_heavily_occupied_on_unlimited_pool_is_false() {
// max_mempool_size == 0 means unlimited; pressure-gated rules must
// NOT fire under that configuration even when threshold_pct is 0.
let mempool = Mempool::new(0);
assert!(!mempool.is_heavily_occupied(0).unwrap());
assert!(!mempool.is_heavily_occupied(50).unwrap());
assert!(!mempool.is_heavily_occupied(99).unwrap());
}

#[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