Skip to content

EIP-8141: revalidate dependency-affected frame transactions on a new head - #12778

Draft
Marchhill wants to merge 51 commits into
eip8141-simulation-guardsfrom
eip8141-dependency-revalidation
Draft

EIP-8141: revalidate dependency-affected frame transactions on a new head#12778
Marchhill wants to merge 51 commits into
eip8141-simulation-guardsfrom
eip8141-dependency-revalidation

Conversation

@Marchhill

@Marchhill Marchhill commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Changes

Closes the biggest correctness hole in the frame-tx mempool stack: a frame transaction was validated once, at submit, and never rechecked. FrameTxDependencySet was constructed by the payer resolver and never consumed — its doc comment promised "a later layer". This is that layer.

  • FrameTxDependencyIndex — maps the chain-head accounts a pending prefix depends on (sender, resolved payer, and the expiry verifier when an expiry frame is present) back to the transaction. Maintained from the pool's Inserted / Removed events, so it can never outlive pool membership.

  • Head-change revalidation — the new block's changed-account list is intersected with the index before it is disposed, and only that subset is re-resolved after included and expired transactions have left. A head whose change list does not describe everything that moved — a reorg, or a non-sequential block — falls back to every indexed prefix; that is the same completeness test the account cache uses, computed once so the two cannot drift. Revalidating the whole pool per head would be its own denial-of-service vector, which is the point of the index.

  • Invalid-against-head eviction — a prefix that no longer resolves a payer, or whose payer can no longer cover the pool's exposure to it, is evicted immediately. That is the first tier of the spec's eviction order: such transactions never compete for pool space at all.

  • Reservation follows the payer — a revalidation that resolves a different payer releases the old reservation and takes a new one against the new payer's balance, evicting if it does not fit.

  • FrameTxSimulationResult.Indeterminate — a simulation rejected by a resource bound (busy, per-head budget spent, timed out) says nothing about validity. Admission still declines, but revalidation leaves the transaction pending; otherwise an exhausted budget would turn into a mass eviction.

  • Near-expiry shedding — when the pool is full at a head boundary, the pending frame transactions whose deadline is within roughly two slots are shed, nearest deadline first and lowest effective priority fee first among equals. That is the spec's second and third eviction tiers applied where they carry real information: a transaction about to expire is worth little, so it yields its slot rather than displacing a live one through the pool's fee-ordered capacity eviction.

The EIP8141-GAP list in NotSupportedTxFilter loses the items now implemented and keeps canonical-paymaster reservation, the failed-APPROVE replay bound, and a deadline-ordered pool index.

Scope

  • Account granularity, not slot. A conservative superset of the spec's wording: a write to a sender storage slot also changes that sender's account, so the account index subsumes the slot index.
  • Helper contracts are not indexed. An opaque prefix may read the code of contracts it reaches through CALL*/EXTCODE*; those are not yet dependencies, so a code change at one does not trigger revalidation. Marked EIP8141-GAP.
  • The deadline order is not applied across the whole pool. The pool's eviction order is a single IComparer<Transaction> over all transaction types. A frame-only expiry tiebreak that returns 0 for mixed pairs is not transitive and would corrupt the sorted pool; a total order would rank every expiring frame transaction below every other transaction, which is a policy change well beyond the spec. Doing it properly needs a deadline-ordered index inside TxDistinctSortedPool and an eviction-preference hook on SortedPool, so this PR ships the bounded form instead: expired transactions are dropped proactively on each head, and the near-expiry shed pass covers the ordering that matters under pressure. What remains unordered is the deadline ranking among frame transactions with real life left.

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

TxPoolTests covers a sponsored frame transaction evicted once its prefix stops validating against the new head, one retained when the simulation failed on a resource bound, one left untouched when the block's changed accounts miss its dependency set (asserting the simulator is not consulted), a reorg revalidating a transaction its change list never mentions, and a sponsor able to fund again after an eviction released its reservation.

Nethermind.TxPool.Test 726 passed / 1 skipped; Nethermind.Evm.Test frame suites 147 passed. dotnet format whitespace clean.

Documentation

Requires documentation update

  • No

Requires explanation in Release Notes

  • No

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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


