Skip to content

fix(revm): carry the token fee transfer's SSTORE refund into the tx refund - #207

Closed
kriss39 wants to merge 1 commit into
morph-l2:mainfrom
kriss39:fix/token-fee-refund-parity
Closed

kriss39 wants to merge 1 commit into
morph-l2:mainfrom
kriss39:fix/token-fee-refund-parity

Conversation

@kriss39

@kriss39 kriss39 commented Sep 13, 2026 •

Copy link
Copy Markdown

Summary

For fee tokens registered without a balanceSlot (EVM-call path), morph-geth and morph-reth disagree on gasUsed whenever the protocol-level fee transfer() earns an SSTORE refund. This PR makes morph-reth match morph-geth.

What morph-geth does

buyAltTokenGas() runs the ERC20 transfer() through evm.Call inside preCheck(), i.e. before StateDB.Prepare. Prepare resets the access list and transient storage but not the refund counter, so whatever the token contract adds to StateDB.refund during that call (for example the 4800-gas clearing refund when the payer's balance slot goes to zero) is still there when refundGas() computes the user's refund from st.state.GetRefund().

What morph-reth did

transfer_erc20_with_evm executes the call with a frame-local Gas and discards frame_result.gas().refunded(), so the main transaction's refund counter starts at zero.

Observable divergence

Scenario morph-geth gasUsed morph-reth gasUsed (before)
21000-gas value transfer; fee clears the payer's token balance slot 16800 (21000 - min(4800, 21000/5)) 21000
Same, but the main call hands one token back to the payer (re-sets the cleared slot) 30974 (geth nets +4800 - 4800 = 0) 24780 (main frame ends at -4800; revm's set_final_refund casts the negative refund to u64 and caps it at gas_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 changes gas_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-geth 5744b8f66) with the carry-over disabled fails 4 cases on both Emerald and Jade with state 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 have balanceSlot = 0 and 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 via gas_limit / gas_price) is something a user can construct.

The morph-geth numbers were confirmed with a standalone core test that builds the same slotless token and runs ApplyMessage (not included here; happy to share).

Fix

Return the net refund recorded by the fee-transfer frame from transfer_erc20_with_evm, keep it on MorphEvm::pre_fee_gas_refund, and record_refund it in the refund() hook before post_execution::refund applies 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.rs reproduce 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 -- --check
  • cargo clippy -p morph-revm --all-targets -- -D warnings
  • cargo test -p morph-revm -p morph-evm
  • cargo test -p morph-node --features test-utils --test it morph_tx

…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.
@coderabbitai

coderabbitai Bot commented Sep 13, 2026

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: 403b9646-d603-4134-9239-a54e5559c34a

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.

❤️ Share

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

@panos-xyz

Copy link
Copy Markdown
Contributor

Thanks for this — it is a well-diagnosed bug with a clean, focused fix, and pinning the geth numbers with a standalone core test is exactly the right instinct. We reproduced the divergence and it is consensus-breaking rather than cosmetic, so it is worth landing.

It reaches the state root. Disabling the refund carry-over in morph-reth and replaying PR #200's geth-derived fixture (bin/morph-statetest/tests/fixtures/fee_token_internal_calls.json, golden roots from morph-geth 5744b8f66) fails 4 cases on both Emerald and Jade, and every failure is a state root mismatch — not a gasUsed mismatch. The refund shifts gas_used → fee charged → balances. deduct_clear is your scenario 1; main_restores_cleared_slot is scenario 2.

The trigger is live on mainnet. Mainnet jade_fork_time = 1775628000 (2026-04-08), so post-Jade state-root validation is strictly enforced. Reading L2TokenRegistry (0x5300…0021) live, ids 2 and 6 have balanceSlot = 0 exactly as you described — and both are USDC (FiatTokenV2, 6 decimals, active). The fee transfer must consume the payer's entire token balance, which the caller controls via gas_limit / gas_price, so it is constructible. (Mechanism reproduced in the fixture, registry read live; we have not sent a live mainnet transaction, so the recipe is reasoned rather than demonstrated on-chain.)

One overlap to settle: PR #200 (fix/txpool-token-fee-subpool) already carries an equivalent fix under the name pre_fee_refund — same transfer_erc20_with_evm signature change, same record_refund placement before post_execution::refund, plus a unit test asserting (16_800, 4_200). Both patch the same handler.rs revision, so they will conflict and we should land one rather than both. Yours is the easier one to review at two files — happy to follow your preference on direction.

@kriss39

kriss39 commented Sep 14, 2026

Copy link
Copy Markdown
Author

Thanks for reproducing it and for taking it through the state-root fixture — I had only pinned the gasUsed / receipts-root side, so the four state root mismatch failures on Emerald and Jade are a stronger result than what's in the description. I've updated the summary to say it reaches the state root and to note the USDC / Jade-activation context.

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 pre_fee_refund commit there. Reasoning: it's a two-file change with a self-contained test, so it can go in independently of the txpool refactor's review cycle, and it keeps the consensus fix bisectable on main. If it helps, I'm happy to (a) rename to pre_fee_refund so the rebase is a no-op for #200, and (b) port the deduct_clear / main_restores_cleared_slot fixture cases from #200 into this PR so the state-root regression is covered here too.

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.

@panos-xyz

Copy link
Copy Markdown
Contributor

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 (fix/revm-call-mode-fee-parity), the execution-layer slice extracted so the consensus change can ship independently of the txpool refactor. Every part of it is there: the refund counter carried on the EVM and recorded before the EIP-3529 cap (same placement, after the L1-message early return), transfer_erc20_with_evm returning the frame's net refund, and the zero-amount early return. The only difference is cosmetic — the field is pre_fee_refund there, pre_fee_gas_refund here.

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 main by itself it takes the failures from 8 of 12 case templates down to 4, and the four it clears are exactly the state-root cases — deduct_clear, main_restores_cleared_slot, main_revert and main_oog. main_restores_cleared_slot is your scenario 2, and it confirms your reading precisely: the fee transfer() clears the payer's slot, the main frame re-sets it, and the +4800 and -4800 cancel on one counter only if the fee frame's refund survives. Scenario 1 is pinned by deduction_sstore_refund_reaches_transaction_gas, which asserts (gas, refund) == (16_800, 4_200).

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:

  • origin_guard, gasprice_guard — internal calls ran with a default transaction environment, so balanceOf/transfer saw ORIGIN 0x0 and an effective gas price of 0; a guard on either read the wrong value and the payer's balance resolved to zero, rejecting an affordable transaction.
  • refund_false_keeps_transfer — a successful transfer whose return value failed the later business check was rolled back with its logs, where go-ethereum keeps them.
  • zero_fee — a zero fee did not skip transfer(0).

All four were state-root, receipts or admission divergences on the same path. #210 is 8/12 → 0/12, i.e. 24/24 outcomes across Emerald and Jade.

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.

@panos-xyz

Copy link
Copy Markdown
Contributor

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.

@panos-xyz panos-xyz closed this Sep 15, 2026
panos-xyz added a commit that referenced this pull request Sep 20, 2026
* fix(revm): align call-mode fee-token execution with go-ethereum

A fee token registered without a `balanceSlot` takes the EVM-call path:
the protocol resolves the payer's balance with `balanceOf` and moves the
fee with an ERC20 `transfer`. morph-reth diverged from morph-geth on that
path in ways that change `gasUsed`, receipts and state roots, so a
follower rejects blocks a geth block producer accepts.

Measured against the golden fixtures added in the next commit, main fails
8 of 12 case templates on both Emerald and Jade:

- The fee `transfer()` frame's SSTORE refund was discarded, so a fee that
  clears the payer's balance slot earned the user no refund (the receipt
  `gasUsed` mismatch reported in #207). go-ethereum runs that call through
  `evm.Call` inside `buyAltTokenGas()` before `StateDB.Prepare`, which does
  not reset the refund counter, so the refund reaches `refundGas()`. The
  net counter is now carried on `MorphEvm` and recorded before the
  EIP-3529 cap, so it can also be cancelled by a negative refund from main
  execution.

- Internal calls ran with a default transaction environment and their own
  storage, so `balanceOf`/`transfer` saw ORIGIN = 0x0 and an effective gas
  price of 0. A guard on either read the wrong value and the payer's
  balance resolved to zero, rejecting an affordable transaction. Internal
  frames now keep the outer transaction's ORIGIN and GASPRICE, run in the
  executing block's environment, and `balanceOf` executes as a genuine
  static frame under the same 200k gas allowance geth uses.

- A successful transfer whose return value or balance delta failed a later
  business check was rolled back with its logs. go-ethereum keeps the
  state and logs and only reports the failure. The frame now owns its
  checkpoint: a VM revert still rolls back, a business failure does not,
  and a database failure stays fatal rather than becoming a verdict about
  the token.

- Zero fees skipped neither `transfer(0)` nor the initial `balanceOf`;
  geth skips both transfer modes but still performs the balance query.
  Nonce and cache updates, and call-mode access-list/transient cleanup,
  still happen.

`TokenFeeInfo::effective_fee_limit` replaces the two hand-rolled
fee-limit clamps the execution and pool paths each carried, so they
cannot drift. `MorphEvm::from_env` gives execution and pool queries one
constructor. The receipt builder now reads registry metadata with
`load_storage_only`, which never builds a temporary EVM to resolve a
balance it does not use.

The pool's single call site is adapted to the new `load_for_caller`
signature while keeping its previous behaviour: admission still evaluates
a call-mode `balanceOf` under the hardfork's defaults. Threading the real
head environment through admission is txpool work and is deliberately not
part of this change.

* test(statetest): add geth-derived golden fixtures for fee-token calls

Twelve cases across Emerald and Jade, with state roots, logs roots and
transaction gas generated by morph-geth 5744b8f66. Every case registers
its token with `balanceSlot = 0`, so all twelve exercise the EVM-call
path: deduction clearing the payer's ERC20 balance slot, the one-unit
balance control, main-frame revert and OOG, a main call that restores the
cleared slot, negative-refund cancellation, ORIGIN and GASPRICE guards,
static-call violations, a successful transfer whose refund returns false,
a refund that reverts, and zero-fee storage warmth. Note that "balance
slot" in these case names is the ERC20's own storage slot that the fee
`transfer()` clears, not the registry's optional `balanceSlot` field; the
storage-slot fee path is not covered by these fixtures.

Replaying them against main (v1.3.0) fails 8 of the 12 templates on both
forks. With the preceding commit all 24 outcomes pass.

* docs(revm): document the transaction-id invariant behind the mid-tx finalize

The call-mode fee path commits its deduction by calling `evm.finalize()`
mid-transaction, then re-marks every account and slot cold to reproduce
the warmth go-ethereum's `StateDB.Prepare` would leave behind. Nothing
explained why the whole journal is discarded there, or which part of that
reset the correctness depends on.

The load-bearing property is that the reset must not advance the
transaction id. Warming a slot runs through
`EvmStorageSlot::mark_warm_with_transaction_id`, which re-baselines the
EIP-2200 `original_value` to the present value whenever the slot's id
differs from the journal's. Had that fired on the slot the deduction just
cleared, the main frame's SSTORE would be a create rather than a
recreate and the `SubRefund` cancelling the deduction's `+4800` would be
lost — measured on `main_restores_cleared_slot`, 23_291 gas becomes
38_391.

It cannot fire here because ids stay equal across execution. revm
advances the id only when a transaction finishes — `commit_tx()` from
`execution_result`, or `discard_tx()` on the error path — both after the
main frame; `ExecuteEvm::finalize` then resets it to ZERO before the next
transaction. `finalize()` at this point is therefore idempotent for the
id, while `commit_tx()` would leave the deduction-warmed slots holding 0
against a journal holding 1. Verified by substitution: swapping
`commit_tx()` in fails `main_restores_cleared_slot` with a state root
mismatch.

* fix(revm): return the fee frames' shared memory to the transaction

The fee-token frames are top-level frames that run in the middle of a
transaction, so neither of revm's truncation points covers them:
`free_child_context` only releases a child frame's region, and
`LocalContext::clear` only runs once the whole transaction is done. The
frames therefore left their bytes on the context's shared buffer and the
main transaction frame started on top of them. Measured on a call-mode
MorphTx: the main frame entered with `MSIZE == 32`, and `MLOAD(0)`
returned 9_000_000, the payer's post-fee token balance left behind by the
internal `balanceOf`. go-ethereum allocates a fresh `Memory` for every
interpreter run (core/vm/interpreter.go), so both read zero there, and
both read zero here on the ETH-fee control. Any contract that reads
memory it never wrote, or branches on MSIZE, produced a different result
on morph-reth than on morph-geth.

Carve each fee frame's memory out above whatever the buffer already
holds, and release it on the way out, including on the error path.

The frames keep writing into the context's buffer rather than one of
their own: a nested call hands its callee a `CallInput::SharedBuffer`
range, and while a contract callee resolves that range against its own
frame memory, a precompile callee resolves it against the context's
buffer (`CallInput::as_bytes`). A private buffer would hand every
precompile called from a fee frame empty calldata —
`fee_token_frames_reach_a_precompile_through_memory` fails with
`InsufficientTokenBalance { available: 0 }` under that variant.

Gas is unaffected, since memory expansion is charged from the per-frame
`Gas` counter, and the leak did not cross transactions, since
`local_mut().clear()` runs at the end of each one. The 24 geth-derived
golden fixtures cannot see any of this: ten of their twelve templates
call a codeless EOA and the other two call bytecode that writes before it
reads.

* docs(revm): correct what the pool's balanceOf query aligns with

The comment justified setting the query's ORIGIN to the queried account by
claiming go-ethereum's pool does the same. It does not: `getBalanceFunc`
builds its EVM on an empty `vm.TxContext{}` (core/tx_pool.go:341), so its
ORIGIN is the zero address — and go-ethereum's own execution layer resolves
the same `balanceOf` with ORIGIN set to the sender, so its pool disagrees
with its own execution.

Setting ORIGIN to the account is still the right call, for the opposite
reason to the one recorded: admission exists to predict what the builder
will be able to include, so it follows this client's execution layer rather
than the other client's pool. Say that, and record the one input the query
still cannot match — GASPRICE, which stays at the `TxEnv` default of zero
because the effective price depends on the next block's base fee.

* test(node): run the e2e fee-token path in EVM-call mode

Every registered fee token on mainnet now has its `balanceSlot` cleared, so
the fee is moved by real `balanceOf` and `transfer` calls into the token
contract. The e2e genesis registered `balanceSlot = 2` instead, which put all
128 integration tests on the direct-storage path: the mode that is scheduled
to be disabled by a hardfork, and the one production does not use. The
node-level behaviour of the mode production does run — receipts, log
ordering, pool admission, the replay RPCs — had no integration coverage at
all, while the 24 statetest golden fixtures cover only its state effects.

Give the test token the ERC20 runtime the gas-regression test already carried
inline, and clear the registry's `balanceSlot`. The runtime keeps `balanceOf`
at slot 1, so `test_token_balance_slot` still derives the same slot
independently and remains a test oracle rather than a second copy of the code
under test.

Both gas regressions hold unchanged at 48_128 and 50_428: the fee frames run
on their own 200k budget, and the deduction books no SSTORE refund while the
payer keeps a balance. What does change is the receipt, which now carries the
fee deduction and the fee reimbursement around the transaction's own
transfer. Assert that ordering — deduction, main, refund — since it is what
go-ethereum produces and what indexers read.

* fix(revm): make the fee path's load-bearing invariants local and true

Six items a review of this branch turned up, each verified against revm 42
and go-ethereum before being acted on.

`reimburse_caller_token_fee`'s slot branch reaches `sload`/`sstore` on the
token directly, which panic rather than error when the account is absent from
`journal.state` (`sload_assume_account_present` -> `ColdLoadSkipped` ->
`unwrap_db_error`). It relied on the deduction having loaded it, but the
deduction skips both transfer modes for a zero fee. The two cannot disagree
today — `eth_to_token_amount` rounds up, so a zero token fee means a zero ETH
fee, which returns before the transfer — but that proof lives in another
function. Load it where it is needed instead; today the load is a no-op.

`evm_call` has the same shape of hidden dependency: its `CallValue::Transfer`
frame runs `Journal::transfer_loaded`, whose zero-value path is
`self.state.get_mut(&to).unwrap()`. An ordinary CALL is safe because the
opcode's `load_acc_and_calc_gas` loaded the account; an internal call has no
opcode, so `internal_call_code` is the only load. Its doc comment described
itself purely as a warmth-preserving code read. Say what it is also for.

`MorphBlockExecutor::hardfork` became write-only when `get_morph_tx_fields`
stopped taking a hardfork, leaving a doc comment claiming it is "reused in
`commit_transaction`". Removing it leaves `spec` dead as well — it existed
only to compute it. Drop both, and the constructor argument with them.

Two comments claimed things that are not true of the pinned revm or of the
code they describe. `load_token_fee_info` blamed a "30M gas limit" on the
previous path, which went through `system_call_one` and so already capped at
go-ethereum's 200k; the divergence was the environment and the sender. And
`ExecutionResult::Revert` does carry a `logs` field in revm 42 — the fee logs
need their side channel because the mid-transaction `finalize()` clears the
journal's logs, not because the variant cannot hold them.

The pool restated the fee-limit clamp by hand under a "Match REVM semantics"
comment, although `TokenFeeInfo::effective_fee_limit` was added to be the one
copy. Use it. Finally, `transfer_erc20_with_evm`'s affordability check built
its error message with `ok_or`, rendering two U256s and allocating a String on
every successful call-mode fee transfer; `ok_or_else` defers it.

* test(statetest): cover uninitialized memory and the direct-slot path

The twelve golden cases all target either a codeless EOA or bytecode that
writes before it reads, and all twelve register the token in EVM-call mode.
Two consensus-relevant behaviours were therefore invisible to them.

`main_reads_uninitialized_memory` commits MSIZE and MLOAD(0) to storage from
the transaction's own frame, before writing either. go-ethereum allocates a
fresh `Memory` for every interpreter run, so both read zero and neither SSTORE
changes state; a client whose fee frames leave their bytes on the
transaction's shared memory writes two non-zero slots and misses the root.
Verified to have teeth: reverting the fee frames to a checkpoint-zero
`SharedMemory` fails it with a state root mismatch.

`slot_deduct_keep` and `slot_deduct_clear` are the first coverage of the
registry's direct-slot path, which has to keep working for replaying blocks
produced before every mainnet token moved to the call path. `slot_deduct_clear`
is byte-for-byte the transaction `deduct_clear` runs and costs 21_000 against
its 16_800: clearing the payer's balance through a real `transfer` books a
`+4800` SSTORE refund that reaches the transaction, while `SetState` books
nothing. That 4_200 is the only way the two modes bill differently, and it is
now pinned from both sides.

Roots and logs hashes come from morph-geth 4012f174b, which reproduces all
twelve existing cases unchanged.

* test(node): assert the fee logs survive a reverting main frame

`receipt.rs` caches the fee `Transfer` events outside the journal because
go-ethereum's `StateDB.logs` is not part of the state snapshot/revert
mechanism: when the main frame reverts, the deduction's log must still be in
the receipt. Nothing asserted that. Every reverting golden case used a token
that emits no logs, so its expected `logs` hash is the empty hash and a client
that dropped `pre_fee_logs` on the floor would produce the same value. The
property decides the receipt's logs and therefore the block's receipts root.

`morph_tx_v0_token_fee_still_charged_on_revert` already reverts the main frame
against the real ERC20 test token and already runs through
`MorphBlockExecutor` and the production receipt builder. Assert the two fee
transfers it must carry, in go-ethereum's order, and that the deduction moved a
non-zero fee. Verified to have teeth: not extending `pre_fee_logs` in
`build_receipt` fails it.

One comment described the code wrongly and is corrected:

- The call-mode deduction comment said re-marking accounts and slots cold
  "reproduces the warmth `Prepare` would have left behind". It does not, and
  must not: the coinbase and access list are re-warmed later by upstream
  `pre_execution::load_accounts`, which runs after this deduction because the
  deduction happens in `validate()`. Say so, and say what breaks if a future
  change reorders those phases.

`load_token_fee_info`'s claim that the old path "capped at
`SYSTEM_CALL_GAS_LIMIT`, which is go-ethereum's 200k" reads wrong, because
revm's `SYSTEM_CALL_GAS_LIMIT` is 30_000_000. It is right, though: this crate
defines its own 200_000 in `exec.rs` and sets it in the `SystemCallEvm` impl,
shadowing revm's. Name that shadowing, since the bare constant reads as a
mistake and invites exactly the wrong "fix".

`expectException` stays presence-only, which reads like an oversight. It is
deliberate: go-ethereum's own statetest harness returns early on
`len(ExpectException) > 0` under a standing "TODO check error string", so
matching the text here would make this runner stricter than the client the
fixtures come from. A comment now records that.

* refactor(revm): keep the fee-token helpers' invariants inside them

Four cleanups from a review of this branch. None of them changes execution.

`transfer_erc20_with_slot` needs the token account in `journal.state`,
because the journal's `sload`/`sstore` panic rather than error when it is
absent, and both callers loaded it themselves with a comment apiece saying
why. The helper now loads and touches the token ahead of its checkpoint.
That is exactly what the deduction did before. The refund's extra touch is a
no-op: a refund only runs after a non-zero deduction, which already touched
the token in the same transaction. The slot-path golden fixtures pass
unchanged, and the unit test that exercises the helper no longer pre-loads
the token.

`reimburse_caller_token_fee` built its missing-cache error with `ok_or`,
allocating the message on every token refund. It now uses `ok_or_else`.

`TokenRegistryEntry`, its `load` and its `load_for_caller` had become `pub`
and re-exported with no user outside this crate, and `load_for_caller` hands
back a `TokenFeeInfo` without going through `ensure_usable`. They are
`pub(crate)` again. The pool keeps using `TokenFeeInfo::load_for_caller`.

The handler and token-fee tests each carried an identical database that
fails storage reads of one token. A single copy now lives in the token-fee
test module, which is `pub(crate)` so the handler tests can use it.

* docs(evm): give the real reason fee logs are kept out of the result

The receipt builder said fee logs are cached apart from the journal because
revm's `ExecutionResult::Revert` carries no logs. In revm 42 it does. The
real reason is that the call-mode deduction runs a mid-transaction
`finalize()` that clears the journal's logs, so the handler moves them out
first, and it drains the refund's logs the same way. `result` then holds only
the main frame's logs, which a revert has already discarded.

The same wrong claim was corrected in `handler.rs` earlier on this branch;
this is the copy that was left behind.

* test(statetest): replay a mainnet slot-mode fee-token transaction

Slot mode is being retired on mainnet, but blocks that already ran it must
keep replaying identically, and only two synthetic golden cases exercised
the registry's direct-slot path.

The new case replays transaction
0x9ebfdac9040d7c2a8739ffdaae8baf5e7aa22fdb48585f80592de4b4cf39ed44 from block
26836567: a V0 MorphTx paying its fee in token 1 through the direct slot,
sent by an EIP-7702-delegated account holding no ETH, whose call transfers
that same token. One transaction covers the deduction, the main frame
writing the payer's already-debited balance slot, and the slot-mode refund.

go-ethereum's state-test runner only signs with `secretKey` and fixes the
chain id to 1, so the sender moves to the harness account, carrying its
nonce, delegation code and re-keyed token balance, and the fee vault's
balance is re-keyed to the harness vault. The prestate tracer reports zero
for the balance slots the fee logic reads straight from state, so those, the
registry entry and the L1 gas price oracle slots are taken from the parent
block. That is exact here because the transaction is alone in its block.

Roots come from morph-geth 4012f174b, which passes the fixture, as does this
runner. The gas used equals the on-chain receipt's 51_257, and the logs root
equals the on-chain logs with the sender topic substituted. Verified to have
teeth: swapping the slot-mode refund's direction fails it with a state root
mismatch.
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.

2 participants