Skip to content

EIP-8141: cap pending frame transactions per non-canonical paymaster - #12774

Draft
Marchhill wants to merge 29 commits into
eip8141-mempool-phase2from
eip8141-paymaster-pending-cap
Draft

EIP-8141: cap pending frame transactions per non-canonical paymaster#12774
Marchhill wants to merge 29 commits into
eip8141-mempool-phase2from
eip8141-paymaster-pending-cap

Conversation

@Marchhill

Copy link
Copy Markdown
Contributor

Changes

Enforces the EIP-8141 public-mempool cap on how many pending frame transactions may pay through one non-canonical paymaster (MAX_PENDING_TXS_USING_NON_CANONICAL_PAYMASTER = 1). Nothing enforced it before: a flood of frame transactions all naming one contract sponsor was admitted and gossiped, so a single balance or code change on that sponsor could invalidate an unbounded set of pending transactions — exactly the dependency the mempool rules exist to bound.

  • FrameTxValidation.GetPrefixPaymaster — the pay frame target ending a recognized validation prefix, derived from the frame layout alone (no state read). Reuses the existing shared prefix grammar, so a leading expiry_verify / deploy frame is skipped just as it is for pricing and payer resolution. A self-relay prefix, an unrecognized layout, or a null pay target (which resolves to the sender, not a sponsor) all yield null.
  • PendingPaymasterCache — pending-transaction count per paymaster, incremented on pool insert and decremented on pool removal (covering eviction, replacement, inclusion and reorg removal, which all funnel through the existing Removed event). Because the key is state-free, the count a transaction contributes on insert is exactly the one it releases on removal, even if the paymaster's code changes while it is pending. Over-release clamps at zero so the cap can never be disabled.
  • FrameTxPaymasterFilter — rejects with a new AcceptTxResult.NonCanonicalPaymasterLimitReached when the pay target already sponsors the maximum. Registered ahead of payer resolution and validation-prefix simulation so a flood naming one sponsor is dropped before that work is spent.
  • Metric PendingTransactionsFrameTxPaymasterLimitReached.

