Skip to content

feat(l1): punishSpammer — drop highest-nonce half on per-sender cap breach#6625

Open
ilitteri wants to merge 5 commits into
feat/mempool-account-slots-capfrom
feat/mempool-punish-spammer
Open

feat(l1): punishSpammer — drop highest-nonce half on per-sender cap breach#6625
ilitteri wants to merge 5 commits into
feat/mempool-account-slots-capfrom
feat/mempool-punish-spammer

Conversation

@ilitteri
Copy link
Copy Markdown
Collaborator

@ilitteri ilitteri commented May 12, 2026

Motivation

When a sender exceeds the per-sender pending-tx cap (#6603), the default behavior is to reject only the new transaction. A persistent spammer can keep pinning their existing 16 (or whatever the cap is) pool slots indefinitely, paying nothing while denying other senders the budget. The geth/erigon punishment pattern: on cap breach, ALSO drop the breaching sender's highest-nonce half of pool entries.

Description

  • Mempool::add_transaction accepts a punish_spammer: bool flag. When set AND the sender's pending count is >= max_pending_txs_per_account AND the count is > 1, the per-sender entries are sorted by nonce descending and the top ceil(N/2) are removed via remove_transaction_with_lock. The new incoming tx is still rejected with MaxPendingTxsPerAccountExceeded { count: post_prune_count, limit }.
  • Skips the prune when count <= 1 so a tighter cap (e.g. delegated_sender_cap = 1 from feat(l1): cap 7702-delegated EOAs at 1 in-flight mempool tx #6630) doesn't make every collision wipe the sender's lone tx.
  • After punishment, sweeps txs_order against the live transaction_pool so dropped hashes don't leave tombstones that skew the lazy mempool_prune_threshold accounting.
  • CLI: --mempool.punish-spammer (env ETHREX_MEMPOOL_PUNISH_SPAMMER, default true). Operators who want pure-reject (no eviction) semantics can disable it.

Behavioral change

Senders that hit the per-account cap are penalised: they lose their upper-half pool entries on every cap-breach attempt, in addition to having the new tx rejected. Default-on for all networks; operators can opt out via the CLI flag.

Notes

Stacked on top of feat/mempool-account-slots-cap (PR #6603) because the atomic cap check it extends only exists on that branch. The PR diff shows only the punishment-specific delta.

…reach

When a sender exceeds the per-account pending-tx cap, drop the
highest-nonce half (rounded up) of their existing pool entries in
addition to rejecting the new transaction. A sender hitting the cap
is likely spamming with future nonces; dropping their upper-half
entries reclaims pool budget for transactions more likely to execute
next. The breaching tx is still rejected — punishment doesn't help
the spammer, it only frees pool budget for legitimate senders.

The prune happens inside Mempool::add_transaction under the same
write lock that does the cap check, so the count, removal, and
rejection are all atomic. Blob bundles are removed for dropped
EIP-4844 entries via remove_transaction_with_lock.

Adds an opt-out: --mempool.punish-spammer (env
ETHREX_MEMPOOL_PUNISH_SPAMMER, default true). Plumbed through
BlockchainOptions::punish_spammer for both L1 and L2 initializers.
@ilitteri ilitteri requested review from a team, ManuelBilbao and avilagaston9 as code owners May 12, 2026 04:21
@github-actions github-actions Bot added the L1 Ethereum client label May 12, 2026
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

The implementation is solid and follows Rust best practices. The spammer punishment logic is correctly implemented with proper locking, and the test coverage is comprehensive (including critical 4844 blob bundle cleanup).

Minor suggestions:

crates/blockchain/mempool.rs:171-176
The new_top_nonce calculation performs a second reverse iteration. While correct, you could derive this from the victims collection or the first iteration to avoid the extra scan. Given the small cap (default 16), this is negligible, but worth noting for large caps.

crates/blockchain/mempool.rs:193
If remove_transaction_with_lock fails (e.g., invariant violation where hash exists in txs_by_sender_nonce but not in txs_by_hash), the function returns that internal error instead of MaxPendingTxsPerAccountExceeded. Consider whether an invariant violation should panic instead, since it indicates internal corruption:

for victim_hash in &victims {
    inner.remove_transaction_with_lock(victim_hash)
        .expect("mempool invariant violated: hash in txs_by_sender_nonce missing from txs_by_hash");
}

However, the current error propagation is safe and acceptable.

cmd/ethrex/cli.rs:196
The action = clap::ArgAction::Set combined with default_value_t = true correctly allows explicit disabling via --mempool.punish-spammer false.

Documentation:
The doc comments and CLI help text clearly explain the Erigon-style behavior. The documentation update in docs/CLI.md is consistent with the implementation.

Security:
No vulnerabilities identified. The write lock is held throughout the punishment logic, preventing race conditions. The div_ceil operation prevents integer overflow issues.

Testing:
The test punish_spammer_removes_blob_bundles_for_dropped_blob_txs at line 803 correctly verifies that EIP-4844 blob sidecars are properly cleaned up when their transactions are evicted, which is critical for preventing blob pool leaks.

Verdict: LGTM. The code is correct, well-tested, and handles the consensus-critical mempool resource limits appropriately.


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

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 12, 2026

Lines of code report

Total lines added: 189
Total lines removed: 0
Total lines changed: 189

Detailed view
+----------------------------------------+-------+------+
| File                                   | Lines | Diff |
+----------------------------------------+-------+------+
| ethrex/cmd/ethrex/cli.rs               | 1018  | +11  |
+----------------------------------------+-------+------+
| ethrex/cmd/ethrex/initializers.rs      | 655   | +1   |
+----------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/initializers.rs   | 379   | +1   |
+----------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs | 2505  | +4   |
+----------------------------------------+-------+------+
| ethrex/crates/blockchain/mempool.rs    | 703   | +172 |
+----------------------------------------+-------+------+

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 12, 2026

Greptile Summary

This PR implements an Erigon-style "punishSpammer" policy: when a sender's pending-tx count hits the per-account cap, the mempool now also evicts the highest-nonce ceil(N/2) entries from that sender's pool (in addition to rejecting the new transaction). The feature is opt-out via --mempool.punish-spammer=false and is wired through BlockchainOptions into both L1 and L2 initializers.

  • Core eviction logic (mempool.rs): performed atomically under the existing write lock; victims are collected from txs_by_sender_nonce in reverse-nonce order, then removed via remove_transaction_with_lock, which correctly handles EIP-4844 blob bundle cleanup.
  • New CLI flag (cli.rs, docs/CLI.md): --mempool.punish-spammer with ArgAction::Set, env-var override, and default true; plumbed identically into both L1 (initializers.rs) and L2 (l2/initializers.rs) paths.
  • Tests (mempool.rs): four new unit tests covering the happy path (half dropped), disabled flag (no-op), below-cap admission, and blob-bundle cleanup for dropped EIP-4844 transactions.

Confidence Score: 4/5

The punishment eviction is correctly locked, nonce-ordered, and handles blob bundles. Two minor rough edges in the mempool bookkeeping are worth addressing before this pattern is used at higher spam volumes.

The cap-breach detection, victim selection, and blob-bundle cleanup are all correct and well-tested. The two concerns are: txs_order accumulates stale hashes for every punished batch (amplifying a pre-existing gap in remove_transaction_with_lock), and the MaxPendingTxsPerAccountExceeded error reports the sender's pre-punishment count rather than the post-removal count. Neither causes incorrect pool state, but txs_order staleness could drive excessive work in remove_oldest_transaction under sustained spam.

crates/blockchain/mempool.rs — the new punishment block in add_transaction (lines ~174–204)

Important Files Changed

Filename Overview
crates/blockchain/mempool.rs Core change: adds punish_spammer path in add_transaction that drops ceil(N/2) highest-nonce entries on cap breach. Logic is correct and lock-safe; two minor issues: txs_order not pruned for removed victims (amplifying pre-existing staleness), and the error's count field reflects pre-removal state.
crates/blockchain/blockchain.rs Threads punish_spammer from BlockchainOptions through to Mempool::add_transaction at both call sites; adds field with correct default (true). Straightforward plumbing, no issues.
cmd/ethrex/cli.rs Adds --mempool.punish-spammer CLI flag with ArgAction::Set (correct for bool args), env var, and default. Default impl also updated consistently.
cmd/ethrex/initializers.rs Passes opts.mempool_punish_spammer into BlockchainOptions for the L1 initializer. Correct and minimal.
cmd/ethrex/l2/initializers.rs Same plumbing for L2 initializer. Consistent with L1 path.
docs/CLI.md Documents the new --mempool.punish-spammer flag with env var and default. Accurate and complete.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A["add_transaction(hash, sender, tx, cap, punish_spammer)"] --> B["Acquire write lock"]
    B --> C["count = txs_by_sender_nonce.range(sender).count()"]
    C --> D{count >= cap?}
    D -- No --> E["Normal insertion path\n(prune check → insert → notify)"]
    E --> F["Ok(())"]
    D -- Yes --> G{punish_spammer?}
    G -- No --> H["return Err(MaxPendingTxsPerAccountExceeded)"]
    G -- Yes --> I["dropped_count = ceil(count / 2)"]
    I --> J["victims = top dropped_count hashes (rev order)"]
    J --> K["new_top_nonce = nth(dropped_count) from rev iter"]
    K --> L["for each victim:\nremove_transaction_with_lock(victim)"]
    L --> M["removes from transaction_pool,\ntxs_by_sender_nonce,\nbroadcast_pool,\nblobs_bundle_pool"]
    M --> N["warn! log (sender, dropped_count, new_top_nonce)"]
    N --> H
Loading

Comments Outside Diff (1)

  1. crates/blockchain/mempool.rs, line 206-209 (link)

    P2 Error count reflects pre-punishment sender count

    count is captured before victims are removed, so MaxPendingTxsPerAccountExceeded { count, limit } always reports the breach-time size. After punishment fires, the sender's actual pool occupancy is count - dropped_count (half the cap), but any caller that inspects the error's count field to understand current pool state will see an inflated value. At minimum the doc-comment on the struct field should note this; ideally count is either not included in the punish path or updated to the post-removal value.

    Prompt To Fix With AI
    This is a comment left during a code review.
    Path: crates/blockchain/mempool.rs
    Line: 206-209
    
    Comment:
    Error `count` reflects pre-punishment sender count
    
    `count` is captured before victims are removed, so `MaxPendingTxsPerAccountExceeded { count, limit }` always reports the breach-time size. After punishment fires, the sender's actual pool occupancy is `count - dropped_count` (half the cap), but any caller that inspects the error's `count` field to understand current pool state will see an inflated value. At minimum the doc-comment on the struct field should note this; ideally `count` is either not included in the punish path or updated to the post-removal value.
    
    How can I resolve this? If you propose a fix, please make it concise.
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/mempool.rs:195-197
`txs_order` not pruned during punishment removals

`remove_transaction_with_lock` removes a hash from `transaction_pool`, `txs_by_sender_nonce`, `broadcast_pool`, and `blobs_bundle_pool`, but never from `txs_order`. When punishment drops `ceil(N/2)` entries, all those hashes stay in `txs_order` as stale tombstones. Under sustained spam, `txs_order.len()` can diverge significantly from `transaction_pool.len()`, making the prune-threshold check (`txs_order.len() > mempool_prune_threshold`) fire prematurely on every legitimate insertion and causing `remove_oldest_transaction` to iterate through many dead entries before finding a live one to evict. This is a pre-existing gap in `remove_transaction_with_lock`, but the new batch-removal path amplifies it — a single cap-breach event can add up to 8 stale entries at once instead of one.

### Issue 2 of 2
crates/blockchain/mempool.rs:206-209
Error `count` reflects pre-punishment sender count

`count` is captured before victims are removed, so `MaxPendingTxsPerAccountExceeded { count, limit }` always reports the breach-time size. After punishment fires, the sender's actual pool occupancy is `count - dropped_count` (half the cap), but any caller that inspects the error's `count` field to understand current pool state will see an inflated value. At minimum the doc-comment on the struct field should note this; ideally `count` is either not included in the punish path or updated to the post-removal value.

Reviews (1): Last reviewed commit: "feat(l1): punishSpammer — drop highest-n..." | Re-trigger Greptile

Comment thread crates/blockchain/mempool.rs Outdated
Comment on lines +195 to +197
for victim_hash in &victims {
inner.remove_transaction_with_lock(victim_hash)?;
}
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 txs_order not pruned during punishment removals

remove_transaction_with_lock removes a hash from transaction_pool, txs_by_sender_nonce, broadcast_pool, and blobs_bundle_pool, but never from txs_order. When punishment drops ceil(N/2) entries, all those hashes stay in txs_order as stale tombstones. Under sustained spam, txs_order.len() can diverge significantly from transaction_pool.len(), making the prune-threshold check (txs_order.len() > mempool_prune_threshold) fire prematurely on every legitimate insertion and causing remove_oldest_transaction to iterate through many dead entries before finding a live one to evict. This is a pre-existing gap in remove_transaction_with_lock, but the new batch-removal path amplifies it — a single cap-breach event can add up to 8 stale entries at once instead of one.

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

Comment:
`txs_order` not pruned during punishment removals

`remove_transaction_with_lock` removes a hash from `transaction_pool`, `txs_by_sender_nonce`, `broadcast_pool`, and `blobs_bundle_pool`, but never from `txs_order`. When punishment drops `ceil(N/2)` entries, all those hashes stay in `txs_order` as stale tombstones. Under sustained spam, `txs_order.len()` can diverge significantly from `transaction_pool.len()`, making the prune-threshold check (`txs_order.len() > mempool_prune_threshold`) fire prematurely on every legitimate insertion and causing `remove_oldest_transaction` to iterate through many dead entries before finding a live one to evict. This is a pre-existing gap in `remove_transaction_with_lock`, but the new batch-removal path amplifies it — a single cap-breach event can add up to 8 stale entries at once instead of one.

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.

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Now I have enough context to write the full review.


Review: feat(l1): punishSpammer — drop highest-nonce half on per-sender cap breach

Overall: The implementation is correct and well-tested. Lock handling, data-structure invariants, and test coverage are solid. A few points worth noting below.


Core logic (crates/blockchain/mempool.rs)

new_top_nonce computed pre-removal — correct but subtle

// mempool.rs ~190
let new_top_nonce = inner
    .txs_by_sender_nonce
    .range((sender, 0)..=(sender, u64::MAX))
    .rev()
    .nth(dropped_count)            // computed on the un-mutated map
    .map(|((_, nonce), _)| *nonce);
for victim_hash in &victims {
    inner.remove_transaction_with_lock(victim_hash)?;
}
warn!(..., new_top_nonce = ?new_top_nonce, ...);

The value is mathematically correct: after dropping the top dropped_count entries, the new highest nonce is rev().nth(dropped_count) on the original sequence. However, readers will need a moment to convince themselves this is right; a one-line comment clarifying the "pre-removal but still accurate" nature would prevent future confusion. Worth adding:

// nth(dropped_count) on the pre-removal reversed sequence equals the highest
// nonce that survives, because exactly dropped_count entries precede it.

?-propagation inside the removal loop

// mempool.rs ~196
for victim_hash in &victims {
    inner.remove_transaction_with_lock(victim_hash)?;
}

remove_transaction_with_lock currently always returns Ok(()) (it guards with let Some(...) else { return Ok(()) } and has no other fallible operations). So the ? is harmless today. But the signature is Result<(), StoreError>, and if that function gains a real error path in the future, a mid-loop failure would:

  • Leave the mempool in a partially-pruned state (some victims removed, some not).
  • Propagate an unexpected StoreError instead of MaxPendingTxsPerAccountExceeded, breaking any caller pattern-matching on the cap error.

Hardening this to best-effort removal is cheap and makes the invariant explicit:

for victim_hash in &victims {
    if let Err(e) = inner.remove_transaction_with_lock(victim_hash) {
        warn!(target: "mempool", ?e, "punishSpammer: failed to remove victim {victim_hash:?}");
    }
}

Stale count in the returned error

return Err(MempoolError::MaxPendingTxsPerAccountExceeded {
    count,          // original count before pruning
    limit: max_pending_txs_per_account,
});

After punishment, the sender's actual count is count - dropped_count, but the error reports the original count. This is fine semantically (the error describes why the new tx was rejected), though any diagnostic tool consuming the error field will see a misleading number. A comment noting that count reflects the pre-prune state would clarify intent.

Repeated punishment on each subsequent attempt

If a spammer submits tx N+1, gets punished (count halves to N/2), then immediately submits another, punishment fires again (count halves to N/4). This exponential-halving effect is likely intentional — it's consistent with Erigon's approach — but it isn't mentioned in the doc comment. Adding a note would prevent surprise for operators debugging aggressive sender behavior.


CLI / configuration (cmd/ethrex/cli.rs)

action = clap::ArgAction::Set

Correct choice for --mempool.punish-spammer=false negation. The env var ETHREX_MEMPOOL_PUNISH_SPAMMER=false also works as expected. No issues here.


Tests

The four new tests are thorough:

  • punish_spammer_drops_highest_nonce_half_at_cap: correctly asserts that nonces 0–7 survive when cap=16.
  • punish_spammer_disabled_leaves_existing_txs: clean negative test.
  • below_cap_admits_normally_without_punishment: important regression guard.
  • punish_spammer_removes_blob_bundles_for_dropped_blob_txs: verifies EIP-4844 blob bundle cleanup, which is easy to miss.

One minor observation: the blob test verifies blobs_bundle_pool cleanup via get_blobs_bundle but doesn't check blobs_bundle_by_versioned_hash (the secondary reverse-lookup map that remove_blob_bundle also updates). In practice remove_blob_bundle handles both, but a spot-check for the versioned-hash map would close that gap entirely.


Summary

Finding Severity
? propagation in removal loop — latent wrong-error-type risk if remove_transaction_with_lock gains a failure path Low (not a current bug)
count in error struct is pre-prune Nit
new_top_nonce computed pre-removal needs clarifying comment Nit
Repeated-punishment behavior undocumented Nit
Blob test doesn't verify versioned-hash secondary map Nit

The punish logic, locking model, and test coverage are all correct. The best-effort removal fix (swapping ? for if let Err(e)) is the one change worth making before merge.


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

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

Findings

  1. crates/blockchain/blockchain.rs around 2348-2362 and 2380-2392 still implements replacement as validate -> remove old tx -> add new tx, but crates/blockchain/mempool.rs now mutates the pool on the over-cap error path. That makes the existing race destructive: if another tx from the same sender lands between the remove and the re-add, the replacement can now return MaxPendingTxsPerAccountExceeded after evicting half of that sender’s remaining txs. This needs to be collapsed into one mempool-locked replace-and-insert operation.

  2. crates/blockchain/blockchain.rs around 2353-2362 inserts the blob bundle before tx admission. If crates/blockchain/mempool.rs rejects at the sender cap, the candidate bundle is left behind, and crates/blockchain/mempool.rs can still serve it by versioned hash even though no matching tx exists in transaction_pool. The PR added cleanup coverage for evicted old blob txs, but not for the rejected candidate.

  3. test/tests/blockchain/mempool_tests.rs still calls the old 3-argument Mempool::add_transaction API at lines 373, 376, and 443, while crates/blockchain/mempool.rs now requires max_pending_txs_per_account and punish_spammer. ethrex-test will fail to compile until those call sites are updated.

Aside from those points, the highest-nonce eviction logic itself is straightforward and the new unit coverage for victim cleanup is useful.

I couldn’t run cargo test here because the sandboxed toolchain tries to write under /home/runner/.rustup/tmp and fails.


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

ilitteri added 4 commits May 12, 2026 13:49
…prune count)

Phase 2 cross-check produced three confirmed refinements:

1. **Skip prune when count <= 1**. With cap=1 the previous code wiped
   the sender's only pending tx on every cap-breach attempt — especially
   harmful under PR #6630's delegated cap=1 where every collision would
   leave the sender with nothing. Now the prune is gated on `count > 1`.

2. **Single-pass range scan**. The three independent
   `txs_by_sender_nonce.range(...)` traversals (count + victims +
   new_top_nonce) collapse into one `.range().rev().collect()`. The
   victims are taken from the front of the reversed Vec, new_top_nonce
   from `entries.get(dropped_count)`.

3. **Post-prune count in error**. `MaxPendingTxsPerAccountExceeded.count`
   now reports the sender's count after the prune (was: pre-prune),
   matching the actual post-state visible to RPC clients.

Two new tests: punish_spammer_skips_prune_when_count_is_one,
punish_spammer_reports_post_prune_count.
Phase 2 review (greptile P2): `remove_transaction_with_lock` does not
touch `txs_order`. When `punishSpammer` drops `ceil(N/2)` entries
under the same write lock, all those hashes stay in `txs_order` as
stale tombstones. Under sustained spam, `txs_order.len()` diverges
significantly from `transaction_pool.len()`, and the
`mempool_prune_threshold` check (which compares `txs_order.len()`)
overcounts the pool's apparent fill — possibly triggering the costly
lazy-rebuild path more often than needed.

After the punishment removals, re-sweep `txs_order` against the live
`transaction_pool` once. Scoped to the punishment-fire branch so the
common single-add path stays untouched. Existing
`punish_spammer_drops_highest_nonce_half_at_cap` test now asserts
`txs_order.len() == transaction_pool.len()` after punishment.
…ool.punish-spammer

CI's CLI help consistency check expected this line after the [default: true]
entry. Regenerating with `cargo run --bin ethrex -- --help` now produces it
because clap emits possible values for bool args by default.
When this branch added max_pending_txs_per_account and punish_spammer 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 spammer-cap behavior, so pass usize::MAX / false to opt out.
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.

1 participant