Skip to content
Open
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
7 changes: 7 additions & 0 deletions .changelog/keen-cows-climb.md
Original file line number Diff line number Diff line change
@@ -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).
1 change: 1 addition & 0 deletions crates/contracts/src/precompiles/tip20.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
///
Expand Down
61 changes: 59 additions & 2 deletions crates/evm/src/block.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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, VALIDATOR_CONFIG_V2_ADDRESS,
};
Expand Down Expand Up @@ -133,6 +136,7 @@ pub struct TempoBlockExecutor<'a, DB: Database, I> {
pub(crate) inner:
EthBlockExecutor<'a, TempoEvm<DB, I>, &'a TempoChainSpec, TempoReceiptBuilder>,

hardfork: TempoHardfork,
section: BlockSection,
seen_subblocks: Vec<(PartialValidatorKey, Vec<TempoTxEnvelope>)>,
validator_set: Option<Vec<B256>>,
Expand All @@ -156,6 +160,7 @@ where
) -> Self {
Self {
incentive_gas_used: 0,
hardfork: chain_spec.tempo_hardfork_at(evm.block().timestamp.to::<u64>()),
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,
Expand Down Expand Up @@ -373,6 +378,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,
Expand Down Expand Up @@ -409,7 +429,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
//
Expand Down Expand Up @@ -666,6 +686,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},
Expand All @@ -686,6 +707,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;
Expand Down Expand Up @@ -1087,6 +1121,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();
Expand Down
2 changes: 1 addition & 1 deletion crates/primitives/src/address.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
75 changes: 48 additions & 27 deletions crates/primitives/src/transaction/envelope.rs
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
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,
crypto::RecoveryError,
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;

/// 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);
Expand Down Expand Up @@ -168,7 +169,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]: <https://docs.tempo.xyz/protocol/tip20/overview#get-predictable-payment-fees>
pub fn is_payment_v1(&self) -> bool {
Expand All @@ -181,17 +182,19 @@ 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]: <https://docs.tempo.xyz/protocol/tip20/overview#get-predictable-payment-fees>
pub fn is_payment_v2(&self) -> bool {
Expand All @@ -214,9 +217,12 @@ impl TempoTxEnvelope {
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()
Expand Down Expand Up @@ -475,7 +481,7 @@ impl From<TempoTransaction> 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
Expand Down Expand Up @@ -517,7 +523,7 @@ impl reth_rpc_convert::TryIntoSimTx<TempoTxEnvelope> 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,
};
Expand Down Expand Up @@ -850,8 +856,7 @@ mod tests {
);
}

#[test]
fn test_payment_v2_aa_rejects_key_authorization() {
fn aa_with_key_authorization(limits: Option<Vec<TokenLimit>>) -> TempoTxEnvelope {
let calldata = ITIP20::transferCall {
to: Address::random(),
amount: U256::from(1),
Expand All @@ -870,22 +875,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::<Vec<_>>();
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]
Expand Down
3 changes: 2 additions & 1 deletion crates/primitives/src/transaction/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading