Conversation
…efund morph-geth runs the ERC20 fee `transfer()` for slotless fee tokens through `evm.Call` inside `buyAltTokenGas()`, before `StateDB.Prepare`. `Prepare` resets the access list and transient storage but not the refund counter, so any SSTORE refund the token contract earns during that protocol call (e.g. clearing the payer's balance slot) is settled against the user's own gas in `refundGas()`. morph-reth ran the same call with a frame-local `Gas` and dropped its refund, so the two clients disagreed on `gasUsed` whenever the fee transfer touched a refundable slot: - a 21000-gas value transfer whose fee clears the payer's balance settles at 16800 on geth and 21000 on reth; - if the main call then hands tokens back to the payer, geth nets +4800 against -4800 and refunds nothing, while reth's main frame ended at -4800 and revm's final-refund cast turned that into the maximum `gas_used / 5` refund (30974 vs 24780). Both paths are reachable on mainnet: token ids 2 and 6 in the L2 token registry have no `balanceSlot` and take the EVM-call path. Record the net refund returned by the fee transfer frame on `MorphEvm` and fold it into the transaction's refund counter before the EIP-3529 cap, matching go-ethereum's accounting.
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: Warning Billing warning: we have not been able to collect payment for this subscription for more than 72 hours. Please update the payment method or pay any pending invoices in Billing to avoid service interruption. 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 |
|
Thanks for this — it is a well-diagnosed bug with a clean, focused fix, and pinning the geth numbers with a standalone It reaches the state root. Disabling the refund carry-over in morph-reth and replaying PR #200's geth-derived fixture ( The trigger is live on mainnet. Mainnet One overlap to settle: PR #200 ( |
|
Thanks for reproducing it and for taking it through the state-root fixture — I had only pinned the On the overlap: my suggestion would be to land #207 first as the standalone consensus fix and rebase #200 on top of it, dropping the That said, if you'd rather keep everything in #200 since it's already carrying the geth-derived fixture, I'm fine closing this one — just say the word. |
|
Thanks — this was a correct diagnosis, and it is why the call path ended up fixed properly rather than patched. Your fix is fully absorbed by #210 ( I measured your fix on its own, against the geth golden fixtures, since that is the clearest way to state what it does. Applied to One caveat so you know exactly what became of your tests: your two named unit tests were not transplanted verbatim — there is no 30_974 assertion in #210, and scenario 2 is pinned by the fixture instead. Say the word and we will port both forms in. Why it landed as #210 rather than #207. The remaining four templates are three further divergences on the same path, none of which your report could have surfaced:
All four were state-root, receipts or admission divergences on the same path. #210 is Why it is timely. The plan is to move every mainnet fee token to the call path, which makes this path universal rather than two tokens (ids 2 and 6, both USDC, today). Unmodified v1.3.0 rejects blocks containing such transactions, so the fix has to reach nodes before the registry entries are flipped. So we would like to close this as superseded — with thanks. Your report is what surfaced the rest of it. |
|
Closing as superseded by #210, which absorbs this fix along with the rest of the call-path divergences found while investigating it. Thanks again for the report — it is what surfaced them. |
Summary
For fee tokens registered without a
balanceSlot(EVM-call path), morph-geth and morph-reth disagree ongasUsedwhenever the protocol-level feetransfer()earns an SSTORE refund. This PR makes morph-reth match morph-geth.What morph-geth does
buyAltTokenGas()runs the ERC20transfer()throughevm.CallinsidepreCheck(), i.e. beforeStateDB.Prepare.Prepareresets the access list and transient storage but not the refund counter, so whatever the token contract adds toStateDB.refundduring that call (for example the 4800-gas clearing refund when the payer's balance slot goes to zero) is still there whenrefundGas()computes the user's refund fromst.state.GetRefund().What morph-reth did
transfer_erc20_with_evmexecutes the call with a frame-localGasand discardsframe_result.gas().refunded(), so the main transaction's refund counter starts at zero.Observable divergence
gasUsedgasUsed(before)21000 - min(4800, 21000/5))+4800 - 4800 = 0)-4800; revm'sset_final_refundcasts the negative refund tou64and caps it atgas_used / 5)Both are header
gasUsed/ receipts-root mismatches, so a morph-reth follower rejects the block. It also reaches the state root: the refund changesgas_used, which changes the fee charged and therefore the payer's/coinbase's balances. Replaying PR #200's geth-derived fixture (fee_token_internal_calls.json, golden roots from morph-geth5744b8f66) with the carry-over disabled fails 4 cases on both Emerald and Jade withstate root mismatch(deduct_clear= scenario 1,main_restores_cleared_slot= scenario 2; reproduced by @panos-xyz, see below).The path is live on mainnet: L2TokenRegistry (
0x5300…0021) token ids 2 and 6 havebalanceSlot = 0and take the EVM-call path (both are USDC, FiatTokenV2, active), Jade (jade_fork_time = 1775628000, 2026-04-08) strictly enforces state-root validation, and the trigger (the fee transfer consuming exactly the remaining token balance, controllable viagas_limit/gas_price) is something a user can construct.The morph-geth numbers were confirmed with a standalone
coretest that builds the same slotless token and runsApplyMessage(not included here; happy to share).Fix
Return the net refund recorded by the fee-transfer frame from
transfer_erc20_with_evm, keep it onMorphEvm::pre_fee_gas_refund, andrecord_refundit in therefund()hook beforepost_execution::refundapplies the EIP-3529 cap. The reimbursement-path call is unaffected (geth reads the counter before that transfer runs).Tests
Two unit tests in
crates/revm/src/handler.rsreproduce both scenarios with a hand-assembled slotless ERC20 and assert the morph-geth values (16800 and 30974). Both fail without the fix with exactly the numbers above.cargo fmt --all -- --checkcargo clippy -p morph-revm --all-targets -- -D warningscargo test -p morph-revm -p morph-evmcargo test -p morph-node --features test-utils --test it morph_tx