fix(txpool): restore cumulative affordability enforcement with fee-delegation accounting#116
fix(txpool): restore cumulative affordability enforcement with fee-delegation accounting#116colinkim wants to merge 1 commit into
Conversation
Port go-wbft cumulative overdraft validation onto stableNet's fee model. Track sender-side pending cost via list.totalcost and fee-payer gas via pool-wide pendingGas, and include both in ExistingExpenditure so shared fee-payer expenditure are checked cumulatively. Fee-payer gas is reserved at gasFeeCap (tx.FeeCost()) as a conservative upper bound, matching upstream mempool semantics.
0xmhha
left a comment
There was a problem hiding this comment.
The description in this PR uses "pending expenditure" in the Demotion section:
▎ "ensuring a transaction is removed from pending if its fee payer later becomes unable to cover the gas pending expenditure"
However, the original wording in go-wbft#197 uses "obligation":
▎ "unable to cover the gas obligation"
The word "pending expenditure" implies the demotion check uses the cumulative pendingGas index, but the actual Filter implementation does a per-tx spot check against the on-chain balance (tx.FeeCost() > stateDB.GetBalance(feePayer)). This is correct and intentional behavior — the cumulative guard runs at Add time, not during demotion — but the rephrased description could mislead readers into expecting a cumulative check at demotion.
Suggest reverting the wording in the Demotion section to match go-wbft#197 for consistency.
There was a problem hiding this comment.
File: core/txpool/legacypool/legacypool_test.go
Location: After the existing fee-delegation test group (e.g., after TestFeeDelegationFilterShortCircuitGate)
The existing tests cover the case where sender != feePayer, but there is no test for a fee-delegated transaction where sender == feePayer (i.e., an account sponsors its own gas via the fee-delegation path).
This case exercises a non-obvious accounting path: in addCost, the sender-as-feePayer splits its own Cost() across two buckets — totalcost receives tx.Value() and
pendingGas[sender] receives tx.FeeCost(). In ExistingExpenditure, both are summed back together, which must equal tx.Cost(). In validation, the from == gasPayer branch then treats the combined total as a single balance check.
A test should verify:
- totalcost[sender] == tx.Value() (not tx.Cost())
- pendingGas[sender] == tx.FeeCost()
- ExistingExpenditure(sender) == tx.Cost() (the sum of both)
- A second FD tx from the same sender-as-feePayer is correctly rejected when the cumulative total would exceed the balance
Without this coverage, a regression that accidentally routes FD self-pay through the else branch (fee-delegation split path) — charging only tx.Value() to the sender and leaving tx.FeeCost() unchecked — would go undetected.
Summary
Fee-delegated transactions (type 0x16) split their financial pending expenditure:
The upstream cumulative overdraft check introduced by ethereum/go-ethereum#27429 assumed that the sender pays the entire transaction cost. Because that assumption caused false rejections for fee-delegated transactions, the check had been disabled globally.
Disabling it allowed individually affordable but collectively unfundable transactions to occupy txpool capacity:
This change restores cumulative overdraft protection while accounting for sender and fee-payer pending expenditure separately, preserving StableNet's fee model.
Changes
Cumulative accounting
list.totalcostfor sender-side pending expenditure:tx.Cost()tx.Value()pendingGas, indexed by fee payer, to aggregate delegated gas pending expenditure across sender lists.pendingGasthrough paired add/remove callbacks. Queued transactions remain outside pending expenditure accounting.Stateful validation
ExistingTxso replacement validation can subtract the previous transaction's value and gas from the correct accounts, including fee-payer changes.Demotion
Tests
pendingGasaccountingVerified with: