fix(revm): carry the prepaid rounding credit into alt-token refunds (Celadon) - #212
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
🚧 Files skipped from review as they are similar to previous changes (1)
Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review. 📝 WalkthroughWalkthroughMorphTx V2 adds EIP-7702 authorization lists. Celadon activates V2 by timestamp. RPC, consensus, transaction-pool, EVM, fee-token, node, and state-test paths now support the new transaction shape and validation rules. ChangesCeladon activation
Priority: ➖ Normal Estimated code review effort: 5 (Critical) | ~90 minutes Change: Bug fix Sequence Diagram(s)sequenceDiagram
participant Client
participant RPC
participant TxPool
participant Consensus
participant EVM
Client->>RPC: submit MorphTx with authorizationList
RPC->>RPC: derive V2
RPC->>TxPool: forward MorphTx V2
TxPool->>Consensus: validate Celadon and authorization rules
Consensus-->>TxPool: accept transaction
TxPool->>EVM: execute transaction
EVM-->>Client: return receipt and transaction result
Merge Risk: ⚪ Minimal · up to No actionable current-head risk remains from the reviewed changes. 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Rounding the prepaid alt-token fee up and the unused-gas refund up again under-collects: the chain gives back part of a token unit the caller never spent. From Celadon on, the deduction records the numerator its ceiling overcharged and the refund adds it back before rounding down, so the caller is charged the ceiling of the *net* fee. Before Celadon both halves keep rounding up, because that is what mainnet state was built from. Rounding down can reach zero, which the ceiling never did for a non-zero refund. go-ethereum's `TransferAltTokenHybrid` returns early on a zero amount, so the refund now does too: no `Transfer(.., 0)` log on the call path, no slot writes on the direct-slot path. This is not gated on the transaction being MorphTx v2 — it applies to every alt-fee transaction at the fork, so a client without it diverges on the first token-fee transaction after activation, not on the first v2 one. Ports go-ethereum#371 `886d7f40b` and `4d71e2b72`.
Nine golden roots from morph-geth 5a0d0d771: three consecutive gas limits on each of Emerald, Jade and Celadon. The fee token is registered with `priceRatio = 3` against `scale = 1` and the transaction carries one non-zero calldata byte, so neither the prepaid fee nor the transaction's gas cost is a multiple of the ratio. That is the only shape where rounding both halves up independently disagrees with charging the ceiling of the net fee: Celadon collects ceil(21_016 / 3) = 7_006 on all three limits, while Emerald and Jade collect 7_005 on two of them and land on a second state root. The 21_016 also pins the gas: morph does not apply the EIP-7623 calldata floor, which would bill 21_040 and miss every root in the fixture.
`cached_alt_fee_rounding_credit` was the one per-transaction cache the reset at the top of `validate_against_state_and_deduct_caller` left alone. That is safe today: the credit is only read next to `cached_token_fee_info`, and the same deduction writes both. Clearing it with the rest keeps that true without depending on where the reads happen. No behaviour change. The new test fails if the reset is removed. Claude-Session: https://claude.ai/code/session_01WYbNZVUBHa4qCoRK46taTS
…fixture The fixture ran one calldata length, whose net fee of 21_016 gas leaves a remainder of 1 modulo the price ratio of 3. For that remainder the prepaid rounding credit never carries into the refund, so a client that rounds the refund down but drops the credit lands on all nine roots: the fixture pinned the fork gate and the rounding direction, not the credit. Run one, two and three non-zero calldata bytes (21_016, 21_032 and 21_048 gas, remainders 1, 2 and 0) against the same three gas limits. With the credit dropped, Celadon over-collects a token unit on three of the six new Celadon cases and misses their roots. The 27 state and logs roots come from morph-geth 5a0d0d771 (go-ethereum#371) and `evm statetest` reads them back from this file. The nine roots that were already here are unchanged. Claude-Session: https://claude.ai/code/session_01WYbNZVUBHa4qCoRK46taTS
d7162b8 to
2b26c0f
Compare
Summary
From Celadon on, an alt-fee transaction's unused-gas refund carries the rounding
credit the deduction overcharged and rounds down, instead of rounding up a second
time. Ports go-ethereum#371
886d7f40band4d71e2b72, the one part of that PRmorph-reth was missing.
This is not about MorphTx v2. The rule applies to every transaction paying its
fee in an ERC20, so without it a morph-reth follower diverges on the first alt-fee
transaction after Celadon activates, not on the first v2 one. Mainnet's slotless
fee tokens are USDC (registry ids 2 and 6), so the divergence is reachable by
ordinary traffic.
The arithmetic
Rounding the prepaid fee up and the refund up again gives the caller back part of
a token unit it never spent:
EthToAltnow also reportsrate - remainder, the numerator that ceiling leftunused, and the refund adds it back before rounding down:
Before Celadon both halves keep rounding up: that is what mainnet state was built
from and it must stay bit-identical.
Rounding down can reach zero, which the ceiling never did for a non-zero refund.
go-ethereum's
TransferAltTokenHybridreturns early on a zero amount, so therefund now does too — no
Transfer(.., 0)log on the call path, no slot writes onthe direct-slot path.
Changes
TokenFeeInfo::eth_to_token_amount_with_creditandeth_to_token_amount_floormirror geth's
EthToAlt/EthToAltFloor. A misconfigured token floors to zero(refund nothing) where the ceiling returns
U256::MAX(make the deduction fail);unreachable either way, since such a transaction never clears the balance check.
MorphEvm::cached_alt_fee_rounding_creditcarries the credit from deduction torefund, like geth's
st.altFeeRoundingCredit, and is cleared with the otherper-transaction caches.
eth_callgas allowance, the L1 fee intoken units — keeps the ceiling, matching geth, which only changed
refundGas.Twelve lines of production code; the rest is tests.
Cross-client evidence
bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.jsoncarries 27state and logs roots generated by morph-geth
5a0d0d771, which reads them backfrom the same fixture: three calldata lengths against three consecutive gas limits
on each of Emerald, Jade and Celadon. The fee token is registered with
priceRatio = 3againstscale = 1. One, two and three non-zero calldata bytescost 21_016, 21_032 and 21_048 gas, which covers every remainder of the net fee
modulo the ratio; the gas limits 100_001 to 100_003 cover every remainder of the
prepaid fee.
Tokens collected, per gas limit:
Celadon collects
ceil(net / 3)everywhere, so it ends on one state root percalldata length. Emerald and Jade come out a unit short on three of the nine, so
the first two rows end on two roots each.
The last column is a broken client, not a fork: it rounds the refund down but drops
the credit. With a net fee of 21_016 the credit never carries into the refund, so
that client still lands on every root of the first row. The other two rows are what
pin the credit itself.
The gas figures also pin the transaction's gas: morph does not apply the EIP-7623
calldata floor, which would bill 21_040 for the first row and miss every root in
the fixture.
Verified to have teeth: with the fork gate removed, Celadon lands on the
pre-Celadon root (
0xdfaedbf5…instead of0x29e5e2da…) — a state rootmismatch, which is what a mixed network would see.
Each of the six ways to break this change fails at least one test, and the first
four fail the geth-derived fixture:
Base and merge order
#210 is now on
mainas4955f7c(squashed), so this branch was rebased onto #211'stip: the diff is exactly the four commits that belong here —
c391f5c,9778509,2524997and2b26c0f, five files, +957.The base is #211's branch rather than
mainbecause #211 defines the Celadon forkthis change is gated on. The cost is that
test.yml,lint.ymlandbuild.ymlaregated on
pull_request: branches: [main], so against a non-mainbase onlycargo-deny and the title check fire. The full suite was run locally on this exact
commit instead — see the test plan. Once #211 merges, GitHub retargets this PR to
mainand the four commits rebase cleanly.Neither client should schedule
celadonTimeuntil this is on both sides.Test plan
cargo nextest run --profile ci --workspace(985 passed)cargo nextest run --profile ci -p morph-node --test it --features test-utils(146 passed)cargo clippy --all --all-targets -- -D warningscargo fmt --all -- --checkcargo test --doc --all— exceptmorph-chainspec'slib.rs:23doctest,whose executable is killed by a signal on my machine. Pre-existing and
unrelated: it fails the same way on
mainwithout these commits, and thischange touches no chainspec code. CI is the authority on it, and feat: support MorphTx v2 with EIP-7702 authorization lists #211's run
of the same tree has Doc Tests green.
Summary by CodeRabbit
New Features
Bug Fixes