feat(l1): cap mempool pending transactions per sender (default 16)#6603
feat(l1): cap mempool pending transactions per sender (default 16)#6603ilitteri wants to merge 10 commits into
Conversation
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`).
🤖 Kimi Code ReviewThe 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 Found1. Race Condition (TOCTOU) in Account Limit CheckFile: The check acquires a read lock, drops it, and later // 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 hereRecommendation: 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 DuplicationFiles:
The default value Recommendation: Import and use 3. Error Message AccuracyFile: #[error("Sender has {count} pending transactions, exceeds the per-account cap of {limit}")]When Recommendation: Change to: 4. Potential Performance NoteFile: The 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
SummaryThe 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 |
Lines of code reportTotal lines added: Detailed view |
🤖 Claude Code ReviewNow 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
Replacement bypass logic ( Bug:
|
There was a problem hiding this comment.
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_senderusing 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.
| TxMaxInitCodeSizeError, | ||
| #[error("Transaction max data size exceeded")] | ||
| TxMaxDataSizeError, | ||
| #[error("Sender has {count} pending transactions, exceeds the per-account cap of {limit}")] |
| // 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, | ||
| }); | ||
| } | ||
| } |
| #[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, |
| dev: Default::default(), | ||
| force: false, | ||
| mempool_max_size: Default::default(), | ||
| mempool_max_pending_txs_per_account: 16, | ||
| tx_broadcasting_time_interval: Default::default(), |
Greptile SummaryThis PR caps pending mempool transactions per sender at 16 (configurable) to prevent a single address from monopolizing the pool. The cap is enforced in
Confidence Score: 3/5The new per-sender cap is implemented correctly for the single-threaded case but can be exceeded under concurrent submissions from the same address because the count check and the insertion are not atomic. The core validation path reads the sender count under a read lock, releases that lock, then re-acquires a write lock in add_transaction. Two simultaneous submissions from the same address can both see count=15 < 16, both pass validation, and both be inserted — ending with 17 pending slots. Given that the explicit goal of this PR is to bound per-sender pool footprint, allowing the bound to be silently overshot undermines the stated invariant. The rest of the change looks correct. crates/blockchain/blockchain.rs — the validate_transaction function where the count check and add are split across two non-overlapping lock acquisitions.
|
| Filename | Overview |
|---|---|
| crates/blockchain/blockchain.rs | Adds per-account cap check in validate_transaction; the count check and the subsequent add_transaction call are not atomic, creating a TOCTOU window where the cap can be exceeded under concurrent submissions. |
| crates/blockchain/mempool.rs | Adds count_for_sender using a BTreeMap range over txs_by_sender_nonce; implementation and unit tests look correct. |
| crates/blockchain/error.rs | New MaxPendingTxsPerAccountExceeded variant added; error message is slightly misleading when count equals the limit exactly. |
| cmd/ethrex/cli.rs | New CLI flag --mempool.max-pending-txs-per-account with default 16 and env var wired correctly; default in Options::default() matches. |
| cmd/ethrex/initializers.rs | Threads max_pending_txs_per_account from CLI opts into BlockchainOptions for L1 initializer. |
| cmd/ethrex/l2/initializers.rs | Threads max_pending_txs_per_account from CLI opts into BlockchainOptions for L2 initializer. |
| docs/CLI.md | CLI docs updated to document new flag and its default value. |
Sequence Diagram
sequenceDiagram
participant Caller
participant Blockchain
participant Mempool
Caller->>Blockchain: add_transaction_to_pool(tx)
Blockchain->>Mempool: find_tx_to_replace(sender, nonce, tx)
Mempool-->>Blockchain: None (new nonce) or Some(hash) (replacement)
alt New nonce (no replacement)
Blockchain->>Mempool: count_for_sender(sender) [read lock → release]
Mempool-->>Blockchain: count
alt "count >= max_pending_txs_per_account"
Blockchain-->>Caller: Err(MaxPendingTxsPerAccountExceeded)
end
end
Note over Blockchain,Mempool: TOCTOU window — lock released
Blockchain->>Mempool: add_transaction(hash, sender, mtx) [write lock]
Mempool-->>Blockchain: Ok(())
Blockchain-->>Caller: Ok(hash)
Prompt To Fix All With AI
Fix the following 2 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 2
crates/blockchain/blockchain.rs:2521-2529
**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.
### Issue 2 of 2
crates/blockchain/error.rs:86-87
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 },
```
Reviews (1): Last reviewed commit: "rename(l1): mempool per-account cap from..." | Re-trigger Greptile
| 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, | ||
| }); | ||
| } | ||
| } |
There was a problem hiding this 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.
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.| #[error("Sender has {count} pending transactions, exceeds the per-account cap of {limit}")] | ||
| MaxPendingTxsPerAccountExceeded { count: usize, limit: usize }, |
There was a problem hiding this 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.
| #[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.
🤖 Codex Code ReviewFindings
Aside from those points, the wiring is straightforward. I couldn’t run Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
…instead of magic number
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.
| .txs_by_sender_nonce | ||
| .range((sender, 0)..=(sender, u64::MAX)) | ||
| .count(); |
There was a problem hiding this comment.
This is bounded, so I think it's OK for now.
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`).
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.
…ambdaclass#6599) **Motivation** Every major Ethereum execution client (geth `txMaxSize`, reth `DEFAULT_MAX_TX_INPUT_BYTES`, nethermind `MaxTxSize` / `MaxBlobTxSize`, erigon size enforcement) ships a per-transaction wire-size cap at admission. Without one a single oversized transaction can chew bandwidth and pool capacity at near-zero attacker cost. **Description** - New constants `MAX_TX_SIZE = 128 KiB` and `MAX_BLOB_TX_SIZE = 1 MiB` in `crates/common/types/constants.rs`. - `Blockchain::validate_transaction` enforces `MAX_TX_SIZE` on the canonical RLP encoding of non-blob transactions. - `Blockchain::add_blob_transaction_to_pool` enforces `MAX_BLOB_TX_SIZE` on the **wire wrapper** — `Transaction::encode_canonical_to_vec().len() + BlobsBundle::encode_to_vec().len()` — to match geth (`tx.Size()` includes the attached sidecar), nethermind (`MaxBlobTxSize` is checked on the wrapper form), and erigon (`ValidateSerializedTxn` works on the raw wire bytes which for blob txs is the wrapper-with-sidecar). The two encoded sizes are summed because ethrex stores the core tx and the bundle in separate structs; the ±few bytes of outer list framing are rounding error at the 1 MiB scale. - New error `MempoolError::TxSizeExceeded { actual, limit }`. - Drops the redundant `MAX_TRANSACTION_DATA_SIZE` calldata-only check from `validate_transaction` — the new encoded-size cap is strictly tighter (a 128 KiB calldata payload always encodes to > 128 KiB). - Unit test in `mempool_tests.rs` covering the non-blob path. Integration test for the wrapper check deferred (the `c-kzg`-gated `add_blob_transaction_to_pool` isn't currently exercised by `mempool_tests`, same pattern as PRs lambdaclass#6603/lambdaclass#6576).
…slots-cap # Conflicts: # crates/blockchain/error.rs
|
| EIP | Bucket | Count |
|---|---|---|
| EIP-7702 | set_code_txs |
24 |
| EIP-7702 | set_code_txs_2 |
15 |
| EIP-7702 | gas |
1 |
| EIP-8037 | state_gas_set_code |
17 |
| EIP-8037 | state_gas_pricing |
1 |
| EIP-8037 | state_gas_sstore |
1 |
| EIP-7928 | block_access_lists_eip7702 |
8 |
| EIP-7928 | block_access_lists |
1 |
| EIP-7778 | gas_accounting |
3 |
| EIP-7708 | transfer_logs |
1 |
| EIP-7976 | refunds |
1 |
| EIP-1344 | chainid (Amsterdam fork-transition fixture) |
1 |
| Total | 74 |
Re-enable once we either:
- (a) bump fixtures to a snobal-devnet-7 release that locks in the new
accounting; or - (b) revert the bal-devnet-7-prep subtraction for bal-devnet-6
compatibility.
Full test list (74)
EIP-7702 — for_amsterdam/prague/eip7702_set_code_tx/set_code_txs/
delegation_clearingdelegation_clearing_and_setdelegation_clearing_failing_txdelegation_clearing_tx_toeoa_tx_after_set_codeext_code_on_chain_delegating_set_codeext_code_on_self_delegating_set_codeext_code_on_self_set_codeext_code_on_set_codemany_delegationsnonce_overflow_after_first_authorizationnonce_validityreset_codeself_code_on_set_codeself_sponsored_set_codeset_code_multiple_valid_authorization_tuples_same_signer_increasing_nonceset_code_multiple_valid_authorization_tuples_same_signer_increasing_nonce_self_sponsoredset_code_to_logset_code_to_non_empty_storage_non_zero_nonceset_code_to_self_destructset_code_to_self_destructing_account_deployed_in_same_txset_code_to_sstoreset_code_to_sstore_then_sloadset_code_to_system_contract
EIP-7702 — for_amsterdam/prague/eip7702_set_code_tx/set_code_txs_2/
call_pointer_to_created_from_create_after_oog_call_againcall_to_precompile_in_pointer_contextcontract_storage_to_pointer_with_storagedelegation_replacement_call_previous_contractdouble_authpointer_measurementspointer_normalpointer_reentrypointer_resets_an_empty_code_account_with_storagepointer_revertspointer_to_pointerpointer_to_precompilepointer_to_staticpointer_to_static_reentrystatic_to_pointer
EIP-7702 — for_amsterdam/prague/eip7702_set_code_tx/gas/
account_warming
EIP-8037 — for_amsterdam/amsterdam/eip8037_state_creation_gas_cost_increase/state_gas_set_code/
auth_refund_block_gas_accountingauth_refund_bypasses_one_fifth_capauth_with_calldata_and_access_listauth_with_multiple_sstoresauthorization_exact_state_gas_boundaryauthorization_to_precompile_addressauthorization_with_sstoreduplicate_signer_authorizationsexisting_account_auth_header_gas_used_uses_worst_caseexisting_account_refundexisting_account_refund_enables_sstoreexisting_auth_with_reverted_execution_preserves_intrinsicmany_authorizations_state_gasmixed_auths_header_gas_used_uses_worst_casemixed_new_and_existing_authsmixed_valid_and_invalid_authsmulti_tx_block_auth_refund_and_sstore
EIP-8037 — state_gas_pricing/
auth_state_gas_scales_with_cpsb
EIP-8037 — state_gas_sstore/
sstore_state_gas_all_tx_types
EIP-7928 — for_amsterdam/amsterdam/eip7928_block_level_access_lists/block_access_lists_eip7702/
bal_7702_delegation_clearbal_7702_delegation_createbal_7702_delegation_updatebal_7702_double_auth_resetbal_7702_double_auth_swapbal_7702_null_address_delegation_no_code_changebal_selfdestruct_to_7702_delegationbal_withdrawal_to_7702_delegation
EIP-7928 — block_access_lists/
bal_all_transaction_types
EIP-7778 — for_amsterdam/amsterdam/eip7778_block_gas_accounting_without_refunds/gas_accounting/
multiple_refund_types_in_one_txsimple_gas_accountingvarying_calldata_costs
EIP-7708 — for_amsterdam/amsterdam/eip7708_eth_transfer_logs/transfer_logs/
transfer_with_all_tx_types
EIP-7976 — for_amsterdam/amsterdam/eip7976_increase_calldata_floor_cost/refunds/
gas_refunds_from_data_floor
EIP-1344 — for_amsterdam/istanbul/eip1344_chainid/chainid/
chainid(Amsterdam fork-transition fixture)
When this branch added max_pending_txs_per_account to Mempool::add_transaction, three test sites (one filter test, two blob-bundle tests) carried over from main were left on the old 3-arg form. They are unrelated to the per-sender cap, so pass usize::MAX to opt out.
Motivation
A single sender can currently occupy 100 % of the 10 000-tx pool. This trivially bypasses the size cap (#6599), EIP-3607 check (#6600) and percentage-RBF rule (#6601) by submitting many distinct nonces from one address. A per-sender pending-slot cap bounds that blast radius.
Description
BlockchainOptionsgainsmax_pending_txs_per_account: usize(default 16) plus aDEFAULT_ACCOUNT_SLOTSconstant.Mempool::count_for_sender(sender)counts a sender's pending txs by ranging the existingtxs_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_transactionconsults the count afterfind_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 withMempoolError::MaxPendingTxsPerAccountExceeded { count, limit }.--mempool.max-pending-txs-per-account(envETHREX_MEMPOOL_MAX_PENDING_TXS_PER_ACCOUNT), plumbed through both L1 and L2 initializers.Behavioral change
Admission becomes stricter: senders already at 16 pending transactions submitting a 17th at a new nonce are rejected at ingress. Replacements remain allowed and route through the existing RBF logic.
Tests
Five unit tests in
mempool.rscoveringcount_for_sender: empty pool, single tx, multiple nonces from same sender, isolation between senders, unknown sender returns zero. The six-line wire-up insidevalidate_transactionis visually verifiable; a full end-to-end integration test would require seeding sender-account storage state, which isn't currently done intest/tests/blockchain/mempool_tests.rs.