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/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 a0d9f2eb..eb74c079 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(); @@ -826,41 +825,99 @@ 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(evm: &mut MorphEvm, token: Address, account: Address) -> U256 +pub(crate) 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 +944,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 +976,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 +1161,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 +1560,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/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 c32827cb..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"); @@ -60,6 +67,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, @@ -84,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)) } @@ -109,7 +128,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, @@ -133,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`]. @@ -292,26 +311,33 @@ 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), } } /// 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, @@ -322,44 +348,6 @@ 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) - } - Ok(_) => Ok(U256::ZERO), - 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` @@ -376,6 +364,160 @@ 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 { + 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; + 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(); + } + + 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 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 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, &env) + .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, &env).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/maintain.rs b/crates/txpool/src/maintain.rs index 7f54f551..f063fe54 100644 --- a/crates/txpool/src/maintain.rs +++ b/crates/txpool/src/maintain.rs @@ -12,26 +12,40 @@ //! //! 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 morph_revm::L1BlockInfo; +use futures::{FutureExt, StreamExt}; +use morph_chainspec::hardfork::{MorphHardfork, MorphHardforks}; +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; use reth_revm::database::StateProviderDatabase; use reth_storage_api::StateProviderFactory; use reth_transaction_pool::{PoolTransaction, TransactionPool}; @@ -108,6 +122,186 @@ 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, + 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 { + 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(), + }; + // 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 + // 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; + } + + // 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", + 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, + evm_env, + }; + + 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: @@ -115,7 +309,7 @@ fn exceeds_block_gas_limit(tx_gas_limit: u64, block_gas_limit: u64) -> bool { /// - 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 @@ -123,21 +317,51 @@ where + CanonStateSubscriptions + Clone + 'static, + Evm: ConfigureEvm::Primitives>, + EvmFactoryFor: EvmFactory, { - 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, evm_config, chain_events).await; +} + +/// [`maintain_morph_pool`] with an explicit canonical event stream. +async fn maintain_morph_pool_with( + pool: Pool, + client: Client, + evm_config: Evm, + mut chain_events: Events, +) where + Pool: TransactionPool + Clone, + Client: ChainSpecProvider + + StateProviderFactory + + CanonStateSubscriptions + + Clone + + 'static, + Evm: ConfigureEvm::Primitives>, + EvmFactoryFor: EvmFactory, + 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(); let block_gas_limit = new_tip.gas_limit(); tracing::trace!( @@ -146,18 +370,29 @@ where "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(); - 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 +433,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, + &evm_env, + 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 +607,397 @@ 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) + } + + /// 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(), + &test_evm_env(), + 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()]); + } + + #[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); + + 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(), + &test_evm_env(), + 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, + MorphEvmConfig::new_with_default_factory(MORPH_MAINNET.clone()), + futures::stream::iter([event]), + )); + + assert!( + pool.get(&descendant).is_some(), + "an independently affordable ETH-fee successor must be parked, not deleted" + ); + } } diff --git a/crates/txpool/src/morph_tx_validation.rs b/crates/txpool/src/morph_tx_validation.rs index e666c295..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,9 +112,9 @@ 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: fee_token_id, + token_id: Some(fee_token_id), message: format!("{err:?}"), })? .ok_or(MorphTxError::TokenNotFound { @@ -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 1afea643..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()) @@ -383,10 +397,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,11 +444,13 @@ 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(), })?; 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 { @@ -446,6 +459,7 @@ where eth_balance, l1_data_fee, hardfork, + evm_env: &evm_env, }; let result = crate::validate_morph_tx(&mut db, &input)?; @@ -490,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; @@ -519,6 +538,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 +643,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();