Review: EIP-8141 dependency revalidation

  • Read the PR diff
  • Read surrounding TxPool / simulator / exposure-cache context
  • Analyze correctness, thread-safety, performance
  • Post review (7 inline comments)

The design is right and the layering is clean — FrameTxDependencyIndex maintained purely off the pool's Inserted/Removed events is a good choice (it structurally cannot outlive pool membership), the Indeterminate split is a genuinely necessary distinction, and driving revalidation from the block's changed-account list rather than the whole pool is the correct answer to the obvious DoS. The EIP8141-GAP bookkeeping and the scope section in the description are honest about what's left.

That said, I found one issue I'd call blocking and several that should be resolved before merge. The headline one is that the new eviction path leaks payer reservations, and its effects compound with two of the others.

Findings

# Severity Where Issue
1 Critical TxPool.cs:609-613 Payer-exposure reservation permanently leaked on 4 of 5 eviction paths
2 High TxPool.cs:578-582 Reorg / non-sequential fallback never fires — guard tests the wrong condition
3 High TxPool.cs:644 Unbounded synchronous EVM simulation inside the _newHeadLock write lock
4 Medium FrameTxDependencyIndex.cs:50-56 Empty-bucket pruning race silently drops live index entries
5 Medium TxPool.cs:656-659 Index not refreshed when revalidation moves the payer
6 Medium TxPool.cs:651-654 Same-payer check wipes a payer's entire pending set, not the marginal txs
7 Medium FrameTxPrefixSimulator.cs:146-152 A thrown exception isn't a resource bound — Indeterminate retains it forever
8 Low TxPool.cs:296-303 Double array allocation + duplicate HasExpiryDeadline parse per insert

Critical: the reservation leak (finding 1)

Worth expanding here because it's the merge-blocker. RevalidateFrameTransactions clears tx.PayerAddress unconditionally before RemoveTransaction, which makes ReleasePayerExposure (line 968, early-returns on a null payer) a no-op. But TryRevalidateFrameTransaction only released the old reservation on one of its five false returns:

TryCalculateMaxCost fails      -> no Subtract  -> leaked
NoPayer                        -> no Subtract  -> leaked
simulator rejected (definite)  -> no Subtract  -> leaked
same payer, over balance       -> no Subtract  -> leaked
payer moved, TryReserve fails  -> Subtract ran -> correct  <- the only case the comment describes

PayerExposureCache is monotonic apart from Subtract, so this is permanent. It's also remotely triggerable and self-amplifying: fill a paymaster's exposure headroom with sponsored frame txs, make the prefix stop resolving, and the eviction pass leaves that paymaster showing a full reservation with zero pending transactions — it can never sponsor again for the process lifetime. Finding 6 then guarantees the whole set goes in that first pass rather than the surplus, and because GetReserved never drops afterwards, every future submission for that payer is rejected at admission too.

The fix is small — move the PayerAddress = null into the one branch that earned it (suggested diff in the inline comment).

Testing

