Conversation
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
|
Warning Review limit reachedNext included review available in 52 minutes. View limit detailsLimit 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. Review configuration: ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Advanced Run ID: 📒 Files selected for processing (3)
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 |
Consensus-facing. Latent today, but it is a real divergence from morph-geth.
The problem
validate_and_deduct_token_feeresolved the caller's ERC20 balance throughTokenRegistryEntry::load_for_caller, which — for a call-mode token, i.e. one with no registeredbalanceSlot— builds a throwawayMorphEvmover the raw database:That EVM carries
BlockEnv::default()andCfgEnv::default(), andsystem_call_oneissues the call fromSYSTEM_ADDRESS. Against morph-geth, which reads the same balance throughst.evm(GetAltTokenBalanceHybrid, core/token_gas.go:43):msg.senderSYSTEM_ADDRESS0xff..feblock.numberblock.timestampblock.coinbase/basefee/gaslimit/difficultyu64::MAX/ 0chainidThis is not a diagnostic read. The balance it returns
fee_limitand decidesInsufficientTokenBalance, andtransfer_erc20_with_evmasfrom_balance_before, which the post-transferfrom_balance_after == from_balance_before - amountcheck is measured against.So for a token whose
balanceOfconsults 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:sender := vm.AccountRef(userAddress)(core/token_gas.go:109), instead ofAddress::ZERO.Result, soEVMError::Databasepropagates 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 ofbuyAltTokenGas(core/state_transition.go:314).The receipt-field fallback in the block executor (
crates/evm/src/block/mod.rs) switches toload_storage_only. It reads onlyprice_ratioandscale, 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
balanceOfis a plain storage read — no block context, nomsg.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— abalanceOfreturningTIMESTAMP; previously answered1.fee_token_balance_query_names_the_queried_account_as_the_caller— abalanceOfreturningCALLER; previously answered0xff..fe.make lint,cargo test --all,cargo test --docandmake test-e2e(128/128) pass.Not in this PR
The pool reads the balance in the same fabricated environment (
morph_tx_validation.rsstill callsload_for_caller). morph-geth builds a realBlockContextfrom 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