Skip to content

fix(revm): read fee-token balances under the executing block environment - #205

Closed
panos-xyz wants to merge 1 commit into
mainfrom
fix/token-balance-real-block-env
Closed

panos-xyz wants to merge 1 commit into
mainfrom
fix/token-balance-real-block-env

Conversation

@panos-xyz

Copy link
Copy Markdown
Contributor

Consensus-facing. Latent today, but it is a real divergence from morph-geth.

The problem

validate_and_deduct_token_fee resolved the caller's ERC20 balance through TokenRegistryEntry::load_for_caller, which — for a call-mode token, i.e. one with no registered balanceSlot — builds a throwaway MorphEvm over the raw database:

let mut evm = MorphEvm::new(MorphContext::new(db, hardfork), NoOpInspector {});
query_balance_via_system_call(&mut evm, token, account)

That EVM carries BlockEnv::default() and CfgEnv::default(), and system_call_one issues the call from SYSTEM_ADDRESS. Against morph-geth, which reads the same balance through st.evm (GetAltTokenBalanceHybrid, core/token_gas.go:43):

morph-geth morph-reth (before)
msg.sender the queried account SYSTEM_ADDRESS 0xff..fe
gas cap 200 000 30 000 000
block.number header 0
block.timestamp header 1
block.coinbase / basefee / gaslimit / difficulty header 0 / 0 / u64::MAX / 0
chainid 2818 / 2910 1

This is not a diagnostic read. The balance it returns

  1. caps fee_limit and decides InsufficientTokenBalance, and
  2. is passed to transfer_erc20_with_evm as from_balance_before, which the post-transfer from_balance_after == from_balance_before - amount check is measured against.

So for a token whose balanceOf consults any of the above, one client accepts the transaction and the other rejects it — a state-root fork, not just a wrong number in a log.

The fix

Resolve the balance against the executing EVM (load_token_fee_info).

Slot mode keeps reading storage directly through the database: there is no environment to get wrong there, and going through the journal would sload-warm a slot that the fee deduction immediately below is careful to leave cold.

evm_call_balance_of — which already runs on the real EVM and already uses a 200k cap — changes in two ways:

  • It queries as the account being asked about, matching sender := vm.AccountRef(userAddress) (core/token_gas.go:109), instead of Address::ZERO.
  • It returns a Result, so EVMError::Database propagates instead of being reported as a zero balance. An I/O failure must never decide a block's contents. A revert or unusable return value still maps to a zero balance, which produces the same rejection morph-geth reaches by erroring out of buyAltTokenGas (core/state_transition.go:314).

The receipt-field fallback in the block executor (crates/evm/src/block/mod.rs) switches to load_storage_only. It reads only price_ratio and scale, both plain registry storage, and was building a temporary EVM to resolve a balance it then discards.

Reachability

Nothing registered today is affected. Every call-mode fee token on mainnet (2) and hoodi (7) is a Circle FiatTokenV2_2 or an OZ ERC20 whose balanceOf is a plain storage read — no block context, no msg.sender, well under 200k gas. This closes a latent divergence rather than an active one, and it is the same risk window as the token-refund soft-failure finding: it opens the moment governance registers a non-standard call-mode token.

Tests

Both confirmed to fail against the previous implementation:

  • fee_token_balance_is_read_under_the_executing_block_environment — a balanceOf returning TIMESTAMP; previously answered 1.
  • fee_token_balance_query_names_the_queried_account_as_the_caller — a balanceOf returning CALLER; previously answered 0xff..fe.

make lint, cargo test --all, cargo test --doc and make test-e2e (128/128) pass.

Not in this PR

The pool reads the balance in the same fabricated environment (morph_tx_validation.rs still calls load_for_caller). morph-geth builds a real BlockContext from the header there too (core/tx_pool.go:330). That has no consensus impact — it only makes admission and maintenance disagree with execution — and needs the validator to cache more of the head header, so it gets its own PR.

https://claude.ai/code/session_01PiUjd47Da71WG2BFkDQz9q

The token-fee handler resolved the caller's ERC20 balance by building a
throwaway `MorphEvm` over the raw database. That EVM carries `BlockEnv::default()`
and `CfgEnv::default()` — block 0, timestamp 1, chain id 1, zero coinbase and
base fee, `u64::MAX` gas limit — and `system_call_one` issues the call from
`SYSTEM_ADDRESS` with a 30M gas cap.

go-ethereum reads the same balance through `st.evm` (`GetAltTokenBalanceHybrid`,
core/token_gas.go:43), so the call sees the real header, the real chain config,
the user as `msg.sender` and a 200k cap. For any call-mode token whose
`balanceOf` reads block context or `msg.sender`, the two clients were computing
different balances for the same transaction — and that balance both caps
`fee_limit` and becomes the `from_balance_before` the post-transfer equality
check is measured against, so it decides whether the transaction is valid at all.

Resolve it against the executing EVM instead. Slot mode keeps reading storage
directly: there is no environment to get wrong, and an `sload` would warm a slot
the deduction below is careful to leave cold.

`evm_call_balance_of` now queries as the account being asked about, matching
`sender := vm.AccountRef(userAddress)`, and returns a `Result` so a failed state
read propagates rather than being reported as a zero balance — an I/O failure
must not decide a block's contents. A revert or unusable return value stays a
zero balance, which produces the same rejection go-ethereum reaches by erroring
out of `buyAltTokenGas`.

The receipt-field fallback in the block executor switches to `load_storage_only`:
it only reads `price_ratio` and `scale`, both plain registry storage, and was
spinning up a temporary EVM to resolve a balance it discards.

No currently registered fee token is affected — every call-mode token on mainnet
and hoodi is a FiatTokenV2_2 or OZ ERC20 whose `balanceOf` is a plain storage
read — so this closes a latent divergence rather than an active one.

Claude-Session: https://claude.ai/code/session_01PiUjd47Da71WG2BFkDQz9q
@coderabbitai

coderabbitai Bot commented Sep 11, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 52 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Advanced

Run ID: d2996cea-1360-4bc2-b27b-868677ce0d17

📥 Commits

Reviewing files that changed from the base of the PR and between dfae5d4 and 516b71e.

📒 Files selected for processing (3)
  • crates/evm/src/block/mod.rs
  • crates/revm/src/handler.rs
  • crates/revm/src/token_fee.rs

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 Author

Folded into #200 at the author's request — the commit is preserved there unchanged (cherry-picked, -x trailer intact). Branch kept for now; delete once #200 merges.

@panos-xyz panos-xyz closed this Sep 11, 2026
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