Skip to content

feat(l1): add cumulative balance check across sender pending txs#6606

Open
ilitteri wants to merge 2 commits into
mainfrom
feat/mempool-cumulative-balance
Open

feat(l1): add cumulative balance check across sender pending txs#6606
ilitteri wants to merge 2 commits into
mainfrom
feat/mempool-cumulative-balance

Conversation

@ilitteri
Copy link
Copy Markdown
Collaborator

@ilitteri ilitteri commented May 12, 2026

Motivation

A sender at the per-sender slot cap (#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 #6603 / #6576 / #6600 deferrals.

Reject mempool admission when a sender's total cost (gas + value + blob
gas) summed across their pending pool entries plus the new tx exceeds
the sender's on-chain balance. Without this gate, a sender at the
per-sender slot cap can have only one of N pending txs be fundable
while the other N-1 are guaranteed-fail at execution and just waste
pool slots until eviction. All major peer EL clients enforce this; this
brings ethrex to parity.

Adds `Mempool::sum_cost_for_sender`, which ranges the existing
`txs_by_sender_nonce` index for the sender and saturating-sums each
tx's `cost_without_base_fee()`. A malformed tx returning `None` is
counted as `U256::MAX` so the cumulative check fails closed.

In `Blockchain::validate_transaction`, the new check runs after
`find_tx_to_replace`. For a replacement at an existing (sender, nonce)
the old tx's cost is subtracted from the running total before adding
the new tx's cost, so replacements don't double-count.

Introduces `MempoolError::InsufficientCumulativeBalance { required,
available }` with a descriptive message.

Unit tests in `crates/blockchain/mempool.rs` cover the helper: empty
pool returns zero, sums across multiple nonces of the same sender,
ignores other senders, and reflects removals (the underlying mechanism
for replacement subtraction). Integration tests through
`validate_transaction` that exercise the new branch require seeding
sender account state, which the existing test pattern in
`test/tests/blockchain/mempool_tests.rs` does not support; the helper
unit tests plus the small, reviewable wiring in `validate_transaction`
cover the new logic.
Copilot AI review requested due to automatic review settings May 12, 2026 02:19
@ilitteri ilitteri requested a review from a team as a code owner May 12, 2026 02:19
@github-actions github-actions Bot added the L1 Ethereum client label May 12, 2026
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

The PR implements a cumulative balance check for mempool transaction admission—a critical anti-DoS measure. The logic is sound and the implementation follows Rust best practices.

Correctness & Security

  • Line 2507-2528 (blockchain.rs): Correctly handles transaction replacement by subtracting the old transaction's cost before checking the cumulative total against the sender's balance. Using saturating_sub prevents underflow.
  • Line 2522-2524 (blockchain.rs): Uses saturating_add for U256 arithmetic, preventing overflow attacks that could bypass the balance check.
  • Line 384 (mempool.rs): Conservative fail-closed behavior treating malformed transactions (where cost_without_base_fee() returns None) as U256::MAX is the correct security choice.

Code Quality

  • Line 2509-2513 (blockchain.rs): Clean use of let-chains (if let Some(...) && let Some(...)).
  • Lines 112-113 (error.rs): The new error variant provides actionable debugging information with required and available balance fields.

Potential Issues

  • Race Condition (blockchain.rs:2507-2528): The mempool lock is acquired and released for each method call (find_tx_to_replace, sum_cost_for_sender, get_transaction_by_hash). Between the cumulative balance check and the actual transaction insertion (not shown in diff), another task could modify the mempool, rendering the check stale. This is acceptable as a best-effort spam filter (the block builder will enforce strict validity later), but consider documenting this behavior.

Testing

  • Lines 606-696 (mempool.rs): Comprehensive unit tests covering empty pools, multiple nonces, sender isolation, and removal updates. Good coverage of edge cases.

Nitpick

  • Line 380 (mempool.rs): The range query (sender, 0)..=(sender, u64::MAX) assumes the B-tree key is (Address, u64). While correct based on context, ensure this stays in sync with the actual txs_by_sender_nonce key type.

