Skip to content
Merged
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
2 changes: 1 addition & 1 deletion crates/primitives/src/transaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
66 changes: 41 additions & 25 deletions crates/primitives/src/transaction/tempo_transaction.rs
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,45 @@ pub struct TempoTransaction {
pub tempo_authorization_list: Vec<TempoSignedAuthorization>,
}

/// 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")]
Expand All @@ -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
Expand All @@ -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(())
}

Expand Down
12 changes: 12 additions & 0 deletions crates/revm/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -153,6 +159,12 @@ impl<DBError> From<TempoInvalidTransaction> for EVMError<DBError, TempoInvalidTr
}
}

impl From<&'static str> 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 {
Expand Down
9 changes: 8 additions & 1 deletion crates/revm/src/handler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::{
Expand Down Expand Up @@ -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();

Expand Down
84 changes: 84 additions & 0 deletions crates/revm/src/tx.rs
Original file line number Diff line number Diff line change
Expand Up @@ -384,3 +384,87 @@ impl FromTxWithEncoded<TempoTxEnvelope> 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());
}
}
Loading