From a252bf67539776c6c97a8e5c893940d29695767b Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 11 Sep 2026 13:52:18 +0800 Subject: [PATCH 1/5] fix(txpool): stop the Morph maintenance task from dropping valid transactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MorphTx revalidation task runs alongside reth's own pool maintenance and both subscribe to the canonical state stream independently, so this task can observe a pool snapshot that still contains transactions the new block already executed. It read the sender's account only for the balance and discarded the nonce, so those already-executed transactions were charged against the new (already reduced) post-state balance a second time, and the sender's next, genuinely affordable transaction was evicted. Read the nonce alongside the balance and skip everything below it, as upstream's `AllTransactions::update` and go-ethereum's `demoteUnexecutables` both do. Removal used `remove_transactions_and_descendants`, which deletes every higher-nonce transaction of the sender — including plain ETH-fee transactions that are affordable on their own and only depend on the removed one through the nonce sequence. `remove_transactions` parks them instead (upstream's `remove_transaction_by_hash` calls `park_descendant_transactions`), matching what go-ethereum does by re-enqueueing its `invalids`. A failed token state read was wrapped as `TokenInfoFetchFailed` and handled like any other validation failure, so a transient read error removed a perfectly valid transaction. The rest of this task already skips on a failed state provider, L1 block info fetch or ETH balance read; token reads now follow the same rule. go-ethereum drops the transaction in this case (`executableTxFilter`, core/tx_pool.go:1690) and that is deliberately not mirrored. Also skip ahead to the newest queued notification before starting a round: a round costs one state read per transaction, so the chain can advance while it runs, and the verdicts are a pure function of the latest state. The per-round decision is extracted into `collect_removable_transactions` so it can be driven directly against a hand-built state, which is what the three new regression tests do; each was confirmed to fail before this change. Claude-Session: https://claude.ai/code/session_01PiUjd47Da71WG2BFkDQz9q --- crates/txpool/src/maintain.rs | 716 ++++++++++++++++++++++++++++------ 1 file changed, 587 insertions(+), 129 deletions(-) diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 7f54f551..277c0839 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -12,26 +12,39 @@ //! //! This maintenance task solves this by: //! 1. Listening to canonical state changes (new blocks) -//! 2. Re-validating all MorphTx transactions in the pool +//! 2. Re-validating all MorphTx (0x7F) transactions in the pool //! 3. Removing transactions that no longer have sufficient token balance //! +//! # Relationship with reth's own maintenance task +//! +//! This task runs *alongside* [`reth_transaction_pool::maintain::maintain_transaction_pool`], +//! and both subscribe to the canonical state stream independently — there is no ordering +//! guarantee between them. Everything reth's task already understands (ETH balance, nonces, +//! base fee, mined transactions) stays its responsibility; this task only adds the one +//! dimension reth cannot see, the sender's **ERC20 token** balance. +//! +//! Because the ordering is not guaranteed, this task must tolerate seeing a pool snapshot +//! that still contains transactions the new block already executed. It does so by reading +//! the sender's on-chain nonce and skipping everything below it, mirroring +//! `AllTransactions::update`, which discards those transactions before any affordability +//! check, and go-ethereum's `demoteUnexecutables`, which calls `list.Forward(nonce)` first. +//! //! # Reference //! //! This is similar to how go-ethereum handles MorphTx in `promoteExecutables` //! and `demoteUnexecutables` (tx_pool.go), but implemented as a separate //! maintenance task since we cannot modify reth's internal pool logic. -use crate::MorphPooledTransaction; +use crate::{MorphPooledTransaction, MorphTxError}; use alloy_consensus::Transaction; use alloy_consensus::Typed2718; use alloy_primitives::{Address, TxHash, U256}; -use futures::StreamExt; -use morph_chainspec::hardfork::MorphHardforks; +use futures::{FutureExt, StreamExt}; +use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; use morph_revm::L1BlockInfo; use reth_chainspec::ChainSpecProvider; use reth_primitives_traits::AlloyBlockHeader; use reth_provider::CanonStateSubscriptions; -use reth_revm::Database; use reth_revm::database::StateProviderDatabase; use reth_storage_api::StateProviderFactory; use reth_transaction_pool::{PoolTransaction, TransactionPool}; @@ -108,6 +121,170 @@ fn exceeds_block_gas_limit(tx_gas_limit: u64, block_gas_limit: u64) -> bool { tx_gas_limit > block_gas_limit } +/// Classifies a validation failure as "the transaction is bad" vs. "we could not read the state". +/// +/// A failed state read says nothing about the transaction: the token registry entry or the +/// caller's balance slot simply could not be resolved at this tip. Removing transactions on +/// that basis loses user transactions to transient I/O, so these are treated as unknown and +/// the sender is left alone until the next canonical event. +/// +/// Note that go-ethereum does the opposite — `executableTxFilter` drops the transaction when +/// `getBalanceFunc` errors (core/tx_pool.go:1690) — which is deliberately *not* mirrored here. +/// The rest of this task already skips on a failed state provider, L1 block info fetch or ETH +/// balance read; token state reads follow the same rule. +const fn is_transient(err: &MorphTxError) -> bool { + matches!(err, MorphTxError::TokenInfoFetchFailed { .. }) +} + +/// Determines which MorphTx transactions are no longer viable at the given state. +/// +/// Returns the hashes to remove from the pool. Only the first offending transaction of a +/// sender is returned: the pool parks the rest of that sender's transactions on its own when +/// the returned hash is removed (see [`maintain_morph_pool`]). +fn collect_removable_transactions( + db: &mut DB, + l1_block_info: &L1BlockInfo, + hardfork: MorphHardfork, + block_gas_limit: u64, + morph_txs: Vec<&MorphPooledTransaction>, +) -> Vec { + // Group by sender and process in nonce order so affordability is validated cumulatively. + let mut txs_by_sender: HashMap> = HashMap::new(); + for tx in morph_txs { + txs_by_sender.entry(tx.sender()).or_default().push(tx); + } + + let mut to_remove: Vec = Vec::new(); + + for (sender, mut sender_txs) in txs_by_sender { + sender_txs.sort_by_key(|tx| tx.transaction().nonce()); + + // Read the nonce alongside the balance. The balance seeds the rolling budget; the + // nonce tells us which pooled transactions the new block already executed and must + // therefore not be charged against that (already reduced) balance again. + let account = match db.basic(sender) { + Ok(account) => account.unwrap_or_default(), + Err(err) => { + tracing::warn!( + target: "morph::txpool::maintain", + ?sender, + ?err, + "Failed to get account info; skipping sender" + ); + continue; + } + }; + + let mut budget = SenderBudget { + eth_balance: account.balance, + token_balances: HashMap::new(), + }; + + for tx in sender_txs { + // Access the consensus tx by reference (via Deref chain) instead of + // cloning. Use the pool tx's cached EIP-2718 encoding for L1 fee. + let consensus_tx = tx.transaction(); + + // Already executed by the new block. reth's own maintenance task removes these + // when it applies the same canonical update; both tasks subscribe to the + // canonical stream independently, so this one can still observe them here. + // Charging them would consume a budget the sender no longer owes and strand the + // sender's next, genuinely affordable transaction. + if consensus_tx.nonce() < account.nonce { + continue; + } + + if exceeds_block_gas_limit(consensus_tx.gas_limit(), block_gas_limit) { + tracing::debug!( + target: "morph::txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + tx_gas_limit = consensus_tx.gas_limit(), + block_gas_limit, + "Removing MorphTx: gas limit exceeds current block gas limit" + ); + to_remove.push(*tx.hash()); + break; + } + + let l1_data_fee = l1_block_info.calculate_tx_l1_cost(tx.encoded_2718(), hardfork); + + // Use shared validation logic first with current sender ETH budget. + let input = crate::MorphTxValidationInput { + consensus_tx, + sender, + eth_balance: budget.eth_balance, + l1_data_fee, + hardfork, + }; + + let validation = match crate::validate_morph_tx(db, &input) { + Ok(v) => v, + Err(err) if is_transient(&err) => { + tracing::warn!( + target: "morph::txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + %err, + "Could not read token state; leaving sender's MorphTx in the pool" + ); + break; + } + Err(err) => { + tracing::debug!( + target: "morph::txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + ?err, + "Removing MorphTx: validation failed" + ); + to_remove.push(*tx.hash()); + break; + } + }; + + let fields = consensus_tx.morph_fields(); + let state_token_balance = validation.token_info.as_ref().map(|info| info.balance); + let token_id = fields.as_ref().map(|f| f.fee_token_id); + let fee_limit = fields.as_ref().map(|f| f.fee_limit); + + let affordable = if validation.uses_token_fee { + consume_token_budget( + &mut budget, + consensus_tx.value(), + token_id, + fee_limit, + validation.required_token_amount, + state_token_balance, + ) + } else { + consume_eth_budget( + &mut budget, + consensus_tx.value(), + consensus_tx.gas_limit(), + consensus_tx.max_fee_per_gas(), + l1_data_fee, + ) + }; + if !affordable { + tracing::debug!( + target: "morph::txpool::maintain", + tx_hash = ?tx.hash(), + ?sender, + uses_token_fee = validation.uses_token_fee, + token_id = ?token_id, + required_token_amount = ?validation.required_token_amount, + "Removing MorphTx: insufficient cumulative sender budget" + ); + to_remove.push(*tx.hash()); + break; + } + } + } + + to_remove +} + /// Maintains the Morph transaction pool by revalidating MorphTx transactions. /// /// This task runs continuously and: @@ -124,17 +301,43 @@ where + Clone + 'static, { - let mut chain_events = client.canonical_state_stream(); + let chain_events = client.canonical_state_stream(); tracing::info!(target: "morph::txpool::maintain", "Starting MorphTx maintenance task"); + maintain_morph_pool_with(pool, client, chain_events).await; +} + +/// [`maintain_morph_pool`] with an explicit canonical event stream. +async fn maintain_morph_pool_with( + pool: Pool, + client: Client, + mut chain_events: Events, +) where + Pool: TransactionPool + Clone, + Client: ChainSpecProvider + + StateProviderFactory + + CanonStateSubscriptions + + Clone + + 'static, + Events: + futures::Stream> + Unpin, +{ loop { // Wait for the next canonical state change - let Some(event) = chain_events.next().await else { + let Some(mut event) = chain_events.next().await else { tracing::debug!(target: "morph::txpool::maintain", "Chain event stream ended"); break; }; + // Skip ahead to the newest queued notification. A round costs one state read per + // transaction, so under load the chain can advance while we are working; the verdicts + // this task produces are a pure function of the latest state, which makes every + // intermediate block wasted work against a stale view of the pool. + while let Some(next) = chain_events.next().now_or_never().flatten() { + event = next; + } + let new_tip = event.tip(); let block_number = new_tip.number(); let block_timestamp = new_tip.timestamp(); @@ -153,11 +356,12 @@ where // Collect all MorphTx transactions from the pool let all_txs = pool.all_transactions(); - let morph_txs: Vec<_> = all_txs + let morph_txs: Vec<&MorphPooledTransaction> = all_txs .pending .iter() .chain(all_txs.queued.iter()) - .filter(|tx| tx.transaction.ty() == morph_primitives::MORPH_TX_TYPE_ID) + .map(|tx| &tx.transaction) + .filter(|tx| tx.ty() == morph_primitives::MORPH_TX_TYPE_ID) .collect(); if morph_txs.is_empty() { @@ -198,129 +402,25 @@ where "Revalidating MorphTx transactions" ); - // Group by sender and process in nonce order so affordability is validated cumulatively. - let mut txs_by_sender: HashMap> = HashMap::new(); - for pooled_tx in morph_txs { - let sender = pooled_tx.transaction.sender(); - txs_by_sender.entry(sender).or_default().push(pooled_tx); - } - - // Revalidate each sender's MorphTx set and collect invalid ones - let mut to_remove: Vec = Vec::new(); - - for (sender, mut sender_txs) in txs_by_sender { - sender_txs.sort_by_key(|pooled_tx| pooled_tx.transaction.nonce()); - - // Initialize sender ETH budget once. - let eth_balance = match db.basic(sender) { - Ok(Some(account)) => account.balance, - Ok(None) => U256::ZERO, - Err(err) => { - tracing::warn!( - target: "morph::txpool::maintain", - ?sender, - ?err, - "Failed to get account balance" - ); - continue; - } - }; - - let mut budget = SenderBudget { - eth_balance, - token_balances: HashMap::new(), - }; - - for pooled_tx in sender_txs { - let tx = &pooled_tx.transaction; - // Access the consensus tx by reference (via Deref chain) instead of - // cloning. Use the pool tx's cached EIP-2718 encoding for L1 fee. - let consensus_tx = tx.transaction(); - - if exceeds_block_gas_limit(consensus_tx.gas_limit(), block_gas_limit) { - tracing::debug!( - target: "morph::txpool::maintain", - tx_hash = ?tx.hash(), - ?sender, - tx_gas_limit = consensus_tx.gas_limit(), - block_gas_limit, - "Removing MorphTx: gas limit exceeds current block gas limit" - ); - to_remove.push(*tx.hash()); - break; - } - - let l1_data_fee = l1_block_info.calculate_tx_l1_cost(tx.encoded_2718(), hardfork); - - // Use shared validation logic first with current sender ETH budget. - let input = crate::MorphTxValidationInput { - consensus_tx, - sender, - eth_balance: budget.eth_balance, - l1_data_fee, - hardfork, - }; - - let validation = match crate::validate_morph_tx(&mut db, &input) { - Ok(v) => v, - Err(err) => { - tracing::debug!( - target: "morph::txpool::maintain", - tx_hash = ?tx.hash(), - ?sender, - ?err, - "Removing MorphTx: validation failed" - ); - to_remove.push(*tx.hash()); - break; - } - }; - - let fields = consensus_tx.morph_fields(); - let state_token_balance = validation.token_info.as_ref().map(|info| info.balance); - let token_id = fields.as_ref().map(|f| f.fee_token_id); - let fee_limit = fields.as_ref().map(|f| f.fee_limit); - - let affordable = if validation.uses_token_fee { - consume_token_budget( - &mut budget, - consensus_tx.value(), - token_id, - fee_limit, - validation.required_token_amount, - state_token_balance, - ) - } else { - consume_eth_budget( - &mut budget, - consensus_tx.value(), - consensus_tx.gas_limit(), - consensus_tx.max_fee_per_gas(), - l1_data_fee, - ) - }; - if !affordable { - tracing::debug!( - target: "morph::txpool::maintain", - tx_hash = ?tx.hash(), - ?sender, - uses_token_fee = validation.uses_token_fee, - token_id = ?token_id, - required_token_amount = ?validation.required_token_amount, - "Removing MorphTx: insufficient cumulative sender budget" - ); - to_remove.push(*tx.hash()); - break; - } - } - } + let to_remove = collect_removable_transactions( + &mut db, + &l1_block_info, + hardfork, + block_gas_limit, + morph_txs, + ); - // Remove invalid transactions and all higher-nonce descendants from the same sender. - // Using remove_transactions_and_descendants ensures that nonce-dependent txs are cleaned - // up immediately rather than becoming orphans that are re-validated every block. + // Remove the offending transactions. `remove_transactions` *parks* each removed + // transaction's descendants instead of deleting them (upstream + // `remove_transaction_by_hash` calls `park_descendant_transactions`), so a + // higher-nonce transaction that is still affordable on its own — a plain ETH-fee + // transaction, say — survives in the queued sub-pool and becomes executable again + // once a replacement for the removed nonce arrives. go-ethereum's + // `demoteUnexecutables` does the same thing by re-enqueueing its `invalids` + // (core/tx_pool.go:1888) rather than dropping them. if !to_remove.is_empty() { let count = to_remove.len(); - pool.remove_transactions_and_descendants(to_remove); + pool.remove_transactions(to_remove); tracing::info!( target: "morph::txpool::maintain", count, @@ -476,4 +576,362 @@ mod tests { assert!(exceeds_block_gas_limit(30_000_001, 30_000_000)); assert!(!exceeds_block_gas_limit(30_000_000, 30_000_000)); } + + // --------------------------------------------------------------------------------- + // Revalidation round tests + // + // These drive `collect_removable_transactions` against a hand-built state so the + // removal verdict can be asserted without a pool, and one pool-level test covers the + // descendant handling that only the pool can show. + // --------------------------------------------------------------------------------- + + use alloy_consensus::{Signed, transaction::Recovered}; + use alloy_eips::eip2718::Encodable2718; + use alloy_primitives::{Signature, TxKind, address}; + use morph_primitives::{MorphTxEnvelope, TxMorph}; + use morph_revm::{ + L2_TOKEN_REGISTRY_ADDRESS, compute_mapping_slot, compute_mapping_slot_for_address, + }; + use reth_revm::revm::database::{CacheDB, EmptyDB}; + use reth_revm::revm::state::AccountInfo; + + const SIGNER: Address = address!("0000000000000000000000000000000000000001"); + const FEE_TOKEN: Address = address!("5300000000000000000000000000000000000042"); + const TOKEN_ID: u16 = 1; + const BALANCE_SLOT: u64 = 7; + /// `gas_limit * max_fee_per_gas` of [`token_fee_tx`]; at a 1:1 price ratio this is also + /// the token amount one transaction reserves during revalidation. + const TX_TOKEN_BUDGET: u64 = 21_000 * 100; + + fn token_id_key(token_id: u16) -> [u8; 32] { + let mut key = [0u8; 32]; + key[30..32].copy_from_slice(&token_id.to_be_bytes()); + key + } + + /// State with [`TOKEN_ID`] registered as an active slot-mode token at a 1:1 price ratio. + fn test_state(account_nonce: u64, eth_balance: u64, token_balance: u64) -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + db.insert_account_info( + SIGNER, + AccountInfo { + nonce: account_nonce, + balance: U256::from(eth_balance), + ..Default::default() + }, + ); + + let token_key = token_id_key(TOKEN_ID); + let base = compute_mapping_slot(U256::from(151), &token_key); + let mut packed = [0u8; 32]; + packed[30] = 18; // decimals + packed[31] = 1; // isActive + for (slot, value) in [ + (base, U256::from_be_bytes(FEE_TOKEN.into_word().0)), + // `balanceSlot` is stored as the actual slot plus one. + (base + U256::from(1), U256::from(BALANCE_SLOT + 1)), + (base + U256::from(2), U256::from_be_bytes(packed)), + (base + U256::from(3), U256::from(1)), // scale + ( + compute_mapping_slot(U256::from(153), &token_key), + U256::from(1), // priceRatio + ), + ] { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + } + + db.insert_account_storage( + FEE_TOKEN, + compute_mapping_slot_for_address(U256::from(BALANCE_SLOT), SIGNER), + U256::from(token_balance), + ) + .unwrap(); + + db + } + + /// A token-fee MorphTx reserving [`TX_TOKEN_BUDGET`] tokens and no ETH. + fn token_fee_tx(nonce: u64) -> MorphPooledTransaction { + let tx = TxMorph { + chain_id: 2818, + nonce, + gas_limit: 21_000, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::ZERO, + fee_token_id: TOKEN_ID, + fee_limit: U256::ZERO, + ..Default::default() + }; + let recovered = Recovered::new_unchecked( + MorphTxEnvelope::Morph(Signed::new_unhashed(tx, Signature::test_signature())), + SIGNER, + ); + let encoded_len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, encoded_len) + } + + fn removable(db: &mut CacheDB, txs: Vec<&MorphPooledTransaction>) -> Vec { + collect_removable_transactions( + db, + &L1BlockInfo::default(), + MorphHardfork::Emerald, + 30_000_000, + txs, + ) + } + + #[test] + fn transactions_already_executed_by_the_block_do_not_consume_the_budget_again() { + // The block executed nonce 0, which cost far less than the `TX_TOKEN_BUDGET` it + // reserved, so the post-state still affords nonce 1 — but not both at max fee. + let mut db = test_state(1, 0, TX_TOKEN_BUDGET + TX_TOKEN_BUDGET / 2); + let (tx0, tx1) = (token_fee_tx(0), token_fee_tx(1)); + + assert!( + removable(&mut db, vec![&tx0, &tx1]).is_empty(), + "nonce 1 is affordable against the post-state and nonce 0 is already mined" + ); + } + + #[test] + fn cumulative_budget_still_rejects_an_unaffordable_successor() { + // Same balances, but the block did not execute nonce 0: both transactions are still + // owed and the second one genuinely cannot be paid for. + let mut db = test_state(0, 0, TX_TOKEN_BUDGET + TX_TOKEN_BUDGET / 2); + let (tx0, tx1) = (token_fee_tx(0), token_fee_tx(1)); + + assert_eq!(removable(&mut db, vec![&tx0, &tx1]), vec![*tx1.hash()]); + } + + /// Fails every storage read of the fee token, leaving the rest of the state readable. + #[derive(Debug)] + struct UnreadableToken(CacheDB); + + impl reth_revm::Database for UnreadableToken { + type Error = reth_provider::ProviderError; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + Ok(self.0.basic(address).unwrap()) + } + + fn code_by_hash( + &mut self, + code_hash: alloy_primitives::B256, + ) -> Result { + Ok(self.0.code_by_hash(code_hash).unwrap()) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + if address == FEE_TOKEN { + return Err(reth_provider::ProviderError::BestBlockNotFound); + } + Ok(self.0.storage(address, index).unwrap()) + } + + fn block_hash(&mut self, number: u64) -> Result { + Ok(self.0.block_hash(number).unwrap()) + } + } + + #[test] + fn unreadable_token_state_does_not_remove_transactions() { + let tx = token_fee_tx(0); + let mut db = UnreadableToken(test_state(0, 0, 10 * TX_TOKEN_BUDGET)); + + // Sanity check: the same transaction against readable state is kept as well, so the + // assertion below is about the read failure and not about affordability. + assert!( + removable(&mut db.0.clone(), vec![&tx]).is_empty(), + "transaction is affordable when the token balance can be read" + ); + + let to_remove = collect_removable_transactions( + &mut db, + &L1BlockInfo::default(), + MorphHardfork::Emerald, + 30_000_000, + vec![&tx], + ); + assert!( + to_remove.is_empty(), + "a transient state-read failure must not be treated as an invalid transaction" + ); + } + + // --------------------------------------------------------------------------------- + // Pool-level test: only the pool can show what happens to a removed transaction's + // descendants, so this one drives the maintenance loop against a real pool. + // --------------------------------------------------------------------------------- + + use alloy_consensus::TxLegacy; + use alloy_primitives::Sealable; + use morph_chainspec::{MORPH_MAINNET, MorphChainSpec}; + use morph_evm::MorphEvmConfig; + use morph_primitives::MorphPrimitives; + use reth_provider::test_utils::{ExtendedAccount, MockEthProvider}; + use reth_transaction_pool::{ + CoinbaseTipOrdering, Pool, blobstore::InMemoryBlobStore, + validate::EthTransactionValidatorBuilder, + }; + + type TestProvider = MockEthProvider; + + fn storage_key(slot: U256) -> alloy_primitives::B256 { + alloy_primitives::B256::from(slot.to_be_bytes::<32>()) + } + + /// The chain head the pool validates against: an empty Emerald-active block. + fn head_block() -> morph_primitives::Block { + morph_primitives::Block { + header: morph_primitives::MorphHeader::from(alloy_consensus::Header { + number: 1, + timestamp: 1_767_765_600, + gas_limit: 30_000_000, + base_fee_per_gas: Some(10), + ..Default::default() + }), + body: Default::default(), + } + } + + /// Mirrors [`test_state`] for [`MockEthProvider`], which the pool's validator needs. + fn mock_provider(eth_balance: u64, token_balance: u64) -> TestProvider { + let client = MockEthProvider::::new() + .with_chain_spec((**MORPH_MAINNET).clone()) + .with_genesis_block(); + + // MorphTx is only accepted from Emerald onwards, so the head must be past it. + let head = head_block(); + client.add_block(head.header.hash_slow(), head); + + client.add_account(SIGNER, ExtendedAccount::new(0, U256::from(eth_balance))); + + let token_key = token_id_key(TOKEN_ID); + let base = compute_mapping_slot(U256::from(151), &token_key); + let mut packed = [0u8; 32]; + packed[30] = 18; + packed[31] = 1; + client.add_account( + L2_TOKEN_REGISTRY_ADDRESS, + ExtendedAccount::new(0, U256::ZERO).extend_storage([ + ( + storage_key(base), + U256::from_be_bytes(FEE_TOKEN.into_word().0), + ), + ( + storage_key(base + U256::from(1)), + U256::from(BALANCE_SLOT + 1), + ), + ( + storage_key(base + U256::from(2)), + U256::from_be_bytes(packed), + ), + (storage_key(base + U256::from(3)), U256::from(1)), + ( + storage_key(compute_mapping_slot(U256::from(153), &token_key)), + U256::from(1), + ), + ]), + ); + set_token_balance(&client, token_balance); + client + } + + fn set_token_balance(client: &TestProvider, token_balance: u64) { + client.add_account( + FEE_TOKEN, + ExtendedAccount::new(0, U256::ZERO).extend_storage([( + storage_key(compute_mapping_slot_for_address( + U256::from(BALANCE_SLOT), + SIGNER, + )), + U256::from(token_balance), + )]), + ); + } + + /// A canonical commit of [`head_block`]. + fn commit_event() -> reth_provider::CanonStateNotification { + let block = head_block(); + reth_provider::CanonStateNotification::Commit { + new: std::sync::Arc::new(reth_provider::Chain::new( + [reth_primitives_traits::RecoveredBlock::new_unhashed( + block, + Vec::new(), + )], + Default::default(), + Default::default(), + )), + } + } + + /// A plain ETH-fee transaction, affordable on its own. + fn legacy_tx(nonce: u64) -> MorphPooledTransaction { + let tx = TxLegacy { + chain_id: Some(2818), + nonce, + gas_limit: 21_000, + gas_price: 100, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + value: U256::ZERO, + ..Default::default() + }; + let recovered = Recovered::new_unchecked( + MorphTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())), + SIGNER, + ); + let encoded_len = recovered.encode_2718_len(); + MorphPooledTransaction::new(recovered, encoded_len) + } + + #[test] + fn removing_a_morph_tx_parks_its_descendants_instead_of_deleting_them() { + let client = mock_provider(10_000_000, 10 * TX_TOKEN_BUDGET); + let validator = crate::MorphTransactionValidator::new( + EthTransactionValidatorBuilder::new( + client.clone(), + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + ) + .disable_balance_check() + .with_custom_tx_type(morph_primitives::MORPH_TX_TYPE_ID) + .build::(InMemoryBlobStore::default()), + ); + let pool = Pool::new( + validator, + CoinbaseTipOrdering::default(), + InMemoryBlobStore::default(), + Default::default(), + ); + + // nonce 0 pays in tokens, nonce 1 is a plain ETH transaction that only depends on + // nonce 0 through the nonce sequence. + futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + token_fee_tx(0), + )) + .unwrap(); + let descendant = futures::executor::block_on(pool.add_transaction( + reth_transaction_pool::TransactionOrigin::Local, + legacy_tx(1), + )) + .unwrap() + .hash; + + // The sender spends its whole token balance elsewhere, so nonce 0 is no longer payable. + set_token_balance(&client, 0); + let event = commit_event(); + futures::executor::block_on(maintain_morph_pool_with( + pool.clone(), + client, + futures::stream::iter([event]), + )); + + assert!( + pool.get(&descendant).is_some(), + "an independently affordable ETH-fee successor must be parked, not deleted" + ); + } } From 65ab7ecd7f5dfd3e57ef49f9bb2dde6a335fb2a5 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 11 Sep 2026 15:08:32 +0800 Subject: [PATCH 2/5] fix(txpool): stop judging nonce-gapped MorphTx against a cumulative budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The revalidation walk applied one rolling sender budget across every MorphTx in the pool, pending and queued alike. A transaction sitting behind a nonce gap was therefore charged against whatever the sender's executable transactions had left over — but the transactions filling the gap are not in the pool, so how much of the balance is actually still owed by the time the gapped one executes is unknown. An unrelated block was enough to evict a future-nonce transaction that had passed admission on its own. Stop the walk at the first nonce discontinuity, which is what upstream's `AllTransactions::update` does ("If there's a nonce gap, we can shortcircuit, because there's nothing to update yet"). go-ethereum reaches the same place from the other direction: `promoteExecutables` only ever applies a per-transaction cost check to the queue and discards `FilterF`'s `invalids`. Nothing is lost by leaving those transactions alone: without `NO_NONCE_GAPS` they sit in the queued sub-pool, which is exactly what reth's own stale eviction reaps. Claude-Session: https://claude.ai/code/session_01PiUjd47Da71WG2BFkDQz9q --- crates/txpool/src/maintain.rs | 40 +++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 277c0839..243dcbcd 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -179,6 +179,8 @@ fn collect_removable_transactions( eth_balance: account.balance, token_balances: HashMap::new(), }; + // The nonce the next executable transaction of this sender must carry. + let mut next_nonce_in_line = account.nonce; for tx in sender_txs { // Access the consensus tx by reference (via Deref chain) instead of @@ -194,6 +196,18 @@ fn collect_removable_transactions( continue; } + // Nonce gap: the transactions filling it are not in the pool, so how much of + // this sender's balance is still owed by the time this one executes is unknown, + // and nothing from here on is executable anyway. Upstream's + // `AllTransactions::update` short-circuits the sender on a gap for the same + // reason, and go-ethereum only ever applies a per-transaction cost check to its + // queue, never a cumulative one. Anything left behind the gap sits in the queued + // sub-pool, where reth's own stale eviction reaps it. + if consensus_tx.nonce() != next_nonce_in_line { + break; + } + next_nonce_in_line = next_nonce_in_line.saturating_add(1); + if exceeds_block_gas_limit(consensus_tx.gas_limit(), block_gas_limit) { tracing::debug!( target: "morph::txpool::maintain", @@ -706,6 +720,32 @@ mod tests { assert_eq!(removable(&mut db, vec![&tx0, &tx1]), vec![*tx1.hash()]); } + #[test] + fn a_transaction_behind_a_nonce_gap_is_not_charged_to_the_budget() { + // nonce 0 is executable and reserves the sender's whole token balance. nonce 10 sits + // behind a gap, so nonces 1..9 — which are not in the pool — decide what is actually + // left by the time it executes. Judging it against the residue of nonce 0 alone is + // meaningless, and removing it on that basis destroys a transaction that passed + // admission on its own. + let mut db = test_state(0, 0, TX_TOKEN_BUDGET); + let (tx0, gapped) = (token_fee_tx(0), token_fee_tx(10)); + + assert!( + removable(&mut db, vec![&tx0, &gapped]).is_empty(), + "a nonce-gapped transaction has no meaningful cumulative budget" + ); + } + + #[test] + fn a_sender_holding_only_future_nonces_is_left_alone() { + // Nothing this sender holds is executable at the current state nonce, so there is no + // executable front to evaluate — not even for a sender that now holds no tokens. + let mut db = test_state(0, 0, 0); + let gapped = token_fee_tx(5); + + assert!(removable(&mut db, vec![&gapped]).is_empty()); + } + /// Fails every storage read of the fee token, leaving the rest of the state readable. #[derive(Debug)] struct UnreadableToken(CacheDB); From f1b2f0b332d0938c82f2947cd6306a857b1ef0de Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 11 Sep 2026 15:16:10 +0800 Subject: [PATCH 3/5] fix(txpool): stop turning fee-token state-read failures into verdicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `query_balance_via_system_call` mapped every error, `EVMError::Database` included, to a zero balance. A failed state read therefore came back as "this account holds no tokens" and the transaction was rejected for insufficient funds — and the `Err(EVMError::Database(e)) => Err(e)` arm in `read_token_balance_with_fallback`, which exists precisely to propagate it, was unreachable. Report the database error and leave the revert / short-return cases as a zero balance, which are genuine statements about the token. At admission a failure to even get a state provider became `TransactionValidationOutcome::Invalid`. That is a verdict on the transaction: the pool records it as known-bad and the network layer holds the sending peer responsible for something that may be perfectly valid and merely could not be checked. Route `TokenInfoFetchFailed` to `TransactionValidationOutcome::Error` instead, which discards the attempt without blaming anyone. `TokenInfoFetchFailed::token_id` becomes `Option`: the provider failure happens before any token ID is known and was reporting a hardcoded `0`, so the error read "failed to fetch token info for ID 0" for a token that had nothing to do with it. Claude-Session: https://claude.ai/code/session_01PiUjd47Da71WG2BFkDQz9q --- crates/revm/src/token_fee.rs | 123 +++++++++++++++++++++++ crates/txpool/src/error.rs | 26 +++-- crates/txpool/src/morph_tx_validation.rs | 2 +- crates/txpool/src/validator.rs | 75 +++++++++++++- 4 files changed, 212 insertions(+), 14 deletions(-) diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index c32827cb..a36c190e 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -341,7 +341,15 @@ where } Ok(U256::ZERO) } + // The token reverted or returned nothing usable. That is a statement about the + // token, so it stays a zero balance and the caller rejects the transaction for + // insufficient funds. Ok(_) => Ok(U256::ZERO), + // A failed state read is *not* a statement about the token: report it so the + // caller can tell "this account cannot pay" apart from "we could not find out". + // Swallowing it here made the `EVMError::Database` arm in + // `read_token_balance_with_fallback` unreachable. + Err(err @ EVMError::Database(_)) => Err(err), Err(_) => Ok(U256::ZERO), } } @@ -376,6 +384,121 @@ pub fn encode_balance_of_calldata(account: Address) -> Bytes { mod tests { use super::*; + use alloy_primitives::{B256, address, bytes}; + use revm::bytecode::Bytecode; + use revm::database::{CacheDB, EmptyDB}; + use revm::state::AccountInfo; + + /// Returned by [`FeeTokenUnreadable`] so a state read failure is distinguishable. + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + struct ReadFailed; + + impl core::fmt::Display for ReadFailed { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + f.write_str("state read failed") + } + } + + impl core::error::Error for ReadFailed {} + + impl revm::database_interface::DBErrorMarker for ReadFailed {} + + /// Fails every storage read of the fee token; everything else reads normally. + #[derive(Debug)] + struct FeeTokenUnreadable { + inner: CacheDB, + token: Address, + } + + impl RevmDatabase for FeeTokenUnreadable { + type Error = ReadFailed; + + fn basic(&mut self, address: Address) -> Result, Self::Error> { + Ok(self.inner.basic(address).unwrap()) + } + + fn code_by_hash(&mut self, code_hash: B256) -> Result { + Ok(self.inner.code_by_hash(code_hash).unwrap()) + } + + fn storage(&mut self, address: Address, index: U256) -> Result { + if address == self.token { + return Err(ReadFailed); + } + Ok(self.inner.storage(address, index).unwrap()) + } + + fn block_hash(&mut self, number: u64) -> Result { + Ok(self.inner.block_hash(number).unwrap()) + } + } + + /// Registry state for a call-mode token (no `balanceSlot`) whose `balanceOf` returns + /// storage slot 0, so reading it is a storage read of the token contract. + fn call_mode_token_state(token: Address, balance: u64) -> CacheDB { + let mut db = CacheDB::new(EmptyDB::default()); + let mut token_id_bytes = [0u8; 32]; + token_id_bytes[31] = 1; + let base = compute_mapping_slot(TOKEN_REGISTRY_SLOT, &token_id_bytes); + + let mut packed = [0u8; 32]; + packed[30] = 18; // decimals + packed[31] = 1; // isActive + for (slot, value) in [ + (base, U256::from_be_bytes(token.into_word().0)), + // Zero means "no known balance slot": the EVM `balanceOf` fallback is used. + (base + U256::from(1), U256::ZERO), + (base + U256::from(2), U256::from_be_bytes(packed)), + (base + U256::from(3), U256::from(1)), // scale + ( + compute_mapping_slot(PRICE_RATIO_SLOT, &token_id_bytes), + U256::from(1), // priceRatio + ), + ] { + db.insert_account_storage(L2_TOKEN_REGISTRY_ADDRESS, slot, value) + .unwrap(); + } + + // PUSH1 0x00 SLOAD PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN + let code = bytes!("6000545f5260205ff3"); + db.insert_account_info( + token, + AccountInfo { + code_hash: alloy_primitives::keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + db.insert_account_storage(token, U256::ZERO, U256::from(balance)) + .unwrap(); + db + } + + #[test] + fn balance_of_fallback_reports_a_failed_state_read_instead_of_a_zero_balance() { + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("0000000000000000000000000000000000000001"); + let hardfork = MorphHardfork::Emerald; + + // Readable state: the fallback reaches the token and reads the balance. + let mut readable = call_mode_token_state(token, 10_000_000); + let info = TokenFeeInfo::load_for_caller(&mut readable, 1, caller, hardfork) + .unwrap() + .unwrap(); + assert_eq!(info.balance, U256::from(10_000_000)); + + // Same state, but the token's storage cannot be read. Reporting a zero balance here + // would be indistinguishable from an account that genuinely cannot pay. + let mut unreadable = FeeTokenUnreadable { + inner: call_mode_token_state(token, 10_000_000), + token, + }; + assert_eq!( + TokenFeeInfo::load_for_caller(&mut unreadable, 1, caller, hardfork).unwrap_err(), + ReadFailed + ); + } + #[test] fn test_token_fee_info_default() { let info = TokenFeeInfo::default(); diff --git a/crates/txpool/src/error.rs b/crates/txpool/src/error.rs index d676b0bb..bb3b6785 100644 --- a/crates/txpool/src/error.rs +++ b/crates/txpool/src/error.rs @@ -54,10 +54,13 @@ pub enum MorphTxError { value: U256, }, - /// Failed to fetch token information from state. + /// Failed to read the state needed to evaluate the fee token. + /// + /// This says nothing about the transaction — the state simply could not be read — so + /// callers must not treat it as a permanent rejection. TokenInfoFetchFailed { - /// The token ID. - token_id: u16, + /// The token ID, when the failure happened after it was known. + token_id: Option, /// Error message. message: String, }, @@ -105,9 +108,12 @@ impl fmt::Display for MorphTxError { "insufficient ETH balance for transaction value: balance {balance}, value {value}" ) } - Self::TokenInfoFetchFailed { token_id, message } => { - write!(f, "failed to fetch token info for ID {token_id}: {message}") - } + Self::TokenInfoFetchFailed { token_id, message } => match token_id { + Some(token_id) => { + write!(f, "failed to fetch token info for ID {token_id}: {message}") + } + None => write!(f, "failed to read fee token state: {message}"), + }, Self::InvalidFormat { reason } => { write!(f, "invalid MorphTx format: {reason}") } @@ -260,7 +266,7 @@ mod tests { assert!(!MorphTxError::InvalidPriceRatio { token_id: 1 }.is_bad_transaction()); assert!( !MorphTxError::TokenInfoFetchFailed { - token_id: 1, + token_id: Some(1), message: "error".into() } .is_bad_transaction() @@ -286,9 +292,13 @@ mod tests { value: U256::from(10u64), }, MorphTxError::TokenInfoFetchFailed { - token_id: 5, + token_id: Some(5), message: "db error".into(), }, + MorphTxError::TokenInfoFetchFailed { + token_id: None, + message: "provider unavailable".into(), + }, MorphTxError::InvalidFormat { reason: "bad version".into(), }, diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index e666c295..a7bd3472 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -109,7 +109,7 @@ pub fn validate_morph_tx( let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, input.hardfork) .map_err(|err| MorphTxError::TokenInfoFetchFailed { - token_id: fee_token_id, + token_id: Some(fee_token_id), message: format!("{err:?}"), })? .ok_or(MorphTxError::TokenNotFound { diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index 1afea643..a9966566 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -383,10 +383,7 @@ where l1_data_fee, hardfork, ) { - return TransactionValidationOutcome::Invalid( - valid_tx.into_transaction(), - err.into(), - ); + return morph_tx_validation_outcome(valid_tx.into_transaction(), err); } } else { // Regular transaction: validate ETH balance covers cost + L1 fee @@ -433,7 +430,8 @@ where .client() .state_by_block_number_or_tag(self.block_number().into()) .map_err(|err| MorphTxError::TokenInfoFetchFailed { - token_id: 0, // token_id not yet extracted + // The failure is in getting a state provider at all, so no token ID is known. + token_id: None, message: err.to_string(), })?; @@ -519,6 +517,24 @@ where } } +/// Maps a [`MorphTxError`] onto the right validation outcome. +/// +/// [`TransactionValidationOutcome::Invalid`] is a verdict on the transaction: the pool +/// records it as known-bad and the network layer holds the peer that sent it responsible. +/// A failed state read is not such a verdict — the transaction may be perfectly valid and +/// simply could not be checked — so it is reported as +/// [`TransactionValidationOutcome::Error`], which discards this attempt without blaming +/// anyone and leaves the sender free to try again. +fn morph_tx_validation_outcome( + transaction: Tx, + err: MorphTxError, +) -> TransactionValidationOutcome { + if matches!(err, MorphTxError::TokenInfoFetchFailed { .. }) { + return TransactionValidationOutcome::Error(*transaction.hash(), Box::new(err)); + } + TransactionValidationOutcome::Invalid(transaction, err.into()) +} + /// Helper function to check if a transaction is an L1 message. fn is_l1_message(tx: &impl Typed2718) -> bool { tx.ty() == morph_primitives::L1_TX_TYPE_ID @@ -606,6 +622,55 @@ mod tests { ]) } + fn morph_tx_for_outcome_test() -> crate::MorphPooledTransaction { + let tx = TxMorph { + chain_id: 2818, + nonce: 0, + gas_limit: 21_000, + max_fee_per_gas: 100, + max_priority_fee_per_gas: 10, + to: TxKind::Call(address!("0000000000000000000000000000000000000002")), + fee_token_id: 1, + ..Default::default() + }; + let recovered = Recovered::new_unchecked( + MorphTxEnvelope::Morph(Signed::new_unhashed(tx, Signature::test_signature())), + address!("0000000000000000000000000000000000000001"), + ); + let encoded_len = recovered.encode_2718_len(); + crate::MorphPooledTransaction::new(recovered, encoded_len) + } + + #[test] + fn an_unreadable_fee_token_state_is_an_error_not_an_invalid_transaction() { + let tx = morph_tx_for_outcome_test(); + let hash = *tx.hash(); + + let outcome = morph_tx_validation_outcome( + tx, + MorphTxError::TokenInfoFetchFailed { + token_id: None, + message: "provider unavailable".to_string(), + }, + ); + assert!( + matches!(outcome, TransactionValidationOutcome::Error(reported, _) if reported == hash), + "a failed state read must not mark the transaction known-bad: {outcome:?}" + ); + } + + #[test] + fn a_real_fee_token_failure_is_still_an_invalid_transaction() { + let outcome = morph_tx_validation_outcome( + morph_tx_for_outcome_test(), + MorphTxError::TokenNotActive { token_id: 1 }, + ); + assert!( + matches!(outcome, TransactionValidationOutcome::Invalid(..)), + "{outcome:?}" + ); + } + #[test] fn test_morph_l1_block_info_default() { let info = MorphL1BlockInfo::new(); From 516b71ea68e0d55be9ab1e5e5686114fcce2a940 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 11 Sep 2026 15:23:24 +0800 Subject: [PATCH 4/5] fix(revm): read fee-token balances under the executing block environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- crates/evm/src/block/mod.rs | 16 ++-- crates/revm/src/handler.rs | 144 +++++++++++++++++++++++++++++++---- crates/revm/src/token_fee.rs | 16 +++- 3 files changed, 152 insertions(+), 24 deletions(-) diff --git a/crates/evm/src/block/mod.rs b/crates/evm/src/block/mod.rs index bb032056..a3b0a807 100644 --- a/crates/evm/src/block/mod.rs +++ b/crates/evm/src/block/mod.rs @@ -138,7 +138,6 @@ where &mut self, tx: &MorphTxEnvelope, sender: Address, - hardfork: MorphHardfork, ) -> Result, BlockExecutionError> { if !tx.is_morph_tx() { return Ok(None); @@ -169,12 +168,13 @@ where let token_info = match self.evm.cached_token_fee_info() { Some(info) => Some(info), - None => { - TokenFeeInfo::load_for_caller(self.evm.db_mut(), fee_token_id, sender, hardfork) - .map_err(|e| { - BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) - })? - } + // Only `price_ratio` and `scale` are read below, and both come straight from + // registry storage. `load_storage_only` reads exactly that and never builds a + // temporary EVM to resolve a balance this receipt has no use for. + None => TokenFeeInfo::load_storage_only(self.evm.db_mut(), fee_token_id, sender) + .map_err(|e| { + BlockExecutionError::msg(format!("Failed to fetch token fee info: {e:?}")) + })?, }; Ok(token_info.map(|info| MorphReceiptTxFields { @@ -300,7 +300,7 @@ where // are tracing-only — the trait API no longer permits us to surface errors // from `commit_transaction`. let (tx, signer) = recovered.into_parts(); - let morph_tx_fields = match self.get_morph_tx_fields(&tx, signer, self.hardfork) { + let morph_tx_fields = match self.get_morph_tx_fields(&tx, signer) { Ok(fields) => fields, Err(err) => { tracing::error!( diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index a0d9f2eb..7db94f8e 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -21,7 +21,10 @@ use crate::{ error::MorphHaltReason, evm::MorphContext, l1block::L1BlockInfo, - token_fee::{TokenRegistryEntry, compute_mapping_slot_for_address, encode_balance_of_calldata}, + token_fee::{ + TokenFeeInfo, TokenRegistryEntry, compute_mapping_slot_for_address, + encode_balance_of_calldata, read_balance_from_storage, + }, tx::MorphTxExt, }; @@ -555,11 +558,7 @@ where let hardfork = *evm.ctx_ref().cfg().spec(); - let token_fee_info = token_registry_entry.load_for_caller( - evm.ctx_mut().journal_mut().db_mut(), - caller_addr, - hardfork, - )?; + let token_fee_info = load_token_fee_info(evm, token_registry_entry, caller_addr)?; let beneficiary = evm.ctx_ref().block().beneficiary(); let rlp_bytes = evm.ctx_ref().tx().rlp_bytes.clone().unwrap_or_default(); @@ -841,26 +840,72 @@ where /// /// Uses [`with_evm_snapshot`] to match go-ethereum's StaticCall semantics: /// all state changes and `evm.tx` mutations are reverted after the call. -fn evm_call_balance_of(evm: &mut MorphEvm, token: Address, account: Address) -> U256 +fn evm_call_balance_of( + evm: &mut MorphEvm, + token: Address, + account: Address, +) -> Result> where DB: alloy_evm::Database, { with_evm_snapshot(evm, |evm| { let calldata = encode_balance_of_calldata(account); - match evm_call(evm, Address::ZERO, token, calldata) { + // go-ethereum passes the queried account as the caller + // (`sender := vm.AccountRef(userAddress)`, core/token_gas.go:109). + match evm_call(evm, account, token, calldata) { Ok(ref result) if result.instruction_result().is_ok() => { let output = &result.interpreter_result().output; - if output.len() >= 32 { + Ok(if output.len() >= 32 { U256::from_be_slice(&output[..32]) } else { U256::ZERO - } + }) } - _ => U256::ZERO, + // The token reverted or returned nothing usable: a zero balance, which the + // caller turns into the same rejection go-ethereum reaches by erroring out of + // `buyAltTokenGas` (core/state_transition.go:314). + Ok(_) => Ok(U256::ZERO), + // A failed state read is not an answer about the balance, and must never be + // turned into one: it would make an I/O failure change the block's outcome. + Err(err @ EVMError::Database(_)) => Err(err), + Err(_) => Ok(U256::ZERO), } }) } +/// Resolves the caller's fee-token balance against the **executing** EVM. +/// +/// go-ethereum reads it through `st.evm` (`GetAltTokenBalanceHybrid`, core/token_gas.go:43), +/// so the `balanceOf` call sees the real block context, the real chain config and the user as +/// `msg.sender`. Building a throwaway EVM here instead would answer under +/// `BlockEnv::default()` and `CfgEnv::default()` — block 0, timestamp 1, chain id 1, zero +/// coinbase and base fee — with `SYSTEM_ADDRESS` as the sender and a 30M gas limit in place +/// of go-ethereum's 200k. For any token whose `balanceOf` reads that context the two clients +/// would charge different fees for the same transaction. +fn load_token_fee_info( + evm: &mut MorphEvm, + entry: TokenRegistryEntry, + caller: Address, +) -> Result> +where + DB: alloy_evm::Database, +{ + let balance = match entry.balance_slot() { + // Slot mode is a plain storage read with no environment to get wrong. It goes + // through the database rather than the journal deliberately: the journal is empty + // at this point in the transaction, and an `sload` here would warm a slot that the + // fee deduction below is careful to leave cold. + Some(slot) => read_balance_from_storage( + evm.ctx_mut().journal_mut().db_mut(), + entry.token_address(), + caller, + slot, + )?, + None => evm_call_balance_of(evm, entry.token_address(), caller)?, + }; + Ok(entry.into_fee_info(caller, balance)) +} + /// Matches go-ethereum's `transferAltTokenByEVM` validation: /// 1. Checks EVM call succeeded (no revert) /// 2. Validates ABI-decoded bool return value (supports old tokens with no return data) @@ -887,7 +932,7 @@ where // This uses with_evm_snapshot internally, so evm.tx is safe. let from_balance_before = match from_balance_before { Some(b) => b, - None => evm_call_balance_of(evm, token_address, from), + None => evm_call_balance_of(evm, token_address, from)?, }; with_evm_checkpoint(evm, |evm| { @@ -919,7 +964,7 @@ where // Verify sender balance changed by the expected amount, matching go-ethereum. // evm_call_balance_of uses with_evm_snapshot, so evm.tx is safe here too. - let from_balance_after = evm_call_balance_of(evm, token_address, from); + let from_balance_after = evm_call_balance_of(evm, token_address, from)?; // Verify sender balance decreased by exactly the transfer amount. // Matches go-ethereum's transferAltTokenByEVM which always checks this, @@ -1104,6 +1149,77 @@ mod tests { } } + /// ` PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN` — a `balanceOf` that reports one piece + /// of its environment instead of a balance, so a call made under the wrong environment + /// shows up in the value that comes back. + fn code_returning(opcode: u8) -> Bytes { + Bytes::from(vec![opcode, 0x5f, 0x52, 0x60, 0x20, 0x5f, 0xf3]) + } + + fn insert_contract(db: &mut CacheDB, address: Address, code: Bytes) { + db.insert_account_info( + address, + AccountInfo { + code_hash: keccak256(code.as_ref()), + code: Some(Bytecode::new_raw(code)), + ..Default::default() + }, + ); + } + + /// Loads token 1's registry entry and resolves `caller`'s balance against `evm`. + fn probe_fee_token_balance(db: CacheDB, block: BlockEnv, caller: Address) -> U256 { + let mut evm = MorphEvm::new(MorphContext::new(db, MorphHardfork::Emerald), NoOpInspector); + evm.block = MorphBlockEnv { inner: block }; + + let entry = TokenRegistryEntry::load(evm.ctx_mut().journal_mut().db_mut(), 1) + .unwrap() + .unwrap(); + load_token_fee_info(&mut evm, entry, caller) + .unwrap() + .balance + } + + #[test] + fn fee_token_balance_is_read_under_the_executing_block_environment() { + const TIMESTAMP: u64 = 1_767_765_600; + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("1000000000000000000000000000000000000001"); + + let mut db = CacheDB::new(EmptyDB::default()); + insert_test_fee_token(&mut db, 1, token, true); + insert_contract(&mut db, token, code_returning(0x42)); // TIMESTAMP + + let balance = probe_fee_token_balance( + db, + BlockEnv { + timestamp: U256::from(TIMESTAMP), + ..Default::default() + }, + caller, + ); + + // `BlockEnv::default()` reports timestamp 1, which is what a throwaway EVM would + // have answered with regardless of the block being executed. + assert_eq!(balance, U256::from(TIMESTAMP)); + } + + #[test] + fn fee_token_balance_query_names_the_queried_account_as_the_caller() { + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("1000000000000000000000000000000000000001"); + + let mut db = CacheDB::new(EmptyDB::default()); + insert_test_fee_token(&mut db, 1, token, true); + insert_contract(&mut db, token, code_returning(0x33)); // CALLER + + let balance = probe_fee_token_balance(db, BlockEnv::default(), caller); + + // go-ethereum queries as the account being asked about, not as the zero address and + // not as `SYSTEM_ADDRESS`. + assert_eq!(balance, U256::from_be_bytes(caller.into_word().0)); + } + fn insert_test_fee_token( db: &mut CacheDB, token_id: u16, @@ -1432,7 +1548,7 @@ mod tests { inner: BlockEnv::default(), }; - let balance = evm_call_balance_of(&mut evm, token, account); + let balance = evm_call_balance_of(&mut evm, token, account).unwrap(); assert_eq!(balance, U256::from(42)); let slot_state = evm diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index c32827cb..8a59e353 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -60,6 +60,18 @@ pub(crate) struct TokenRegistryEntry { } impl TokenRegistryEntry { + /// The registered ERC20 contract. + pub(crate) const fn token_address(&self) -> Address { + self.token_address + } + + /// The caller's balance storage slot, when the registry declares one. + /// + /// `None` means call mode: the balance has to be read by calling `balanceOf`. + pub(crate) const fn balance_slot(&self) -> Option { + self.balance_slot + } + /// Load fee-token metadata without reading a caller's token balance. pub(crate) fn load( db: &mut DB, @@ -109,7 +121,7 @@ impl TokenRegistryEntry { Ok(self.into_fee_info(caller, balance)) } - fn into_fee_info(self, caller: Address, balance: U256) -> TokenFeeInfo { + pub(crate) fn into_fee_info(self, caller: Address, balance: U256) -> TokenFeeInfo { TokenFeeInfo { token_address: self.token_address, is_active: self.is_active, @@ -311,7 +323,7 @@ fn read_token_balance_with_fallback( /// Read ERC20 balance directly from storage slot. #[inline] -fn read_balance_from_storage( +pub(crate) fn read_balance_from_storage( db: &mut DB, token: Address, account: Address, From 40c93eb73820a1ec3c053b583856ab1a42645768 Mon Sep 17 00:00:00 2001 From: panos-xyz Date: Fri, 11 Sep 2026 15:39:28 +0800 Subject: [PATCH 5/5] fix(txpool): read fee-token balances under the head block's environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pool resolved a call-mode fee token's balance through a temporary EVM built from `MorphContext::new(db, hardfork)`, which carries `BlockEnv::default()` and `CfgEnv::default()` — block 0, timestamp 1, chain id 1 — and queried it from `SYSTEM_ADDRESS` with a 30M gas cap. go-ethereum's pool builds a real `vm.BlockContext` from the head header and calls `balanceOf` as the user with a 200k cap (`pool.getBalanceFunc`, core/tx_pool.go:330). So for a token whose `balanceOf` reads block context or `msg.sender`, admission and maintenance were answering a different question than the execution layer — admitting transactions that cannot execute, or rejecting ones that would. Thread the block's `EvmEnv` through `MorphTxValidationInput` instead. The validator caches it alongside the L1 block info, built by `ConfigureEvm::evm_env` for the head, and the maintenance task builds it for each canonical tip, so both use exactly what execution would. `read_token_balance_with_fallback` now stands its EVM up in that environment and delegates to the same `evm_call_balance_of` the handler uses, leaving one implementation of the query rather than two that can drift. `evm_call` takes the context error after running the frame group. A database failure inside a frame is recorded on the context and surfaces as a halt; running the frames directly skips the step that normally converts it, so an I/O failure was indistinguishable from the token reverting — which would have silently undone the propagation this relies on. `query_erc20_balance` and `query_balance_via_system_call` are removed: they were the only remaining way to ask this question in the wrong environment. Claude-Session: https://claude.ai/code/session_01PiUjd47Da71WG2BFkDQz9q --- crates/node/src/components/pool.rs | 10 +- crates/revm/src/handler.rs | 18 +++- crates/revm/src/lib.rs | 4 +- crates/revm/src/token_fee.rs | 129 ++++++++++++----------- crates/txpool/src/maintain.rs | 52 ++++++--- crates/txpool/src/morph_tx_validation.rs | 24 ++++- crates/txpool/src/validator.rs | 41 +++++-- 7 files changed, 184 insertions(+), 94 deletions(-) diff --git a/crates/node/src/components/pool.rs b/crates/node/src/components/pool.rs index d3ec9799..3e163ad9 100644 --- a/crates/node/src/components/pool.rs +++ b/crates/node/src/components/pool.rs @@ -41,14 +41,14 @@ where // Use in-memory blob store (Morph doesn't support EIP-4844 blobs) let blob_store = InMemoryBlobStore::default(); - // Build the Morph-specific EVM config for the validator + // Build the Morph-specific EVM config for the validator and the maintenance task let morph_evm_config = MorphEvmConfig::new(ctx.chain_spec(), morph_evm::MorphEvmFactory::default()); // Build the transaction validator with Morph-specific checks let validator = TransactionValidationTaskExecutor::eth_builder( ctx.provider().clone(), - morph_evm_config, + morph_evm_config.clone(), ) .with_max_tx_input_bytes(ctx.config().txpool.max_tx_input_bytes) .with_local_transactions_config(pool_config.local_transactions_config.clone()) @@ -88,7 +88,11 @@ where // cannot track (reth only tracks ETH balance via SenderInfo) ctx.task_executor().spawn_critical_task( "txpool maintenance - morph pool", - morph_txpool::maintain_morph_pool(pool.clone(), ctx.provider().clone()), + morph_txpool::maintain_morph_pool( + pool.clone(), + ctx.provider().clone(), + morph_evm_config, + ), ); info!(target: "morph::node", "Transaction pool initialized"); diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index 7db94f8e..eb74c079 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -825,22 +825,34 @@ where let mut h = MorphEvmHandler::::new(); let init_and_floor_gas = InitialAndFloorGas::new(0, 0); let mut gas = h.tx_gas(evm, &init_and_floor_gas); + // A database failure inside the frame is recorded on the context and surfaces as a halt, + // not as an `Err`. Running the frame group directly skips the step that normally converts + // it, so an I/O failure would otherwise be indistinguishable from the token reverting. + debug_assert!( + evm.ctx_ref().error.is_ok(), + "context error must be taken before evm_call" + ); // `execution` owns this checkpoint: it commits once the runtime gas phase is done, or // unwinds to it when that phase runs out of gas. The `None` arm is only reachable // under EIP-2780 (AMSTERDAM), which Morph never enables, so it is unreachable today; // it is kept faithful to upstream so a future hardfork mapping cannot silently skip it. let checkpoint = evm.ctx().journal_mut().checkpoint(); - match h.execution(evm, checkpoint, &mut gas)? { + let result = match h.execution(evm, checkpoint, &mut gas)? { Some(res) => Ok(res), None => h.runtime_oog_result(evm, &init_and_floor_gas, &mut gas), - } + }; + revm::context_interface::context::take_error::< + EVMError, + DB::Error, + >(&mut evm.ctx_mut().error)?; + result } /// Query ERC20 `balanceOf(address)` via an internal EVM call. /// /// Uses [`with_evm_snapshot`] to match go-ethereum's StaticCall semantics: /// all state changes and `evm.tx` mutations are reverted after the call. -fn evm_call_balance_of( +pub(crate) fn evm_call_balance_of( evm: &mut MorphEvm, token: Address, account: Address, diff --git a/crates/revm/src/lib.rs b/crates/revm/src/lib.rs index 00aea498..a887c914 100644 --- a/crates/revm/src/lib.rs +++ b/crates/revm/src/lib.rs @@ -73,7 +73,7 @@ pub use l1block::{ }; pub use precompiles::MorphPrecompiles; pub use token_fee::{ - L2_TOKEN_REGISTRY_ADDRESS, TokenFeeInfo, compute_mapping_slot, - compute_mapping_slot_for_address, encode_balance_of_calldata, query_erc20_balance, + L2_TOKEN_REGISTRY_ADDRESS, MorphEvmEnv, TokenFeeInfo, compute_mapping_slot, + compute_mapping_slot_for_address, encode_balance_of_calldata, }; pub use tx::{MorphTxEnv, MorphTxExt}; diff --git a/crates/revm/src/token_fee.rs b/crates/revm/src/token_fee.rs index 8d5eecd4..d854ebaf 100644 --- a/crates/revm/src/token_fee.rs +++ b/crates/revm/src/token_fee.rs @@ -9,12 +9,19 @@ use alloy_evm::Database; use alloy_primitives::{Address, Bytes, U256, address, keccak256}; use morph_chainspec::hardfork::MorphHardfork; use revm::Database as RevmDatabase; -use revm::SystemCallEvm; use revm::{context_interface::result::EVMError, inspector::NoOpInspector}; use crate::evm::MorphContext; use crate::{MorphEvm, MorphInvalidTransaction}; +/// The environment a fee-token `balanceOf` call is evaluated in. +/// +/// Produced by `ConfigureEvm::evm_env` for the block whose state is being read, so the pool +/// resolves the same balance the execution layer would. go-ethereum builds the equivalent +/// `vm.BlockContext` from the header before querying a call-mode token +/// (`pool.getBalanceFunc`, core/tx_pool.go:330). +pub type MorphEvmEnv = alloy_evm::EvmEnv; + /// L2 Token Registry contract address on Morph L2. /// Reference: pub const L2_TOKEN_REGISTRY_ADDRESS: Address = address!("5300000000000000000000000000000000000021"); @@ -96,14 +103,14 @@ impl TokenRegistryEntry { self, db: &mut DB, caller: Address, - hardfork: MorphHardfork, + env: &MorphEvmEnv, ) -> Result { let balance = read_token_balance_with_fallback( db, self.token_address, caller, self.balance_slot, - hardfork, + env, )?; Ok(self.into_fee_info(caller, balance)) } @@ -145,14 +152,14 @@ impl TokenFeeInfo { db: &mut DB, token_id: u16, caller: Address, - hardfork: MorphHardfork, + env: &MorphEvmEnv, ) -> Result, DB::Error> { let entry = match TokenRegistryEntry::load(db, token_id)? { Some(e) => e, None => return Ok(None), }; - entry.load_for_caller(db, caller, hardfork).map(Some) + entry.load_for_caller(db, caller, env).map(Some) } /// Storage-only variant of [`Self::load_for_caller`]. @@ -304,20 +311,27 @@ fn read_token_balance_with_fallback( token: Address, account: Address, balance_slot: Option, - hardfork: MorphHardfork, + env: &MorphEvmEnv, ) -> Result { if let Some(slot) = balance_slot { return read_balance_from_storage(db, token, account, slot); } - // EVM fallback: construct temporary MorphEvm for balanceOf call + // Call mode: stand the EVM up in the caller's environment rather than a default one, + // and make the same `balanceOf` call the execution layer makes, so both reach the same + // answer for a token whose balance depends on block context or `msg.sender`. let db: &mut dyn Database = db; - let mut evm = MorphEvm::new(MorphContext::new(db, hardfork), NoOpInspector {}); + let mut ctx = MorphContext::new(db, *env.cfg_env.spec()); + ctx.cfg = env.cfg_env.clone(); + ctx.block = env.block_env.clone(); + let mut evm = MorphEvm::new(ctx, NoOpInspector {}); - match query_balance_via_system_call(&mut evm, token, account) { + match crate::handler::evm_call_balance_of(&mut evm, token, account) { Ok(balance) => Ok(balance), Err(EVMError::Database(e)) => Err(e), - Err(_) => Ok(U256::ZERO), // Non-DB errors → zero (safe fallback) + // The token reverted or returned nothing usable: a zero balance, which the caller + // turns into the same insufficient-funds rejection the execution layer reaches. + Err(_) => Ok(U256::ZERO), } } @@ -334,52 +348,6 @@ pub(crate) fn read_balance_from_storage( read_mapping_value(db, token, balance_slot, &key) } -/// Execute EVM `balanceOf(address)` call. -fn query_balance_via_system_call( - evm: &mut MorphEvm, - token: Address, - account: Address, -) -> Result> -where - DB: Database, -{ - let calldata = encode_balance_of_calldata(account); - match evm.system_call_one(token, calldata) { - Ok(result) if result.is_success() => { - if let Some(output) = result.output() - && output.len() >= 32 - { - return Ok(U256::from_be_slice(&output[..32])); - } - Ok(U256::ZERO) - } - // The token reverted or returned nothing usable. That is a statement about the - // token, so it stays a zero balance and the caller rejects the transaction for - // insufficient funds. - Ok(_) => Ok(U256::ZERO), - // A failed state read is *not* a statement about the token: report it so the - // caller can tell "this account cannot pay" apart from "we could not find out". - // Swallowing it here made the `EVMError::Database` arm in - // `read_token_balance_with_fallback` unreachable. - Err(err @ EVMError::Database(_)) => Err(err), - Err(_) => Ok(U256::ZERO), - } -} - -/// Query ERC20 balance via EVM call. -/// -/// Use this when you have a `MorphEvm` instance and need to call `balanceOf`. -pub fn query_erc20_balance( - evm: &mut MorphEvm, - token: Address, - account: Address, -) -> Result> -where - DB: Database, -{ - query_balance_via_system_call(evm, token, account) -} - /// Encode ERC20 `balanceOf(address)` calldata. /// /// Function selector: `0x70a08231` @@ -448,6 +416,15 @@ mod tests { /// Registry state for a call-mode token (no `balanceSlot`) whose `balanceOf` returns /// storage slot 0, so reading it is a storage read of the token contract. fn call_mode_token_state(token: Address, balance: u64) -> CacheDB { + call_mode_token_state_with_code(token, balance, bytes!("6000545f5260205ff3")) + } + + /// As [`call_mode_token_state`], with an explicit `balanceOf` implementation. + fn call_mode_token_state_with_code( + token: Address, + balance: u64, + code: Bytes, + ) -> CacheDB { let mut db = CacheDB::new(EmptyDB::default()); let mut token_id_bytes = [0u8; 32]; token_id_bytes[31] = 1; @@ -471,8 +448,6 @@ mod tests { .unwrap(); } - // PUSH1 0x00 SLOAD PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN - let code = bytes!("6000545f5260205ff3"); db.insert_account_info( token, AccountInfo { @@ -486,15 +461,47 @@ mod tests { db } + #[test] + fn call_mode_balance_is_read_under_the_supplied_block_environment() { + const TIMESTAMP: u64 = 1_767_765_600; + let token = address!("5300000000000000000000000000000000000042"); + let caller = address!("0000000000000000000000000000000000000001"); + + // TIMESTAMP PUSH0 MSTORE PUSH1 0x20 PUSH0 RETURN — a `balanceOf` that reports the + // block time, so an answer produced under the wrong environment is visible. + let mut db = call_mode_token_state_with_code(token, 0, bytes!("425f5260205ff3")); + + let env = MorphEvmEnv::new( + revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + crate::MorphBlockEnv { + inner: revm::context::BlockEnv { + timestamp: U256::from(TIMESTAMP), + ..Default::default() + }, + }, + ); + + let info = TokenFeeInfo::load_for_caller(&mut db, 1, caller, &env) + .unwrap() + .unwrap(); + + // `BlockEnv::default()` reports timestamp 1, which is what the pool answered with + // regardless of the block it was validating against. + assert_eq!(info.balance, U256::from(TIMESTAMP)); + } + #[test] fn balance_of_fallback_reports_a_failed_state_read_instead_of_a_zero_balance() { let token = address!("5300000000000000000000000000000000000042"); let caller = address!("0000000000000000000000000000000000000001"); - let hardfork = MorphHardfork::Emerald; + let env = MorphEvmEnv::new( + revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + crate::MorphBlockEnv::default(), + ); // Readable state: the fallback reaches the token and reads the balance. let mut readable = call_mode_token_state(token, 10_000_000); - let info = TokenFeeInfo::load_for_caller(&mut readable, 1, caller, hardfork) + let info = TokenFeeInfo::load_for_caller(&mut readable, 1, caller, &env) .unwrap() .unwrap(); assert_eq!(info.balance, U256::from(10_000_000)); @@ -506,7 +513,7 @@ mod tests { token, }; assert_eq!( - TokenFeeInfo::load_for_caller(&mut unreadable, 1, caller, hardfork).unwrap_err(), + TokenFeeInfo::load_for_caller(&mut unreadable, 1, caller, &env).unwrap_err(), ReadFailed ); } diff --git a/crates/txpool/src/maintain.rs b/crates/txpool/src/maintain.rs index 243dcbcd..f063fe54 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -41,8 +41,9 @@ use alloy_consensus::Typed2718; use alloy_primitives::{Address, TxHash, U256}; use futures::{FutureExt, StreamExt}; use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; -use morph_revm::L1BlockInfo; +use morph_revm::{L1BlockInfo, MorphBlockEnv, MorphEvmEnv}; use reth_chainspec::ChainSpecProvider; +use reth_evm::{ConfigureEvm, EvmFactory, EvmFactoryFor}; use reth_primitives_traits::AlloyBlockHeader; use reth_provider::CanonStateSubscriptions; use reth_revm::database::StateProviderDatabase; @@ -144,10 +145,11 @@ const fn is_transient(err: &MorphTxError) -> bool { fn collect_removable_transactions( db: &mut DB, l1_block_info: &L1BlockInfo, - hardfork: MorphHardfork, + evm_env: &MorphEvmEnv, block_gas_limit: u64, morph_txs: Vec<&MorphPooledTransaction>, ) -> Vec { + let hardfork = *evm_env.cfg_env.spec(); // Group by sender and process in nonce order so affordability is validated cumulatively. let mut txs_by_sender: HashMap> = HashMap::new(); for tx in morph_txs { @@ -230,6 +232,7 @@ fn collect_removable_transactions( eth_balance: budget.eth_balance, l1_data_fee, hardfork, + evm_env, }; let validation = match crate::validate_morph_tx(db, &input) { @@ -306,7 +309,7 @@ fn collect_removable_transactions( /// - Re-validates MorphTx (0x7F) transactions in the pool /// - Removes transactions that no longer have sufficient token balance /// -pub async fn maintain_morph_pool(pool: Pool, client: Client) +pub async fn maintain_morph_pool(pool: Pool, client: Client, evm_config: Evm) where Pool: TransactionPool + Clone, Client: ChainSpecProvider @@ -314,18 +317,21 @@ where + CanonStateSubscriptions + Clone + 'static, + Evm: ConfigureEvm::Primitives>, + EvmFactoryFor: EvmFactory, { let chain_events = client.canonical_state_stream(); tracing::info!(target: "morph::txpool::maintain", "Starting MorphTx maintenance task"); - maintain_morph_pool_with(pool, client, chain_events).await; + maintain_morph_pool_with(pool, client, evm_config, chain_events).await; } /// [`maintain_morph_pool`] with an explicit canonical event stream. -async fn maintain_morph_pool_with( +async fn maintain_morph_pool_with( pool: Pool, client: Client, + evm_config: Evm, mut chain_events: Events, ) where Pool: TransactionPool + Clone, @@ -334,6 +340,8 @@ async fn maintain_morph_pool_with( + CanonStateSubscriptions + Clone + 'static, + Evm: ConfigureEvm::Primitives>, + EvmFactoryFor: EvmFactory, Events: futures::Stream> + Unpin, { @@ -354,7 +362,6 @@ async fn maintain_morph_pool_with( let new_tip = event.tip(); let block_number = new_tip.number(); - let block_timestamp = new_tip.timestamp(); let block_gas_limit = new_tip.gas_limit(); tracing::trace!( @@ -363,10 +370,20 @@ async fn maintain_morph_pool_with( "Processing new block for MorphTx validation" ); - // Get the hardfork at this block - let hardfork = client - .chain_spec() - .morph_hardfork_at(block_number, block_timestamp); + // Build the environment execution would use for this block, so a call-mode fee + // token's `balanceOf` resolves to the balance the execution layer would see. + let evm_env = match evm_config.evm_env(new_tip.header()) { + Ok(evm_env) => evm_env, + Err(err) => { + tracing::warn!( + target: "morph::txpool::maintain", + %err, + "Failed to build EVM env for MorphTx revalidation" + ); + continue; + } + }; + let hardfork = *evm_env.cfg_env.spec(); // Collect all MorphTx transactions from the pool let all_txs = pool.all_transactions(); @@ -419,7 +436,7 @@ async fn maintain_morph_pool_with( let to_remove = collect_removable_transactions( &mut db, &l1_block_info, - hardfork, + &evm_env, block_gas_limit, morph_txs, ); @@ -687,11 +704,19 @@ mod tests { MorphPooledTransaction::new(recovered, encoded_len) } + /// The environment the revalidation round is evaluated in. + fn test_evm_env() -> MorphEvmEnv { + MorphEvmEnv::new( + reth_revm::revm::context::CfgEnv::new_with_spec(MorphHardfork::Emerald), + MorphBlockEnv::default(), + ) + } + fn removable(db: &mut CacheDB, txs: Vec<&MorphPooledTransaction>) -> Vec { collect_removable_transactions( db, &L1BlockInfo::default(), - MorphHardfork::Emerald, + &test_evm_env(), 30_000_000, txs, ) @@ -791,7 +816,7 @@ mod tests { let to_remove = collect_removable_transactions( &mut db, &L1BlockInfo::default(), - MorphHardfork::Emerald, + &test_evm_env(), 30_000_000, vec![&tx], ); @@ -966,6 +991,7 @@ mod tests { futures::executor::block_on(maintain_morph_pool_with( pool.clone(), client, + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), futures::stream::iter([event]), )); diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index a7bd3472..336c3bb3 100644 --- a/crates/txpool/src/morph_tx_validation.rs +++ b/crates/txpool/src/morph_tx_validation.rs @@ -8,7 +8,7 @@ use alloy_evm::Database; use alloy_primitives::{Address, U256}; use morph_chainspec::hardfork::MorphHardfork; use morph_primitives::{MorphTxEnvelope, transaction::morph_transaction::MORPH_TX_VERSION_1}; -use morph_revm::TokenFeeInfo; +use morph_revm::{MorphEvmEnv, TokenFeeInfo}; use crate::MorphTxError; @@ -27,6 +27,11 @@ pub struct MorphTxValidationInput<'a> { pub l1_data_fee: U256, /// Current hardfork pub hardfork: MorphHardfork, + /// The environment a call-mode fee token's `balanceOf` is evaluated in. + /// + /// Must be the environment of the block whose state `db` exposes, so admission and + /// maintenance resolve the same balance the execution layer would. + pub evm_env: &'a MorphEvmEnv, } /// Result of MorphTx validation. @@ -107,7 +112,7 @@ pub fn validate_morph_tx( }); } - let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, input.hardfork) + let token_info = TokenFeeInfo::load_for_caller(db, fee_token_id, input.sender, input.evm_env) .map_err(|err| MorphTxError::TokenInfoFetchFailed { token_id: Some(fee_token_id), message: format!("{err:?}"), @@ -166,6 +171,14 @@ pub fn validate_morph_tx( #[cfg(test)] mod tests { use super::*; + + /// The environment the fee-token balance query is evaluated in. + fn test_evm_env(hardfork: MorphHardfork) -> MorphEvmEnv { + MorphEvmEnv::new( + reth_revm::revm::context::CfgEnv::new_with_spec(hardfork), + morph_revm::MorphBlockEnv::default(), + ) + } use alloy_consensus::Signed; use alloy_primitives::{B256, Signature, TxKind, address}; use morph_primitives::{TxMorph, transaction::morph_transaction::MORPH_TX_VERSION_1}; @@ -201,6 +214,7 @@ mod tests { eth_balance: U256::from(1_000_000_000_000_000_000u128), // 1 ETH l1_data_fee: U256::from(100_000), hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; assert_eq!(input.sender, sender); @@ -239,6 +253,7 @@ mod tests { eth_balance: U256::from(1_000_000_000_000_000_000u128), l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), }; let mut db = EmptyDB::default(); @@ -280,6 +295,7 @@ mod tests { eth_balance: U256::from(1_000_000_000_000_000_000u128), l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; let mut db = EmptyDB::default(); @@ -317,6 +333,7 @@ mod tests { eth_balance: U256::from(100u64), // Insufficient ETH l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; let mut db = EmptyDB::default(); @@ -358,6 +375,7 @@ mod tests { eth_balance: U256::from(10u128.pow(18)), // 1 ETH (sufficient) l1_data_fee: U256::from(1000u64), hardfork: MorphHardfork::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), }; let mut db = EmptyDB::default(); @@ -400,6 +418,7 @@ mod tests { eth_balance: U256::from(100u64), // Way too low l1_data_fee: U256::from(1000u64), hardfork: MorphHardfork::Jade, + evm_env: &test_evm_env(MorphHardfork::Jade), }; let mut db = EmptyDB::default(); @@ -438,6 +457,7 @@ mod tests { eth_balance: U256::from(10u128.pow(18)), l1_data_fee: U256::ZERO, hardfork: MorphHardfork::Viridian, + evm_env: &test_evm_env(MorphHardfork::Viridian), }; let mut db = EmptyDB::default(); diff --git a/crates/txpool/src/validator.rs b/crates/txpool/src/validator.rs index a9966566..fa0dee18 100644 --- a/crates/txpool/src/validator.rs +++ b/crates/txpool/src/validator.rs @@ -12,14 +12,14 @@ use crate::MorphTxError; use alloy_consensus::{BlockHeader, Transaction}; use alloy_eips::{Encodable2718, Typed2718}; use alloy_primitives::{Address, U256}; -use morph_chainspec::hardfork::MorphHardforks; +use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; use morph_primitives::MorphTxEnvelope; -use morph_revm::L1BlockInfo; +use morph_revm::{L1BlockInfo, MorphBlockEnv, MorphEvmEnv}; use parking_lot::RwLock; use reth_chainspec::ChainSpecProvider; -use reth_evm::ConfigureEvm; +use reth_evm::{ConfigureEvm, EvmFactory, EvmFactoryFor}; use reth_primitives_traits::{ - Block, BlockTy, GotExpected, SealedBlock, transaction::error::InvalidTransactionError, + Block, BlockTy, GotExpected, HeaderTy, SealedBlock, transaction::error::InvalidTransactionError, }; use reth_revm::database::StateProviderDatabase; use reth_storage_api::{BlockReaderIdExt, StateProviderFactory}; @@ -54,6 +54,11 @@ pub struct MorphL1BlockInfo { timestamp: AtomicU64, /// Current block number. number: AtomicU64, + /// The head block's EVM environment, as `ConfigureEvm` would build it for execution. + /// + /// A call-mode fee token's `balanceOf` is evaluated in this, so admission resolves the + /// same balance the execution layer would rather than one under a default environment. + evm_env: RwLock, } impl MorphL1BlockInfo { @@ -176,9 +181,14 @@ fn insufficient_funds_outcome( impl MorphTransactionValidator where - Client: ChainSpecProvider + StateProviderFactory + BlockReaderIdExt, + Client: ChainSpecProvider + + StateProviderFactory + + BlockReaderIdExt
>, Tx: EthPoolTransaction, Evm: ConfigureEvm, + // Pins the cached environment to Morph's, so the fee-token balance query runs in + // exactly what the execution layer would use. + EvmFactoryFor: EvmFactory, { /// Create a new [`MorphTransactionValidator`]. pub fn new(inner: EthTransactionValidator) -> Self { @@ -206,10 +216,7 @@ where } /// Update the L1 block info for the given header. - pub fn update_l1_block_info(&self, header: &H) - where - H: BlockHeader, - { + pub fn update_l1_block_info(&self, header: &HeaderTy) { self.block_info .timestamp .store(header.timestamp(), Ordering::Relaxed); @@ -218,6 +225,13 @@ where .store(header.number(), Ordering::Relaxed); *self.block_info.base_fee_per_gas.write() = header.base_fee_per_gas(); + match self.inner.evm_config().evm_env(header) { + Ok(evm_env) => *self.block_info.evm_env.write() = evm_env, + Err(err) => { + tracing::warn!(target: "morph::txpool", %err, "Failed to build EVM env for head block") + } + } + let provider = match self .client() .state_by_block_number_or_tag(header.number().into()) @@ -436,6 +450,7 @@ where })?; let mut db = StateProviderDatabase::new(provider); + let evm_env = self.block_info.evm_env.read().clone(); // Use shared validation logic with unified API (includes ETH balance check) let input = crate::MorphTxValidationInput { @@ -444,6 +459,7 @@ where eth_balance, l1_data_fee, hardfork, + evm_env: &evm_env, }; let result = crate::validate_morph_tx(&mut db, &input)?; @@ -488,9 +504,14 @@ where impl TransactionValidator for MorphTransactionValidator where - Client: ChainSpecProvider + StateProviderFactory + BlockReaderIdExt, + Client: ChainSpecProvider + + StateProviderFactory + + BlockReaderIdExt
>, Tx: EthPoolTransaction, Evm: ConfigureEvm, + // Pins the cached environment to Morph's, so the fee-token balance query runs in + // exactly what the execution layer would use. + EvmFactoryFor: EvmFactory, { type Transaction = Tx; type Block = BlockTy;