Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions crates/revm/src/token_fee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
}
}
Expand Down Expand Up @@ -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<EmptyDB>,
token: Address,
}

impl RevmDatabase for FeeTokenUnreadable {
type Error = ReadFailed;

fn basic(&mut self, address: Address) -> Result<Option<AccountInfo>, Self::Error> {
Ok(self.inner.basic(address).unwrap())
}

fn code_by_hash(&mut self, code_hash: B256) -> Result<Bytecode, Self::Error> {
Ok(self.inner.code_by_hash(code_hash).unwrap())
}

fn storage(&mut self, address: Address, index: U256) -> Result<U256, Self::Error> {
if address == self.token {
return Err(ReadFailed);
}
Ok(self.inner.storage(address, index).unwrap())
}

fn block_hash(&mut self, number: u64) -> Result<B256, Self::Error> {
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<EmptyDB> {
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();
Expand Down
26 changes: 18 additions & 8 deletions crates/txpool/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u16>,
/// Error message.
message: String,
},
Expand Down Expand Up @@ -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}")
}
Expand Down Expand Up @@ -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()
Expand All @@ -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(),
},
Expand Down
2 changes: 1 addition & 1 deletion crates/txpool/src/morph_tx_validation.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ pub fn validate_morph_tx<DB: Database>(

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 {
Expand Down
75 changes: 70 additions & 5 deletions crates/txpool/src/validator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(),
})?;

Expand Down Expand Up @@ -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<Tx: EthPoolTransaction>(
transaction: Tx,
err: MorphTxError,
) -> TransactionValidationOutcome<Tx> {
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
Expand Down Expand Up @@ -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();
Expand Down