diff --git a/.changelog/keen-cows-climb.md b/.changelog/keen-cows-climb.md new file mode 100644 index 0000000000..06270c69fb --- /dev/null +++ b/.changelog/keen-cows-climb.md @@ -0,0 +1,7 @@ +--- +tempo-evm: minor +tempo-primitives: minor +tempo-contracts: patch +--- + +Enshrined the stricter TIP-1045 payment classifier (`is_payment_v2`) at the T5 hardfork for consensus-level payment lane validation. Relaxed the v2 classifier to allow bounded `key_authorization` (RLP length ≤ 1024 bytes). diff --git a/crates/contracts/src/precompiles/tip20.rs b/crates/contracts/src/precompiles/tip20.rs index bcd9a5ca5b..cd3ffaff08 100644 --- a/crates/contracts/src/precompiles/tip20.rs +++ b/crates/contracts/src/precompiles/tip20.rs @@ -177,6 +177,7 @@ impl ITIP20::ITIP20Calls { /// Returns `true` if `input` matches one of the recognized [TIP-20 payment] selectors: /// - `transfer` / `transferWithMemo` /// - `transferFrom` / `transferFromWithMemo` + /// - `approve` /// - `mint` / `mintWithMemo` /// - `burn` / `burnWithMemo` /// diff --git a/crates/contracts/src/precompiles/tip20_channel_escrow.rs b/crates/contracts/src/precompiles/tip20_channel_escrow.rs index 9722c380ca..3e67306f24 100644 --- a/crates/contracts/src/precompiles/tip20_channel_escrow.rs +++ b/crates/contracts/src/precompiles/tip20_channel_escrow.rs @@ -3,6 +3,7 @@ pub use ITIP20ChannelEscrow::{ ITIP20ChannelEscrowEvents as TIP20ChannelEscrowEvent, }; use alloy_primitives::{Address, address}; +use alloy_sol_types::{SolCall, SolType}; pub const TIP20_CHANNEL_ESCROW_ADDRESS: Address = address!("0x4D50500000000000000000000000000000000000"); @@ -173,6 +174,36 @@ crate::sol! { } } +impl ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls { + /// Returns `true` if `input` matches one of the recognized [TIP-20 channel escrow payment] + /// selectors: `open`, `topUp`, `settle`, `close` + /// + /// # NOTES + /// - Only validates calldata; caller must check the that `to == TIP20_CHANNEL_ESCROW_ADDRESS`. + /// - Static-only calls require exact ABI-encoded length. + /// - Dynamic calls require ABI decoding and total calldata length <= 2048 bytes. + /// + /// [TIP-20 channel escrow payment]: + pub fn is_payment(input: &[u8]) -> bool { + fn is_call(input: &[u8]) -> bool { + if input.first_chunk::<4>() != Some(&C::SELECTOR) { + return false; + } + + if let Some(canonical_size) = as SolType>::ENCODED_SIZE { + input.len() == 4 + canonical_size + } else { + input.len() <= 2048 && C::abi_decode_validate(input).is_ok() + } + } + + is_call::(input) + || is_call::(input) + || is_call::(input) + || is_call::(input) + } +} + impl TIP20ChannelEscrowError { pub const fn channel_already_exists() -> Self { Self::ChannelAlreadyExists(ITIP20ChannelEscrow::ChannelAlreadyExists {}) @@ -238,3 +269,86 @@ impl TIP20ChannelEscrowError { Self::TransferFailed(ITIP20ChannelEscrow::TransferFailed {}) } } + +#[cfg(test)] +mod tests { + use super::*; + use alloc::{vec, vec::Vec}; + use alloy_primitives::{B256, aliases::U96}; + + fn descriptor() -> ITIP20ChannelEscrow::ChannelDescriptor { + ITIP20ChannelEscrow::ChannelDescriptor { + payer: Address::random(), + payee: Address::random(), + operator: Address::random(), + token: Address::random(), + salt: B256::random(), + authorizedSigner: Address::random(), + expiringNonceHash: B256::random(), + } + } + + #[rustfmt::skip] + fn payment_calldatas() -> [Vec; 4] { + let descriptor = descriptor(); + [ + ITIP20ChannelEscrow::openCall { payee: Address::random(), operator: Address::random(), token: Address::random(), deposit: U96::from(1), salt: B256::random(), authorizedSigner: Address::random() }.abi_encode(), + ITIP20ChannelEscrow::topUpCall { descriptor: descriptor.clone(), additionalDeposit: U96::from(1) }.abi_encode(), + ITIP20ChannelEscrow::settleCall { descriptor: descriptor.clone(), cumulativeAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode(), + ITIP20ChannelEscrow::closeCall { descriptor, cumulativeAmount: U96::from(1), captureAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode(), + ] + } + + #[test] + fn test_is_payment() { + for calldata in payment_calldatas() { + assert!(ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &calldata + )); + } + + assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &ITIP20ChannelEscrow::requestCloseCall { + descriptor: descriptor() + } + .abi_encode(), + )); + + let mut unknown = payment_calldatas()[0].clone(); + unknown[..4].copy_from_slice(&[0xde, 0xad, 0xbe, 0xef]); + assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &unknown + )); + } + + #[test] + fn test_is_payment_rejects_malformed_dynamic_calldata() { + let mut calldata = ITIP20ChannelEscrow::settleCall { + descriptor: descriptor(), + cumulativeAmount: U96::from(1), + signature: vec![1, 2, 3].into(), + } + .abi_encode(); + // Corrupt the dynamic `signature` offset word. + calldata[4 + 8 * 32 + 31] = 0; + assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &calldata + )); + + let mut oversized = ITIP20ChannelEscrow::settleCall { + descriptor: descriptor(), + cumulativeAmount: U96::from(1), + signature: vec![0; 2048].into(), + } + .abi_encode(); + assert!(oversized.len() > 2048); + assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &oversized + )); + + oversized.truncate(4); + assert!(!ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls::is_payment( + &oversized + )); + } +} diff --git a/crates/evm/src/block.rs b/crates/evm/src/block.rs index 5dd00f4603..0a7fe8ba89 100644 --- a/crates/evm/src/block.rs +++ b/crates/evm/src/block.rs @@ -26,7 +26,10 @@ use reth_revm::{ state::{Account, Bytecode, EvmState}, }; use std::collections::{HashMap, HashSet}; -use tempo_chainspec::{TempoChainSpec, hardfork::TempoHardforks}; +use tempo_chainspec::{ + TempoChainSpec, + hardfork::{TempoHardfork, TempoHardforks}, +}; use tempo_contracts::precompiles::{ ADDRESS_REGISTRY_ADDRESS, SIGNATURE_VERIFIER_ADDRESS, TIP20_CHANNEL_ESCROW_ADDRESS, VALIDATOR_CONFIG_V2_ADDRESS, @@ -134,6 +137,7 @@ pub struct TempoBlockExecutor<'a, DB: Database, I> { pub(crate) inner: EthBlockExecutor<'a, TempoEvm, &'a TempoChainSpec, TempoReceiptBuilder>, + hardfork: TempoHardfork, section: BlockSection, seen_subblocks: Vec<(PartialValidatorKey, Vec)>, validator_set: Option>, @@ -157,6 +161,7 @@ where ) -> Self { Self { incentive_gas_used: 0, + hardfork: chain_spec.tempo_hardfork_at(evm.block().timestamp.to::()), validator_set: ctx.validator_set, non_payment_gas_left: ctx.general_gas_limit, non_shared_gas_left: evm.block().gas_limit - ctx.shared_gas_limit, @@ -374,6 +379,21 @@ where } } + /// Returns whether `tx` qualifies for the payment lane under the active hardfork. + /// + /// T5+: TIP-1045 classification ([`is_payment_v2`]). + /// Pre-T5: legacy TIP-20 prefix-only check ([`is_payment_v1`]). + /// + /// [`is_payment_v1`]: TempoTxEnvelope::is_payment_v1 + /// [`is_payment_v2`]: TempoTxEnvelope::is_payment_v2 + pub(crate) fn is_payment(&self, tx: &TempoTxEnvelope) -> bool { + if self.hardfork.is_t5() { + tx.is_payment_v2() + } else { + tx.is_payment_v1() + } + } + pub(crate) fn validate_tx( &self, tx: &TempoTxEnvelope, @@ -410,7 +430,7 @@ where match self.section { BlockSection::StartOfBlock | BlockSection::NonShared => { if gas_used > self.non_shared_gas_left - || (!tx.is_payment_v1() && gas_used > self.non_payment_gas_left) + || (!self.is_payment(tx) && gas_used > self.non_payment_gas_left) { // Assume that this transaction wants to make use of gas incentive section // @@ -670,6 +690,7 @@ mod tests { }; use std::sync::Arc; use tempo_chainspec::spec::DEV; + use tempo_contracts::precompiles::PATH_USD_ADDRESS; use tempo_primitives::{ SubBlockMetadata, TempoSignature, TempoTransaction, TempoTxType, subblock::{SubBlockVersion, TEMPO_SUBBLOCK_NONCE_KEY_PREFIX}, @@ -690,6 +711,19 @@ mod tests { TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())) } + fn create_tip20_empty_calldata_tx() -> TempoTxEnvelope { + let tx = TxLegacy { + chain_id: Some(1), + nonce: 0, + gas_price: 1, + gas_limit: 21000, + to: TxKind::Call(PATH_USD_ADDRESS), + value: U256::ZERO, + input: Bytes::new(), + }; + TempoTxEnvelope::Legacy(Signed::new_unhashed(tx, Signature::test_signature())) + } + #[test] fn test_build_receipt() { let builder = TempoReceiptBuilder; @@ -1091,6 +1125,29 @@ mod tests { ); } + #[test] + fn test_is_payment_uses_v2_from_t5() { + let tx = create_tip20_empty_calldata_tx(); + assert!( + tx.is_payment_v1(), + "pre-T5 prefix check accepts TIP-20 target" + ); + assert!( + !tx.is_payment_v2(), + "T5 classifier rejects empty calldata per TIP-1045" + ); + + let chainspec = test_chainspec(); + let mut db = State::builder().with_bundle_update().build(); + let pre_t5_executor = TestExecutorBuilder::default().build(&mut db, &chainspec); + assert!(pre_t5_executor.is_payment(&tx)); + + let chainspec = DEV.clone(); + let mut db = State::builder().with_bundle_update().build(); + let t5_executor = TestExecutorBuilder::default().build(&mut db, &chainspec); + assert!(!t5_executor.is_payment(&tx)); + } + #[test] fn test_validate_tx() { let chainspec = test_chainspec(); diff --git a/crates/primitives/src/address.rs b/crates/primitives/src/address.rs index e7342be360..99de3ea144 100644 --- a/crates/primitives/src/address.rs +++ b/crates/primitives/src/address.rs @@ -2,7 +2,7 @@ use alloy_primitives::{Address, FixedBytes, hex}; /// TIP20 token address prefix (12 bytes) /// The full address is: TIP20_TOKEN_PREFIX (12 bytes) || derived_bytes (8 bytes) -const TIP20_TOKEN_PREFIX: [u8; 12] = hex!("20C000000000000000000000"); +pub const TIP20_TOKEN_PREFIX: [u8; 12] = hex!("20C000000000000000000000"); /// Returns `true` if `addr` has the TIP-20 token prefix. /// diff --git a/crates/primitives/src/transaction/envelope.rs b/crates/primitives/src/transaction/envelope.rs index 356ad574ad..6d3e933e2a 100644 --- a/crates/primitives/src/transaction/envelope.rs +++ b/crates/primitives/src/transaction/envelope.rs @@ -1,5 +1,5 @@ use super::tt_signed::AASigned; -use crate::{TempoTransaction, subblock::PartialValidatorKey}; +use crate::{TempoAddressExt, TempoTransaction, subblock::PartialValidatorKey}; use alloy_consensus::{ EthereumTxEnvelope, SignableTransaction, Signed, Transaction, TxEip1559, TxEip2930, TxEip7702, TxLegacy, TxType, TypedTransaction, @@ -7,13 +7,16 @@ use alloy_consensus::{ error::{UnsupportedTransactionType, ValueError}, transaction::Either, }; -use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256, hex}; +use alloy_primitives::{Address, B256, Bytes, Signature, TxKind, U256}; +use alloy_rlp::Encodable; use core::fmt; -use tempo_contracts::precompiles::ITIP20; +use tempo_contracts::precompiles::{ + ITIP20, ITIP20ChannelEscrow::ITIP20ChannelEscrowCalls, TIP20_CHANNEL_ESCROW_ADDRESS, +}; -/// TIP20 payment address prefix (12 bytes for payment classification) -/// Same as TIP20_TOKEN_PREFIX -pub const TIP20_PAYMENT_PREFIX: [u8; 12] = hex!("20C000000000000000000000"); +/// Maximum RLP-encoded size of a `key_authorization` permitted in a payment transaction +/// (TIP-1045). Comfortably fits realistic provisioning payloads with limits and scopes. +pub const KEY_AUTHORIZATION_MAX_RLP_LEN: usize = 1024; /// Fake signature for Tempo system transactions. pub const TEMPO_SYSTEM_TX_SIGNATURE: Signature = Signature::new(U256::ZERO, U256::ZERO, false); @@ -168,7 +171,7 @@ impl TempoTxEnvelope { /// /// # NOTE /// Consensus-level classifier, used during block validation, against `general_gas_limit`. - /// See [`is_payment_v2`](Self::is_payment_v2) for the stricter builder-level variant. + /// See [`is_payment_v2`](Self::is_payment_v2) for the stricter T5+ variant. /// /// [TIP-20 payment]: pub fn is_payment_v1(&self) -> bool { @@ -181,46 +184,51 @@ impl TempoTxEnvelope { } } - /// Strict [TIP-20 payment]: `0x20c0` prefix, recognized calldata, and NO gas-bearing sidecars. + /// Strict [TIP-20 payment] (TIP-1045): every call matches the payment call allow-list, + /// `access_list` and authorization lists are empty, and key authorization is bounded. /// /// Like [`is_payment_v1`](Self::is_payment_v1), but additionally requires: /// - calldata to match a recognized payment selector with exact ABI-encoded length. - /// - NO access lists or authorization lists are attached. - /// - AA transactions have at least one call. + /// - `access_list` is empty. + /// - `authorization_list` (EIP-7702) is empty. + /// - For AA: `calls` is non-empty, `tempo_authorization_list` is empty, and any + /// `key_authorization` has RLP-encoded length `<= KEY_AUTHORIZATION_MAX_RLP_LEN`. /// /// # NOTE - /// Builder-level classifier, used by the transaction pool and payload builder to prevent DoS of - /// the payment lane. NOT enforced during block validation — a future TIP will enshrine this - /// stricter classification at the protocol level. + /// Used by the transaction pool and payload builder to prevent DoS of the payment lane, + /// and enshrined at the consensus level at the T5 hardfork. /// /// [TIP-20 payment]: pub fn is_payment_v2(&self) -> bool { match self { - Self::Legacy(tx) => is_tip20_payment(tx.tx().to.to(), &tx.tx().input), + Self::Legacy(tx) => is_tip1045_call(tx.tx().to.to(), &tx.tx().input), Self::Eip2930(tx) => { let tx = tx.tx(); - tx.access_list.is_empty() && is_tip20_payment(tx.to.to(), &tx.input) + tx.access_list.is_empty() && is_tip1045_call(tx.to.to(), &tx.input) } Self::Eip1559(tx) => { let tx = tx.tx(); - tx.access_list.is_empty() && is_tip20_payment(tx.to.to(), &tx.input) + tx.access_list.is_empty() && is_tip1045_call(tx.to.to(), &tx.input) } Self::Eip7702(tx) => { let tx = tx.tx(); tx.access_list.is_empty() && tx.authorization_list.is_empty() - && is_tip20_payment(Some(&tx.to), &tx.input) + && is_tip1045_call(Some(&tx.to), &tx.input) } Self::AA(tx) => { let tx = tx.tx(); !tx.calls.is_empty() - && tx.key_authorization.is_none() && tx.access_list.is_empty() && tx.tempo_authorization_list.is_empty() + && tx + .key_authorization + .as_ref() + .is_none_or(|auth| auth.length() <= KEY_AUTHORIZATION_MAX_RLP_LEN) && tx .calls .iter() - .all(|call| is_tip20_payment(call.to.to(), &call.input)) + .all(|call| is_tip1045_call(call.to.to(), &call.input)) } } } @@ -475,14 +483,21 @@ impl From for TempoTypedTransaction { /// Returns `true` if `to` has the TIP-20 payment prefix. #[inline] fn is_tip20_call(to: Option<&Address>) -> bool { - to.is_some_and(|to| to.starts_with(&TIP20_PAYMENT_PREFIX)) + to.is_some_and(|to| to.is_tip20()) } -/// Returns `true` if `to` has the TIP-20 payment prefix and `input` is recognized payment -/// calldata (selector + exact ABI-encoded length). +/// Returns `true` if the call is in the TIP-1045 payment lane allow-list. #[inline] -fn is_tip20_payment(to: Option<&Address>, input: &[u8]) -> bool { - is_tip20_call(to) && ITIP20::ITIP20Calls::is_payment(input) +fn is_tip1045_call(to: Option<&Address>, input: &[u8]) -> bool { + match to { + // TIP20 call + payment calldata constraints + Some(to) if to.is_tip20() => ITIP20::ITIP20Calls::is_payment(input), + // TIP20ChannelEscrow call + payment calldata constraints + Some(to) if *to == TIP20_CHANNEL_ESCROW_ADDRESS => { + ITIP20ChannelEscrowCalls::is_payment(input) + } + _ => false, + } } #[cfg(feature = "rpc")] @@ -517,7 +532,7 @@ impl reth_rpc_convert::TryIntoSimTx for alloy_rpc_types_eth::Tr mod tests { use super::*; use crate::transaction::{ - Call, TempoSignedAuthorization, TempoTransaction, + Call, TempoSignedAuthorization, TempoTransaction, TokenLimit, key_authorization::{KeyAuthorization, SignedKeyAuthorization}, tt_signature::PrimitiveSignature, }; @@ -526,8 +541,9 @@ mod tests { eip2930::{AccessList, AccessListItem}, eip7702::SignedAuthorization, }; - use alloy_primitives::{Bytes, Signature, TxKind, U256, address}; + use alloy_primitives::{Bytes, Signature, TxKind, U256, address, aliases::U96}; use alloy_sol_types::SolCall; + use tempo_contracts::precompiles::ITIP20ChannelEscrow; const PAYMENT_TKN: Address = address!("20c0000000000000000000000000000000000001"); @@ -548,40 +564,75 @@ mod tests { ] } + fn channel_descriptor() -> ITIP20ChannelEscrow::ChannelDescriptor { + ITIP20ChannelEscrow::ChannelDescriptor { + payer: Address::random(), + payee: Address::random(), + operator: Address::random(), + token: PAYMENT_TKN, + salt: B256::random(), + authorizedSigner: Address::random(), + expiringNonceHash: B256::random(), + } + } + + #[rustfmt::skip] + fn channel_escrow_payment_calldatas() -> [Bytes; 4] { + let descriptor = channel_descriptor(); + [ + ITIP20ChannelEscrow::openCall { payee: Address::random(), operator: Address::random(), token: PAYMENT_TKN, deposit: U96::from(1), salt: B256::random(), authorizedSigner: Address::random() }.abi_encode().into(), + ITIP20ChannelEscrow::topUpCall { descriptor: descriptor.clone(), additionalDeposit: U96::from(1) }.abi_encode().into(), + ITIP20ChannelEscrow::settleCall { descriptor: descriptor.clone(), cumulativeAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode().into(), + ITIP20ChannelEscrow::closeCall { descriptor, cumulativeAmount: U96::from(1), captureAmount: U96::from(1), signature: vec![1, 2, 3].into() }.abi_encode().into(), + ] + } + /// Returns one envelope per tx type, all targeting `PAYMENT_TKN` with the given calldata. fn payment_envelopes(calldata: Bytes) -> [TempoTxEnvelope; 5] { + payment_envelopes_to(PAYMENT_TKN, calldata) + } + + /// Returns one envelope per tx type, all targeting `to` with the given calldata. + fn payment_envelopes_to(to: Address, calldata: Bytes) -> [TempoTxEnvelope; 5] { let legacy = TempoTxEnvelope::Legacy(Signed::new_unhashed( TxLegacy { - to: TxKind::Call(PAYMENT_TKN), + to: TxKind::Call(to), input: calldata.clone(), ..Default::default() }, Signature::test_signature(), )); let [eip2930, eip1559, eip7702, aa] = - payment_envelopes_with_access_list(calldata, AccessList::default()); + payment_envelopes_with_access_list_to(to, calldata, AccessList::default()); [legacy, eip2930, eip1559, eip7702, aa] } /// Like [`payment_envelopes`], but with `access_list` set. Supported by: Eip2930, Eip1559, Eip7702, AA. + fn payment_envelopes_with_access_list( + calldata: Bytes, + access_list: AccessList, + ) -> [TempoTxEnvelope; 4] { + payment_envelopes_with_access_list_to(PAYMENT_TKN, calldata, access_list) + } + #[rustfmt::skip] - fn payment_envelopes_with_access_list(calldata: Bytes, access_list: AccessList) -> [TempoTxEnvelope; 4] { + fn payment_envelopes_with_access_list_to(to: Address, calldata: Bytes, access_list: AccessList) -> [TempoTxEnvelope; 4] { [ TempoTxEnvelope::Eip2930(Signed::new_unhashed( - TxEip2930 { to: TxKind::Call(PAYMENT_TKN), input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, + TxEip2930 { to: TxKind::Call(to), input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, Signature::test_signature(), )), TempoTxEnvelope::Eip1559(Signed::new_unhashed( - TxEip1559 { to: TxKind::Call(PAYMENT_TKN), input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, + TxEip1559 { to: TxKind::Call(to), input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, Signature::test_signature(), )), TempoTxEnvelope::Eip7702(Signed::new_unhashed( - TxEip7702 { to: PAYMENT_TKN, input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, + TxEip7702 { to, input: calldata.clone(), access_list: access_list.clone(), ..Default::default() }, Signature::test_signature(), )), TempoTxEnvelope::AA(TempoTransaction { fee_token: Some(PAYMENT_TKN), - calls: vec![Call { to: TxKind::Call(PAYMENT_TKN), value: U256::ZERO, input: calldata }], + calls: vec![Call { to: TxKind::Call(to), value: U256::ZERO, input: calldata }], access_list, ..Default::default() }.into_signed(Signature::test_signature().into())), @@ -770,6 +821,60 @@ mod tests { } } + #[test] + fn test_payment_v2_accepts_valid_channel_escrow_calldata() { + for calldata in channel_escrow_payment_calldatas() { + for envelope in payment_envelopes_to(TIP20_CHANNEL_ESCROW_ADDRESS, calldata) { + assert!(!envelope.is_payment_v1(), "V1 only accepts TIP-20 prefix"); + assert!( + envelope.is_payment_v2(), + "V2 must accept valid TIP20ChannelEscrow calldata" + ); + } + } + } + + #[test] + fn test_payment_v2_rejects_channel_escrow_calldata_to_tip20() { + for calldata in channel_escrow_payment_calldatas() { + for envelope in payment_envelopes_to(PAYMENT_TKN, calldata) { + assert!(envelope.is_payment_v1(), "V1 accepts TIP-20 prefix"); + assert!(!envelope.is_payment_v2(), "V2 only accepts allowed combos"); + } + } + } + + #[test] + fn test_payment_v2_rejects_invalid_channel_escrow_dynamic_calldata() { + let mut corrupted_calldata = ITIP20ChannelEscrow::settleCall { + descriptor: channel_descriptor(), + cumulativeAmount: U96::ONE, + signature: vec![1, 2, 3].into(), + } + .abi_encode(); + // Corrupt the dynamic `signature` offset word. + corrupted_calldata[4 + 8 * 32 + 31] = 0; + + for envelope in + payment_envelopes_to(TIP20_CHANNEL_ESCROW_ADDRESS, corrupted_calldata.into()) + { + assert!(!envelope.is_payment_v2(), "V2 must reject malformed ABI"); + } + + // Calldata > 2KB + let long_calldata = ITIP20ChannelEscrow::settleCall { + descriptor: channel_descriptor(), + cumulativeAmount: U96::ONE, + signature: vec![0; 2048].into(), + } + .abi_encode(); + assert!(long_calldata.len() > 2048); + + for envelope in payment_envelopes_to(TIP20_CHANNEL_ESCROW_ADDRESS, long_calldata.into()) { + assert!(!envelope.is_payment_v2(), "V2 must reject large calldata"); + } + } + #[test] fn test_payment_v2_rejects_empty_calldata() { for envelope in payment_envelopes(Bytes::new()) { @@ -850,8 +955,7 @@ mod tests { ); } - #[test] - fn test_payment_v2_aa_rejects_key_authorization() { + fn aa_with_key_authorization(limits: Option>) -> TempoTxEnvelope { let calldata = ITIP20::transferCall { to: Address::random(), amount: U256::from(1), @@ -870,22 +974,38 @@ mod tests { key_type: crate::SignatureType::Secp256k1, key_id: Address::random(), expiry: None, - limits: None, + limits, allowed_calls: None, }, signature: PrimitiveSignature::Secp256k1(Signature::test_signature()), }), ..Default::default() }; - let envelope = TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())); - assert!( - envelope.is_payment_v1(), - "V1 ignores side-effect fields (backwards compat)" - ); - assert!( - !envelope.is_payment_v2(), - "V2 must reject AA tx with key_authorization" - ); + TempoTxEnvelope::AA(tx.into_signed(Signature::test_signature().into())) + } + + #[test] + fn test_payment_v2_aa_accepts_bounded_key_authorization() { + // TIP-1045: key auth is allowed in payment txs as long as it's bounded. + let envelope = aa_with_key_authorization(None); + assert!(envelope.is_payment_v1()); + assert!(envelope.is_payment_v2(), "V2 must accept bounded key auth"); + + // Pad `limits` with enough entries to push the RLP encoding past the 1 KB cap. + let limits = (0..32) + .map(|i| TokenLimit { + token: Address::repeat_byte(i as u8), + limit: U256::from(u128::MAX), + period: 1, + }) + .collect::>(); + let envelope = aa_with_key_authorization(Some(limits)); + assert!(envelope.is_payment_v1(), "V1 ignores key auth size"); + assert!(!envelope.is_payment_v2(), "V2 must reject huge key auth"); + + let tx = envelope.as_aa().unwrap().tx(); + let key_auth = tx.key_authorization.as_ref().unwrap(); + assert!(key_auth.length() > KEY_AUTHORIZATION_MAX_RLP_LEN); } #[test] diff --git a/crates/primitives/src/transaction/mod.rs b/crates/primitives/src/transaction/mod.rs index f2775beee5..ae7c0ea696 100644 --- a/crates/primitives/src/transaction/mod.rs +++ b/crates/primitives/src/transaction/mod.rs @@ -12,8 +12,9 @@ pub use tt_signature::{ derive_p256_address, }; +pub use crate::address::TIP20_TOKEN_PREFIX as TIP20_PAYMENT_PREFIX; pub use alloy_eips::eip7702::Authorization; -pub use envelope::{TIP20_PAYMENT_PREFIX, TempoTxEnvelope, TempoTxType, TempoTypedTransaction}; +pub use envelope::{TempoTxEnvelope, TempoTxType, TempoTypedTransaction}; pub use key_authorization::{ CallScope, KeyAuthorization, KeyAuthorizationChainIdError, SelectorRule, SignedKeyAuthorization, TokenLimit, diff --git a/tips/tip-1045.md b/tips/tip-1045.md index a340790664..38bad0a85b 100644 --- a/tips/tip-1045.md +++ b/tips/tip-1045.md @@ -42,10 +42,10 @@ A call qualifies as a payment call if, for some entry in the payment call allow- 3. The first 4 bytes of its calldata equal the entry's selector. 4. Its calldata satisfies the size and encoding constraints derived from the entry's signature, as defined below. -Calldata MUST be the canonical ABI encoding for the entry's signature. Additional constraints depend on whether the entry's parameters contain any dynamic ABI types (`bytes`, `string`, dynamic arrays, or tuples/arrays transitively containing them): +Calldata MUST satisfy the ABI encoding constraints for the entry's signature. Additional constraints depend on whether the entry's parameters contain any dynamic ABI types (`bytes`, `string`, dynamic arrays, or tuples/arrays transitively containing them): - **Static-only entries**: calldata MUST have no trailing bytes; equivalently, its length MUST be the 4-byte selector plus one 32-byte ABI word per parameter. -- **Entries with any dynamic type**: calldata's total length MUST be `<= 2048` bytes. +- **Entries with any dynamic type**: calldata MUST be valid ABI-decodable calldata for the matched signature and its total length MUST be `<= 2048` bytes. The initial payment call allow-list is: @@ -99,7 +99,7 @@ Before T5, only the legacy TIP-20 address prefix check is enforced at the consen 1. **Classification completeness**: Every transaction MUST be classified as exactly one of payment or general — never both, never neither. 2. **Allow-list strictness**: A transaction containing any call that does not match an allow-listed `(target, selector, calldata constraint)` entry MUST be classified as general and subject to `general_gas_limit`. -3. **Canonical calldata**: Static-only entries MUST have exactly the static ABI-encoded length. Entries with dynamic types MUST use canonical ABI encoding and MUST be at most 2048 bytes. Any malformed encoding, trailing bytes, or size violation MUST be classified as general. +3. **ABI calldata validity**: Static-only entries MUST have exactly the static ABI-encoded length; any malformed encoding, trailing bytes, or size violation MUST be classified as general. Likewise, entries with dynamic types MUST be valid ABI-decodable calldata for the matched signature and MUST be at most 2048 bytes; otherwise, they MUST be classified as general. 4. **No contract creation**: Any transaction or AA call that creates a new contract MUST be classified as general. 5. **AA atomicity**: An AA transaction is a payment only if `calls` is non-empty and **all** of its calls independently match the payment call allow-list. A single non-qualifying call MUST cause the entire transaction to be classified as general. 6. **Bounded key authorization**: Any transaction whose `key_authorization` is present and whose RLP encoding exceeds 1024 bytes MUST be classified as general. No consensus cap is placed on the overall transaction size.