diff --git a/crates/primitives/src/transaction/mod.rs b/crates/primitives/src/transaction/mod.rs index fa1d340662..3de7d8d47a 100644 --- a/crates/primitives/src/transaction/mod.rs +++ b/crates/primitives/src/transaction/mod.rs @@ -16,7 +16,7 @@ pub use envelope::{TempoTxEnvelope, TempoTxType, TempoTypedTransaction}; pub use key_authorization::{KeyAuthorization, SignedKeyAuthorization, TokenLimit}; pub use tempo_transaction::{ Call, MAX_WEBAUTHN_SIGNATURE_LENGTH, P256_SIGNATURE_LENGTH, SECP256K1_SIGNATURE_LENGTH, - SignatureType, TEMPO_TX_TYPE_ID, TempoTransaction, + SignatureType, TEMPO_TX_TYPE_ID, TempoTransaction, validate_calls, }; pub use tt_signed::AASigned; diff --git a/crates/primitives/src/transaction/tempo_transaction.rs b/crates/primitives/src/transaction/tempo_transaction.rs index 08404b150d..0c35401bf2 100644 --- a/crates/primitives/src/transaction/tempo_transaction.rs +++ b/crates/primitives/src/transaction/tempo_transaction.rs @@ -223,6 +223,45 @@ pub struct TempoTransaction { pub tempo_authorization_list: Vec, } +/// Validates the calls list structure for Tempo transactions. +/// +/// This is a shared validation function used by both `TempoTransaction::validate()` +/// and the revm handler's `validate_env()` to ensure consistent validation. +/// +/// Rules: +/// - Calls list must not be empty +/// - CREATE calls are not allowed when authorization list is non-empty (EIP-7702 semantics) +/// - Only the first call can be a CREATE; all subsequent calls must be CALL +pub fn validate_calls(calls: &[Call], has_authorization_list: bool) -> Result<(), &'static str> { + // Calls must not be empty (similar to EIP-7702 rejecting empty auth lists) + if calls.is_empty() { + return Err("calls list cannot be empty"); + } + + let mut calls_iter = calls.iter(); + + // Only the first call in the batch can be a CREATE call. + if let Some(call) = calls_iter.next() + // Authorization list validation: Can NOT have CREATE when authorization list is non-empty + // This follows EIP-7702 semantics - when using delegation + && has_authorization_list + && call.to.is_create() + { + return Err("calls cannot contain CREATE when 'aa_authorization_list' is non-empty"); + } + + // All subsequent calls must be CALL. + for call in calls_iter { + if call.to.is_create() { + return Err( + "only one CREATE call is allowed per transaction, and it must be the first call of the batch", + ); + } + } + + Ok(()) +} + impl TempoTransaction { /// Get the transaction type #[doc(alias = "transaction_type")] @@ -232,10 +271,8 @@ impl TempoTransaction { /// Validates the transaction according to the spec rules pub fn validate(&self) -> Result<(), &'static str> { - // calls must not be empty (similar to EIP-7702 rejecting empty auth lists) - if self.calls.is_empty() { - return Err("calls list cannot be empty"); - } + // Validate calls list structure using the shared function + validate_calls(&self.calls, !self.tempo_authorization_list.is_empty())?; // validBefore must be greater than validAfter if both are set if let Some(valid_after) = self.valid_after @@ -245,27 +282,6 @@ impl TempoTransaction { return Err("valid_before must be greater than valid_after"); } - let mut calls = self.calls.iter(); - - // Only the first call in the batch can be a CREATE call. - if let Some(call) = calls.next() - // Authorization list validation: Can NOT have CREATE when `aa_authorization_list` is non-empty - // This follows EIP-7702 semantics - when using delegation - && !self.tempo_authorization_list.is_empty() - && call.to.is_create() - { - return Err("calls cannot contain CREATE when 'aa_authorization_list' is non-empty"); - } - - // All subsequent calls must be CALL. - for call in calls { - if call.to.is_create() { - return Err( - "only one CREATE call is allowed per transaction, and it must be the first call of the batch", - ); - } - } - Ok(()) } diff --git a/crates/revm/src/error.rs b/crates/revm/src/error.rs index 6f516dfb5d..ed9bd5a889 100644 --- a/crates/revm/src/error.rs +++ b/crates/revm/src/error.rs @@ -129,6 +129,12 @@ pub enum TempoInvalidTransaction { /// Fee payment error. #[error(transparent)] CollectFeePreTx(#[from] FeePaymentError), + + /// Tempo transaction validation error from validate_calls(). + /// + /// This wraps validation errors from the shared validate_calls function. + #[error("{0}")] + CallsValidation(&'static str), } impl InvalidTxError for TempoInvalidTransaction { @@ -153,6 +159,12 @@ impl From for EVMError for TempoInvalidTransaction { + fn from(err: &'static str) -> Self { + Self::CallsValidation(err) + } +} + /// Error type for fee payment errors. #[derive(Debug, Clone, PartialEq, Eq, Hash, thiserror::Error)] pub enum FeePaymentError { diff --git a/crates/revm/src/handler.rs b/crates/revm/src/handler.rs index d6bb04b2e1..4c7691484c 100644 --- a/crates/revm/src/handler.rs +++ b/crates/revm/src/handler.rs @@ -40,7 +40,7 @@ use tempo_precompiles::{ tip20::{self, ITIP20::InsufficientBalance, TIP20Error, TIP20Token}, }; use tempo_primitives::transaction::{ - PrimitiveSignature, SignatureType, TempoSignature, calc_gas_balance_spending, + PrimitiveSignature, SignatureType, TempoSignature, calc_gas_balance_spending, validate_calls, }; use crate::{ @@ -1075,6 +1075,13 @@ where let tx = evm.ctx_ref().tx(); if let Some(aa_env) = tx.tempo_tx_env.as_ref() { + // Validate AA transaction structure (calls list, CREATE rules) + validate_calls( + &aa_env.aa_calls, + !aa_env.tempo_authorization_list.is_empty(), + ) + .map_err(TempoInvalidTransaction::from)?; + let has_keychain_fields = aa_env.key_authorization.is_some() || aa_env.signature.is_keychain(); diff --git a/crates/revm/src/tx.rs b/crates/revm/src/tx.rs index 4d6440ac63..4a764f7db5 100644 --- a/crates/revm/src/tx.rs +++ b/crates/revm/src/tx.rs @@ -384,3 +384,87 @@ impl FromTxWithEncoded for TempoTxEnv { Self::from_recovered_tx(tx, sender) } } + +#[cfg(test)] +mod tests { + use alloy_primitives::TxKind; + use tempo_primitives::transaction::{Call, validate_calls}; + + fn create_call(to: TxKind) -> Call { + Call { + to, + value: alloy_primitives::U256::ZERO, + input: alloy_primitives::Bytes::new(), + } + } + + #[test] + fn test_validate_empty_calls_list() { + let result = validate_calls(&[], false); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("empty")); + } + + #[test] + fn test_validate_single_call_ok() { + let calls = vec![create_call(TxKind::Call(alloy_primitives::Address::ZERO))]; + assert!(validate_calls(&calls, false).is_ok()); + } + + #[test] + fn test_validate_single_create_ok() { + let calls = vec![create_call(TxKind::Create)]; + assert!(validate_calls(&calls, false).is_ok()); + } + + #[test] + fn test_validate_create_with_authorization_list_fails() { + let calls = vec![create_call(TxKind::Create)]; + let result = validate_calls(&calls, true); // has_authorization_list = true + assert!(result.is_err()); + assert!(result.unwrap_err().contains("CREATE")); + } + + #[test] + fn test_validate_create_not_first_call_fails() { + let calls = vec![ + create_call(TxKind::Call(alloy_primitives::Address::ZERO)), + create_call(TxKind::Create), // CREATE as second call - should fail + ]; + let result = validate_calls(&calls, false); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("first call")); + } + + #[test] + fn test_validate_multiple_creates_fails() { + let calls = vec![ + create_call(TxKind::Create), + create_call(TxKind::Create), // Second CREATE - should fail + ]; + let result = validate_calls(&calls, false); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("first call")); + } + + #[test] + fn test_validate_create_first_then_calls_ok() { + let calls = vec![ + create_call(TxKind::Create), + create_call(TxKind::Call(alloy_primitives::Address::ZERO)), + create_call(TxKind::Call(alloy_primitives::Address::random())), + ]; + // No auth list, so CREATE is allowed + assert!(validate_calls(&calls, false).is_ok()); + } + + #[test] + fn test_validate_multiple_calls_ok() { + let calls = vec![ + create_call(TxKind::Call(alloy_primitives::Address::ZERO)), + create_call(TxKind::Call(alloy_primitives::Address::random())), + create_call(TxKind::Call(alloy_primitives::Address::random())), + ]; + assert!(validate_calls(&calls, false).is_ok()); + } +}