Verdict: LGTM. The implementation correctly prevents "guaranteed-fail" mempool spam while handling replacements atomically with respect to cost calculation.


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

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Now I have all the context needed to write the review.


PR #6606feat(l1): add cumulative balance check across sender pending txs

Overview

This PR gates mempool admission when a sender's total pending cost (summed across all existing pool entries plus the incoming transaction) exceeds their on-chain balance. This is a well-known anti-spam measure implemented by all major peers (geth, reth, nethermind, erigon) and the motivation is solid.


Correctness

Redundant cost_without_base_fee() call

tx_cost (computed at blockchain.rs:2494–2496) and new_cost (computed at blockchain.rs:2525–2527) are identical — both call tx.cost_without_base_fee() on the same transaction object. tx_cost is already validated to be Some (returns InvalidTxGasvalues otherwise), so new_cost is guaranteed to return the same value. The first computation's result can be reused directly:

// Before:
let tx_cost = tx.cost_without_base_fee().ok_or(MempoolError::InvalidTxGasvalues)?;
// ...later...
let new_cost = tx.cost_without_base_fee().ok_or(MempoolError::InvalidTxGasvalues)?;

// After:
let new_cost = tx.cost_without_base_fee().ok_or(MempoolError::InvalidTxGasvalues)?;
// reuse new_cost in the per-tx check at line 2498

saturating_sub undercount in the replacement path

At blockchain.rs:2522–2523, when subtracting the old tx's cost:

let old_cost = old_tx.cost_without_base_fee().unwrap_or(U256::MAX);
existing_cost = existing_cost.saturating_sub(old_cost);

Both sum_cost_for_sender (mempool.rs:390) and this path use the same unwrap_or(U256::MAX) fallback, so they should be consistent for any given transaction. The invariant holds as long as a tx with a None cost can only appear once per sender (or not at all, given the ok_or guard at admission). The saturating_sub prevents a panic but could silently undercount other senders' costs if the pool ever contains more than one such malformed tx. Since admission now rejects on None cost, this is theoretical but worth a brief code comment on the invariant.

Check ordering vs. chain_id validation

The cumulative balance check (blockchain.rs:2518–2534) runs before the chain_id check (blockchain.rs:2536–2541). A transaction failing both conditions will surface InsufficientCumulativeBalance rather than InvalidChainId. While this doesn't affect security, it degrades debuggability for clients. Moving the chain_id check above the cumulative check (or at minimum before the balance-related checks) would give callers the most specific rejection reason.


Concurrency / TOCTOU

sum_cost_for_sender, get_transaction_by_hash, and add_transaction each acquire the mempool lock independently (mempool.rs:381, 353, 145). There is no outer lock held across the read-check-then-write sequence in validate_transactionadd_transaction. Under concurrent admission for the same sender, two goroutines can each observe existing_cost before either's transaction is committed, pass the check, and both be added — pushing the cumulative total over balance.

This is a pre-existing architectural pattern in this codebase and is shared with reference clients (geth also has a "soft" TOCTOU window here), but it means the new check is a best-effort admission filter, not a hard guarantee. A comment to that effect on validate_transaction would prevent future readers from assuming it provides a strong invariant.


FeeToken Transactions

cost_without_base_fee() for TxType::FeeToken (transaction.rs:451) computes max_fee_per_gas * gas_limit + value. Since the gas fees for FeeToken transactions are paid in a non-ETH token, counting the gas-fee component against the sender's ETH balance will over-estimate the ETH cost. This causes legitimate FeeToken transactions to be spuriously rejected when gas_limit * max_fee_per_gas pushes the cumulative total over the ETH balance.

This issue exists in the pre-existing per-tx check at blockchain.rs:2498 as well (pre-dating this PR), so this PR does not introduce a regression — it extends a pre-existing incorrect check. Nevertheless, since the new cumulative check amplifies the false-rejection surface (by multiplying the error across N pending transactions), it's worth filing a follow-up to either skip the cumulative check for FeeToken txs or correct cost_without_base_fee() to exclude the token-denominated gas cost from the ETH calculation.


Tests

