Sitelet https://github.com/ethereum-optimism/optimism/pull/23076
Skip to content

op-reth: include OP fees in txpool cost for sender affordability - #23076

Merged
geoknee merged 2 commits into
developfrom
op-reth-pool-l1-cost
Sep 29, 2026
Merged

geoknee merged 2 commits into
developfrom
op-reth-pool-l1-cost

Conversation

@geoknee

@geoknee geoknee commented Sep 29, 2026 •

Copy link
Copy Markdown
Contributor

Why

  • OpTransactionValidator::apply_op_checks checks L2 cost + L1 data fee + operator fee <= balance, but only for one transaction at a time, against the on-chain balance.
  • reth's pool decides which of a sender's consecutive nonces are pending (ENOUGH_BALANCE) by summing PoolTransaction::cost(), both on insert and after each canonical update. For OpPooledTransaction that value was EthPooledTransaction::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 list adds txpool.TotalTxCost(tx, rollupCostFn) (L1 cost included) to totalcost, so unaffordable descendants go to queued.

What

  • OpPooledTx::set_op_fee_reservation(fee): a new required trait method. OpPooledTransaction stores L2 cost + fee and returns it from cost().
  • apply_op_checks reserves the L1 data fee and operator fee it already computes, then runs its balance check against the resulting cost().
  • Result: reth's own cumulative accounting now covers the OP fees. Unaffordable later nonces go to queued, where lifetime eviction removes them.

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's PBPooledTransaction (policy-engine/src/pb_tx.rs) will need a one-line delegation on its next monorepo bump:

fn set_op_fee_reservation(&mut self, fee: U256) { self.inner.set_op_fee_reservation(fee) }

Its cost() already delegates to the inner transaction.

Tests

  • New validator::tests::pool_parks_descendant_unaffordable_with_l1_fee builds a real reth Pool with OpTransactionValidator, a nonzero L1 fee, and two nonces that are each affordable on their own but not together.
    • Before the fix it fails with pending: [0, 1].
    • After the fix it passes with pending: [0], queued: [1].
  • cargo test -p reth-optimism-txpool: 63 passed.
  • cargo test -p reth-optimism-payload-builder: 31 passed.
  • cargo clippy on both crates adds no new warnings. The reth-optimism-evm unused-import warning was already there.
  • cargo check passes for reth-optimism-node and reth-optimism-rpc.

🤖 Generated with Claude Code

`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>
Comment thread rust/op-reth/crates/txpool/src/validator.rs Outdated
Comment thread rust/op-reth/crates/txpool/src/validator.rs Outdated
Co-authored-by: George Knee <georgeknee@googlemail.com>
@geoknee
geoknee marked this pull request as ready for review September 29, 2026 11:03
@geoknee
geoknee requested a review from a team as a code owner September 29, 2026 11:03
@geoknee
geoknee enabled auto-merge September 29, 2026 11:19
@geoknee
geoknee added this pull request to the merge queue Sep 29, 2026

@pcw109550 pcw109550 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, nonblocking: this will break any downstream wrapper.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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::update when the sender's account changes, but with the stored cost(). 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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.
  2. 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 update cost() in place, only remove. That makes this more code for about the same result as (1).
  3. 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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, nonblocking: can we rename this to op_cost?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Renamed to op_cost in #23077.

}
}

/// Helper trait to provide payload builder with access to conditionals and encoded bytes of

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should update the comment; we now have write operation: set_op_fee_reservation

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated in #23077: the trait doc now covers set_op_fee_reservation as well as the payload-builder accessors.

Merged via the queue into develop with commit f850b2c Sep 29, 2026
104 checks passed
@geoknee
geoknee deleted the op-reth-pool-l1-cost branch September 29, 2026 12:01
geoknee added a commit that referenced this pull request Sep 29, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants