op-reth: include OP fees in txpool cost for sender affordability - #23076
Conversation
`OpTransactionValidator` checks L2 cost + L1 data fee + operator fee against the sender's balance, but only per transaction. reth's pool then decides which of a sender's consecutive nonces are pending by summing `PoolTransaction::cost()`, which for `OpPooledTransaction` was the L2 cost alone. A sender whose balance covers one transaction's L1 data fee but not two had every nonce marked pending; the payload builder then rejected the later ones for insufficient funds each block, and since lifetime eviction only applies to queued/basefee transactions they never left the pool (observed as a growing stuck-pending cohort on ink-mainnet). Reserve the OP fees on the pooled transaction during validation so `cost()` reports the worst-case total, matching op-geth's pool which counts `TotalTxCost` in its per-account cost. Unaffordable descendants now park in queued and age out. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: George Knee <georgeknee@googlemail.com>
pcw109550
left a comment
There was a problem hiding this comment.
Approved with nits; there is a design choice how we can handle the freshness of L1 fees / operator fee.
| /// | ||
| /// The pool sums `cost()` over a sender's consecutive nonces to decide which are executable, so | ||
| /// without the reservation it marks transactions pending that cannot pay their L1 data fee. | ||
| fn set_op_fee_reservation(&mut self, fee: U256); |
There was a problem hiding this comment.
nit, nonblocking: this will break any downstream wrapper.
There was a problem hiding this comment.
Yes, that's intentional. With a default no-op, a wrapper would compile but keep reporting the L2-only cost(), which is exactly this bug. I'd rather it fail to compile. The only downstream implementor I know of is op-reth-premium's PBPooledTransaction. I'll add the one-line delegation in the premium PR that bumps to this rev, since the scheduled bump wouldn't compile without it.
| tx.set_op_fee_reservation(cost_addition) | ||
| } | ||
| } | ||
| let cost = *valid_tx.transaction().cost(); |
There was a problem hiding this comment.
Limitation, non-blocking: we bake the L1 data fee and operator fee into cost() here, at admission, and nothing refreshes them afterwards. What happens when the L1 fee moves after admission?
- Fee rises: reth re-runs the cumulative balance check in
AllTransactions::updatewhen the sender's account changes, but with the storedcost(). So a later nonce can stay pending while op-revm now rejects it for insufficient funds. That is the same stuck-in-pending shape this PR fixes (retried every block, never evicted), just confined to the window between admission and inclusion instead of every nonzero L1 fee. Much smaller window, but not zero, and accounts funded "just enough" have little slack. - Fee falls: the descendant is over-reserved and sits in queued until the sender's account changes. With a tight balance it may never get promoted and lifetime eviction removes it.
There was a problem hiding this comment.
Agreed on both counts. Some notes, and a correction to my PR description.
op-geth isn't admission-only either. I wrote "like op-geth" and that overstated it. op-geth caches each tx's total cost (L1 cost included) at list.Add, and uses that for the cumulative totalcost. On every reset, though, demoteUnexecutables → list.Filter re-prices each tx on its own with the current rollupCostFn, drops the ones the balance no longer covers, and in strict mode drops their descendants too. It short-circuits when costcap <= balance, so it's partial. It still covers the fee-rise case you describe better than this PR does.
Fee rises. You're right. It's the same shape confined to a smaller window, and the accounts with the least slack are the most exposed. Options:
- Evict when the builder gets
InsufficientFunds. When payload execution rejects a pool tx for insufficient funds, remove it and its descendants from the pool, the way op-geth's strict demotion does. Today the builder only skips them for that payload. This targets "retried every block, never evicted" whatever the cause, including fee drift and any other gap between pool and EVM accounting. - Re-price on new heads in op-reth's maintain loop. When the L1 fee params rise, re-price the affected senders' pending txs against their balances and remove the ones that no longer fit. The pool holds
Arcs, so we can't updatecost()in place, only remove. That makes this more code for about the same result as (1). - Reserve with headroom (price at a multiplier of the current L1 fee). It's cheap, but it trades a rarer stuck-pending for more over-reservation in queued, and we'd have to pick the multiplier.
I'd lean towards (1) as the follow-up.
Fee falls. Also right. I think over-reserving is the acceptable bias here: the tx ages out of queued rather than sitting in pending forever, and the sender can resubmit. That's the same outcome op-geth gives a tx its filter removes.
Happy to open an issue for (1) and pick it up. It's a separate change from the nits in #23077.
There was a problem hiding this comment.
Draft for the fee-rise case: #23078. I went with option 2, re-pricing in the txpool maintenance loop, rather than the builder eviction I suggested above. OpPayloadBuilderCtx has no pool handle, and op-reth-premium builds that context itself for its subblocks producer, so eviction in the builder would need a coordinated premium change and would only ever clean the sequencer. The maintenance task starts from OpPoolBuilder::build_pool, so every node, premium included, gets it. The fee-fall case is left as is. Details are in the PR description.
|
|
||
| /// Worst-case cost including the OP fees reserved at validation, see | ||
| /// [`OpPooledTx::set_op_fee_reservation`]. | ||
| cost: U256, |
There was a problem hiding this comment.
nit, nonblocking: can we rename this to op_cost?
| } | ||
| } | ||
|
|
||
| /// Helper trait to provide payload builder with access to conditionals and encoded bytes of |
There was a problem hiding this comment.
Should update the comment; we now have write operation: set_op_fee_reservation
There was a problem hiding this comment.
Updated in #23077: the trait doc now covers set_op_fee_reservation as well as the payload-builder accessors.
Rename the reserved cost field to `op_cost`, describe the new write operation in the `OpPooledTx` doc, and trim two comments per review on #23076. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Why
OpTransactionValidator::apply_op_checkschecksL2 cost + L1 data fee + operator fee <= balance, but only for one transaction at a time, against the on-chain balance.ENOUGH_BALANCE) by summingPoolTransaction::cost(), both on insert and after each canonical update. ForOpPooledTransactionthat value wasEthPooledTransaction::cost, the L2 cost only.So a sender who can afford one transaction's L1 fee but not two passes admission for both, and both are marked pending. reth's lifetime eviction (
max_tx_lifetime, 3h) only scans the queued and basefee subpools, so these transactions never leave the pool. They stay until the pending subpool hits its size limit, the sender tops up or replaces them, or the node restarts.op-geth already handles this: its per-account
listaddstxpool.TotalTxCost(tx, rollupCostFn)(L1 cost included) tototalcost, so unaffordable descendants go to queued.What
OpPooledTx::set_op_fee_reservation(fee): a new required trait method.OpPooledTransactionstoresL2 cost + feeand returns it fromcost().apply_op_checksreserves the L1 data fee and operator fee it already computes, then runs its balance check against the resultingcost().As in op-geth, the fees are priced at admission. They are not re-priced when the L1 fee parameters change.
Downstream
The trait method is required on purpose, so a wrapper can't silently keep the old L2-only
cost(). op-reth-premium'sPBPooledTransaction(policy-engine/src/pb_tx.rs) will need a one-line delegation on its next monorepo bump:Its
cost()already delegates to the inner transaction.Tests
validator::tests::pool_parks_descendant_unaffordable_with_l1_feebuilds a real rethPoolwithOpTransactionValidator, a nonzero L1 fee, and two nonces that are each affordable on their own but not together.pending: [0, 1].pending: [0],queued: [1].cargo test -p reth-optimism-txpool: 63 passed.cargo test -p reth-optimism-payload-builder: 31 passed.cargo clippyon both crates adds no new warnings. Thereth-optimism-evmunused-import warning was already there.cargo checkpasses forreth-optimism-nodeandreth-optimism-rpc.🤖 Generated with Claude Code