Repository navigation
Fix legacypool total-cost overflow edge cases (PLT-995) - #97
Conversation
Return ErrTotalCostOverflow instead of misreporting underpriced replacements, reject overflow before pool eviction, stop promotion on failure, and drop txs that cannot be re-queued. Apply projected totalcost atomically so validation and mutation stay consistent. Co-authored-by: Cursor <cursoragent@cursor.com>
PR SummaryMedium Risk Overview Adds When the pool is full, overflow is rejected before evicting underpriced remotes via Reviewed by Cursor Bugbot for commit 7855495. Bugbot is set up for automated code reviews on this repo. Configure here. |
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit 7855495. Configure here.
There was a problem hiding this comment.
The overflow hardening is directionally sound and keeps pool.all consistent where the old code left orphans, but the PR's headline change — rejecting total-cost overflow before evicting underpriced remote transactions when the pool is full — is never exercised by any of the three new tests, and that same pre-eviction check is incomplete because eviction can itself raise the target list's total cost. Remaining findings are code-quality, metrics/observability, and test-hygiene items.
Findings: 1 blocking | 17 non-blocking | 10 posted inline
Blockers
- The PR's central change is untested. The new pre-eviction guard (
legacypool.go:703-707,targetList,addCostOverflow) only runs insideif uint64(pool.all.Slots()+numSlots(tx)) > pool.config.GlobalSlots+pool.config.GlobalQueue.setupPoolusestestTxPoolConfig = DefaultConfig(GlobalSlots 5120 + GlobalQueue 1024 = 6144 slots) andTestAddRemoteTotalCostOverflowErrorpools only three transactions, so that branch never executes — the test'sErrTotalCostOverflowassertion is satisfied entirely by the pre-existingenqueueTx->list.Addpath. Add a test built on a config with smallGlobalSlots/GlobalQueuethat actually fills the pool and asserts both (a)ErrTotalCostOverflowis returned and (b) no previously-pooled remote transaction was evicted, which is the behaviour the PR description claims.targetListandaddCostOverflowalso have no direct unit test.
Non-blocking
- Cursor's second-opinion pass produced no output (
cursor-review.mdis empty). Codex's pass produced one finding, merged into the inline comment onlegacypool.go:705. REVIEW_GUIDELINES.md(taken from the base branch) is empty, so no repository-specific review standards could be applied.- Error-class change leaks into
eth/fetcher: an overflowing brand-new transaction previously surfacedErrReplaceUnderpriced, whichtx_fetcher.go:346records in theunderpricedcache to suppress re-requests.ErrTotalCostOverflownow falls into thedefault/otherrejectbranch, so such transactions get re-fetched on every re-announcement and count toward the >25%otherreject200ms throttle. Consider addingErrTotalCostOverflowto that set — an overflowing tx will not become acceptable while the sender's list stays saturated. - Error classification is now pool-fullness dependent: the pre-eviction check deliberately ignores fee-bump rules, so a replacement that is both underpriced and overflowing reports
ErrTotalCostOverflowwhen the pool is full, butErrReplaceUnderpricedwhen it is not (becauselist.Addchecks the bump first and returns beforeprojectedTotalCost). Worth a note in the PR/commit message so the inconsistency is intentional and documented. - Practical reachability is worth stating explicitly somewhere durable (commit message or a code comment): reaching a
totalcostoverflow requires an account balance on the order of 2^256 wei (~10^77), sincevalidateTx/ValidateTransactionWithStatebound each tx'sCost()by the sender balance andlist.Filterbounds the aggregate. This is audit-driven hardening, not a live exploitable path — useful context so future readers don't over-index on the severity. - No test asserts the gauge accounting of the new re-queue path in
promoteExecutables:enqueueTxdoesqueuedGauge.Inc(1)per re-queued tx while the caller does a singlequeuedGauge.Dec(len(readies)). The netting reads correct, but it is load-bearing and untested. ErrTotalCostOverflowis user-facing (returned frompool.Addto RPC and p2p callers) but is only described in theerrors.gocomment; consider surfacing it where other pool rejection reasons are documented.- 10 suggestion(s)/nit(s) flagged inline on specific lines.
Thread the cached replacement tx and uint256 cost through projectedTotalCost so the admission hot path computes cost once. Co-authored-by: Cursor <cursoragent@cursor.com>
…ejects. Document the re-enqueue failure exception in demoteUnexecutables, and treat ErrTotalCostOverflow like underpriced in the tx fetcher so saturated-list rejects are not re-requested. Co-authored-by: Cursor <cursoragent@cursor.com>
Wait for pool reorg before seeding the queue and asserting overflow rejection so the test does not race the background promotion loop. Co-authored-by: Cursor <cursoragent@cursor.com>
|
@seidroid review |
|
Claude finished @amir-deris's task in 6m 38s —— View job Review —
|
There was a problem hiding this comment.
Well-scoped, audit-driven hardening of total-cost overflow handling in legacypool: the new ErrTotalCostOverflow, the projectedTotalCost helper, and the promotion/re-enqueue orphan cleanup are all correct as far as I can verify statically, with genuine test coverage. No blockers; the notable gaps are a pre-existing-but-now-widened reservation leak when enqueueTx fails after creating an empty queue list, plus some clarity and hot-path allocation nits.
Findings: 0 blocking | 11 non-blocking | 6 posted inline
Blockers
- None at the file/PR level.
Non-blocking
cursor-review.mdis empty — the Cursor pass produced no output, so it contributed nothing to this synthesis.codex-review.mdreports no material issues (it also could not run tests due to restricted network).- I could not execute
go vet/go testin this environment (bash approval denied), so this review is static only. The PR's stated test plan should be confirmed green in CI before merge. - The documented "known limitation" (pre-eviction guard projects onto
targetListas it exists beforepriced.Discard/removeTx, so eviction can demote the sender's pending txs into its queue afterwards) is acceptable to defer given overflow requires aggregate tracked cost near 2^256. If closing it later: either exclude the sender's own txs from the discard set, or re-run the projection after eviction and roll back by re-Put-ing the dropped txs intopool.priced— the existingErrFutureReplacePendingbranch at legacypool.go:753 already demonstrates that rollback shape. - No test covers the address-reservation / stale-
pool.queue[addr]cleanup after a failed re-enqueue, nor the newErrTotalCostOverflowclassification ineth/fetcher. Both are cheap to add and would lock in the bookkeeping invariants this PR is specifically about. - The new tests seed pool internals directly (
pool.mu.Lock(), assigningpool.queue[from],pool.all.Add) rather than going through public APIs. Pragmatic given the near-2^256 state required, but brittle against future refactors — worth a short comment in the file header explaining why the direct seeding is unavoidable. - 6 suggestion(s)/nit(s) flagged inline on specific lines.
| inserted, old := pool.queue[from].Add(tx, pool.config.PriceBump) | ||
| inserted, old, err := pool.queue[from].Add(tx, pool.config.PriceBump) | ||
| if err != nil { | ||
| return false, err |
There was a problem hiding this comment.
[suggestion] This early return leaves a phantom empty list behind: lines 851-853 create pool.queue[from] = newList(false) before Add runs, so on error the account gets an empty queue entry that nothing removes.
That matters because all three unreserve sites gate on the queue entry's absence — removeTx (1111), promoteExecutables (1504), demoteUnexecutables (1698) all do if _, ok := pool.queue[addr]; !ok { pool.reserve(addr, false) }. With an empty list present, ok is true and the reservation is never released. The Lifetime eviction loop won't clean it up either: it does pool.queue[addr].Flatten() and calls removeTx per tx, which is a no-op for an empty list. Net effect is a permanently reserved address (blocking blobpool txs from that sender) plus a stale entry surfacing in Content()/ContentFrom(), until some later add() for that account happens to repopulate it.
This is pre-existing for the !inserted/ErrReplaceUnderpriced case, but that case can only fire when the queue was already non-empty, so it never actually leaked. The new overflow error is the first one reachable with a freshly-created list, so this PR is what makes it real. Simplest fix, given the PR's goal of not leaving orphaned state:
inserted, old, err := pool.queue[from].Add(tx, pool.config.PriceBump)
if err != nil {
if pool.queue[from].Empty() {
delete(pool.queue, from)
}
return false, err
}(add() itself is safe — overflow can't occur against a genuinely empty list — so only the internal addAll=false callers are affected.)
| promoted = append(promoted, tx) | ||
| continue | ||
| } | ||
| // Stop promotion for this account to avoid a nonce gap in pending and |
There was a problem hiding this comment.
[suggestion] promoteTx returns false for two distinct reasons, and this now treats them identically. The overflow case (899-905) genuinely must stop to avoid a nonce gap; the pre-existing !inserted case (906-912) means pending already holds a better tx at that nonce, which is not a gap situation — the old code correctly continued to the next ready tx, and stopping instead needlessly bounces the remaining txs back into the queue where they wait for the next promoteExecutables run on that account.
In practice !inserted looks unreachable from here (readies starts at pool.pendingNonces.get(addr), and pending can't contain its own next nonce), so this isn't a live regression. But the conflation is easy to remove and makes the intent explicit — e.g. have promoteTx return (bool, error) and only break when err != nil.
| // into pool.queue[from], raising that list's totalcost after this check. | ||
| // enqueueTx will still reject with ErrTotalCostOverflow, but only after | ||
| // eviction may have already dropped other accounts' transactions. | ||
| if err := pool.targetList(from, tx).addCostOverflow(tx); err != nil { |
There was a problem hiding this comment.
[nit] targetList returns newList(false) for a sender with no pending-nonce match and no queue, which allocates a list plus a full SortedMap (map + index slice) purely to run a check that can never fail against an empty list. This sits on the pool-full path, which is the steady state on a busy node, so it's a per-add allocation for no result.
Consider having targetList return nil in that case and skipping the check:
if l := pool.targetList(from, tx); l != nil {
if err := l.addCostOverflow(tx); err != nil {
return false, err
}
}TestTargetList would need its first assertion flipped to expect nil.
| } | ||
| projected = new(uint256.Int).Set(l.totalcost) | ||
| if old != nil { | ||
| if _, underflow := projected.SubOverflow(projected, uint256.MustFromBig(old.Cost())); underflow { |
There was a problem hiding this comment.
[nit] Two small things here:
-
The underflow branch returns
ErrTotalCostOverflow, which will read as misleading if it ever surfaces in a log or an RPC error — underflow meansl.totalcostis out of sync with the list contents, a different (and more alarming) failure than an overflow. The doc comment above says "overflow/underflow", so the intent is deliberate; a distinct sentinel or at least a wrapped message would make the distinction visible. -
uint256.MustFromBig(old.Cost())panics ifold's cost doesn't fit uint256. That's safe by construction — every tx in the list passed theuint256.FromBigcheck at line 304 on the way in — but it sits directly next to a defensive underflow check that assumes the invariant might be broken. Either trust the invariant in both places or useFromBigand return an error in both.
| // If the new transaction is accepted into the list, the lists' cost and gas | ||
| // thresholds are also potentially updated. | ||
| func (l *list) Add(tx *types.Transaction, priceBump uint64) (bool, *types.Transaction) { | ||
| func (l *list) Add(tx *types.Transaction, priceBump uint64) (bool, *types.Transaction, error) { |
There was a problem hiding this comment.
[nit] The doc comment above ("returning whether the transaction was accepted, and if yes, any previous transaction it replaced") no longer describes the signature — worth noting the third return and, importantly, that the two rejection modes are distinct: (false, nil, nil) for a fee-bump failure vs. (false, nil, ErrTotalCostOverflow). Every caller has to check err before inserted, so making that contract explicit here is worth a line.
| duplicate++ | ||
|
|
||
| case errors.Is(err, txpool.ErrUnderpriced) || errors.Is(err, txpool.ErrReplaceUnderpriced): | ||
| case errors.Is(err, txpool.ErrUnderpriced) || errors.Is(err, txpool.ErrReplaceUnderpriced) || errors.Is(err, txpool.ErrTotalCostOverflow): |
There was a problem hiding this comment.
[nit] Folding ErrTotalCostOverflow into the underpriced counter conflates a per-sender aggregate-cost condition with an actual pricing rejection, which will quietly skew that metric if it ever fires. The LRU insert at line 346 is defensible — it stops a re-request loop the same way the underpriced cases do — but the metric bucket here is a separate decision. Consider a default-bucket fallthrough or its own counter so the two are distinguishable in telemetry.
Superseded: latest AI review found no blocking issues.

Summary
Addresses auditor feedback in PLT-995 for correctness of total-cost overflow handling in the legacy transaction pool.
ErrTotalCostOverflowso overflow is no longer reported asErrReplaceUnderpricedfor brand-new transactions.Ready.removeTxanddemoteUnexecutablesby dropping txs frompool.allinstead of leaving orphans that later returnErrAlreadyKnown.totalcostvia a single overflow-checked projection (projectedTotalCost) so validation and mutation use the same subtract-old-then-add-new order.Builds on the PLT-909 list-level overflow guard; this PR closes the remaining pool-level edge cases around promotion, eviction, and re-enqueue.
Known limitation (accepted)
The full-pool pre-eviction guard projects only onto
targetList(from, tx)as it exists beforepriced.Discard/removeTx. If the sender has pending transactions but no queue yet,targetListuses a throwaway empty list and the check usually passes. Eviction can then demote that sender's pending txs intopool.queue[from], raising itstotalcostafterward;enqueueTxstill rejects withErrTotalCostOverflow, but only after other accounts' transactions may have been evicted. This narrows the bad window (covered byTestAddRemoteTotalCostOverflowErrorFullPool) rather than closing it entirely; fixing it properly would require re-checking after eviction with rollback or excluding the sender's pending txs from the discard set.Context
Total-cost overflow requires aggregate tracked cost near 2²⁵⁶ wei;
validateTxbounds each tx by sender balance, so this is audit-driven hardening rather than a live exploitable path.Test plan
go test ./core/txpool/legacypool/ -run 'TestListAddTotalCost|TestListAddTotalcost|TestAddRemoteTotalCost|TestPromoteExecutablesStops|TestDemoteReenqueue|TestTargetList|TestListAddCostOverflow'go test ./core/txpool/legacypool/