The three new tests are well-chosen for the paths they cover (definite rejection evicts, indeterminate retains, unrelated change list doesn't consult the simulator — the DidNotReceive assertion is the right shape). The gaps line up with the findings above:

  • no assertion that an eviction releases the payer reservation (finding 1 — this is the test that would have caught it; asserting the same payer can sponsor again afterwards is enough);
  • no reorg case, i.e. PreviousBlock is not null with a non-null AccountChanges (finding 2);
  • no payer-move case at all — neither that the reservation follows the payer, which is a headline bullet in the description, nor that the index follows it (finding 5);
  • no concurrent insert/remove exercise of FrameTxDependencyIndex (finding 4).

Smaller notes

  • Simulate(tx) at line 644 doesn't pass a cancellation token, so a shutdown during a revalidation sweep waits it out. _cts.Token is right there and the interface already accepts one — the XML doc says the interpreter polls it.
  • Metrics.FrameTxRevalidations counts every re-resolve including the native fast path, while the metric description says "revalidated because a new head touched their tracked dependencies". That reads fine, but a separate counter for simulator-backed revalidations would make the cost of finding 3 visible in production rather than inferred.
  • RevalidateFrameTransactions returns early on the IsEip8141Enabled check without clearing _frameTxsToRevalidate. Harmless today since CollectFrameTxsToRevalidate clears on entry, but it leaves the field holding stale hashes between heads; clearing at the top of the guard would make the lifecycle local.
  • NotSupportedTxFilter's trimmed EIP8141-GAP comment is accurate against what's implemented here, assuming findings 2 and 3 are resolved — as written, "dependency-set revalidation" is only partly delivered (reorgs missed, and the fallback path is unbounded).

Verdict

Not mergeable as-is: finding 1 is a remotely-triggerable permanent DoS on any paymaster, and findings 2/3 mean the feature both misses the reorg case it claims to cover and reintroduces the head-processing stall it sets out to avoid. Findings 1, 2 and 5 are each a few lines. Findings 3, 4, 6 and 7 are design calls that need either a fix or an explicit rationale in the code.

I have not built or run the test suite here (review only, no changes pushed).
· branch eip8141-dependency-revalidation

Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/FrameTxDependencyIndex.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs
Comment thread src/Nethermind/Nethermind.Consensus/Processing/FrameTxPrefixSimulator.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs Outdated
@Marchhill
Marchhill force-pushed the eip8141-dependency-revalidation branch from 6152ef9 to 6214489 Compare August 11, 2026 20:12
@Marchhill

Copy link
Copy Markdown
Contributor Author

The eight table findings all had inline threads and are resolved there. Responding here to the "Smaller notes", which did not:

  • Simulate(tx) without a cancellation token — fixed; the sweep now passes _cts.Token, so shutdown is not held behind it.
  • _frameTxsToRevalidate not cleared on the early return — fixed; the guard clears before returning, so the field never holds stale hashes between heads.
  • Metrics.FrameTxRevalidations counts the native fast path too — kept as one counter. The metric measures how much of the pool a head forced back through resolution, which is the number that says whether the index is doing its job; simulator-backed cost is already visible through FrameTxSimulations.
  • EIP8141-GAP accuracy — the two findings it was contingent on (reorg fallback, head-lock cost) are resolved, so the trimmed text holds.

Separately, a summary-only note from #12624 applies here and is now fixed: an EIP-7702-delegated sender runs the delegate's code, so the delegate is a dependency of the prefix; the sender's own code hash only pins the designation. IndexFrameTxDependencies now indexes it, gated on the account carrying code so a codeless sender still costs one account read.

@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 7m 25s —— View job


Review: EIP-8141 dependency revalidation (round 2)

  • Read the PR diff vs origin/eip8141-simulation-guards
  • Verify round-1 findings are resolved
  • Fresh pass: correctness, thread-safety, performance
  • Post review (5 inline comments)

Round-1 findings — all resolved

# Round-1 finding Verdict
1 Reservation leaked on 4 of 5 eviction paths Fixed. exposureReleased is now an out param and PayerAddress is cleared only on the branch where Subtract already ran. I walked all four previousPayer/payer null combinations — release happens exactly once in each.
2 Reorg fallback never fired Fixed. changeListIsComplete is computed once and drives both _accountCache and CollectFrameTxsToRevalidate; they cannot drift. Reorg test added.
3 Unbounded simulation under the write lock Acknowledged, and the rebuttal is correct. Admission takes the _newHeadLock read lock, head processing the write lock, so Monitor.TryEnter in the simulator is uncontended during a sweep — my O(affected) × 250 ms claim does not hold, and per-head work stays bounded by the budget. _cts.Token is now threaded through.
4 Empty-bucket pruning race Fixed properly. Replacing the lock-free index with a Lock over Dictionary/HashSet removes the reference-equality trap entirely rather than patching it.
5 Index not refreshed on a payer move Fixed for the payer — but see finding B below: the head commit added a second mutable dependency that the same code path still misses.
6 Same-payer check wiped the whole pending set Fixed as a consequence of #1, with the policy stated in <remarks>.
7 Thrown exception classified Indeterminate Fixed. Back to a definite Reject once the tracer exists, with the reasoning in the comment; env-level failures stay indeterminate.
8 Double array allocation Fixed (single sized pass). The duplicate HasExpiryDeadline parse is knowingly deferred.

The "smaller notes" replies are all reasonable, including keeping FrameTxRevalidations as one counter.

New findings

# Severity Where Issue
A Medium TxPool.cs:642 Blob-carrying frame txs are indexed but never revalidated
B Medium TxPool.cs:699 Re-index only on a payer move leaves the delegate dependency permanently stale
C Medium TxPool.cs:650 Evicted hash stays in the long-term _hashCache, so a reversible eviction is permanent
D Low TxPool.cs:322-332 DelegationTargetOf bypasses the account cache on the admission path
E Low TxPool.cs:690-700 <remarks> overstate the shed order; spec-source split; eviction metric gap

A — blob pool. OnInsertedTx is wired to _transactions and _blobTransactions (lines 156–167), and NotSupportedTxFilter only rejects blob-carrying frame txs under persistent blob storage. Under BlobsSupportMode.InMemory such a tx is indexed and collected every head, then silently skipped because line 642 only consults _transactions — so for that config the feature is a no-op. Both neighbours in the same file already handle both pools: RemoveExpiredFrameTransactions sweeps both snapshots, and ContainsTx carries the comment "a type-6 frame tx may carry blobs (blob pool) or not (normal pool), so check both." Non-default config, hence Medium rather than High.

B — the new delegate dependency. This is finding 5's twin, opened up by the head commit. IndexFrameTxDependencies snapshots the sender's EIP-7702 delegation target, but re-indexing still happens only when previousPayer != payer. A sender that delegates (or re-delegates) after admission changes its own account, so the tx is collected and revalidated — but if the payer resolves the same, the method returns at line 699 without re-indexing, and the new delegate never becomes a tracked dependency. A code change at the account whose code the prefix actually runs then triggers nothing. Re-indexing on every successful revalidation costs one TryGetAccount and closes it. Also: no test exercises the delegate path at all — every SponsoredFrameTx sender is codeless, so DelegationTargetOf returns null in all five new cases.

C — hash cache. RemoveTransaction doesn't touch _hashCache, so an evicted tx is AlreadyKnown until it ages out of _longTermCache. That is deliberate and correct for expiry (monotonic, and documented as such at line 596), but revalidation invalidity is head-relative and reverses — a sponsor whose balance dips for one block cannot resubmit any of the transactions it lost. AddCore's capacity eviction already calls DeleteFromLongTerm for exactly this reason. Note the new reservation-release test resubmits under a different sender, which is what hides this.

Testing

The five new tests cover their paths well, and the reservation-lifecycle and reorg cases requested last round are both there. Remaining gaps map onto the findings: no blob-pool case (A), no delegation case at all (B), no "resubmittable after the condition clears" case (C).

I could not build or run the suite in this environment (dotnet build was not permitted), so the analysis is static; the author reports Nethermind.TxPool.Test 726 passed / 1 skipped.

Verdict

Much improved — the round-1 blockers are genuinely fixed, and #4 and #7 were fixed by removing the risky construct rather than patching it. Nothing here is a merge blocker on its own: A and B are silent coverage holes in the new feature rather than regressions, and C is a behavioural sharp edge inherited from the expiry path. All three are small changes, so I'd resolve them before merge rather than as follow-ups.
· branch eip8141-dependency-revalidation

Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs Outdated
@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 10m 20s —— View job


Review: EIP-8141 dependency revalidation (round 4)

  • Read the PR diff vs origin/eip8141-simulation-guards
  • Verify round-3 findings (1–5) are resolved
  • Fresh pass over the newest commits
  • Post review (3 inline comments)

Round-3 findings — all resolved

# Round-3 finding Verdict
1 Shed pass evicted every candidate; sort was dead work Fixed. if (!pool.IsFull()) break; at line 683, and Shedding_takes_the_nearest_deadline_first_and_stops_at_the_freed_slot pins the selection with two candidates and one slot. See the Low note below on what the break implies for the sort.
2 Shed pass saw only _transactions Fixed properly. Split into a per-pool helper called for both pools, each gated on its own IsFull() — which is the part that matters, since blob capacity is independent.
3 ExpiryShedHorizonSeconds = 24 bakes in a 12 s slot Acknowledged, and the rationale now holds. It didn't in round 3; once the shed is bounded by pressure the horizon is only a candidate filter, so a wider window on a fast chain admits more candidates to the deadline order rather than dropping more. Comment says as much.
4 exposureReleased reported true where nothing was released Fixed, and the rebuttal was right. Narrowing the assignment as I suggested would have introduced a bug: with previousPayer is null and a failed TryReserve, leaving PayerAddress set would make ReleasePayerExposure subtract a reservation never taken. holdsNoReservation is the correct framing and the <param> now covers both cases.
5 DelegationTargetOf's code read under the pool's McsLock Acknowledged with rationale at the read site. Fair trade: the HasCode gate keeps a codeless sender to one cached account read, and the lifecycle guarantee from staying in the event is worth more.

I also re-walked the round-1/2 fixes against the current text, since the re-index wrapper and the blob-pool fallthrough both sit on the reservation path: all four previousPayer/payer null combinations still release exactly once, TryRevalidateFrameTransaction re-indexes on every successful return so no early exit skips the delegate snapshot, and _hashCache.DeleteFromLongTerm is on the revalidation path only. Lock ordering is one-directional throughout (pool McsLockFrameTxDependencyIndex._lock, never the reverse), so the new Lock cannot deadlock against inserts.

I checked the load-bearing assumption behind the whole index and it holds: a storage-only write to a dependency account does surface in Block.AccountChanges on both producers — StateProvider.ChangedAddresses() returns every key of _blockChanges, which PersistentStorageProvider.Commit populates through AccountExists for each address whose storage root moved, and BlockAccessListBasedWorldState filters on HasStateChanges, which includes StorageChanges.Length > 0. Account granularity really is a superset of slot granularity here.

New findings

# Severity Where Issue
1 Medium TxPool.cs:412 Shed pass reads the pressure signal before UpdateBuckets(), so it can shed a live transaction for a slot that frees itself
2 Low TxPool.cs:673-683 The break makes the shed exactly one tx/head, so the pool-sized sort selects a single minimum and the plural wording overstates
3 Low TxPool.cs:304-308 GAP note omits block-context dependencies (TIMESTAMP/NUMBER), the one axis that moves every head

1 — the only one I'd resolve before merge. ShedNearlyExpiredFrameTransactions runs at line 412; UpdateBuckets() runs at line 419 and removes transactions — UpdateBucket marks them with a null bottleneck and TxDistinctSortedPool.UpdatePool then calls TryRemove. Those are exactly the transactions the new head invalidated (nonce consumed, balance below value, blob nonce-gap cascade). So on a head where the pool is at capacity and UpdateBuckets is about to drop several stale transactions, this pass has already shed a live frame transaction with up to 24 s of deadline left for a slot nothing needed. Same class as round 3's finding — that one bounded how many are shed, this bounds whether any should be. Frequency is modest, since RemoveProcessedTransactions at line 409 usually takes the pool under capacity first, but the fix is moving one call after UpdateBuckets().

2 is not a correctness problem, just a mismatch between what the code does (free exactly one slot per pool per head — IsFull() is a Count >= Capacity threshold and SortedPool.Remove calls UpdateIsFull()) and what the summary/remarks/PR description imply (a set yielding together). Whichever is intended, a linear min-scan would replace the pool-sized ArrayPoolList + sort if one-per-head stands.

I also chased two things that turned out not to be findings, recorded so they don't get re-raised: mutating tx.PayerAddress on a pooled transaction is safe because the only readers are the pool's own admission filters, which run under the _newHeadLock read lock and so are exclusive with head processing; and nonce gaps opened by these direct RemoveTransaction calls are closed by UpdateBuckets() later in the same head (which is, incidentally, a second reason to keep it before — so if you move the shed pass per finding 1, that ordering stays intact since revalidation stays put).

Testing

Seven new tests, and the cases requested across rounds 1–3 are all present and well-shaped — reservation lifecycle, resubmittability under the same sender, reorg, the two-step delegation case, and the DidNotReceive().Simulate(...) negative. Gaps, in order of what I'd add:

  • the fee tiebreak at equal deadlines is unexercised — the two-transaction shed test varies only the deadline, so a.Fee.CompareTo(b.Fee) has no coverage;
  • no blob-pool case for either the revalidation lookup (round 2 finding A) or the per-pool shed (round 3 finding 2), so both fixes are static-only;
  • nothing covers finding 1's interaction (a full pool holding a transaction UpdateBuckets will drop).

dotnet build is not permitted in this environment, so this is static analysis; the author reports Nethermind.TxPool.Test 726 passed / 1 skipped.

Verdict

This is in good shape. Across four rounds the fixes have consistently been the structural kind rather than patches — the lock-free index replaced outright, holdsNoReservation named for what the caller asks, the shed split per pool — and the <remarks> are unusually candid about which orderings are and aren't delivered. Nothing here is a merge blocker: finding 1 is a bounded over-eviction with a one-line fix, and 2 and 3 are a policy statement and a comment. Worth taking 1 and 2 before merge since both are small; 3 can be waved off if you'd rather keep the GAP list terse.

· branch eip8141-dependency-revalidation ·

Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs Outdated
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs
…ut a sort

ShedNearlyExpiredFrameTransactions read IsFull before UpdateBuckets dropped what the
new head invalidated, so it could evict a live frame transaction for a slot that was
about to free itself. One removal clears IsFull, so the pass sheds at most one
transaction per pool per head; the sort is replaced by a linear min-scan and the docs
now say so. Also names block context as the second axis the account index cannot see.
Keeps this branch's Indeterminate remark: the revalidation pass added here is the
production reader whose absence the upstream wording described.
Revert-checked: with the shed call moved back before UpdateBuckets the near-expiry
frame transaction is evicted for a slot the stale-nonce drop was about to free, and
the test fails.
@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 54s —— View job


Review: EIP-8141 dependency revalidation (round 5)

  • Read the PR diff vs origin/eip8141-simulation-guards
  • Verify round-4 findings (1–3) are resolved
  • Fresh pass over the newest commits (8e99ec3, 3ff302d, c3d730a)
  • Post review (3 inline comments)

Round-4 findings — all resolved

# Round-4 finding Verdict
1 Shed read the pressure before UpdateBuckets() Fixed. The call moved to line 422, right after UpdateBuckets(), with the reason stated inline. Shedding_reads_the_pressure_left_after_the_head_s_own_bucket_cleanup pins it, and it fails for the right reason (B's nonce is consumed, so UpdateBucket dumps that transaction and the pool is no longer full). Revalidation correctly stayed put — it consumes _frameTxsToRevalidate and its evictions are not capacity-driven. The direction argument holds: the pass now reads a pool that is the same size or smaller, so it can only ever shed less.
2 The break made the shed one tx/head, so the sort was dead work Fixed, and the policy is now stated rather than implied. ArrayPoolList + Comparison<T> sort replaced by one linear pass tracking the best (deadline, fee); summary reads "Frees one slot in each pool at capacity" and the <remarks> name the one-per-pool-per-head bound and the missing deadline-ordered index as the reason. Shedding_breaks_an_equal_deadline_on_the_lower_priority_fee covers the fee tiebreak that was unexercised.
3 GAP note omitted block-context dependencies Fixed. TIMESTAMP/NUMBER named in the same sentence, with only the protocol expiry frame swept by RemoveExpiredFrameTransactions.

I re-walked the earlier rounds' load-bearing fixes against the current text, since the shed move touches the head pipeline they sit in: all four previousPayer/payer null combinations still release the reservation exactly once; TryRevalidateFrameTransaction still re-indexes on every successful return, so no early exit skips the delegate snapshot; _hashCache.DeleteFromLongTerm is on the revalidation path only; and the change-list completeness decision is still computed once for both the account cache and the dependency collection. Lock ordering remains one-directional (pool McsLockFrameTxDependencyIndex._lock, never the reverse) — RevalidateFrameTransactions releases the pool lock between TryGetValue and the index writes, so the new Lock cannot deadlock against inserts.

I also re-checked the round-4 dismissal that mutating tx.PayerAddress on a pooled transaction is safe, because the shed move changes what runs where: PayerAddress still has exactly four readers, all of them pool admission filters (FrameTxPayerFilter, FrameTxSimulationFilter, FrameTxPayerExposureFilter twice), which run under the _newHeadLock read lock and so are exclusive with head processing. Nothing in block production or RPC reads it. Still safe.

New findings

# Severity Where Issue
1 Medium TxPool.cs:690-693 The shed is a capacity eviction, so leaving the hash long-term-cached makes a reversible drop permanent
2 Low TxPool.cs:688 Victim choice ignores nonce position, so one shed slot can cascade to the sender's whole bucket
3 Low TxPool.cs:665 Third and fourth full-pool snapshot of the same head, walked for the same deadline parse

1 — the only one I'd resolve before merge, and it is round-2's finding C on the sibling path. The comment says "a deadline only ever gets closer, so unlike a revalidation eviction this cannot reverse" — but the transaction shed here has not expired. The predeploy reverts only once block.timestamp > deadline, so it is includable at this head; the half of the predicate that actually decided the eviction is pool.IsFull(), and capacity pressure reverses within a block. RemoveTransaction also stops broadcast, and the hash stays SetLongTerm'd, so the node both stops gossiping the transaction and answers AlreadyKnown to every resubmission until it ages out of _longTermCache. Every other pressure-driven drop in the file goes the other way — AddCore's capacity eviction, MarkForEviction's allowLaterPoolReentrance, UpdateBucket's balance dump, EvictTransaction, and this PR's own revalidation eviction. Two amplifiers: the pass fires on the steady state of a full pool, so it burns a live transaction per head even with nothing waiting to enter; and it is where round 3's chain-agnostic-horizon rationale stops being symmetric — a wider window admitting more candidates to the deadline order is harmless, discarding ~12 blocks of useful life irrecoverably on a 2 s chain is not.

2 is the pool's no-nonce-gap policy meeting a pass that gets to choose its victim: pick the transaction at the sender's current nonce and the next head's UpdateBucket dumps that sender's entire bucket, so "at most one transaction per pool per head" bounds this call rather than the shed's cost. Unavoidable for the expiry sweep and revalidation, where the eviction is mandatory; avoidable here.

For the record, two things I chased that are not findings: the revalidation sweep passes local: false, so a locally-submitted frame transaction is revalidated under the budget its admission was exempt from — but the failure direction is Indeterminate → retained, so the asymmetry can never evict a local transaction; and Metrics.PendingTransactionsEvicted++ is non-atomic at the two new sites, which matches all five existing sites in the file rather than diverging from them.

Testing

Eleven new tests now, and every case requested in rounds 1–4 is present: reservation lifecycle, resubmittability under the same sender, reorg, the two-step delegation case, the DidNotReceive().Simulate(...) negative, shed selection with one slot, and the fee tiebreak. Shedding_reads_the_pressure_left_after_the_head_s_own_bucket_cleanup is the right shape — the filler transaction deliberately carries no deadline so it can never itself be a shed candidate. Remaining gaps, in order of what I'd add:

  • resubmittability after a shed (finding 1);
  • still no blob-pool case for either the revalidation lookup (round-2 A) or the per-pool shed (round-3 finding 2), so both of those fixes remain static-only;
  • nothing covers a shed victim that has later pending transactions from the same sender (finding 2).

dotnet build is not permitted in this environment, so this is static analysis; the author reports Nethermind.TxPool.Test 726 passed / 1 skipped.

Verdict

The feature itself reads as correct to me now — five rounds in, the fixes have consistently removed the risky construct rather than patched it (lock-free index → plain Lock, sort → linear scan, exposureReleasedholdsNoReservation, shed split per pool), and the <remarks> are unusually honest about which orderings are and are not delivered. Finding 1 is a one-line change plus a corrected comment and is the only one I'd hold for; 2 and 3 are a policy statement and an efficiency note, either takeable or waveable with a sentence.
· branch eip8141-dependency-revalidation

Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs
Comment thread src/Nethermind/Nethermind.TxPool/TxPool.cs
Capacity pressure, not expiry, decides the shed, so the transaction is
still includable and must be able to re-enter once the pressure passes.
Carry the tri-state simulation outcome through revalidation. A node fault now
reports Undecided rather than a rejection, and since it is also indeterminate
the pass leaves the transaction pending exactly as it already does for an
exhausted budget, instead of evicting over the node's own malfunction.
…lidation

The only conflict is the remaining-gaps note. This branch's list is the further
along one — simulation, dependency-set revalidation and the shared max_cost
helper are all closed here — so it stands, with the restored-blob-record gap
carried over: LightTxDecoder still does not persist the payer, so a record
reloaded from disk holds no exposure reservation.
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