The four unit tests for sum_cost_for_sender are well-structured and cover the essential paths: empty pool, multi-nonce sum, sender isolation, and post-removal update. The absence of integration tests through validate_transaction is acknowledged in the PR description; given the existing test infrastructure limitation noted there, the deferral is reasonable.

One gap: there is no test asserting that the check is bypassed when a fee-bump replacement is valid (i.e., that saturating_sub of the old cost correctly prevents double-counting and allows a valid replacement). This is the most subtle code path.


Minor Nits

  • The doc comment on sum_cost_for_sender (mempool.rs:367–379) is thorough but longer than needed given the surrounding code's clarity. Standard for a new public method, no change needed.
  • U256::zero() (mempool.rs:382) can be written as U256::ZERO in newer versions of the primitive-types / ethereum-types crate, but this is a style matter consistent with the rest of the file.

Summary

The core logic is sound and consistent with peer implementations. The two items most worth addressing before merge are the redundant cost computation (trivial cleanup) and the FeeToken false-rejection (at minimum a tracking issue, since this PR amplifies an existing bug). The TOCTOU note is informational — it doesn't block the PR but should be documented.


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

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 12, 2026

Lines of code report

Total lines added: 116
Total lines removed: 0
Total lines changed: 116

Detailed view
+----------------------------------------+-------+------+
| File                                   | Lines | Diff |
+----------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs | 2503  | +13  |
+----------------------------------------+-------+------+
| ethrex/crates/blockchain/error.rs      | 149   | +2   |
+----------------------------------------+-------+------+
| ethrex/crates/blockchain/mempool.rs    | 554   | +101 |
+----------------------------------------+-------+------+

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

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 adds a cumulative balance check during mempool admission to prevent a sender from filling their per-sender pending slots with transactions that are individually valid but collectively unfundable, aligning ethrex behavior with other major execution clients.

Changes:

  • Added Mempool::sum_cost_for_sender to compute a sender’s total pending transaction cost (saturating sum of cost_without_base_fee()).
  • Extended Blockchain::validate_transaction to reject admission when (existing_pending_cost + new_tx_cost) > sender_balance, with special handling for same-(sender, nonce) replacements.
  • Introduced MempoolError::InsufficientCumulativeBalance { required, available } for a descriptive failure reason, plus unit tests for the helper.

Reviewed changes

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

File Description
crates/blockchain/mempool.rs Adds a per-sender cumulative cost helper and unit tests.
crates/blockchain/error.rs Adds a new mempool error variant for cumulative-balance rejection.
crates/blockchain/blockchain.rs Enforces cumulative pending-cost vs. on-chain balance during tx validation (including replacement handling).

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

Comment thread crates/blockchain/mempool.rs Outdated
Comment on lines +387 to +389
let Some(tx) = inner.transaction_pool.get(hash) else {
continue;
};
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 crates/blockchain/blockchain.rs Outdated
Comment on lines +2518 to +2524
let mut existing_cost = self.mempool.sum_cost_for_sender(sender)?;
if let Some(replace_hash) = tx_to_replace_hash
&& let Some(old_tx) = self.mempool.get_transaction_by_hash(replace_hash)?
{
let old_cost = old_tx.cost_without_base_fee().unwrap_or(U256::MAX);
existing_cost = existing_cost.saturating_sub(old_cost);
}
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 crates/blockchain/blockchain.rs Outdated
Comment on lines +2525 to +2534
let new_cost = tx
.cost_without_base_fee()
.ok_or(MempoolError::InvalidTxGasvalues)?;
let total = existing_cost.saturating_add(new_cost);
if total > sender_balance {
return Err(MempoolError::InsufficientCumulativeBalance {
required: total,
available: sender_balance,
});
}
@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. blockchain.rs:2518, blockchain.rs:2334, blockchain.rs:2361, mempool.rs:145 The cumulative-balance gate is not atomic. validate_transaction() snapshots the sender’s current pooled cost, but the actual remove/replace/insert happens later under separate lock acquisitions. Two concurrent submissions from the same sender can both pass against the same existing_cost and leave the pool over balance, so the new anti-spam invariant is bypassable. This wants a single write-locked mempool operation that does lookup, cost adjustment, replacement, and insert together.

  2. blockchain.rs:2512, mempool.rs:367, default_hook.rs:226, default_hook.rs:616 The new rejection criterion is stricter than actual execution semantics. It sums gas_limit * max_fee_per_gas + value (+ blob max fee) across all pending txs and compares that to current balance, but execution only needs each tx’s upfront cost one at a time and refunds unused gas afterwards. That means valid same-sender chains can now be rejected. If this is intended as conservative txpool policy, the “guaranteed-fail spam” comment is misleading; if not, this is a behavioral regression.

  3. mempool.rs:380, cli.rs:394 sum_cost_for_sender() is an O(k) scan on the hot admission path. With the default mempool_max_size = 10_000, one sender can make fill-up O(k^2) overall. Even aside from Item 1, I’d keep a per-sender running total in MempoolInner and update it on add/remove/replacement instead of rescanning the sender range every time.