Only a pay target that carries code is a paymaster: per the spec, a target with the empty code hash is a default-code sponsor, governed by the per-payer exposure rule alone (FrameTxPayerExposureFilter, #12617).

The check reads the pending count rather than taking a reservation, so it holds no state a later rejecting filter would have to release — the same trade-off DelegatedAccountFilter already makes for pending delegations, where concurrent submissions naming one address may briefly exceed the bound.

Base branch

Stacked on eip8141-mempool-phase2 (#12624), the tip of the EIP-8141 mempool chain (#12610#12617#12624), for two reasons: the shared validation-prefix grammar this builds on lands in that chain, and the paymaster cap is the sibling of the per-payer exposure rule in #12617 — the spec states them in the same paragraph. It is otherwise independent of the simulation layer and could be rebased onto an earlier link if the chain is reordered.

Deferred

  • Canonical paymaster exemption. No canonical paymaster runtime is pinned in production yet (the reference implementation is assembled in EIP-8141: canonical paymaster reference assembler and test suite #12612's test suite), so every code-carrying pay target is treated as non-canonical. That is the conservative direction — declining is always mempool-legal — but it also caps a canonical instance that should instead be admitted under the balance-reservation rule. Exempting one requires both the pinned runtime code hash and reserved_pending_cost / pending_withdrawal_amount accounting, which stays on the EIP8141-GAP list in NotSupportedTxFilter.
  • Revalidation. A paymaster whose code or balance changes under a new head does not trigger re-evaluation of the transactions pending against it; that is part of the deferred dependency-set revalidation slice.

Types of changes

What types of changes does your code introduce?

  • New feature (a non-breaking change that adds functionality)

Testing

Requires testing

  • Yes

If yes, did you write tests?

  • Yes

Notes on testing

FrameTxPaymasterFilterTests covers the cap matrix (deployed paymaster rejected; the same prefix behind leading expiry_verify + deploy frames still keyed on the pay target; default-code sponsor accepted; self-relay, null pay target, unrecognized prefix and non-frame transactions all pass through), the first-admitted / second-rejected sequence, re-admission once the pending transaction leaves the pool, and the cache's count-up / clamp-at-zero behaviour.

Full Nethermind.TxPool.Test suite green (729 passed, 1 skipped); Nethermind.Core.Test frame suites green (88); full Runner build clean, dotnet format whitespace clean.

Documentation

Requires documentation update

  • No

Requires explanation in Release Notes

  • No

The public-mempool rules bound how many pending transactions a single
non-canonical paymaster may sponsor (MAX_PENDING_TXS_USING_NON_CANONICAL_PAYMASTER
= 1), so one sponsor's balance or code change cannot invalidate an unbounded
set of pending transactions. Nothing enforced that: a flood of frame txs all
paying through one contract sponsor was admitted and gossiped.

FrameTxPaymasterFilter rejects a frame tx whose recognized validation prefix
ends in a pay frame targeting a code-carrying account that already sponsors the
maximum pending transactions. The count is keyed on the pay-frame target, which
FrameTxValidation.GetPrefixPaymaster derives from the frame layout alone, so the
pool's insert and remove hooks stay symmetric even when the paymaster's code
changes while a transaction is pending. A default-code sponsor is not a
paymaster and stays governed by the per-payer exposure rule.

No canonical paymaster runtime is pinned in production yet, so every code-
carrying pay target is treated as non-canonical; exempting canonical instances
additionally requires their balance reservation, which remains outstanding.
@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 5m 46s —— View job


Review: EIP-8141 non-canonical paymaster pending cap

Reviewed df5e026 against origin/eip8141-mempool-phase2.

  • Gather context (diff vs eip8141-mempool-phase2)
  • Review FrameTxValidation.GetPrefixPaymaster
  • Review PendingPaymasterCache (thread safety, lifecycle)
  • Review FrameTxPaymasterFilter + registration order in TxPool
  • Review tests & repo-rule adherence
  • Post findings (3 inline comments)

What holds up

The core design is sound and I could not break the accounting:

  • GetPrefixPaymaster is index-safe. RecognizedPrefixLength returns next + 1 or next + 2, never 0 or a value > frames.Length, so frames[length - 1] cannot throw. The self-verify branch ends on ApproveExecutionAndPayment, which IsPayFrame (ApprovePayment) rejects → null, as intended.
  • Increment/decrement symmetry holds on every path I traced. Replacement (DistinctValueSortedPool.InsertCoreTryRemoveNonLockedRemoved, then Inserted), capacity eviction (RemoveLast), self-eviction (tx.Hash == removed?.Hash — both events fire, nets zero), UpdateGroup/UpdatePool drops, expiry eviction, block inclusion, reorg re-add, and ResetTxPoolState (which goes through RemoveTransaction, not a raw clear) all funnel through Removed. The !inserted path in AddCore never fired Inserted, so unlike the exposure reservation there is nothing to release — the asymmetric handling is correct.
  • The key really is state-free. For a frame tx SenderAddress is explicit in the payload rather than recovered, so the layout→key mapping cannot shift between admission and removal even across a paymaster code change.
  • PendingPaymasterCache concurrency is right. The AddOrUpdate + conditional ICollection.Remove(KVP(key, 0)) pattern mirrors DelegationCache/PayerExposureCache; the interleavings I checked (decrement-then-increment racing the removal) all leave the count consistent, and the clamp prevents a negative count from disabling the cap.
  • AcceptTxResult id 24 is free, TxPoolErrorMessages/Metrics are wired, and no external consumer switches on these results.

Findings

# Severity Finding
1 Medium A sponsored frame tx can never be fee-bumped — with the cap at 1, a same-sender/same-nonce replacement is rejected because the tx it would replace still holds the slot. The pool would swap it correctly (RemovedDecrement precedes InsertedIncrement); only this pre-insert gate blocks it. So no re-pricing under a rising base fee and no cancel-by-replacement, for 100% of sponsored txs. Nothing in the EIP requires counting the tx being replaced.
2 Medium Placement undercuts the stated DoS rationale — the filter is appended after FrameTxSignatureFilter, so the flood it exists to cheapen still pays uncapped elliptic-curve verification per tx. FrameTxSignatureFilter's own remarks declare it must run last precisely so cheap filters reject first. This filter needs only the frame layout + one account read and could sit right after FrameTxPayerlessFilter.
3 Medium No end-to-end test of the TxPool wiring — every test drives PendingPaymasterCache by hand. Removing the filter registration or making GetPaymaster return null leaves the suite green. The sibling exposure rule has exactly such a test (TxPoolTests.cs:2440).
4 Low The cap is bypassable by ordering, not just stale. Submit N txs naming a code-less address (skipped here, since IsNonCanonicalPaymaster requires HasCode), then deploy code to it (or 7702-delegate it) — N pending txs now depend on a contract sponsor with no re-evaluation. The "Deferred → Revalidation" note frames this as passive staleness; it is attacker-controlled. Severity is only Low because FrameTxPayerExposureFilter still bounds that flood by the sponsor's balance, but it's worth stating explicitly in the deferred list.
5 Low Doc nit: GetPrefixPaymaster's remark "Only the EIP-8141 canonical-paymaster prefixes end in a pay frame" reads oddly in a change whose whole subject is non-canonical paymasters — a reader can easily take it as "this only applies to canonical paymasters". Consider "sponsored prefixes" or naming the layout directly.

Style, DI, and comment discipline follow the repo rules; the shared prefix grammar is reused rather than duplicated, and the constant carries its EIP reference.

Verdict: not blocking-critical, but I'd want #1 fixed (or explicitly deferred with rationale in the PR body) and #3 added before merge, since the wiring is the only part of this change with no test behind it.

Note: I could not run dotnet build/dotnet test in this environment (command approval denied), so I verified by reading only and relied on the PR's stated green run for compilation.
• branch eip8141-paymaster-pending-cap

Comment thread src/Nethermind/Nethermind.TxPool/Filters/FrameTxPaymasterFilter.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs Outdated
…nature work

The pending cap counted the transaction a fee bump would displace, so with
MAX_PENDING_TXS_USING_NON_CANONICAL_PAYMASTER = 1 a sponsored frame tx could
never be re-priced or cancelled by replacement: the tx it replaces still held
the paymaster's only slot when the filter ran, and the pool only swaps them
later, inside TryInsert. The spec decrements on replacement, so the pending set
does not grow; discount a same-sender, same-nonce pending tx naming the same
paymaster before comparing against the cap.

Requiring the same paymaster matters, since replacing a tx sponsored elsewhere
frees that sponsor's slot while still taking one here.

Also move the filter ahead of FrameTxSignatureFilter. It needs only the frame
layout and one account read, so leaving it behind the uncapped elliptic-curve
work defeated the point of a cheap flood gate; the trade-off is that a flood tx
is no longer also reported as malformed.

Cover the TxPool wiring end to end, which the hand-driven counter in the filter
tests did not: two senders sharing one code-carrying pay target, admitted then
capped then admitted again after removal.
@Marchhill

Copy link
Copy Markdown
Contributor Author

Two summary-only findings (no inline threads):

  • Finding 4 (Low), the cap is bypassable by ordering — the payer half is now covered: EIP-8141: revalidate dependency-affected frame transactions on a new head #12778 indexes each pending frame transaction by its sender, payer and expiry verifier, so deploying code to a previously code-less sponsor changes an indexed account and forces re-resolution of the transactions naming it. What is still not re-evaluated is the paymaster count itself, so the finding stands for the cap specifically and is worth keeping in the deferred list.
  • Finding 5 (Low), GetPrefixPaymaster doc wording — already fixed on the branch tip; the remark no longer reads as canonical-only.

This branch has also been merged forward onto the current devnet7 tip so it sits on the same base as the rest of the chain.

@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 5m 37s —— View job


Review: EIP-8141 non-canonical paymaster pending cap (re-review)

Re-reviewed 3df011b against origin/eip8141-mempool-phase2, focusing on the fixes in 3f3443e.

  • Gather context (diff vs eip8141-mempool-phase2)
  • Verify fixes for prior findings 1–3
  • Review FrameTxValidation.GetPrefixPaymaster
  • Review PendingPaymasterCache + TxPool wiring
  • Review FrameTxPaymasterFilter and filter ordering
  • Review tests & repo-rule adherence
  • Post findings (1 inline comment)

Prior findings — all addressed

Prior Status
#1 replacement always rejected Fixed in 3f3443eReplacesPendingTxOfSamePaymaster discounts the displaced tx. Matching on the paymaster too is the right call; the ReplacementCases matrix pins all three branches.
#2 registered after FrameTxSignatureFilter Fixed — now sits between DelegatedAccountFilter and FrameTxSignatureFilter, and the trade-off comment is accurate. SenderAddress is explicit in the frame payload and already consumed by earlier filters (TxTypeTxFilter, LowNonceFilter, GapNonceFilter), so nothing here depends on recovery.
#3 no end-to-end TxPool test FixedTxPoolTests.cs:2477 submits through the real pool: Accepted → NonCanonicalPaymasterLimitReached → Accepted after RemoveTransaction, so registration and both event hooks are covered.
#4 ordering bypass (code deployed to a code-less sponsor) Acknowledged; #12778 covers the payer half, the count itself stays deferred. Reasonable.
#5 doc wording Fixed.

What I verified on the new code

  • ReplacesPendingTxOfSamePaymaster is sound on the standard-pool path. VisitBucket iterates under the pool lock in the group-comparer order, and TxDistinctSortedPool.GetGroupComparerGetPoolUniqueTxComparerByNonce puts CompareTxByNonce.Instance first, so the ascending-nonce early exit the comment claims really holds. DistinctCompareTx keeps at most one tx per (sender, nonce), so stopping at the first Nonce == state.Nonce cannot miss a second candidate.
  • The visitor is safe to run under the lock. GetPrefixPaymaster is pure, allocation-free (?? [] lowers to Array.Empty), O(frames), and never re-enters the pool — which is what VisitBucket's own remark requires.
  • Non-frame pending txs degrade correctly. GetPrefixPaymaster on a tx with Frames is null returns null, so replacing a plain tx with a sponsored frame tx correctly gets no discount.
  • GetPrefixPaymaster stays index-safe. RecognizedPrefixLength returns next + 1 or next + 2, each guarded by the bound that makes frames[length - 1] in range; it never returns 0.
  • A failed fee bump costs nothing. If the replacement loses CompareReplacedTxByFee, TryInsert returns false and no Inserted fires, so the discount cannot desync the counter.
  • Persistent blob storage is not an accounting leak. PersistentBlobTxDistinctSortedPool.InsertCore stores a LightTransaction (Type hard-coded TxType.Blob), so GetPaymaster returns null on both insert and removal — symmetric. And NotSupportedTxFilter.cs:31 rejects blob-carrying frame txs outright in that mode, so no cap hole either.
  • Increment/decrement symmetry across eviction, replacement, UpdateGroup, expiry, inclusion, reorg re-add and ResetTxPoolState still holds; PendingPaymasterCache's AddOrUpdate + conditional zero-valued ICollection.Remove mirrors DelegationCache and clamps so the cap can't be disabled.

Findings

# Severity Finding
1 Medium The replacement discount misses the blob pool — the XML remark asserts "Frame txs never carry blobs", but e991f6f (in the base branch) added exactly that: NotSupportedTxFilter.cs:31 admits a blob-carrying frame tx under BlobsSupportMode.InMemory, and TxPool.cs:767 routes it to _blobTransactions. The in-memory blob pool keeps the full tx, so SupportsFrames is true and OnInsertedTx does count it against the cap — but ReplacesPendingTxOfSamePaymaster only visits standardPool, so its fee bump gets no discount and is rejected. Finding #1 from the last round, still live on the blob path. Fix is the DelegatedAccountFilter shape: take both pools, select by tx.CarriesBlobs.
2 Low Deferred list. The PR body's "Deferred → Revalidation" bullet still frames staleness as passive; per the discussion on prior #4 the count is not re-evaluated when a code-less pay target later gains code, which is attacker-ordered. Worth one explicit line in the body/NotSupportedTxFilter's EIP8141-GAP comment rather than only in a review thread.
3 Low Trace message: after the discount, pending is the count excluding the displaced tx, so "already sponsors {pending} pending transactions" is off by one on the replacement path. Log paymasters.GetPendingCount(paymaster) instead, or reword.

Style, DI and comment discipline follow the repo rules; the prefix grammar is reused rather than duplicated, the constant carries its EIP reference, tests are parameterized via TestCaseSource rather than copy-pasted, and no new public interface surface is introduced (FrameTxPaymasterFilter and PendingPaymasterCache are internal sealed).

Verdict: finding #1 is the only one I'd hold merge on — it's the same fee-bump regression that 3f3443e fixed, reintroduced for blob-carrying frame transactions, and the false invariant in the remark is what hides it. #2 and #3 are cosmetic.

Note: dotnet build / dotnet test were denied in this environment, so this is a read-only review; I relied on the PR's stated green run for compilation.
• branch eip8141-paymaster-pending-cap

Comment thread src/Nethermind/Nethermind.TxPool/Filters/FrameTxPaymasterFilter.cs Outdated
Also discount a replacement from the blob pool: a blob-carrying frame tx is counted
against the cap from there, so its fee bump was rejected for want of the discount.
# Conflicts:
#	src/Nethermind/Nethermind.TxPool/Metrics.cs
@Marchhill

Copy link
Copy Markdown
Contributor Author

Two findings from the summary table that were not filed inline:

#2 (deferred list). The ordering bypass is now stated rather than framed as passive staleness: a code-less pay target that later gains code is attacker-ordered, and the count is not re-evaluated. It is recorded in the EIP8141-GAP comment in NotSupportedTxFilter rather than only in a review thread.

#3 (trace message off by one). Valid — after the discount, pending excludes the displaced transaction. The message now reads the undiscounted count from the cache.

@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 5m 55s —— View job


Review: EIP-8141 non-canonical paymaster pending cap (round 3)

Re-reviewed fbc1bc6 against origin/eip8141-mempool-phase2.

  • Gather context (diff vs eip8141-mempool-phase2)
  • Verify fixes for prior findings (blob-pool discount, deferred list, trace off-by-one)
  • Review FrameTxValidation.GetPrefixPaymaster
  • Review PendingPaymasterCache + TxPool wiring
  • Review FrameTxPaymasterFilter, filter ordering, reorg/broadcast paths
  • Review tests & repo-rule adherence
  • Post findings (1 inline comment)

Prior findings — all addressed

Prior Status
R2 #1 replacement discount missed the blob pool Fixed. The filter now takes both pools and visits tx.CarriesBlobs ? blobPool : standardPool; the false "frame txs never carry blobs" remark is gone. I checked the part that made this fragile: BlobTxDistinctSortedPool overrides only GetReplacementComparer, so it inherits GetGroupComparerGetPoolUniqueTxComparerByNonce, and the visitor's ascending-nonce early exit is valid on the blob path too. TxTypeTxFilter (:16) rejects a sender that has a bucket in the other pool, so the CarriesBlobs pool selection is exhaustive — there is no cross-pool same-nonce case left uncovered.
R2 #2 deferred list wording Fixed in NotSupportedTxFilter.cs:41-42 — the ordering bypass is now stated as submitter-ordered rather than passive staleness.
R2 #3 trace off by one Fixed — the message reads paymasters.GetPendingCount(paymaster), the undiscounted count.
R1 #1 fee-bump rejected / #2 filter placement / #3 no end-to-end test / #5 doc wording Still fixed; re-verified SubmitTx_FrameTransactions_SharingNonCanonicalPaymaster_BoundByPendingCap_ReleasedOnRemoval covers registration and both event hooks.

What I re-verified on this tip

  • Placement is safe now that it precedes FrameTxSignatureFilter. The cap only ever rejects; Increment fires from the pool Inserted event, which is unreachable without passing the signature filter. So an unsigned flood cannot poison another sponsor's count. SenderAddress is recovered by MalformedTxFilter well upstream, and DelegatedAccountFilter/BalanceTooLowFilter already do state reads at this point, so no new pre-signature state-read exposure.
  • VisitBucket under the pool lock is fine. Accept runs under _newHeadLock's read lock only, never the pool's McsLock, and the visitor is pure, allocation-free and never re-enters the pool — which is what SortedPool.VisitBucket's remark requires.
  • Index safety holds. RecognizedPrefixLength returns next + 1 or next + 2, each guarded by the bound that keeps frames[length - 1] in range; frames.Length == 0 (non-frame tx, ?? []) falls through to null without indexing.
  • Increment/decrement symmetry. Replacement, capacity eviction, self-eviction (both events fire, nets zero), UpdateGroup/UpdatePool, expiry, inclusion, reorg re-add and ResetTxPoolState all funnel through Removed. The !inserted path never fired Inserted, so unlike the exposure reservation there is nothing to release. PendingPaymasterCache's AddOrUpdate + conditional zero-valued ICollection.Remove mirrors DelegationCache, and I could not construct an interleaving that leaves a stale or negative count.
  • Persistent blob storage is not a hole either way: LightTransaction is hard-coded TxType.Blob, so GetPaymaster returns null symmetrically, and NotSupportedTxFilter.cs:31 rejects blob-carrying frame txs in that mode outright.

Findings

# Severity Finding
1 Medium An explicitly self-targeted pay frame is counted as a third-party paymasterIsPayFrame never compares Target to the sender, so only_verify(self) + pay(target = sender) makes GetPrefixPaymaster return the sender's own address. For a code-carrying sender (an EIP-7702 smart account — the shape that actually uses this split form, since implicit self_verify defers to simulation for a code-carrying sender) IsNonCanonicalPaymaster is true, the discount only covers the same nonce, and the sender is therefore limited to one pending frame transaction, ever. This also contradicts the method's own remark ("a pay frame targeting the sender rather than a sponsor … yields null") and diverges from IsSelfTargetedVerify (:348), which treats Target is null and Target == sender as equivalent everywhere else in the file. No accounting leak — a false rejection only — but the sender is already bounded by FrameTxPayerExposureFilter plus the nonce limits, so the cap adds nothing here.
2 Low Test fidelity: Accept_FeeBumpOfBlobCarryingSponsoredTx_Accepted wires a plain TxDistinctSortedPool as the blob argument, so it pins the filter's pool selection but not the real blob pool's bucket ordering. Correct today (the blob pool inherits the nonce group comparer, verified above), but the test would stay green if BlobTxDistinctSortedPool ever overrode GetGroupComparer and broke the ascending-nonce early exit the visitor comment depends on. A BlobTxDistinctSortedPool instance, or a TxPoolTests case with BlobsSupport = InMemory, would close that.
3 Low Reorg re-admission collapses. ReAddReorganisedTransactions resubmits through SubmitTx, so a reorged block containing N transactions sponsored by one paymaster re-admits one and drops the rest with NonCanonicalPaymasterLimitReached — and their hashes were just _hashCache.Deleted, so they will be re-fetched from gossip and re-rejected. Spec-legal (the cap is a mempool rule and these are no longer mined), but with the cap at 1 it is a sharp effect worth a line next to the other deferred notes.
4 Low Nit: the self-eviction + persistent-broadcast path leaves a broadcaster-retained tx contributing 0 to its paymaster's count (both Inserted and Removed fired). That mirrors the exposure under-count AddCore documents deliberately at :790-796, but nothing says so for the paymaster count — one clause on that comment would stop the next reader treating it as a leak.

Style, DI and comment discipline follow the repo rules: FrameTxPaymasterFilter/PendingPaymasterCache are internal sealed, no new public interface surface, the prefix grammar is reused rather than duplicated, the constant carries its EIP reference, Metrics/TxPoolErrorMessages/AcceptTxResult id 24 are wired consistently, and the tests are parameterized via TestCaseSource instead of copy-pasted.

Verdict: finding #1 is the one I'd resolve before merge — it's a two-line predicate change plus a test case, and as it stands the documented contract and the behaviour disagree in a way that silently throttles smart-account senders to a single pending frame transaction. #2#4 are cosmetic or documentation.

Note: dotnet build / dotnet test were denied in this environment, so this is a read-only review; I relied on the PR's stated green run for compilation.
• branch eip8141-paymaster-pending-cap

Comment thread src/Nethermind/Nethermind.Core/FrameTxValidation.cs
…not self-payment

Pin a code-carrying sender naming itself in the pay frame as capped, drive the blob-path
replacement test through the real blob pool type, and record the reorg re-admission and
broadcaster-retained count gaps.
# Conflicts:
#	src/Nethermind/Nethermind.TxPool.Test/TxPoolTests.cs
#	src/Nethermind/Nethermind.TxPool/Filters/NotSupportedTxFilter.cs
# Conflicts:
#	src/Nethermind/Nethermind.TxPool/Filters/NotSupportedTxFilter.cs
#	src/Nethermind/Nethermind.TxPool/TxPoolErrorMessages.cs
Renumber the paymaster-cap result: AcceptTxResult compares by id, so it collided with the
simulation-failed result the merge brought down.
@Marchhill

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Claude finished @Marchhill's task in 6m 45s —— View job


Review: EIP-8141 non-canonical paymaster pending cap (round 4)

Re-reviewed ed70196 against origin/eip8141-mempool-phase2.

  • Gather context (diff vs eip8141-mempool-phase2)
  • Verify fixes for prior round-3 findings
  • Review FrameTxValidation.GetPrefixPaymaster
  • Review PendingPaymasterCache + TxPool wiring
  • Review FrameTxPaymasterFilter and ordering
  • Review tests & repo-rule adherence
  • Post findings (3 inline comments)

Prior findings

Prior Status
R3 #1 self-targeted pay frame counted as a paymaster Acknowledged with spec rationale — the carve-out is the empty code hash, and acceptance rule 7 caps a sender at one pending frame tx anyway, so the effect coincides with the spec. The remark now states the code-hash carve-out and Accept_CodeCarryingSenderPayingItself_IsCapped pins it. Fair resolution.
R3 #2 blob test used a plain pool FixedPool(bool blobs, …) now builds a real BlobTxDistinctSortedPool, so the blob path exercises the actual bucket ordering.
R3 #3 reorg re-admission collapses Documented in the EIP8141-GAP list.
R3 #4 self-eviction under-counts the paymaster DocumentedTxPool.cs:788-790 now names the paymaster count alongside the payer exposure.
R2/R1 findings (blob-pool discount, filter placement, end-to-end test, trace off-by-one, doc wording) Still fixed; re-verified.

What holds up

  • Increment/decrement symmetry. Replacement, capacity eviction, self-eviction (both events fire, nets zero), UpdateGroup/UpdatePool, expiry, inclusion, reorg re-add and ResetTxPoolState all funnel through Removed. The !inserted path never fired Inserted, so unlike the exposure reservation there is nothing to release — the asymmetry with ReleasePayerExposure is correct.
  • No drain needed on dispose. DisposeAsync clears _payerExposure because FrameTxPayersWithReservedExposure is a static gauge; the paymaster cache backs only a counter, so leaving it is right, not an omission.
  • Startup is not a hole. The persistent blob pool loads in its constructor, before Inserted += OnInsertedTx — but it stores LightTransaction (Type hard-coded TxType.Blob), so GetPaymaster returns null symmetrically, and NotSupportedTxFilter.cs:31 rejects blob-carrying frame txs in that mode outright.
  • Index safety in GetPrefixPaymaster still holds (RecognizedPrefixLength returns next + 1 or next + 2, each guarded; frames.Length == 0 falls through without indexing), and the visitor is pure, allocation-free and never re-enters the pool, which is what SortedPool.VisitBucket requires of a callback holding its McsLock.
  • Blob-path ordering is still valid: BlobTxDistinctSortedPool overrides only GetReplacementComparer, inheriting GetGroupComparerGetPoolUniqueTxComparerByNonce.
  • PendingPaymasterCache's AddOrUpdate + conditional zero-valued ICollection.Remove mirrors DelegationCache and clamps so the cap can't be disabled.

Findings

# Severity Finding
1 Medium The replacement discount ignores the EIP-8250 keyed-nonce domainTxDistinctSortedPool is built with CompetingTransactionEqualityComparer (:28), which treats same-Nonce txs in different keyed domains as non-competing, so one sender's bucket can hold several at Nonce == N, none displacing the others. This visitor matches on nonce alone and stops at the first hit. Both directions fail: a keyed sibling naming the same paymaster grants a false discount, so TryInsert adds rather than replaces and count(P) reaches 2 against a cap of 1 (one sender, two nonce keys — the bound the filter exists to enforce, doubled); and because same-nonce entries tie-break on fee-then-hash, the real competing tx can sit after a sibling in another domain, so a valid fee bump is rejected — the round-1 regression again. FrameTxPayerExposureFilter.ReplacedPendingReservation (:74-86) already handles exactly this, and the base branch pins it with TxPoolTests.cs:2666.
2 Medium AcceptTxResult id 25 is already FrameSimulationFailedEquals/GetHashCode are id-based, so NonCanonicalPaymasterLimitReached == FrameSimulationFailed. Only Code/ToString() still differ. It silently weakens FrameTxSimulationFilterTests.cs:84 and TxPoolTests.cs:2556 — the latter is the end-to-end wiring test added for round-1 #3, and the simulation gate sits in that same chain, so the one assertion meant to pin the paymaster path can no longer distinguish it. 26 is free (the ids are already non-contiguous — KeyedNonceUnmet = 24 sits above 22/23, which is probably how 25 looked unused).
3 Low The EIP8141-GAP rewrite drops a still-open item — deleting "validation-prefix simulation" is right (FrameTxPrefixSimulator is registered at BlockProcessingModule.cs:93), but the payer-exposure under-reservation is still live: IsOverflowInTxCostAndValue prices MaxFeePerGas * tx.GasLimit, and for a frame tx GasLimit excludes the intrinsic and EIP-7623 floor terms — as FrameTxPayerExposureFilter.cs:31-32 still says in its own EIP8141-DEVIATION note. Also re-flows an unrelated line, against AGENTS.md's "on unrelated code, be even more conservative".
4 Low The bucket walk is unconditional. int pending = paymasters.GetPendingCount(paymaster) - (ReplacesPendingTxOfSamePaymaster(…) ? 1 : 0) evaluates both operands, so every sponsored frame tx takes the pool's McsLock and walks the sender's bucket even when the count is 0 and no discount can matter. The sibling filter got exactly this optimization (FrameTxPayerExposureFilter.cs:45: "TryReserve ignores the discount when the payer holds no reservation, so skip the bucket walk and its pool lock there") — guarding on GetPendingCount(paymaster) >= MaxPendingTxsUsingNonCanonicalPaymaster before walking would match it.
5 Low Metrics.PendingTransactionsFrameTxPaymasterLimitReached++ is non-atomic, and declaring the metric as a { get; set; } property makes Interlocked.Increment impossible. The sibling counter is a field incremented under Interlocked with a comment explaining why (FrameTxPayerExposureFilter.cs:48-49), and this filter runs concurrently under the same head read lock, so counts are lost the same way. FrameTxPayersWithReservedExposure right above it in Metrics.cs is already a field.

Style, DI and comment discipline follow the repo rules: FrameTxPaymasterFilter/PendingPaymasterCache are internal sealed, no new public interface surface, the prefix grammar is reused rather than duplicated, the constant carries its EIP reference, and the tests are parameterized via TestCaseSource rather than copy-pasted.

Verdict: #1 is the one I'd hold merge on — it is the same fee-bump regression and a bypass of the cap itself, and the fix is already written two files away in FrameTxPayerExposureFilter. #2 is a two-character change but worth doing before merge because it degrades the wiring test that closed an earlier finding. #3#5 are cosmetic or follow-the-sibling polish.

Note: dotnet build / dotnet test were not run in this environment, so this is a read-only review; I relied on the PR's stated green run for compilation.
• branch eip8141-paymaster-pending-cap

Comment thread src/Nethermind/Nethermind.TxPool/Filters/FrameTxPaymasterFilter.cs
Comment thread src/Nethermind/Nethermind.TxPool/AcceptTxResult.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/Filters/NotSupportedTxFilter.cs Outdated
NonCanonicalPaymasterLimitReached shared id 25 with FrameSimulationFailed, and
AcceptTxResult compares by id alone, so the two were equal; 26 is taken by
FrameSimulationDeferred on the simulation-guards branch, so this takes 27.

The replacement discount now matches on the pool's competing key, since an EIP-8250
transaction sharing a nonce in another key domain joins the pending set rather than
displacing it and must still count against the sponsor.

Restores the payer-exposure gap item, which is still open on this branch.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant