Skip to content

feat(l1): cap 7702-delegated EOAs at 1 in-flight mempool tx#6630

Open
ilitteri wants to merge 8 commits into
mainfrom
feat/mempool-delegated-sender-cap
Open

feat(l1): cap 7702-delegated EOAs at 1 in-flight mempool tx#6630
ilitteri wants to merge 8 commits into
mainfrom
feat/mempool-delegated-sender-cap

Conversation

@ilitteri
Copy link
Copy Markdown
Collaborator

@ilitteri ilitteri commented May 12, 2026

Motivation

EIP-7702 delegated EOAs are accounts whose code is the designation 0xef0100 || address. A delegate contract can act on behalf of multiple identities, amplifying spam potential. Peer execution clients (geth, reth) hold delegated EOAs to a tighter per-sender pending-tx cap than regular accounts.

Description

  • Shared predicate is_eip7702_delegation and constants in ethrex-common so both this PR and feat(l1): reject contract senders at mempool admission (EIP-3607) #6600 (EIP-3607 contract-sender rejection) work against the same definition.
  • Blockchain::effective_per_sender_cap(sender) async helper returns options.delegated_sender_cap for delegated EOAs, u64::MAX otherwise. Uses a length pre-check via Store::get_code_metadata so the full bytecode is only fetched when the length matches the 23-byte designation.
  • Cap enforced atomically inside Mempool::add_transaction under the same write lock as the insertion (no TOCTOU window). Mempool::add_transaction now takes max_pending_txs_per_account: u64 — pass u64::MAX to disable.
  • Blockchain::validate_transaction keeps a read-lock check as defense-in-depth (and so the existing integration tests continue to exercise the rejection path). RBF replacements at the same (sender, nonce) bypass the cap regardless.
  • CLI: --mempool.delegated-sender-cap (env ETHREX_MEMPOOL_DELEGATED_SENDER_CAP, default 1, type u64). Setting this to 0 admits zero pending transactions from delegated senders (i.e. blocks them entirely, NOT "unlimited"); use a large value such as u64::MAX for environments that want no cap.

Behavioral change

Delegated EOAs are limited to delegated_sender_cap pending txs by default. Non-delegated senders are unaffected.

Notes

The constants in ethrex-common (EIP7702_DELEGATION_PREFIX, EIP7702_DELEGATION_CODE_LEN) will need to align with the same names used in #6600 at merge time; whichever PR lands second should rename to match. Currently this PR uses EIP7702_DELEGATION_CODE_LEN.

ilitteri added 4 commits May 12, 2026 12:59
Shared predicate + constants (EIP7702_DELEGATION_PREFIX = 0xef0100,
EIP7702_DELEGATION_CODE_LEN = 23) for detecting EIP-7702 delegation
designations from sender bytecode. Used by both the EIP-3607 admission
check (PR #6600) and the new delegated-sender cap.
Delegated EOAs (those whose code is the EIP-7702 designation
`0xef0100 || address`) can be used by their delegate to amplify spam,
so peer execution clients (geth, reth) tighten the per-sender cap to 1
for these accounts. Match that.

`Blockchain::validate_transaction` reads the sender's code metadata
(length-pre-check via `Store::get_code_metadata`, fetching full
bytecode only when length == 23) and applies
`options.delegated_sender_cap` (default 1) instead of
`max_pending_txs_per_account` when the sender is delegated. RBF
replacements at the same (sender, nonce) still bypass the cap.

`Mempool::pending_tx_count_for_sender` is the cap-side accessor.
New error `MempoolError::MaxDelegatedPendingTxsExceeded(u64)`.

A TODO marks the future move into `Mempool::add_transaction` once the
atomic per-sender cap check from PR #6603 lands on main.
Operator knob (env ETHREX_MEMPOOL_DELEGATED_SENDER_CAP, default 1)
for the new EIP-7702 delegated-sender cap. Plumbed through both L1
and L2 initializers and documented in docs/CLI.md.
Four integration tests on Blockchain::validate_transaction:
- delegated_sender_second_tx_rejected_by_default_cap
- delegated_sender_replacement_bypasses_cap
- non_delegated_sender_second_tx_admitted
- delegated_sender_cap_override_admits_more_txs

Uses a genesis allocation that pre-installs `0xef0100 || address`
bytecode on the sender so admission can exercise the delegation
branch without simulated state.
Copilot AI review requested due to automatic review settings May 12, 2026 16:00
@ilitteri ilitteri requested review from a team, ManuelBilbao and avilagaston9 as code owners May 12, 2026 16:00
@github-actions github-actions Bot added the L1 Ethereum client label May 12, 2026
@github-actions
Copy link
Copy Markdown

🤖 Kimi Code Review

This PR correctly implements EIP-7702 delegated sender caps in the mempool. The logic is sound and the implementation is well-tested.

Code Correctness & Security

  • EIP-7702 format validation (crates/common/types/account.rs:117-126): Correctly checks for the delegation prefix 0xef0100 and 23-byte length. This matches the EIP-7702 specification exactly.
  • Replacement bypass (crates/blockchain/blockchain.rs:2524): Correctly allows RBF replacements to bypass the cap (tx_to_replace_hash.is_none() check), preventing transaction lockout when fees need adjustment.
  • Store consistency check (crates/blockchain/blockchain.rs:2548-2552): Properly handles the edge case where code metadata exists but bytecode is missing, returning a store error rather than silently treating the account as non-delegated (fail-safe behavior).

Performance

  • Length pre-check optimization (crates/blockchain/blockchain.rs:2539-2542): Uses get_code_metadata to check code length before fetching full bytecode, avoiding disk I/O for non-delegated accounts. This is critical for mempool performance.

Issues & Suggestions

  1. TOCTOU Race Condition (crates/blockchain/blockchain.rs:2524-2530): The comment acknowledges the time-of-check-to-time-of-use window between counting pending transactions and insertion. While noted in the TODO, consider prioritizing the atomic version mentioned to prevent edge-case overflows under high concurrency.

  2. Default implementation inconsistency (cmd/ethrex/cli.rs:462-463): The new field uses DEFAULT_DELEGATED_SENDER_CAP while mempool_max_size uses Default::default() (which resolves to 0). While this matches the existing pattern, consider aligning mempool_max_size to use its constant default for clarity.

  3. Test store naming (test/tests/blockchain/mempool_tests.rs:552): The in-memory store uses the static name "test_delegated". While likely safe for in-memory engines, consider using unique names or tempfile patterns to prevent potential collisions if the storage backend changes.

Nits

  • Error message formatting (crates/blockchain/blockchain.rs:2550): The {:?} formatting for code_hash produces hex with prefixes (e.g., 0x1234...), which is fine, but consider consistent lowercase hex formatting.

The implementation is ready for merge pending resolution of the TOCTOU item (tracked in the TODO).


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: 184
Total lines removed: 0
Total lines changed: 184

Detailed view
+----------------------------------------+-------+------+
| File                                   | Lines | Diff |
+----------------------------------------+-------+------+
| ethrex/cmd/ethrex/cli.rs               | 1122  | +14  |
+----------------------------------------+-------+------+
| ethrex/cmd/ethrex/initializers.rs      | 656   | +1   |
+----------------------------------------+-------+------+
| ethrex/cmd/ethrex/l2/initializers.rs   | 382   | +1   |
+----------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs | 2557  | +49  |
+----------------------------------------+-------+------+
| ethrex/crates/blockchain/constants.rs  | 18    | +1   |
+----------------------------------------+-------+------+
| ethrex/crates/blockchain/error.rs      | 153   | +4   |
+----------------------------------------+-------+------+
| ethrex/crates/blockchain/mempool.rs    | 548   | +84  |
+----------------------------------------+-------+------+
| ethrex/crates/common/types/account.rs  | 357   | +30  |
+----------------------------------------+-------+------+

@github-actions
Copy link
Copy Markdown

🤖 Claude Code Review

Now I have enough context for the review.


PR #6630feat(l1): cap 7702-delegated EOAs at 1 in-flight mempool tx

Overview

This PR closes a residual spam vector: EIP-7702 delegated EOAs can amplify spam because their delegate contract executes as the EOA on every queued transaction. The fix caps these accounts at 1 pending mempool transaction by default, matching the behavior of geth and reth. It adds a --mempool.delegated-sender-cap CLI flag so operators can tune the limit.

The implementation is well-structured: the is_eip7702_delegation predicate and its constants are placed in ethrex-common for shared reuse (EIP-3607 in PR #6600 also needs them), the length pre-check avoids unnecessary disk reads, and the RBF bypass is correctly handled. Tests are comprehensive. The acknowledged TOCTOU TODO is honest and clearly flags the known gap.


Correctness

TOCTOU window — acknowledged but broader than noted

The TODO in blockchain.rs:2533 correctly identifies the race between pending_tx_count_for_sender and add_transaction. However, the window is also opened by find_tx_to_replace being called with a separate read lock:

// blockchain.rs ~2518
let tx_to_replace_hash = self.mempool.find_tx_to_replace(sender, nonce, tx)?;
// ... chain_id check ...
// Another thread can insert for this sender HERE
if tx_to_replace_hash.is_none() && self.is_sender_delegated(sender_code_hash)? {
    let pending = self.mempool.pending_tx_count_for_sender(sender)?;
    // pending could be 1 even though tx_to_replace_hash was None

Two concurrent RPC calls for a delegated sender at count=0 could both pass the cap check simultaneously. For a cap of 1 this means 2 txs land instead of 1. The TODO's fix (moving the check under a write lock in add_transaction) is the right resolution — this is a pre-existing structural issue and not newly introduced by this PR.

Empty-code EOA short-circuit

get_code_metadata(EMPTY_KECCAK) returns Some(CodeMetadata { length: 0 }), and 0 != EIP7702_DELEGATION_CODE_LEN (23) causes is_sender_delegated to return Ok(false) immediately. Non-code EOAs are correctly excluded. No issue here.

delegated_sender_cap = 0 edge case

If an operator sets --mempool.delegated-sender-cap 0, the condition pending >= cap evaluates to 0 >= 0 = true for the very first transaction submission, silently blocking all delegated EOA transactions. The resulting error message — "already has the maximum number of pending transactions in the mempool (cap: 0)" — would be confusing. Either clamp the value to >= 1 in Default/from-CLI or add a note in the CLI help text clarifying that 0 means "reject all".

// cli.rs — could add a validator
.value_parser(clap::value_parser!(u64).range(1..))

Double length check

is_eip7702_delegation checks code.len() == EIP7702_DELEGATION_CODE_LEN, and is_sender_delegated already pre-checks metadata.length != EIP7702_DELEGATION_CODE_LEN as u64 before calling it. The redundancy is harmless and correct for callers outside is_sender_delegated, but worth being aware of.


Security

is_sender_delegated error on metadata/bytecode inconsistency — good defensive behavior

// blockchain.rs ~2567
return Err(MempoolError::StoreError(StoreError::Custom(format!(
    "code metadata present for {code_hash:?} but bytecode missing"
))));

Surfacing store inconsistency as an error rather than silently treating the sender as non-delegated is the correct choice — it avoids a store integrity issue enabling a bypass of the cap.

Bypassing cap via RBF is correct

tx_to_replace_hash.is_none() guards the cap check. A replacement swaps a slot without consuming a new one, so it should not count against the cap. The find_tx_to_replaceUnderpricedReplacement check ensures the replacement actually has higher fees, so this bypass cannot be trivially abused.


Performance

The length pre-check in is_sender_delegated is a meaningful optimization: get_code_metadata typically reads from an in-memory cache or a small metadata table, and the full bytecode (another DB read + RLP decode) is only fetched when the length is exactly 23. For the common case (non-delegated accounts), this avoids the code read entirely.

pending_tx_count_for_sender uses a BTreeMap::range scan (O(log n + k) where k is the sender's tx count). For the default cap of 1 this is at most 2 entries before returning. This is fine.


Tests

Coverage is good:

  • delegated_sender_second_tx_rejected_by_default_cap — cap=1 blocks the 2nd tx
  • delegated_sender_replacement_bypasses_cap — RBF at same nonce is admitted
  • non_delegated_sender_second_tx_admitted — cap does not bleed into non-delegated accounts
  • delegated_sender_cap_override_admits_more_txs — operator override works
  • Unit tests for is_eip7702_delegation (recognition, wrong length, wrong prefix)
  • Unit tests for pending_tx_count_for_sender (isolation, decrement on remove)

Missing test: delegated_sender_cap = 0 — given the edge case above, a test showing that cap=0 rejects the very first transaction would be useful and would surface the confusing error message.


Minor / Style

  • mempool.rs:429-435count as u64 from usize is always safe since usize ≤ u64 on all supported targets. No issue, just noting.
  • The L2 initializer (l2/initializers.rs:227) plumbs delegated_sender_cap even though EIP-7702 isn't active on L2. This is harmless since no L2 account will carry delegation code, but a short comment like // EIP-7702 not active on L2; this cap is a no-op would help future readers.
  • Error variant naming (MaxDelegatedPendingTxsExceeded) is consistent with the style of the surrounding variants.

Summary

The implementation is correct, well-motivated, and matches peer client behavior. The main actionable items are:

  1. Guard against or document delegated_sender_cap = 0 (confusing error + unintended full block).
  2. Consider a test for cap=0.
  3. (Optional / already tracked) Move the cap enforcement under the mempool write lock once PR feat(l1): cap mempool pending transactions per sender (default 16) #6603 lands, to close the TOCTOU window fully.

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

@github-actions
Copy link
Copy Markdown

🤖 Codex Code Review

  1. High: validate_transaction checks the delegated cap from a read-side count in pending_tx_count_for_sender, but insertion still happens later in add_transaction_to_pool / add_blob_transaction_to_pool, and Mempool::add_transaction never re-checks under the write lock. On the current Arc<Blockchain> call paths, two concurrent submissions from the same delegated sender can both observe pending < cap and both land, so the new default cap of 1 is bypassable. The TODO is accurate, but as merged this is only best-effort protection.

  2. Medium: The new delegated-sender classification is applied even before Prague. The gate in validate_transaction does not consult ChainConfig::is_prague_activated, even though other EIP-7702 behavior is Prague-gated in default_hook.rs. The added tests also only activate Cancun in mempool_tests.rs, so they currently codify rejecting pre-Prague transactions on custom chains/genesis states that happen to contain 0xef0100 || address.

Aside from those two points, the patch is straightforward and the code-length precheck for delegation detection is a sensible optimization.

Assumption: the intent is a hard admission cap for Prague+ delegated EOAs, not a best-effort heuristic.

I couldn’t run the targeted Rust tests here because cargo needed to update dependencies (libssz / crates.io) and write under /home/runner/.cargo, which is read-only in this environment with network disabled.


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

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

Adds a stricter per-sender pending-transaction cap for EIP-7702 delegated EOAs to reduce delegated-account spam potential, wiring the new limit through blockchain options, CLI configuration, and test coverage.

Changes:

  • Introduces an EIP-7702 delegation predicate/constants in ethrex-common and uses code-length prechecks to avoid unnecessary bytecode reads.
  • Enforces a dedicated delegated_sender_cap (default 1) during mempool admission, while preserving RBF replacement behavior.
  • Plumbs a new CLI flag/env var through L1/L2 initializers and documents it; adds tests for delegated vs non-delegated behavior.

Reviewed changes

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

Show a summary per file
File Description
test/tests/blockchain/mempool_tests.rs Adds integration-style tests validating delegated-sender cap behavior and RBF bypass.
docs/CLI.md Documents new --mempool.delegated-sender-cap option in CLI help output.
crates/common/types/account.rs Adds EIP-7702 delegation constants + predicate and unit tests.
crates/blockchain/mempool.rs Adds pending_tx_count_for_sender helper and tests for counting behavior.
crates/blockchain/error.rs Adds MempoolError::MaxDelegatedPendingTxsExceeded.
crates/blockchain/constants.rs Adds DEFAULT_DELEGATED_SENDER_CAP default value.
crates/blockchain/blockchain.rs Adds BlockchainOptions::delegated_sender_cap and delegated-sender admission check using code metadata.
cmd/ethrex/l2/initializers.rs Wires delegated cap option into L2 blockchain initialization.
cmd/ethrex/initializers.rs Wires delegated cap option into L1 blockchain initialization.
cmd/ethrex/cli.rs Adds CLI flag/env var/defaults for delegated sender cap and passes into BlockchainOptions.

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

Comment on lines +2533 to +2544
// TODO: once an atomic version of the per-sender pending-tx cap lands
// (mirroring the count + insert pair under the mempool write lock),
// move the effective-cap computation to be passed into
// `Mempool::add_transaction` and enforce the check there to close the
// TOCTOU window between this read and insertion.
if tx_to_replace_hash.is_none() && self.is_sender_delegated(sender_code_hash)? {
let cap = self.options.delegated_sender_cap;
let pending = self.mempool.pending_tx_count_for_sender(sender)?;
if pending >= cap {
return Err(MempoolError::MaxDelegatedPendingTxsExceeded(cap));
}
}
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/mempool.rs Outdated

/// Returns the number of pending transactions in the pool for `sender`.
///
/// Used by per-sender admission caps (see [`Blockchain::validate_transaction`]).
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.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 12, 2026

Greptile Summary

This PR introduces a tighter mempool admission cap for EIP-7702 delegated EOAs — accounts whose bytecode is the 23-byte designation 0xef0100 || address. The limit (default 1, configurable via --mempool.delegated-sender-cap) is checked inside validate_transaction; RBF replacements are exempt because they swap an existing slot. Shared predicates (is_eip7702_delegation, EIP7702_DELEGATION_PREFIX) are added to ethrex-common and re-exported for future use by the EIP-3607 check.

  • New BlockchainOptions::delegated_sender_cap field, wired through both the L1 and L2 CLI initializers and documented in docs/CLI.md.
  • is_sender_delegated uses a length pre-check via get_code_metadata to avoid loading bytecode for the common case of plain EOAs.
  • A TOCTOU window between validate_transaction (count read) and add_transaction (actual insert) is explicitly acknowledged with a TODO pointing to an upcoming atomic fix in PR feat(l1): cap mempool pending transactions per sender (default 16) #6603.

Confidence Score: 4/5

The change is well-scoped and safe to merge; the only non-obvious behaviour is that setting the cap to 0 permanently blocks all delegated senders rather than acting as an "unlimited" sentinel.

The core admission check, the metadata length pre-filter, the RBF bypass, and the CLI wiring are all correct. The acknowledged TOCTOU window between the count read and the actual mempool insert is a known limitation with a clear follow-up plan, not a latent correctness issue in typical usage. The only concern worth noting is that delegated_sender_cap = 0 silently refuses all delegated-sender transactions without any documentation warning, which could confuse operators who expect 0 to mean "unlimited".

crates/blockchain/blockchain.rs — specifically the cap guard at line 2541, where the 0 edge case is undocumented.

Important Files Changed

Filename Overview
crates/blockchain/blockchain.rs Core logic: extracts sender_code_hash, then after find_tx_to_replace calls is_sender_delegated and enforces the cap; TOCTOU between count read and add_transaction is acknowledged via TODO.
crates/blockchain/mempool.rs Adds pending_tx_count_for_sender via a BTreeMap range over txs_by_sender_nonce; new unit tests for isolation and decrement behavior are correct.
crates/common/types/account.rs Adds EIP7702_DELEGATION_PREFIX, EIP7702_DELEGATION_CODE_LEN constants and is_eip7702_delegation predicate; well-tested and correctly scoped.
crates/blockchain/constants.rs Adds DEFAULT_DELEGATED_SENDER_CAP = 1 with clear documentation; straightforward.
crates/blockchain/error.rs New MaxDelegatedPendingTxsExceeded(u64) variant with descriptive error message; straightforward addition.
cmd/ethrex/cli.rs Adds mempool_delegated_sender_cap CLI flag with env var, default, and help text; correctly initialized in all three option preset functions.
cmd/ethrex/initializers.rs Plumbs delegated_sender_cap into BlockchainOptions for L1; correct and minimal.
cmd/ethrex/l2/initializers.rs Plumbs delegated_sender_cap into BlockchainOptions for L2; correct and minimal.
test/tests/blockchain/mempool_tests.rs Four integration tests cover: cap rejection, RBF bypass, non-delegated sender exemption, and cap override; good coverage of the expected behaviors.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[validate_transaction called] --> B[get_account_info - nonce / balance checks]
    B --> C[extract sender_code_hash]
    C --> D[find_tx_to_replace - nonce conflict / RBF check]
    D --> E{tx_to_replace_hash is None?}
    E -- No / RBF replacement --> F[Skip delegation cap check]
    E -- Yes / new slot --> G[is_sender_delegated?]
    G --> H[get_code_metadata - length pre-check]
    H -- length != 23 --> I[Not delegated - skip cap]
    H -- length == 23 --> J[get_account_code - fetch bytecode]
    J --> K{starts with 0xef0100?}
    K -- No --> I
    K -- Yes --> L[pending_tx_count_for_sender]
    L --> M{count >= delegated_sender_cap?}
    M -- Yes --> N[MaxDelegatedPendingTxsExceeded]
    M -- No --> O[Ok - proceed to add_transaction]
    F --> O
    I --> O
Loading
Prompt To Fix All With AI
Fix the following 1 code review issue. Work through them one at a time, proposing concise fixes.

---

### Issue 1 of 1
crates/blockchain/blockchain.rs:2538-2543
**`delegated_sender_cap = 0` silently blocks every delegated-sender tx**

When `delegated_sender_cap` is set to `0`, the condition `pending >= cap` evaluates to `0 >= 0 = true` for the very first transaction from a delegated EOA — so *no* delegated sender can ever enter the mempool. This is probably unintentional for operators who set the value thinking "0 means unlimited" (a common convention), but the code treats it as "ban all delegated senders." There is no guard, clamp, or documentation in the CLI help or doc-comment that explains this edge case. If the intent is that `0` means "no delegated senders allowed," that should be explicitly called out in the `--mempool.delegated-sender-cap` help text.

Reviews (1): Last reviewed commit: "test(l1): cover EIP-7702 delegated-sende..." | Re-trigger Greptile

Comment on lines +2538 to +2543
if tx_to_replace_hash.is_none() && self.is_sender_delegated(sender_code_hash)? {
let cap = self.options.delegated_sender_cap;
let pending = self.mempool.pending_tx_count_for_sender(sender)?;
if pending >= cap {
return Err(MempoolError::MaxDelegatedPendingTxsExceeded(cap));
}
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 delegated_sender_cap = 0 silently blocks every delegated-sender tx

When delegated_sender_cap is set to 0, the condition pending >= cap evaluates to 0 >= 0 = true for the very first transaction from a delegated EOA — so no delegated sender can ever enter the mempool. This is probably unintentional for operators who set the value thinking "0 means unlimited" (a common convention), but the code treats it as "ban all delegated senders." There is no guard, clamp, or documentation in the CLI help or doc-comment that explains this edge case. If the intent is that 0 means "no delegated senders allowed," that should be explicitly called out in the --mempool.delegated-sender-cap help text.

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

Comment:
**`delegated_sender_cap = 0` silently blocks every delegated-sender tx**

When `delegated_sender_cap` is set to `0`, the condition `pending >= cap` evaluates to `0 >= 0 = true` for the very first transaction from a delegated EOA — so *no* delegated sender can ever enter the mempool. This is probably unintentional for operators who set the value thinking "0 means unlimited" (a common convention), but the code treats it as "ban all delegated senders." There is no guard, clamp, or documentation in the CLI help or doc-comment that explains this edge case. If the intent is that `0` means "no delegated senders allowed," that should be explicitly called out in the `--mempool.delegated-sender-cap` help text.

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.

ilitteri added a commit that referenced this pull request May 12, 2026
…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.
ilitteri added 2 commits May 12, 2026 15:41
Phase 2 review (Copilot): the delegated-sender cap check ran in
`validate_transaction` under a mempool read lock, but the actual
insertion happens later in `Mempool::add_transaction` under a separate
write lock. Concurrent admissions from the same delegated EOA could
both pass the count check and exceed `delegated_sender_cap`.

Move the authoritative check inside `Mempool::add_transaction`:
- `add_transaction` takes `max_pending_txs_per_account: u64` and
  re-counts under the write lock; `u64::MAX` disables the cap.
- New `Blockchain::effective_per_sender_cap(sender)` async helper
  computes the cap from the sender's code hash (returns
  `delegated_sender_cap` for delegated EOAs, `u64::MAX` otherwise).
- `Blockchain::add_transaction_to_pool` /
  `add_blob_transaction_to_pool` compute the effective cap after
  `validate_transaction` and pass it to `add_transaction`.

The read-lock check in `validate_transaction` is kept as
defense-in-depth and so the existing integration tests
(`delegated_sender_second_tx_rejected_by_default_cap` and friends)
continue to exercise the rejection path. Existing
`mempool.add_transaction(...)` test callers pass `u64::MAX` to disable
the cap in unit-test scenarios that don't care about it.
Phase 2 review (Copilot + greptile):
- The intra-doc link `[Blockchain::validate_transaction]` on
  `Mempool::pending_tx_count_for_sender` (and a similar one on
  `add_transaction`'s docstring, already updated) wouldn't resolve from
  inside the `mempool` module because `Blockchain` isn't in scope.
  Use the fully-qualified `crate::Blockchain::validate_transaction`.
- The `--mempool.delegated-sender-cap` help text didn't mention what
  `0` means. Some operators assume "0 means unlimited" by convention;
  ethrex's check (`pending >= cap`) treats 0 as "ban all delegated
  senders". Spell that out explicitly so the surprise is documented.

Also regenerated `docs/CLI.md` with the new help text.
Copy link
Copy Markdown
Contributor

@ElFantasma ElFantasma left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the cleanest of the recent mempool-hardening stack — the atomic count-and-insert inside Mempool::add_transaction under the write lock is exactly the right shape for a per-sender cap, and it closes the TOCTOU window I flagged on #6606 and #6609. The shared is_eip7702_delegation predicate in ethrex-common (coordinating with #6600) is the right place for a definition this load-bearing.

One inline note — a stale TODO that contradicts what the PR actually shipped.

// is `Some`) bypass the cap: they swap a slot rather than consuming a
// new one.
//
// TODO: once an atomic version of the per-sender pending-tx cap lands
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 TODO describes a future state — "once an atomic version of the per-sender pending-tx cap lands… move the effective-cap computation to be passed into Mempool::add_transaction and enforce the check there" — but the atomic version IS landing in this PR (mempool.rs:154-172 does exactly that: takes max_pending_txs_per_account: u64, checks count under the write lock, refuses if at cap).

So the TODO is stale on arrival. Per the PR description, this read-lock check at :2548-2554 is deliberately kept as defense-in-depth (and to keep the existing integration tests exercising the rejection path), which is a different story than the TODO tells.

Swap the TODO for a note explaining the actual rationale, e.g.:

// Defense-in-depth: the authoritative cap is enforced atomically inside
// `Mempool::add_transaction` under the write lock. This read-lock check
// surfaces the rejection earlier (before the storage write paths in
// `add_transaction_to_pool`) and keeps the existing integration tests
// asserting via `validate_transaction`.

Non-blocking, but future readers will trip on the current TODO.

…d-sender-cap

# Conflicts:
#	crates/blockchain/blockchain.rs
#	crates/blockchain/constants.rs
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 13, 2026

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

Known Issues

Tests intentionally excluded from CI. Source of truth for the Known
Issues
section the L1 workflow appends to each ef-tests job summary
and posts as a sticky PR comment.

Hive — bal-devnet-6 Amsterdam consume-engine tests — 32 functions / 54 cases

Same root cause as the blockchain-runner skip list (see EF Tests —
Blockchain
below): snobal-devnet-6 fixtures expect bal-devnet-6 spec
semantics, but our impl runs ahead due to the bal-devnet-7-prep
set_delegation SELFDESTRUCT-style refund subtraction. These fixtures
are routed through hive's eels/consume-engine simulator and produce
the same failures. Excluded via KNOWN_EXCLUDED_TESTS (substring
match on test_<name>[fork_Amsterdam, anchoring to the Amsterdam fork
so legacy Prague/Osaka variants still run).

Affected EELS test functions (32)
  • test_auth_refund_block_gas_accounting
  • test_auth_refund_bypasses_one_fifth_cap
  • test_auth_state_gas_scales_with_cpsb
  • test_auth_with_calldata_and_access_list
  • test_auth_with_multiple_sstores
  • test_authorization_exact_state_gas_boundary
  • test_authorization_to_precompile_address
  • test_authorization_with_sstore
  • test_bal_7702_delegation_clear
  • test_bal_7702_delegation_create
  • test_bal_7702_delegation_update
  • test_bal_7702_double_auth_reset
  • test_bal_7702_double_auth_swap
  • test_bal_7702_null_address_delegation_no_code_change
  • test_bal_all_transaction_types
  • test_bal_selfdestruct_to_7702_delegation
  • test_bal_withdrawal_to_7702_delegation
  • test_duplicate_signer_authorizations
  • test_existing_account_auth_header_gas_used_uses_worst_case
  • test_existing_account_refund
  • test_existing_account_refund_enables_sstore
  • test_existing_auth_with_reverted_execution_preserves_intrinsic
  • test_many_authorizations_state_gas
  • test_mixed_auths_header_gas_used_uses_worst_case
  • test_mixed_new_and_existing_auths
  • test_mixed_valid_and_invalid_auths
  • test_multi_tx_block_auth_refund_and_sstore
  • test_multiple_refund_types_in_one_tx
  • test_simple_gas_accounting
  • test_sstore_state_gas_all_tx_types
  • test_transfer_with_all_tx_types
  • test_varying_calldata_costs

EF Tests — Stateless coverage narrowed to EIP-8025 optional-proofs

make -C tooling/ef_tests/blockchain test calls test-stateless-zkevm
instead of test-stateless. The zkevm@v0.3.3 fixtures are filled against
bal@v5.6.1, out of sync with current bal spec; the broad target trips ~549
fixtures. Re-broaden once the zkevm bundle is regenerated.

Why and resolution path

PR #6527 broadened
test-stateless to extract the entire for_amsterdam/ tree from the
zkevm bundle and run all of it under --features stateless; combined with
this branch's bal-devnet-6+ semantics (and bal-devnet-7-prep
set_delegation re-application) that scope produces ~549
GasUsedMismatch / ReceiptsRootMismatch /
BlockAccessListHashMismatch failures.

test-stateless-zkevm filters cargo to the eip8025_optional_proofs
suite, which still validates the stateless harness without the bal-version
mismatch.

Re-broaden by switching test: back to test-stateless in
tooling/ef_tests/blockchain/Makefile once the zkevm bundle is regenerated
against the current bal spec.

EF Tests — Blockchain bal-devnet-6 (Amsterdam fork) — 74 tests

snobal-devnet-6 fixtures expect bal-devnet-6 spec semantics, but our impl
runs ahead due to the bal-devnet-7-prep set_delegation SELFDESTRUCT-style
refund subtraction. Skipped in
tooling/ef_tests/blockchain/tests/all.rs::SKIPPED_BASE, anchored on
[fork_Amsterdam so legacy Prague / Osaka variants still run.

Bucket breakdown (74 total) and resolution path
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_clearing
  • delegation_clearing_and_set
  • delegation_clearing_failing_tx
  • delegation_clearing_tx_to
  • eoa_tx_after_set_code
  • ext_code_on_chain_delegating_set_code
  • ext_code_on_self_delegating_set_code
  • ext_code_on_self_set_code
  • ext_code_on_set_code
  • many_delegations
  • nonce_overflow_after_first_authorization
  • nonce_validity
  • reset_code
  • self_code_on_set_code
  • self_sponsored_set_code
  • set_code_multiple_valid_authorization_tuples_same_signer_increasing_nonce
  • set_code_multiple_valid_authorization_tuples_same_signer_increasing_nonce_self_sponsored
  • set_code_to_log
  • set_code_to_non_empty_storage_non_zero_nonce
  • set_code_to_self_destruct
  • set_code_to_self_destructing_account_deployed_in_same_tx
  • set_code_to_sstore
  • set_code_to_sstore_then_sload
  • set_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_again
  • call_to_precompile_in_pointer_context
  • contract_storage_to_pointer_with_storage
  • delegation_replacement_call_previous_contract
  • double_auth
  • pointer_measurements
  • pointer_normal
  • pointer_reentry
  • pointer_resets_an_empty_code_account_with_storage
  • pointer_reverts
  • pointer_to_pointer
  • pointer_to_precompile
  • pointer_to_static
  • pointer_to_static_reentry
  • static_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_accounting
  • auth_refund_bypasses_one_fifth_cap
  • auth_with_calldata_and_access_list
  • auth_with_multiple_sstores
  • authorization_exact_state_gas_boundary
  • authorization_to_precompile_address
  • authorization_with_sstore
  • duplicate_signer_authorizations
  • existing_account_auth_header_gas_used_uses_worst_case
  • existing_account_refund
  • existing_account_refund_enables_sstore
  • existing_auth_with_reverted_execution_preserves_intrinsic
  • many_authorizations_state_gas
  • mixed_auths_header_gas_used_uses_worst_case
  • mixed_new_and_existing_auths
  • mixed_valid_and_invalid_auths
  • multi_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_clear
  • bal_7702_delegation_create
  • bal_7702_delegation_update
  • bal_7702_double_auth_reset
  • bal_7702_double_auth_swap
  • bal_7702_null_address_delegation_no_code_change
  • bal_selfdestruct_to_7702_delegation
  • bal_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_tx
  • simple_gas_accounting
  • varying_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)

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