I couldn’t run cargo test here because rustup failed to create temp files under /home/runner/.rustup/tmp in this sandbox.


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

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 12, 2026

Greptile Summary

This PR adds a cumulative balance check at mempool admission time, preventing a sender from filling their per-sender slot cap with transactions whose aggregate cost exceeds their on-chain balance — matching the behaviour of geth, reth, nethermind, and erigon.

  • Mempool::sum_cost_for_sender ranges txs_by_sender_nonce for the sender and saturating-sums each tx's cost_without_base_fee(), treating None-cost txs as U256::MAX to fail closed.
  • Blockchain::validate_transaction computes existing_cost + new_cost after find_tx_to_replace, subtracting the old tx's cost for same-nonce replacements to avoid double-counting, and rejects with the new InsufficientCumulativeBalance error when the total exceeds the sender's balance.
  • Four unit tests are added directly in mempool.rs covering empty pool, multi-nonce sums, cross-sender isolation, and post-removal consistency.

Confidence Score: 4/5

Safe to merge; the new check is additive and conservative, and the core replacement-subtraction arithmetic is correct for all normal inputs.

The implementation is clean and the saturation arithmetic correctly handles the common cases. The one non-trivial edge — where a None-cost tx is both already in the pool and the exact tx being replaced — causes the subtraction to collapse U256::MAX to 0, silently dropping all sibling costs from the running total. This is mitigated by the invariant that such txs should never be admitted, but it means the cumulative check's fail-closed guarantee has a gap in the replacement-of-malformed-tx path. The redundant cost_without_base_fee() call is cosmetic.

The replacement-subtraction block in crates/blockchain/blockchain.rs (lines 2519–2523) warrants a second look for the saturation edge case.

Important Files Changed

Filename Overview
crates/blockchain/blockchain.rs Adds cumulative balance check in validate_transaction after find_tx_to_replace; uses correct saturation arithmetic and replacement deduction, but calls cost_without_base_fee() twice on the same immutable tx.
crates/blockchain/mempool.rs Adds sum_cost_for_sender using BTreeMap range over txs_by_sender_nonce; correctly uses saturation arithmetic and treats None-cost txs as U256::MAX; unit tests cover the four key paths.
crates/blockchain/error.rs Adds InsufficientCumulativeBalance { required, available } variant to MempoolError with a clear error message; adds the necessary U256 import.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[validate_transaction] --> B{Account exists?}
    B -- No --> E1[Err: NotEnoughBalance]
    B -- Yes --> C{single tx_cost > balance?}
    C -- Yes --> E2[Err: NotEnoughBalance]
    C -- No --> D[find_tx_to_replace]
    D --> F[sum_cost_for_sender]
    F --> G{tx_to_replace found in pool?}
    G -- Yes --> H[existing_cost -= old_cost]
    G -- No --> I[existing_cost unchanged]
    H --> J[total = existing_cost + new_cost]
    I --> J
    J --> K{total > sender_balance?}
    K -- Yes --> E3[Err: InsufficientCumulativeBalance]
    K -- No --> L[Continue validation...]
