Skip to content

feat(l1): cap mempool queued (future-nonce) txs per sender (geth AccountQueue-style)#6603

Merged
edg-l merged 23 commits into
mainfrom
feat/mempool-account-slots-cap
Jul 7, 2026
Merged

feat(l1): cap mempool queued (future-nonce) txs per sender (geth AccountQueue-style)#6603
edg-l merged 23 commits into
mainfrom
feat/mempool-account-slots-cap

Conversation

@ilitteri

@ilitteri ilitteri commented May 11, 2026

Copy link
Copy Markdown
Collaborator

Motivation

A single sender can currently occupy 100% of the 10,000-tx pool by parking many future (nonce-gapped) transactions from one address, trivially bypassing the size cap (#6599), EIP-3607 check (#6600) and percentage-RBF rule (#6601). This adds a per-account cap on the queued (future-nonce) subpool — geth's AccountQueue model — to bound that blast radius without penalizing legitimate high-throughput senders.

Description

  • BlockchainOptions gains max_queued_txs_per_account: usize (default DEFAULT_MAX_QUEUED_TXS_PER_ACCOUNT = 64, matching geth's AccountQueue).
  • Only queued / future txs count against the cap: a tx is future when its nonce is beyond the sender's contiguous executable run from the on-chain nonce (is_future / queued_count_for_sender, ranging the existing txs_by_sender_nonce index). Executable (contiguous-nonce) txs are never capped, so a legitimate high-throughput single sender is unaffected — only nonce-gap parking spam is bounded.
  • The cap is enforced atomically inside Mempool::add_transaction, under the same write lock as the insertion, so two concurrent future-nonce submissions from one sender can't both pass a stale count and race past the limit. Blockchain::validate_transaction threads the sender's on-chain nonce out (via QueuedCap) so the locked check can distinguish future from executable without a storage read under the lock.
  • Replacements are exempt: the admission path checks find_tx_to_replace first and passes queued_cap = None when it matches — a same-(sender, nonce) replace reuses the slot and never grows the queue, so it routes through the normal RBF logic even when the sender is at the cap.
  • Over-cap submissions are rejected with MempoolError::MaxQueuedTxsPerAccountExceeded { sender, count, limit }.
  • New CLI flag --mempool.max-queued-txs-per-account (env ETHREX_MEMPOOL_MAX_QUEUED_TXS_PER_ACCOUNT), plumbed through both L1 and L2 initializers.

Behavioral change

Admission becomes stricter only for future-nonce txs: a sender with 64 queued (nonce-gapped) txs submitting a 65th future tx is rejected at ingress. Executable txs and replacements remain unaffected.

Tests

Unit tests in mempool.rs cover the future/queued classification and per-sender isolation (gapped_tx_is_future_and_counts_only_queued, executable_flood_is_never_capped, queued_cap_isolates_senders, and the queued_count_if_future cases). The atomic enforcement + replacement-exemption wiring in the admission path is exercised by the existing mempool integration tests.

Checklist

  • Cap re-scoped to the queued (future-nonce) subpool, enforced atomically in add_transaction; replacements exempt.

ilitteri added 3 commits May 11, 2026 18:30
Adds the peer-policy per-sender pending-slot cap that every major
Ethereum execution client enforces by default — geth `AccountSlots`,
reth `TXPOOL_MAX_ACCOUNT_SLOTS_PER_SENDER`, erigon `AccountSlots`,
nethermind `MaxPendingBlobTxsPerSender`, all defaulting to 16. Without
this cap a single sender can occupy 100% of the 10,000-tx pool, which
trivially bypasses the size cap (#6599), EIP-3607 check (#6600) and
percentage-RBF rule (#6601) by submitting many nonces from one address.

- `BlockchainOptions` gains `account_slots: usize` (default 16) plus
  a new `DEFAULT_ACCOUNT_SLOTS` constant referencing the peer-client
  norms inline.
- `Mempool::count_for_sender(sender)` counts a sender's pending txs by
  ranging the existing `txs_by_sender_nonce: BTreeMap<(H160, u64), H256>`
  index — O(log n + k) where k is the per-sender count (≤ 16). No new
  state, no new invariant.
- `Blockchain::validate_transaction` consults the count AFTER
  `find_tx_to_replace`, so replacement candidates at an existing
  `(sender, nonce)` bypass the cap — they don't grow the pool
  footprint. Senders at the cap submitting a new nonce are rejected
  with `MempoolError::SenderSlotsExceeded { count, limit }`.
- New CLI flag `--mempool.account-slots` (env
  `ETHREX_MEMPOOL_ACCOUNT_SLOTS`), plumbed through L1 and L2
  initializers.
- Five unit tests in `mempool.rs` covering: empty pool, single tx,
  multiple nonces from same sender, isolation between senders, unknown
  sender returns zero. The wire-up inside `validate_transaction` is a
  six-line conditional that's visually verifiable; a full end-to-end
  integration test would require seeding sender-account storage state,
  which isn't currently done in `test/tests/blockchain/mempool_tests.rs`.

All cargo fmt / clippy / unit + integration test runs clean (441 tests
in `ethrex-test`).
Copilot AI review requested due to automatic review settings May 11, 2026 21:46
@ilitteri
ilitteri requested review from a team, ManuelBilbao and avilagaston9 as code owners May 11, 2026 21:46
@github-actions github-actions Bot added the L1 Ethereum client label May 11, 2026
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

The PR implements a per-account pending transaction limit in the mempool (default: 16) to prevent DoS spam. Overall, the implementation is correct and follows Rust best practices.

Issues Found

1. Race Condition (TOCTOU) in Account Limit Check

File: crates/blockchain/blockchain.rs (lines 2518-2525)

The check acquires a read lock, drops it, and later add_transaction acquires a write lock. Between these operations, another transaction could be added for the same sender, potentially exceeding the cap.

// Current flow:
let count = self.mempool.count_for_sender(sender)?; // read lock dropped here
if count >= self.options.max_pending_txs_per_account {
    return Err(...);
}
// ... gap where another tx could be added ...
self.mempool.add_transaction(...); // write lock acquired here

Recommendation: While acceptable for mempool best-effort limits (not consensus-critical), document this behavior or consider acquiring the write lock earlier if strict enforcement is required.

2. Magic Number Duplication

Files:

  • cmd/ethrex/cli.rs (line 189: default_value_t = 16)
  • cmd/ethrex/cli.rs (line 462: hardcoded 16)
  • crates/blockchain/blockchain.rs (line 256: DEFAULT_MAX_PENDING_TXS_PER_ACCOUNT)

The default value 16 is defined in three separate locations. If changed in one place, the others become inconsistent.

Recommendation: Import and use DEFAULT_MAX_PENDING_TXS_PER_ACCOUNT from blockchain crate in cli.rs since cmd/ethrex already depends on crates/blockchain.

3. Error Message Accuracy

File: crates/blockchain/error.rs (line 86)

#[error("Sender has {count} pending transactions, exceeds the per-account cap of {limit}")]

When count == limit, the message claims the count "exceeds" the limit, which is logically incorrect (16 does not exceed 16).

Recommendation: Change to: "Sender has {count} pending transactions, reaches/exceeds the per-account cap of {limit}" or "Per-account cap of {limit} exceeded (current: {count})".

4. Potential Performance Note

File: crates/blockchain/mempool.rs (lines 456-460)

The count_for_sender method iterates over all nonces for a sender. While bounded by the cap, it executes on every new transaction for non-replacement cases.

let count = inner
    .txs_by_sender_nonce
    .range((sender, 0)..=(sender, u64::MAX))
    .count();

Note: This is O(k) where k is the sender's current tx count. With default cap of 16, this is negligible, but monitor if operators increase the limit significantly.

Positive Aspects

  • Correct bypass logic: Replacement transactions correctly bypass the cap check (line 2518: if tx_to_replace_hash.is_none())
  • Proper isolation: count_for_sender correctly uses range queries on the (Address, u64) composite key
  • Good test coverage: Tests cover empty pools, single/multiple nonces, and sender isolation
  • Consistent configuration: Properly threaded through L1 and L2 initializers

Summary

The implementation is sound and secure for its intended purpose. Address the magic number duplication (Item 2) before merging if possible; the race condition (Item 1) is acceptable for mempool admission control but should be documented.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

github-actions Bot commented May 11, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 238
Total lines removed: 0
Total lines changed: 238

Detailed view
+---------------------------------------------+-------+------+
| File                                        | Lines | Diff |
+---------------------------------------------+-------+------+
| ethrex/cmd/ethrex/cli.rs                    | 1285  | +10  |
+---------------------------------------------+-------+------+
| ethrex/cmd/ethrex/initializers.rs           | 804   | +1   |
+---------------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/initializers.rs        | 394   | +1   |
+---------------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs      | 3170  | +26  |
+---------------------------------------------+-------+------+
| ethrex/crates/blockchain/error.rs           | 206   | +8   |
+---------------------------------------------+-------+------+
| ethrex/crates/blockchain/mempool.rs         | 929   | +191 |
+---------------------------------------------+-------+------+
| ethrex/crates/networking/rpc/eth/account.rs | 330   | +1   |
+---------------------------------------------+-------+------+

@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Now I have everything I need to write a thorough review.


Review: feat(l1): cap mempool pending transactions per sender (default 16)

The design is sound and the implementation is clean. The per-sender slot cap correctly addresses a real DoS vector. Most of the core issues are already flagged by the automated review; I'll expand on the significant ones and add a couple new ones.


Correctness

count_for_sender range logic (mempool.rs:456–460) — correct. The range (sender, 0)..=(sender, u64::MAX) on a BTreeMap<(H160, u64), H256> isolates exactly the sender's entries because BTreeMap uses lexicographic tuple ordering: any key whose first element is strictly less or strictly greater than sender falls outside the bounds.

Replacement bypass logic (blockchain.rs:2518) — correct. Calling find_tx_to_replace first and only checking the cap when tx_to_replace_hash.is_none() ensures replacements can always proceed regardless of the sender's slot count.


Bug: max_pending_txs_per_account = 0 silently blocks all new transactions

With limit 0, count_for_sender returns 0 for an empty slot, and 0 >= 0 causes every non-replacement ingress to be rejected. There is no input validation or minimum enforced anywhere in the CLI or BlockchainOptions. An operator who sets --mempool.max-pending-txs-per-account 0 (perhaps intending "unlimited") will silently brick ingress.

Suggested fix — add a clap value_parser that rejects 0:

value_parser = clap::value_parser!(usize).range(1..),

Or validate at startup and error out clearly.


Duplicated magic number 16

The value appears in three independent places:

  • cli.rs default_value_t = 16
  • cli.rs Default impl hardcoded 16
  • blockchain.rs DEFAULT_MAX_PENDING_TXS_PER_ACCOUNT

Since cmd/ethrex already depends on crates/blockchain, the CLI could import and use the constant directly:

use ethrex_blockchain::blockchain::DEFAULT_MAX_PENDING_TXS_PER_ACCOUNT;

#[arg(default_value_t = DEFAULT_MAX_PENDING_TXS_PER_ACCOUNT, ...)]
pub mempool_max_pending_txs_per_account: usize,

// and in Default impl:
mempool_max_pending_txs_per_account: DEFAULT_MAX_PENDING_TXS_PER_ACCOUNT,

Error message inaccuracy (error.rs:86)

#[error("Sender has {count} pending transactions, exceeds the per-account cap of {limit}")]

When count == limit (e.g., 16 == 16), the message claims the count "exceeds" the limit, which is false. Suggest:

"Sender has {count} pending transactions, the per-account cap is {limit}"

TOCTOU race between count and insert

Between count_for_sender releasing its read lock and add_transaction acquiring a write lock, a concurrent request for the same sender can slip through. The cap is a soft admission limit (not a consensus rule), so minor over-admission is acceptable — but it should be documented, since operators may be surprised if a node under concurrent load admits e.g. 18 instead of 16.


Missing test: cap enforcement at validate_transaction

All five new unit tests exercise only count_for_sender. There is no test verifying the end-to-end enforcement path:

  • A sender at the cap is rejected for a new nonce.
  • A sender at the cap is accepted for a replacement at an existing nonce.

The replacement bypass is a subtle invariant that's explicitly called out in the PR description and in comments — it deserves a regression test. Without one, a future refactor of validate_transaction could silently break it.


Minor nits

  • count_for_sender is documented as "Used by the per-sender slot cap at admission" in its doc comment. This is useful context but ties the method to a specific caller. Neutral on this.
  • DEFAULT_MAX_PENDING_TXS_PER_ACCOUNT is placed after the Default impl that references it. Rust allows forward references within a module, so this compiles, but putting the constant before the Default impl would be more readable.

Summary

The implementation is functionally correct for the common case. The max_pending_txs_per_account = 0 footgun is the most impactful item to address before merging. The duplicated magic number 16 is a maintenance risk. Tests for the actual enforcement path (particularly the replacement bypass) should be added. The error message inaccuracy is minor but easy to fix.


Automated review by Claude (Anthropic) · sonnet · custom prompt

Copilot AI left a comment

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.

Pull request overview

This PR introduces a per-sender cap on the number of pending transactions allowed in the mempool (default 16) to reduce single-sender pool domination and related spam vectors, while allowing same-nonce replacements to bypass the cap.

Changes:

  • Add BlockchainOptions::max_pending_txs_per_account (default 16) and enforce it during transaction admission (with replacement bypass).
  • Implement Mempool::count_for_sender using the existing (sender, nonce) index, plus unit tests for the counting behavior.
  • Expose configuration via CLI flag/env var and document it; plumb the option through both L1 and L2 initializers.

Reviewed changes

Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.

Show a summary per file
File Description
docs/CLI.md Documents the new --mempool.max-pending-txs-per-account flag (default 16).
crates/blockchain/mempool.rs Adds count_for_sender and unit tests for per-sender counting.
crates/blockchain/error.rs Adds MaxPendingTxsPerAccountExceeded { count, limit } error variant/message.
crates/blockchain/blockchain.rs Adds option/default constant and enforces per-account pending-tx cap in validate_transaction.
cmd/ethrex/l2/initializers.rs Plumbs the new mempool cap option into L2 blockchain initialization.
cmd/ethrex/initializers.rs Plumbs the new mempool cap option into L1 blockchain initialization.
cmd/ethrex/cli.rs Adds CLI flag/env var for the new cap and wires it into Options.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread crates/blockchain/error.rs Outdated
Comment thread crates/blockchain/blockchain.rs Outdated
Comment on lines +2518 to +2529
// 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.

Comment thread cmd/ethrex/cli.rs Outdated
Comment thread cmd/ethrex/cli.rs
@greptile-apps

greptile-apps Bot commented May 11, 2026

Copy link
Copy Markdown

Greptile Summary

This PR introduces a per-account cap on queued (future/nonce-gapped) transactions in the mempool, mirroring geth's AccountQueue design: only nonce-gapped transactions count against the limit while executable (contiguous-nonce) runs remain uncapped. The new max_queued_txs_per_account option (default 64) is wired through BlockchainOptions, both CLI initializers, and a new --mempool.max-queued-txs-per-account flag.

  • Cap logic in mempool.rs: next_executable_nonce / queued_count_if_future correctly identify and count only gapped txs relative to the current on-chain nonce; five unit tests cover the key cases.
  • Admission check in blockchain.rs: The cap check fires early in validate_transaction — before find_tx_to_replace — which incorrectly blocks a replace-by-fee attempt on an existing queued tx when the sender is exactly at the cap.
  • docs/CLI.md: The documentation added in this PR uses the old flag name (max-pending), old env var, and old default (16) rather than the values actually defined in cli.rs (max-queued, default 64).

Confidence Score: 3/5

Two concrete defects need fixing before merge: the cap check order in validate_transaction breaks RBF for queued transactions at the limit, and docs/CLI.md documents the wrong flag name and default, actively misleading operators.

The admission check in validate_transaction runs before find_tx_to_replace, so a sender at the cap cannot bump fees on any of its queued transactions — contradicting the stated design. docs/CLI.md was updated with the old flag name, old env var, and old default of 16 instead of 64.

crates/blockchain/blockchain.rs (cap check ordering) and docs/CLI.md (stale flag name, env var, and default)

Important Files Changed

Filename Overview
crates/blockchain/blockchain.rs Adds the per-account queued-tx cap check in validate_transaction. The check runs before find_tx_to_replace, meaning a replacement for an existing queued tx is incorrectly blocked when the sender is exactly at the cap.
docs/CLI.md Documents the new flag with the wrong name (max-pending vs max-queued), wrong env var, and wrong default (16 vs 64), making the docs actively misleading.
crates/blockchain/mempool.rs Adds next_executable_nonce, is_future, queued_count_for_sender, and the public queued_count_if_future helper. Logic is sound and tests exercise the key cases.
crates/blockchain/error.rs Adds MaxQueuedTxsPerAccountExceeded error variant with a clear error message.
cmd/ethrex/cli.rs Adds --mempool.max-queued-txs-per-account CLI flag with correct env var and default 64; wired up correctly.
cmd/ethrex/initializers.rs Passes mempool_max_queued_txs_per_account into BlockchainOptions; straightforward plumbing.
cmd/ethrex/l2/initializers.rs Same option wired into L2 initializer; correct.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant Caller
    participant Blockchain
    participant Mempool

    Caller->>Blockchain: add_transaction_to_pool(tx)
    Blockchain->>Mempool: contains_tx(hash)
    Mempool-->>Blockchain: false

    Blockchain->>Blockchain: validate_transaction(tx, sender)
    Blockchain->>Mempool: queued_count_if_future(sender, account_nonce, nonce)
    note over Mempool: read lock counts gapped txs only
    Mempool-->>Blockchain: Some(count) if future tx, None if executable

    alt "count >= max_queued_txs_per_account"
        Blockchain-->>Caller: Err(MaxQueuedTxsPerAccountExceeded)
        note over Blockchain,Caller: BUG blocks RBF of queued tx at cap
    else "count < limit or executable tx"
        Blockchain->>Mempool: find_tx_to_replace(sender, nonce, tx)
        Mempool-->>Blockchain: Option(old_hash)
        Blockchain-->>Blockchain: validate_transaction returns Ok(tx_to_replace)
    end

    alt tx_to_replace exists
        Blockchain->>Mempool: remove_transaction(old_hash)
    end
    Blockchain->>Mempool: add_transaction(hash, sender, tx)
    note over Mempool: write lock global size eviction
    Mempool-->>Blockchain: Ok(())
    Blockchain-->>Caller: Ok(hash)
Loading
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
    participant Caller
    participant Blockchain
    participant Mempool

    Caller->>Blockchain: add_transaction_to_pool(tx)
    Blockchain->>Mempool: contains_tx(hash)
    Mempool-->>Blockchain: false

    Blockchain->>Blockchain: validate_transaction(tx, sender)
    Blockchain->>Mempool: queued_count_if_future(sender, account_nonce, nonce)
    note over Mempool: read lock counts gapped txs only
    Mempool-->>Blockchain: Some(count) if future tx, None if executable

    alt "count >= max_queued_txs_per_account"
        Blockchain-->>Caller: Err(MaxQueuedTxsPerAccountExceeded)
        note over Blockchain,Caller: BUG blocks RBF of queued tx at cap
    else "count < limit or executable tx"
        Blockchain->>Mempool: find_tx_to_replace(sender, nonce, tx)
        Mempool-->>Blockchain: Option(old_hash)
        Blockchain-->>Blockchain: validate_transaction returns Ok(tx_to_replace)
    end

    alt tx_to_replace exists
        Blockchain->>Mempool: remove_transaction(old_hash)
    end
    Blockchain->>Mempool: add_transaction(hash, sender, tx)
    note over Mempool: write lock global size eviction
    Mempool-->>Blockchain: Ok(())
    Blockchain-->>Caller: Ok(hash)
Loading

Reviews (2): Last reviewed commit: "refactor(l1): re-scope per-account cap t..." | Re-trigger Greptile

Comment thread crates/blockchain/blockchain.rs Outdated
Comment thread crates/blockchain/error.rs Outdated
@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. High: The new sender-cap check is raceable. crates/blockchain/blockchain.rs:2516 reads replacement/count state during validate_transaction, but the tx is only inserted later in crates/blockchain/blockchain.rs:2369 and crates/blockchain/mempool.rs:151. Two concurrent submissions from the same sender can both observe count == limit - 1 and both be admitted, so the cap is bypassable. This needs to be enforced in the same write-side mempool operation that resolves replacement and inserts the tx.

  2. Medium: The cap counts stale txs that are already invalid at the current head. crates/blockchain/mempool.rs:456 counts every (sender, nonce) slot, but block import only removes exact included hashes in crates/blockchain/blockchain.rs:2386, and the L1 payload path keeps failed txs in the mempool in crates/blockchain/payload.rs:688. If a different same-nonce tx is mined elsewhere, the local stale tx still consumes one of the sender’s slots and can cause false MaxPendingTxsPerAccountExceeded rejections for later valid txs. I’d either prune nonce < sender_acc_info.nonce entries before enforcing the cap, or exclude them from the count.

Aside from those points, the wiring is straightforward.

I couldn’t run cargo check in this sandbox because cargo/git cache writes are blocked here, so this review is from diff inspection only.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

ilitteri added 2 commits May 11, 2026 18:57
A wrong-chain submission from a sender already at the per-account cap
was previously rejected as MaxPendingTxsPerAccountExceeded — the more
specific InvalidChainId error was never reached. Reordering surfaces
the correct error. The cap check has no side effects (read-only Mempool
query) so the reorder is safe.

Follow-up not in this PR: integration tests against validate_transaction
exercising the cap rejection and replacement-at-cap bypass paths.
Currently blocked on test-fixture infra that doesn't seed sender
accounts in test/tests/blockchain/mempool_tests.rs.

@MegaRedHand MegaRedHand left a comment

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.

LGTM

Comment thread crates/blockchain/mempool.rs Outdated
Comment on lines +459 to +461
.txs_by_sender_nonce
.range((sender, 0)..=(sender, u64::MAX))
.count();

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.

ilitteri added a commit that referenced this pull request May 11, 2026
Cross-client audit flagged that geth (`core/types/transaction.go::Size`),
nethermind (`MaxBlobTxSize`), and erigon (`ValidateSerializedTxn`) all
compare their 1 MiB blob-tx cap against the wire form that **includes
the sidecar** (blobs + commitments + proofs). The previous ethrex check
in `validate_transaction` compared `Transaction::encode_canonical_to_vec`
which only covers the core tx — the sidecar lives in the adjacent
`BlobsBundle`. With 6 blobs (~786 KB blob data + ~100 KB
commitments/proofs ≈ 900 KB) the worst-case wire wrapper can reach
~1.9 MiB while still passing the 1 MiB core-only check. Peers reject
that on the wire so ethrex would be admitting txs nobody else will relay.

Changes:
- `validate_transaction` now only enforces `MAX_TX_SIZE` for non-blob txs.
- `add_blob_transaction_to_pool_inner` runs a new wire-wrapper check
  before bundle validation: `core_tx_encoded + bundle_encoded <=
  MAX_BLOB_TX_SIZE`. Summing the two encoded sizes matches geth's
  `tx.Size()` semantic to within the ±few bytes of outer list framing,
  which is rounding error at this scale.
- Drop `validate_transaction_rejects_oversize_blob_core` — the
  function it covered no longer applies to blob txs. Integration test
  for the new wrapper check deferred (same pattern as PRs #6603/#6576:
  the `c-kzg`-gated `add_blob_transaction_to_pool` isn't currently
  exercised by `mempool_tests`).
ilitteri added 2 commits May 11, 2026 21:38
Copilot + greptile flagged that the message "Sender has 16 pending
transactions, exceeds the per-account cap of 16" is wrong at the
boundary (16 doesn't exceed 16; the check fires at `count >= limit`).
Rephrase so the wording stays accurate at every value:

  "Sender has {count} pending transactions; adding a new one would
   exceed the per-account cap of {limit}"
Copilot + greptile (P1) flagged a TOCTOU race: the count check in
`validate_transaction` (read lock) and `Mempool::add_transaction`
(write lock) are not held jointly. Two concurrent submissions from the
same sender at `count = 15` both pass the cap check and then both
insert, leaving the sender with 17 pending txs against a 16 limit.

`Mempool::add_transaction` now takes `max_pending_txs_per_account` and
re-counts under the same write lock that performs the insertion,
returning `MaxPendingTxsPerAccountExceeded` directly. The redundant
check in `validate_transaction` is dropped — replacements implicitly
bypass the cap because the old tx is removed first, so the
post-removal count is at most cap-1.
@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

  1. High: crates/blockchain/blockchain.rs:3066-3078 enforces the queued-cap before the replacement check at crates/blockchain/blockchain.rs:3136-3138. That means once an account is at the queued limit, a legitimate fee bump for an already-queued nonce is rejected with MaxQueuedTxsPerAccountExceeded instead of being treated as RBF. Example: account nonce 0, queued txs at 5..68, then a replacement for nonce 5 will still be classified as future and blocked. This contradicts the comments and can pin queued transactions in the pool.

  2. High: the cap is not actually enforced atomically. validate_transaction() reads the queued count via queued_count_if_future() under a read lock, but insertion happens later in add_transaction_to_pool() / add_blob_transaction_to_pool() and finally Mempool::add_transaction() under a separate write lock (crates/blockchain/blockchain.rs:2837-2845, 2876-2882, crates/blockchain/mempool.rs:326-355). Two concurrent future txs from the same sender can both observe queued == limit - 1 and both get inserted, so the anti-spam limit is bypassable. The comment at crates/blockchain/blockchain.rs:3147-3153 currently states the opposite.

  3. Medium: the new admission check is O(n) per submission for large contiguous runs, and O(n^2) across a burst. next_executable_nonce() linearly scans the sender’s contiguous nonce run (crates/blockchain/mempool.rs:151-161), and future-tx counting can trigger a second scan (crates/blockchain/mempool.rs:173-177, 760-769). Since executable txs are explicitly uncapped (crates/blockchain/mempool.rs:321-325), a high-throughput sender can make every new tx walk an ever-growing BTree range. That weakens the stated “LargeTxRequest unaffected” goal.

  4. Low: the docs are out of sync with the implementation. docs/CLI.md:114-118 still documents --mempool.max-pending-txs-per-account, env ETHREX_MEMPOOL_MAX_PENDING_TXS_PER_ACCOUNT, default 16, while the code exposes --mempool.max-queued-txs-per-account, env ETHREX_MEMPOOL_MAX_QUEUED_TXS_PER_ACCOUNT, default 64 in cmd/ethrex/cli.rs:240-248.

I couldn’t run cargo test here because cargo attempted to update the Rust toolchain under a read-only rustup home, so this review is based on static analysis.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

github-actions Bot commented Jul 2, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Based on the diff and source review, here is my assessment.

Review: PR 6603 — cap mempool queued (future-nonce) txs per sender

Critical: TOCTOU race reintroduces the exact bug this PR previously fixed

crates/blockchain/blockchain.rs:3073-3083 checks the queued-tx cap via Mempool::queued_count_if_future, which takes a read lock, checks, and releases (crates/blockchain/mempool.rs:762-772). The actual insertion happens later in Mempool::add_transaction (crates/blockchain/mempool.rs:326-366) under a separate write lock, with no re-check of the cap at insertion time.

Two concurrent add_transaction_to_pool calls for the same sender, both submitting new future-nonce txs while the sender sits one below the cap, can both pass the check (same stale count) and both insert — leaving the sender over the configured max_queued_txs_per_account.

This is not a new/subtle issue — commit 3df6bc8 in this same PR's history explicitly fixed this exact TOCTOU by moving the check inside add_transaction's write lock. The final "re-scope to queued subpool" commit (744620e) dropped that fix without replacing it: add_transaction no longer takes a cap parameter or re-checks anything.

Worse, the comment at blockchain.rs:3147-3153 still asserts the old (no-longer-true) guarantee:

// The per-account pending-tx cap is enforced atomically inside
// `Mempool::add_transaction` (under the same write lock as the
// insertion) so concurrent submissions can't both pass a stale
// count check and race past the limit.

This is now false and actively misleading to future readers/reviewers.

Fix: re-check the queued count inside add_transaction under the write lock (mirroring the earlier fix), or otherwise serialize check+insert per sender.

Bug: cap check doesn't exclude the tx being replaced, causing spurious rejection of legitimate RBF at the boundary

The cap check (blockchain.rs:3073-3083) runs before find_tx_to_replace (blockchain.rs:3138). queued_count_for_sender (mempool.rs) counts all pooled entries at or above the executable-run boundary — including an existing tx at the same (sender, tx_nonce) when the incoming tx is actually a fee-bump replacement of an already-queued tx, not a new nonce.

Concretely: if a sender already has exactly max_queued_txs_per_account future txs pooled, and resubmits a fee bump for one of those existing nonces (a legitimate no-new-slot replacement), queued_count_if_future still returns Some(limit) (counting the tx being replaced against itself), and the check queued >= limit rejects it with MaxQueuedTxsPerAccountExceeded — even though the replacement doesn't grow the sender's footprint at all.

This contradicts the PR's own stated intent ("Replacements... bypass it implicitly") and isn't exercised by the added unit tests (all of which test fresh insertions, not replace-at-cap). Consider excluding the tx's own existing slot from the count when a same-nonce entry already exists, or moving the cap check after find_tx_to_replace so replacement candidates are identified first.

Design gap: the PR's stated threat (single sender occupying the whole pool) is only partially mitigated

The motivation says "a single sender can currently occupy 100% of the 10,000-tx pool... by submitting many distinct nonces." The final implementation caps only the queued/future (nonce-gapped) subpool; the executable/pending (contiguous-nonce) subpool remains completely uncapped per sender (there is no analogue of geth's AccountSlots, only AccountQueue). A sender that simply submits sequential nonces (trivial for any script/bot) can still fill the entire 10,000-slot mempool, which is the exact scenario described as the motivating bug. If this narrowing to geth's AccountQueue-only semantics is intentional, the PR description should be updated to stop claiming the broader protection — right now the top-level description still talks about a generic "pending-slot cap" and max_pending_txs_per_account, which no longer matches the code.

Stale documentation

docs/CLI.md:114 still documents the old flag: --mempool.max-pending-txs-per-account / ETHREX_MEMPOOL_MAX_PENDING_TXS_PER_ACCOUNT, with the old "pending" semantics. The actual flag in cmd/ethrex/cli.rs is --mempool.max-queued-txs-per-account / ETHREX_MEMPOOL_MAX_QUEUED_TXS_PER_ACCOUNT. This looks like generated docs that weren't refreshed after the last rename/rescope commit.

Performance note

next_executable_nonce (mempool.rs) walks the sender's contiguous run from account_nonce on every tx admission, and since executable/contiguous txs are uncapped, a single sender's contiguous run can grow up to the whole mempool size. Submitting n sequential-nonce txs from one sender costs O(n²) total BTreeMap traversal across admissions. Given there's no cap on this subpool (see design gap above), this is also a mild self-inflicted DoS surface: the same sender that's allowed to flood with sequential nonces also makes each subsequent submission from itself (and, since the check runs for every sender, contends the read lock) progressively more expensive. Also note queued_count_if_future calls is_future then queued_count_for_sender, each independently recomputing next_executable_nonce — redundant work that could be fused into one pass.

Minor

  • next_executable_nonce's expected += 1 will panic on overflow if a sender somehow has a pooled tx at nonce u64::MAX - 1 with a contiguous run reaching it — extremely unlikely in practice (nonce would need to reach u64::MAX), but checked_add would be defensive-free.
  • The unit tests added are solid for the pure counting logic (count_for_sender/queued_count_if_future) but, as the PR itself notes, there's no integration test exercising the actual rejection path in validate_transaction, which is exactly where both bugs above live — worth prioritizing once the check placement is fixed.

What's good

  • The core idea (bounding the future/queued subpool per sender, geth-AccountQueue-aligned) is a reasonable, well-precedented mitigation, and the executable-vs-future distinction via next_executable_nonce is a clean way to reuse the existing txs_by_sender_nonce index without new state.
  • Good error message hygiene (the earlier off-by-one wording issue on the boundary was correctly fixed).
  • CLI flag wiring through both L1 and L2 initializers is complete and consistent.

Automated review by Claude (Anthropic) · sonnet · custom prompt

@ElFantasma

Copy link
Copy Markdown
Contributor

@iovoid thanks for the review — both points are addressed on the current head:

  1. Blob leak (inline thread): the cap re-scope moved the check upstream of add_blobs_bundle, and b5bca41 rolls back the orphaned bundle if add_transaction errors afterward. Details in the thread.
  2. Obsoleted txs counting against the limit: resolved by the re-scope to the queued (future-nonce) subpool. queued_count_for_sender now counts only txs at/above next_executable_nonce(sender, account_nonce), so obsoleted txs (nonce below the on-chain account nonce) fall outside the range and no longer count toward the per-account cap.

Ready for another look whenever you have a moment.

@ElFantasma
ElFantasma requested a review from iovoid July 2, 2026 21:10

@iovoid iovoid left a comment

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.

The PR description mentions the old design.

Comment thread crates/blockchain/blockchain.rs Outdated
return Err(MempoolError::InvalidChainId(config.chain_id));
}

// The per-account pending-tx cap is enforced atomically inside

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.

We release the lock after checking, so it's not truly atomic.

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.

Fixed in 9d936b5. The queued-cap check moved out of validate_transaction (where it read under a released read lock) and into Mempool::add_transaction, under the same write lock as the insert, so the check and insert are atomic — two concurrent future-nonce txs from one sender can no longer both pass a stale count. validate_transaction now just threads the sender's on-chain nonce out via a QueuedCap so the locked check can tell future from executable without an async storage read under the lock.

Comment thread crates/blockchain/blockchain.rs Outdated
Comment on lines +3082 to +3086
if let Some(queued) =
self.mempool
.queued_count_if_future(sender, sender_acc_info.nonce, nonce)?
&& queued >= self.options.max_queued_txs_per_account
{

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.

This prevents RBF of queued transactions, we should probably check find_tx_to_replace first.

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.

Fixed in 9d936b5. The admission callers now check find_tx_to_replace first and pass queued_cap = None when it matched, so a replacement — which reuses the same (sender, nonce) slot and never grows the queue — is exempt from the cap on all paths (including frame txs, whose predecessor is removed inside add_transaction after the check).

…slots-cap

# Conflicts:
#	crates/blockchain/blockchain.rs
#	crates/blockchain/error.rs
#	crates/blockchain/mempool.rs
@ElFantasma

Copy link
Copy Markdown
Contributor

@iovoid thanks — all three addressed:

  • Not atomic: the queued-cap check moved into Mempool::add_transaction under the insertion write lock (9d936b5), so check+insert are atomic; validate_transaction only threads the sender's on-chain nonce out via QueuedCap (no storage read under the lock). thread
  • RBF of queued txs: the admission path checks find_tx_to_replace first and passes queued_cap = None for replacements, so a same-(sender, nonce) fee-bump is exempt on all paths. thread
  • Stale description: rewritten to the current queued-subpool design (per-account queued/future-nonce cap, default 64, atomic enforcement, replacement-exempt).

Also merged latest main (frame-tx work integrated). 96 integration + 6 mempool unit tests pass, clippy clean.

@ElFantasma
ElFantasma requested a review from iovoid July 3, 2026 20:47
Comment thread crates/blockchain/mempool.rs Outdated
/// avoid a TOCTOU race (EIP-8141, review fix 1.6).
///
/// The per-account **queued** (future/nonce-gapped) cap is enforced upstream
/// in `Blockchain::validate_transaction`, which has the sender's on-chain

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.

False, it's enforced here.

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.

You're right — that doc was stale from before the cap moved into add_transaction. Fixed in fbc476f: the doc now says the queued cap is enforced here (atomically under the write lock, via the passed QueuedCap), not upstream in validate_transaction.

}

#[cfg(test)]
mod tests {

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.

There are no tests for the rejection case.

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.

Added in fbc476f: add_transaction_rejects_future_tx_over_queued_cap (sender at cap → MaxQueuedTxsPerAccountExceeded with count/limit asserted), plus add_transaction_accepts_future_tx_below_queued_cap and add_transaction_never_caps_executable_txs to bracket the boundary and confirm executable txs are never capped.

@github-project-automation github-project-automation Bot moved this from In Review to In Progress in ethrex_l1 Jul 6, 2026
@github-project-automation github-project-automation Bot moved this from In Progress to In Review in ethrex_l1 Jul 6, 2026
@edg-l
edg-l added this pull request to the merge queue Jul 7, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Jul 7, 2026
@edg-l
edg-l added this pull request to the merge queue Jul 7, 2026
Merged via the queue into main with commit 567ea60 Jul 7, 2026
63 of 64 checks passed
@edg-l
edg-l deleted the feat/mempool-account-slots-cap branch July 7, 2026 07:42
@github-project-automation github-project-automation Bot moved this from In Review to Done in ethrex_l1 Jul 7, 2026
han0110 pushed a commit to han0110/ethrex that referenced this pull request Jul 9, 2026
…bdaclass#6606)

## Motivation

A sender at the per-sender slot cap (lambdaclass#6603) can still spam if only a few
of their N pending txs are fundable from balance — the other N-1 are
guaranteed-fail and waste pool budget at execution time. All four peer
EL clients (geth, reth, nethermind, erigon) gate this with a
cumulative-balance check at admission.

## Description

- New `Mempool::sum_cost_for_sender(sender, exclude: Option<H256>) ->
Result<U256, MempoolError>` ranges `txs_by_sender_nonce` for the sender
and sums `cost_without_base_fee()` per pending tx. The `exclude`
parameter is for the replacement case: the caller passes the
about-to-be-replaced tx's hash so its cost is dropped from the running
total at scan time.
- `Blockchain::validate_transaction` computes the new tx's cost ONCE,
performs the single-tx balance check, then `existing_cost =
sum_cost_for_sender(sender, tx_to_replace_hash)?` and rejects with
`MempoolError::InsufficientCumulativeBalance { required, available }`
when `existing_cost + tx_cost > sender_balance`.
- **Fails closed on inconsistency**: if `txs_by_sender_nonce` references
a hash missing from `transaction_pool`, or if any included tx's cost
can't be computed, `sum_cost_for_sender` returns an error rather than
silently undercounting. The gate cannot be bypassed by an invariant
violation.
- Uses `checked_add` rather than `saturating_add` for the running total
so overflow surfaces as `InvalidTxGasvalues` rather than silently
saturating.

## Behavioral change

Admission becomes stricter: a sender's total pending tx cost must fit
within their on-chain balance. Existing single-tx balance checks are
unchanged; the new gate fires for the (sender at cap, multiple expensive
txs queued) case that previously passed individual checks but failed at
execution.

## Deferred

Integration tests for `InsufficientCumulativeBalance` require seeded
sender-account fixtures not currently present in
`test/tests/blockchain/mempool_tests.rs`. Same pattern as lambdaclass#6603 / lambdaclass#6576
/ lambdaclass#6600 deferrals.

---------

Co-authored-by: ElFantasma <estebandh@gmail.com>
Co-authored-by: Esteban Dimitroff Hodi <esteban.dimitroff@lambdaclass.com>
akshay-ap pushed a commit to akshay-ap/ethrex that referenced this pull request Jul 16, 2026
…aclass#6609)

## Motivation

Even after the per-sender slot cap (lambdaclass#6603) lands, a flood of small
senders submitting gapped-nonce transactions can pin pool budget — those
nonces are unreachable until intermediate nonces land, so they sit
forever while productive contiguous-nonce txs from other senders are
evicted.

Reject gapped-nonce admissions when the mempool is heavily occupied; in
steady-state (low occupancy) the pool tolerates gapped nonces, but under
pressure it prefers contiguous, executable nonces.

## Description

- New `Mempool::occupancy_pct(&self) -> u8` returns the pool occupancy
as an integer percentage in `[0, 100]`. Returns 0 for unlimited pools
(`max_mempool_size == 0`) so pressure-gated rules treat unbounded
capacity as "never under pressure".
- `Blockchain::validate_transaction` rejects incoming txs whose nonce
isn't the on-chain next (i.e. `nonce != sender_acc_nonce`) once
`occupancy_pct()` is at or above the configured threshold. The
comparison is integer-only, so the boundary is deterministic; a
threshold of `100` disables the gate, and an unlimited pool (occupancy
`0`) is therefore never gated. Replacement txs (same `(sender, nonce)`
as a pool entry) bypass the gate since they're not gapped.
- Reads occupancy ONCE per admission and reuses the value for both the
gate decision and the `MempoolError::GapAdmissionDeniedUnderPressure {
occupancy_pct, nonce_gap }` error message — no TOCTOU between the
decision and the reported value.
- New CLI flag `--mempool.gap-admit-occupancy-threshold` (env
`ETHREX_MEMPOOL_GAP_ADMIT_OCCUPANCY_THRESHOLD`, default
`DEFAULT_GAP_ADMIT_OCCUPANCY_THRESHOLD = 90`). Setting it to 100
disables the gate.

> Note on terminology: the gate keys on "nonce not equal to the sender's
on-chain nonce," so under pressure it also rejects a nonce that is
contiguous with an *already-pending* tx (e.g. on-chain nonce 10 with a
pending nonce 10 → a nonce 11 is rejected), not only true nonce gaps.
This is intentional — it stops a sender from parking a chain of pending
txs under pressure — but the "gapped" wording is looser than the exact
rule (per @iovoid's review).

## Behavioral change

When the pool is at or above the configured occupancy threshold,
non-contiguous-with-on-chain-nonce admissions return
`GapAdmissionDeniedUnderPressure`. Operators can disable the gate by
setting the threshold to 100.

## Deferred

Origin-aware exemption (skipping the gate for `TxOrigin::Local`) waits
for lambdaclass#6608's TxOrigin enum to merge.

---------

Co-authored-by: Esteban Dimitroff Hodi <esteban.dimitroff@lambdaclass.com>
Co-authored-by: ElFantasma <estebandh@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

7 participants