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();