Loading
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:2525-2527
`cost_without_base_fee()` is called twice on the same immutable `tx` — once inside the `if-let` block (stored as `tx_cost`) and again here as `new_cost`. Since `tx` is immutable and the function is deterministic, the two values are always equal. The computation is cheap, but the double call is confusing and invites divergence if the two sites are ever edited separately. `tx_cost` could be extracted from the `if-let` block's return value so it is available here (similar to how `sender_balance` is threaded out of the block).

```suggestion
        // `tx_cost` was already validated above; reuse it here to avoid
        // a redundant call to cost_without_base_fee().
        let new_cost = tx_cost;
```

### Issue 2 of 2
crates/blockchain/blockchain.rs:2519-2523
**Saturation arithmetic loses sibling-tx costs when replacing a `None`-cost tx**

When a tx whose `cost_without_base_fee()` returns `None` is somehow in the pool (e.g. from a pool inconsistency), `sum_cost_for_sender` saturates to `U256::MAX`. If that very tx is the one being replaced, `old_cost` is also `U256::MAX`, and `U256::MAX.saturating_sub(U256::MAX) = 0` — silently erasing every other sibling tx's contribution. The cumulative check then compares only `new_cost` against `sender_balance`, potentially admitting a tx whose true total (sibling costs + new_cost) exceeds balance.

Per the PR's own invariant these txs shouldn't reach the pool, but if one does, the replacement path becomes the one code path where the fail-closed guarantee breaks. A targeted comment here, or an assertion that `old_cost != U256::MAX`, would make the assumption explicit.

Reviews (1): Last reviewed commit: "feat(l1): add cumulative balance check a..." | Re-trigger Greptile

Comment thread crates/blockchain/blockchain.rs Outdated
Comment on lines +2525 to +2527
let new_cost = tx
.cost_without_base_fee()
.ok_or(MempoolError::InvalidTxGasvalues)?;
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 cost_without_base_fee() is called twice on the same immutable tx — once inside the if-let block (stored as tx_cost) and again here as new_cost. Since tx is immutable and the function is deterministic, the two values are always equal. The computation is cheap, but the double call is confusing and invites divergence if the two sites are ever edited separately. tx_cost could be extracted from the if-let block's return value so it is available here (similar to how sender_balance is threaded out of the block).

Suggested change
let new_cost = tx
.cost_without_base_fee()
.ok_or(MempoolError::InvalidTxGasvalues)?;
// `tx_cost` was already validated above; reuse it here to avoid
// a redundant call to cost_without_base_fee().
let new_cost = tx_cost;
Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/blockchain/blockchain.rs
Line: 2525-2527

Comment:
`cost_without_base_fee()` is called twice on the same immutable `tx` — once inside the `if-let` block (stored as `tx_cost`) and again here as `new_cost`. Since `tx` is immutable and the function is deterministic, the two values are always equal. The computation is cheap, but the double call is confusing and invites divergence if the two sites are ever edited separately. `tx_cost` could be extracted from the `if-let` block's return value so it is available here (similar to how `sender_balance` is threaded out of the block).

```suggestion
        // `tx_cost` was already validated above; reuse it here to avoid
        // a redundant call to cost_without_base_fee().
        let new_cost = tx_cost;
```

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.

