Skip to content

fix(revm): carry the prepaid rounding credit into alt-token refunds (Celadon) - #212

Merged
panos-xyz merged 4 commits into
feat/morphtx-v2-eip7702from
feat/celadon-alt-token-refund-rounding
Sep 20, 2026
Merged

panos-xyz merged 4 commits into
feat/morphtx-v2-eip7702from
feat/celadon-alt-token-refund-rounding

Conversation

@panos-xyz

@panos-xyz panos-xyz commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

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 886d7f40b and 4d71e2b72, the one part of that PR
morph-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:

prepaid  = ceil(gas_limit * price / rate)
refund   = ceil(gas_left  * price / rate)     <- both round up
collected = prepaid - refund                  <= ceil(net fee), sometimes short

EthToAlt now also reports rate - remainder, the numerator that ceiling left
unused, and the refund adds it back before rounding down:

refund    = floor((gas_left * price + credit) / rate)
collected = ceil(net fee)                     <- exactly, always

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 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.

Changes

  • TokenFeeInfo::eth_to_token_amount_with_credit and eth_to_token_amount_floor
    mirror 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_credit carries the credit from deduction to
    refund, like geth's st.altFeeRoundingCredit, and is cleared with the other
    per-transaction caches.
  • Every other conversion — pool admission, eth_call gas allowance, the L1 fee in
    token 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.json carries 27
state and logs roots generated by morph-geth 5a0d0d771, which reads them back
from 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 = 3 against scale = 1. One, two and three non-zero calldata bytes
cost 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:

Net gas Emerald, Jade Celadon Floor without the credit
21_016 7_005, 7_005, 7_006 7_006 on all three 7_006 on all three
21_032 7_011, 7_010, 7_011 7_011 on all three 7_011, 7_011, 7_012
21_048 7_016 on all three 7_016 on all three 7_017, 7_016, 7_017

Celadon collects ceil(net / 3) everywhere, so it ends on one state root per
calldata 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 of 0x29e5e2da…) — a state root
mismatch, 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:

Mutation Failures
fork gate inverted 6
credit computed as zero 6
floor drops the credit 4
credit never plumbed to the refund 4
zero-refund early return removed 1
per-transaction reset of the credit removed 1

Base and merge order

#210 is now on main as 4955f7c (squashed), so this branch was rebased onto #211's
tip: the diff is exactly the four commits that belong here — c391f5c, 9778509,
2524997 and 2b26c0f, five files, +957.

The base is #211's branch rather than main because #211 defines the Celadon fork
this change is gated on. The cost is that test.yml, lint.yml and build.yml are
gated on pull_request: branches: [main], so against a non-main base only
cargo-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
main and the four commits rebase cleanly.

Neither client should schedule celadonTime until 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 warnings
  • cargo fmt --all -- --check
  • cargo test --doc --all — except morph-chainspec's lib.rs:23 doctest,
    whose executable is killed by a signal on my machine. Pre-existing and
    unrelated: it fails the same way on main without these commits, and this
    change 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

    • Added Celadon hardfork support with timestamp-based activation.
    • Added Morph transaction version 2 with EIP-7702 authorization lists across signing, delegation, RPC, simulation, and gas estimation.
    • Added fee-token support for internal calls, including balance resolution and refund handling.
  • Bug Fixes

    • Corrected fee-token rounding, refund calculations, and effective fee limits.
    • Improved validation and error reporting for unsupported authorization lists and invalid contract-creation transactions.
    • Added comprehensive coverage for Celadon, fee tokens, authorization lists, and mainnet transaction behavior.

@coderabbitai

coderabbitai Bot commented Sep 18, 2026

Copy link
Copy Markdown

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 54457736-809a-4cbd-9d3a-02764c44683c

📥 Commits

Reviewing files that changed from the base of the PR and between 0b96839 and d7162b8.

📒 Files selected for processing (3)
  • bin/morph-statetest/tests/celadon_alt_token_refund.rs
  • bin/morph-statetest/tests/fixtures/celadon_alt_token_refund.json
  • crates/revm/src/handler.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • bin/morph-statetest/tests/celadon_alt_token_refund.rs

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.


📝 Walkthrough

Walkthrough

MorphTx 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.

Changes

Celadon activation

Layer / File(s) Summary
Celadon hardfork configuration
crates/chainspec/..., crates/node/...
Genesis parsing and hardfork schedules now support celadonTime. Celadon maps to OSAKA and activates at its configured timestamp.
MorphTx V2 transaction contract
crates/primitives/..., crates/consensus/..., crates/rpc/..., crates/txpool/...
TxMorph supports authorization lists, V2 encoding, decoding, serialization, compact storage, and validation. RPC derives V2 from a non-empty authorization list. Consensus and pool validation gate V2 on Celadon.
Fee-token execution
crates/revm/..., crates/evm/...
Fee-token balance reads use the supplied EVM environment. Rounding credit supports net-fee refunds. Block execution and receipt handling use the updated interfaces.
Integration and state-test coverage
bin/morph-statetest/..., crates/node/tests/...
Tests cover authorization delegation, RPC output, pool limits, fee-token calls, rounding, receipts, logs, and Celadon 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
Loading

Merge Risk: ⚪ Minimal · up to d7162

No actionable current-head risk remains from the reviewed changes.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 85.42% which is sufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 288 functions across 29 files. (1 skipped: …
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the primary change: carrying prepaid rounding credit into alt-token refunds for the Celadon fork. It is concise and directly related to the changeset.
✨ Finishing Touches
📝 Generate docstrings
  • Commit to this branch
  • Create a new PR
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@panos-xyz
panos-xyz changed the base branch from fix/revm-call-mode-fee-parity to main September 18, 2026 09:53
@panos-xyz panos-xyz closed this Sep 18, 2026
@panos-xyz panos-xyz reopened this Sep 18, 2026
@panos-xyz
panos-xyz changed the base branch from main to feat/morphtx-v2-eip7702 September 20, 2026 02:03
panos-xyz and others added 4 commits September 20, 2026 10:20
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
@panos-xyz
panos-xyz force-pushed the feat/celadon-alt-token-refund-rounding branch from d7162b8 to 2b26c0f Compare September 20, 2026 02:23
@panos-xyz
panos-xyz merged commit 2b26c0f into feat/morphtx-v2-eip7702 Sep 20, 2026
3 checks passed
@panos-xyz
panos-xyz deleted the feat/celadon-alt-token-refund-rounding branch September 20, 2026 02:34
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.

1 participant