Comment thread crates/blockchain/blockchain.rs Outdated
Comment on lines +2519 to +2523
if let Some(replace_hash) = tx_to_replace_hash
&& let Some(old_tx) = self.mempool.get_transaction_by_hash(replace_hash)?
{
let old_cost = old_tx.cost_without_base_fee().unwrap_or(U256::MAX);
existing_cost = existing_cost.saturating_sub(old_cost);
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 Saturation arithmetic loses sibling-tx costs when replacing a None-cost tx

When a tx whose cost_without_base_fee() returns None is somehow in the pool (e.g. from a pool inconsistency), sum_cost_for_sender saturates to U256::MAX. If that very tx is the one being replaced, old_cost is also U256::MAX, and U256::MAX.saturating_sub(U256::MAX) = 0 — silently erasing every other sibling tx's contribution. The cumulative check then compares only new_cost against sender_balance, potentially admitting a tx whose true total (sibling costs + new_cost) exceeds balance.

Per the PR's own invariant these txs shouldn't reach the pool, but if one does, the replacement path becomes the one code path where the fail-closed guarantee breaks. A targeted comment here, or an assertion that old_cost != U256::MAX, would make the assumption explicit.

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

Comment:
**Saturation arithmetic loses sibling-tx costs when replacing a `None`-cost tx**

When a tx whose `cost_without_base_fee()` returns `None` is somehow in the pool (e.g. from a pool inconsistency), `sum_cost_for_sender` saturates to `U256::MAX`. If that very tx is the one being replaced, `old_cost` is also `U256::MAX`, and `U256::MAX.saturating_sub(U256::MAX) = 0` — silently erasing every other sibling tx's contribution. The cumulative check then compares only `new_cost` against `sender_balance`, potentially admitting a tx whose true total (sibling costs + new_cost) exceeds balance.

Per the PR's own invariant these txs shouldn't reach the pool, but if one does, the replacement path becomes the one code path where the fail-closed guarantee breaks. A targeted comment here, or an assertion that `old_cost != U256::MAX`, would make the assumption explicit.

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.

…ed tx cleanly

Phase 2 review (Copilot + greptile) flagged four related issues with
the saturating arithmetic in the cumulative-balance gate:
1. `sum_cost_for_sender` silently skipped entries when `txs_by_sender_nonce`
   pointed to a hash missing from `transaction_pool`, undercounting.
2. The replacement adjustment `saturating_sub(old_cost)` on a saturated
   sum could drop the total to 0 and admit a tx that should be rejected.
3. When a tx with `cost_without_base_fee() = None` was both summed
   (as MAX) AND replaced (`old_cost` also MAX), `MAX - MAX = 0` erased
   every sibling tx's contribution.
4. `cost_without_base_fee()` was called twice on the same `tx` (once for
   the single-tx balance check, once as `new_cost`), inviting drift.

Single rewrite addresses all four:
- `sum_cost_for_sender` now takes an `exclude: Option<H256>` so the
  caller can drop the replaced tx's contribution at scan time instead
  of via a non-invertible `saturating_sub` afterward.
- Index/pool inconsistency and `None`-cost txs return `MempoolError`
  rather than silently saturating, so the gate can't be bypassed by
  an invariant violation.
- `checked_add` instead of `saturating_add` for the running total.
- `tx_cost` in `validate_transaction` is computed once and used for
  both the single-tx and cumulative checks.

Existing unit tests updated to the new `(sender, None)` signature.
// `None`-cost or missing tx can't silently zero the total via
// `MAX - MAX = 0`. It also fails closed on any inconsistency so the
// gate can't be bypassed by an invariant violation.
let existing_cost = self
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.

TOCTOU between this cumulative check (mempool read lock) and the caller's later add_transaction/add_transaction_no_broadcast (mempool write lock). Two concurrent submissions from the same sender can both pass the check independently with stale existing_cost, then both insert, ending up above the cumulative budget.

Concrete scenario: sender balance = 10. Existing pending costs = 5 (one tx). Two parallel RPC submissions of cost 3 each arrive on different worker tasks.

  • Task A: sum_cost_for_sender → 5, +3 = 8 ≤ 10 → pass → write-lock-insert.
  • Task B (concurrent): sum_cost_for_sender → 5 (still, A hasn't inserted yet), +3 = 8 ≤ 10 → pass → write-lock-insert.
  • Final state: 5 + 3 + 3 = 11 > 10. Cumulative budget exceeded.

The existing single-tx balance check at :2500 has the same shape against the on-chain balance, so this isn't a new class of bug — but the cumulative gate's whole purpose is rate-limiting one sender, which is exactly the threat model where parallel-from-same-sender matters. Two ways to close it:

  1. Per-sender admission mutex (cheap; only contended for adversarial senders).
  2. Move the cumulative check inside the write-lock-holding add_transaction_inner — re-do sum_cost_for_sender after acquiring the write lock. Slightly more lock time but no races.

Not blocking — the window is small and only adversarial parallel submission exploits it, but worth noting for follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

3 participants