use alloy::consensus::transaction::SignerRecoverable;
use alloy::eips::Decodable2718;
use alloy::network::ReceiptResponse;
use alloy::primitives::{keccak256, Address, Bytes, TxKind, B256, U256};
use alloy::providers::Provider;
use alloy::rpc::types::simulate::{SimBlock, SimCallResult, SimulatePayload};
use alloy::sol_types::SolCall;
use std::future::Future;
use std::sync::Arc;
use tempo_alloy::contracts::precompiles::{
IAccountKeychain, IStablecoinDEX, ACCOUNT_KEYCHAIN_ADDRESS, ITIP20, STABLECOIN_DEX_ADDRESS,
};
use tempo_alloy::rpc::TempoTransactionRequest;
use tempo_alloy::TempoNetwork;
use tempo_primitives::transaction::{PrimitiveSignature, TempoSignature};
use tokio::sync::OnceCell;
use crate::protocol::core::{PaymentCredential, Receipt};
use crate::protocol::intents::ChargeRequest;
use crate::protocol::traits::{ChargeMethod as ChargeMethodTrait, VerificationError};
use crate::store::Store;
use crate::tempo::attribution;
use super::transfers::{get_request_transfers, Transfer};
use super::{
network::TempoNetwork as KnownTempoNetwork, proof, TempoChargeExt, CHAIN_ID,
DEFAULT_CURRENCY_TESTNET, INTENT_CHARGE, METHOD_NAME,
};
const MAX_FEE_PAYER_GAS_LIMIT: u64 = 2_000_000;
const MAX_FEE_PER_GAS_DEFAULT: u128 = 100_000_000_000;
const MAX_PRIORITY_FEE_PER_GAS_DEFAULT: u128 = 10_000_000_000;
const MAX_VALIDITY_WINDOW_SECS_DEFAULT: u64 = 15 * 60;
const MAX_TOTAL_FEE_DEFAULT: u128 = 50_000_000_000_000_000;
const TRANSFER_EVENT_TOPIC: B256 =
alloy::primitives::b256!("ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef");
const TRANSFER_WITH_MEMO_EVENT_TOPIC: B256 =
alloy::primitives::b256!("57bc7354aa85aed339e000bccffabbc529466af35f0772c8f8ee1145927de7f0");
const TRANSFER_SELECTOR: [u8; 4] = [0xa9, 0x05, 0x9c, 0xbb];
const TRANSFER_WITH_MEMO_SELECTOR: [u8; 4] = [0x95, 0x77, 0x7d, 0x59];
fn no_matching_payment_call_error() -> VerificationError {
VerificationError::new("Invalid transaction: no matching payment call found".to_string())
}
fn disallowed_fee_payer_call_pattern_error() -> VerificationError {
VerificationError::new("Fee-sponsored transaction contains disallowed call pattern".to_string())
}
fn call_selector(data: &Bytes) -> Option<[u8; 4]> {
if data.len() < 4 {
None
} else {
data[..4].try_into().ok()
}
}
fn decode_approve_spender(call: &tempo_primitives::transaction::Call) -> Option<Address> {
if call_selector(&call.input) != Some(ITIP20::approveCall::SELECTOR) || call.input.len() != 68 {
return None;
}
Some(Address::from_slice(&call.input[16..36]))
}
fn decode_swap_token_in(call: &tempo_primitives::transaction::Call) -> Option<Address> {
if call_selector(&call.input) != Some(IStablecoinDEX::swapExactAmountOutCall::SELECTOR) {
return None;
}
IStablecoinDEX::swapExactAmountOutCall::abi_decode_raw(&call.input[4..])
.ok()
.map(|decoded| decoded.tokenIn)
}
fn transfer_call_offset(
calls: &[tempo_primitives::transaction::Call],
) -> Result<usize, VerificationError> {
let first_selector = calls.first().and_then(|call| call_selector(&call.input));
if first_selector == Some(ITIP20::approveCall::SELECTOR) {
let second_selector = calls.get(1).and_then(|call| call_selector(&call.input));
if second_selector != Some(IStablecoinDEX::swapExactAmountOutCall::SELECTOR) {
return Err(no_matching_payment_call_error());
}
Ok(2)
} else if first_selector == Some(IStablecoinDEX::swapExactAmountOutCall::SELECTOR) {
Err(no_matching_payment_call_error())
} else {
Ok(0)
}
}
fn get_transfer_calls(
calls: &[tempo_primitives::transaction::Call],
) -> Result<&[tempo_primitives::transaction::Call], VerificationError> {
let offset = transfer_call_offset(calls)?;
let transfer_calls = &calls[offset..];
if transfer_calls.is_empty()
|| transfer_calls.iter().any(|call| {
!matches!(
call_selector(&call.input),
Some(TRANSFER_SELECTOR) | Some(TRANSFER_WITH_MEMO_SELECTOR)
)
})
{
return Err(no_matching_payment_call_error());
}
Ok(transfer_calls)
}
fn validate_fee_payer_calls(
calls: &[tempo_primitives::transaction::Call],
) -> Result<(), VerificationError> {
if calls.is_empty() {
return Err(disallowed_fee_payer_call_pattern_error());
}
let has_swap_prefix = calls.first().and_then(|call| call_selector(&call.input))
== Some(ITIP20::approveCall::SELECTOR);
if has_swap_prefix {
if calls.get(1).and_then(|call| call_selector(&call.input))
!= Some(IStablecoinDEX::swapExactAmountOutCall::SELECTOR)
{
return Err(disallowed_fee_payer_call_pattern_error());
}
} else if calls.first().and_then(|call| call_selector(&call.input))
== Some(IStablecoinDEX::swapExactAmountOutCall::SELECTOR)
{
return Err(disallowed_fee_payer_call_pattern_error());
}
let transfer_calls = &calls[if has_swap_prefix { 2 } else { 0 }..];
if transfer_calls.is_empty()
|| transfer_calls.len() > 11
|| transfer_calls.iter().any(|call| {
!matches!(
call_selector(&call.input),
Some(TRANSFER_SELECTOR) | Some(TRANSFER_WITH_MEMO_SELECTOR)
)
})
{
return Err(disallowed_fee_payer_call_pattern_error());
}
if has_swap_prefix {
let approve_target = match &calls[0].to {
TxKind::Call(address) => *address,
_ => return Err(disallowed_fee_payer_call_pattern_error()),
};
let swap_token_in =
decode_swap_token_in(&calls[1]).ok_or_else(disallowed_fee_payer_call_pattern_error)?;
if approve_target != swap_token_in {
return Err(VerificationError::new(
"Fee-sponsored transaction approve target is not the swap input token".to_string(),
));
}
let approve_spender = decode_approve_spender(&calls[0])
.ok_or_else(disallowed_fee_payer_call_pattern_error)?;
if approve_spender != STABLECOIN_DEX_ADDRESS {
return Err(VerificationError::new(
"Fee-sponsored transaction approve spender is not the DEX".to_string(),
));
}
match &calls[1].to {
TxKind::Call(address) if *address == STABLECOIN_DEX_ADDRESS => {}
_ => {
return Err(VerificationError::new(
"Fee-sponsored transaction swap target is not the DEX".to_string(),
));
}
}
}
Ok(())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum MatchedTransferLog {
Transfer,
Memo([u8; 32]),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ParsedTransferLog {
Transfer {
address: Address,
amount: U256,
from: Address,
to: Address,
},
Memo {
address: Address,
amount: U256,
from: Address,
memo: [u8; 32],
to: Address,
},
}
impl ParsedTransferLog {
fn address(&self) -> Address {
match self {
Self::Transfer { address, .. } | Self::Memo { address, .. } => *address,
}
}
fn amount(&self) -> U256 {
match self {
Self::Transfer { amount, .. } | Self::Memo { amount, .. } => *amount,
}
}
fn from(&self) -> Address {
match self {
Self::Transfer { from, .. } | Self::Memo { from, .. } => *from,
}
}
fn matched(&self) -> MatchedTransferLog {
match self {
Self::Transfer { .. } => MatchedTransferLog::Transfer,
Self::Memo { memo, .. } => MatchedTransferLog::Memo(*memo),
}
}
fn memo(&self) -> Option<[u8; 32]> {
match self {
Self::Transfer { .. } => None,
Self::Memo { memo, .. } => Some(*memo),
}
}
fn to(&self) -> Address {
match self {
Self::Transfer { to, .. } | Self::Memo { to, .. } => *to,
}
}
}
fn parse_receipt_transfer_log(log: &serde_json::Value) -> Option<ParsedTransferLog> {
let address = log
.get("address")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<Address>().ok())?;
let topics: Vec<&str> = log
.get("topics")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())?;
if topics.len() < 3 {
return None;
}
let topic0 = topics[0].parse::<B256>().ok()?;
let from = topics[1]
.parse::<B256>()
.ok()
.map(|b| Address::from_slice(&b[12..]))?;
let to = topics[2]
.parse::<B256>()
.ok()
.map(|b| Address::from_slice(&b[12..]))?;
let data = log.get("data").and_then(|v| v.as_str()).unwrap_or("0x");
if topic0 == TRANSFER_EVENT_TOPIC {
if data.len() < 66 {
return None;
}
let amount = U256::from_str_radix(&data[2..66], 16).ok()?;
return Some(ParsedTransferLog::Transfer {
address,
amount,
from,
to,
});
}
if topic0 == TRANSFER_WITH_MEMO_EVENT_TOPIC {
if topics.len() < 4 || data.len() < 66 {
return None;
}
let amount = U256::from_str_radix(&data[2..66], 16).ok()?;
let memo = topics[3].parse::<B256>().ok().map(|bytes| bytes.0)?;
return Some(ParsedTransferLog::Memo {
address,
amount,
from,
memo,
to,
});
}
None
}
fn parse_hash_credential_source(
source: Option<&str>,
expected_chain_id: u64,
) -> Result<Option<Address>, VerificationError> {
let Some(source) = source else {
return Ok(None);
};
let invalid = || VerificationError::new("Hash credential source is invalid.");
let parsed = proof::parse_proof_source(source).map_err(|_| invalid())?;
if parsed.chain_id != expected_chain_id {
return Err(invalid());
}
Ok(Some(parsed.address))
}
fn match_receipt_transfer_logs(
logs: &[serde_json::Value],
expected_sender: Address,
currency: Address,
expected: &[Transfer],
source: Option<&str>,
validate_sender: Option<&ValidateSenderCallback>,
) -> Result<Vec<MatchedTransferLog>, VerificationError> {
let mut sorted_expected: Vec<(usize, &Transfer)> = expected.iter().enumerate().collect();
sorted_expected.sort_by_key(|(_, t)| if t.memo.is_some() { 0 } else { 1 });
let parsed_logs: Vec<Option<ParsedTransferLog>> =
logs.iter().map(parse_receipt_transfer_log).collect();
let mut used_logs: Vec<bool> = vec![false; logs.len()];
let mut matched_logs = Vec::with_capacity(expected.len());
for (_, transfer) in &sorted_expected {
if transfer.amount.is_zero() {
return Err(VerificationError::new(
"Invalid amount: expected_amount must be greater than zero".to_string(),
));
}
if transfer.recipient.is_zero() {
return Err(VerificationError::new(
"Invalid recipient: expected_recipient cannot be the zero address".to_string(),
));
}
let find_match = |prefer_memo: bool| {
for (log_idx, parsed) in parsed_logs.iter().enumerate() {
if used_logs[log_idx] {
continue;
}
let Some(parsed) = parsed else {
continue;
};
if parsed.address() != currency
|| parsed.to() != transfer.recipient
|| parsed.amount() != transfer.amount
{
continue;
}
if let Some(exp_memo) = transfer.memo {
if parsed.memo() != Some(exp_memo) {
continue;
}
} else if prefer_memo != parsed.memo().is_some() {
continue;
}
let sender = parsed.from();
if sender != expected_sender {
let authorized = validate_sender.is_some_and(|cb| {
cb(SenderValidation {
expected_sender,
sender,
source,
})
});
if !authorized {
continue;
}
}
return Some((log_idx, parsed.matched()));
}
None
};
let matched = if transfer.memo.is_some() {
find_match(true)
} else {
find_match(true).or_else(|| find_match(false))
};
let Some((log_idx, matched_log)) = matched else {
return Err(VerificationError::new(format!(
"No matching transfer event found for {} to {}{}",
transfer.amount,
transfer.recipient,
if transfer.memo.is_some() {
" with memo"
} else {
""
}
)));
};
used_logs[log_idx] = true;
matched_logs.push(matched_log);
}
Ok(matched_logs)
}
fn assert_challenge_bound_memo(
matched_logs: &[MatchedTransferLog],
challenge_id: &str,
realm: &str,
) -> Result<(), VerificationError> {
let bound = matched_logs.iter().any(|log| match log {
MatchedTransferLog::Transfer => false,
MatchedTransferLog::Memo(memo) => {
attribution::verify_server(memo, realm)
&& attribution::verify_challenge_binding(memo, challenge_id)
}
});
if bound {
Ok(())
} else {
Err(VerificationError::new(
"Payment verification failed: memo is not bound to this challenge.",
))
}
}
#[derive(Debug, Clone, Copy)]
pub struct SenderValidation<'a> {
pub expected_sender: Address,
pub sender: Address,
pub source: Option<&'a str>,
}
pub type ValidateSenderCallback =
dyn for<'a> Fn(SenderValidation<'a>) -> bool + Send + Sync + 'static;
#[derive(Clone)]
pub struct ChargeMethod<P> {
provider: Arc<P>,
fee_payer_signer: Option<Arc<alloy::signers::local::PrivateKeySigner>>,
store: Option<Arc<dyn Store>>,
cached_chain_id: Arc<OnceCell<u64>>,
fee_payer_policy_override: Option<FeePayerPolicyOverride>,
validate_sender: Option<Arc<ValidateSenderCallback>>,
fee_payer_allowed_fee_tokens: Option<Vec<Address>>,
}
#[derive(Debug, Clone)]
pub struct FeePayerPolicy {
pub max_gas: u64,
pub max_fee_per_gas: u128,
pub max_priority_fee_per_gas: u128,
pub max_total_fee: u128,
pub max_validity_window_seconds: u64,
}
#[derive(Debug, Clone, Default)]
pub struct FeePayerPolicyOverride {
pub max_gas: Option<u64>,
pub max_fee_per_gas: Option<u128>,
pub max_priority_fee_per_gas: Option<u128>,
pub max_total_fee: Option<u128>,
pub max_validity_window_seconds: Option<u64>,
}
impl Default for FeePayerPolicy {
fn default() -> FeePayerPolicy {
Self::get_by_chain_id(CHAIN_ID)
}
}
impl FeePayerPolicy {
pub fn resolve(chain_id: u64, overrides: Option<&FeePayerPolicyOverride>) -> Self {
let mut policy = Self::get_by_chain_id(chain_id);
if let Some(o) = overrides {
policy.max_gas = o.max_gas.unwrap_or(policy.max_gas);
policy.max_fee_per_gas = o.max_fee_per_gas.unwrap_or(policy.max_fee_per_gas);
policy.max_priority_fee_per_gas = o
.max_priority_fee_per_gas
.unwrap_or(policy.max_priority_fee_per_gas);
policy.max_total_fee = o.max_total_fee.unwrap_or(policy.max_total_fee);
policy.max_validity_window_seconds = o
.max_validity_window_seconds
.unwrap_or(policy.max_validity_window_seconds);
}
policy
}
pub fn default_allowed_fee_tokens(chain_id: u64) -> Vec<Address> {
default_fee_payer_allowed_fee_tokens(chain_id)
}
pub fn default_allows_fee_token(chain_id: u64, fee_token: Address) -> bool {
fee_token_allowed(&Self::default_allowed_fee_tokens(chain_id), fee_token)
}
fn get_by_chain_id(chain_id: u64) -> Self {
let network = KnownTempoNetwork::from_chain_id(chain_id);
let mut policy = Self {
max_gas: MAX_FEE_PAYER_GAS_LIMIT,
max_fee_per_gas: MAX_FEE_PER_GAS_DEFAULT,
max_priority_fee_per_gas: MAX_PRIORITY_FEE_PER_GAS_DEFAULT,
max_total_fee: MAX_TOTAL_FEE_DEFAULT,
max_validity_window_seconds: MAX_VALIDITY_WINDOW_SECS_DEFAULT,
};
if network == Some(KnownTempoNetwork::Moderato) {
policy.max_priority_fee_per_gas = 50_000_000_000;
}
policy
}
}
fn default_fee_payer_allowed_fee_tokens(chain_id: u64) -> Vec<Address> {
let token = KnownTempoNetwork::from_chain_id(chain_id)
.map(|network| network.default_currency())
.unwrap_or(DEFAULT_CURRENCY_TESTNET);
vec![token
.parse()
.expect("default Tempo fee token is a valid address")]
}
fn fee_token_allowed(allowed_fee_tokens: &[Address], fee_token: Address) -> bool {
allowed_fee_tokens.contains(&fee_token)
}
#[derive(Debug, Clone, serde::Deserialize)]
struct TempoSimulateResponse {
#[serde(default)]
blocks: Vec<TempoSimulateBlock>,
}
#[derive(Debug, Clone, serde::Deserialize)]
struct TempoSimulateBlock {
#[serde(default)]
calls: Vec<SimCallResult>,
}
impl<P> ChargeMethod<P>
where
P: Provider<TempoNetwork> + Clone + Send + Sync + 'static,
{
pub fn new(provider: P) -> Self {
Self {
provider: Arc::new(provider),
fee_payer_signer: None,
store: None,
cached_chain_id: Arc::new(OnceCell::new()),
fee_payer_policy_override: None,
validate_sender: None,
fee_payer_allowed_fee_tokens: None,
}
}
pub fn with_validate_sender<F>(mut self, validate_sender: F) -> Self
where
F: for<'a> Fn(SenderValidation<'a>) -> bool + Send + Sync + 'static,
{
self.validate_sender = Some(Arc::new(validate_sender));
self
}
pub fn with_fee_payer_policy_override(mut self, overrides: FeePayerPolicyOverride) -> Self {
self.fee_payer_policy_override = Some(overrides);
self
}
pub fn with_fee_payer_allowed_fee_tokens(mut self, allowed_fee_tokens: Vec<Address>) -> Self {
self.fee_payer_allowed_fee_tokens = Some(allowed_fee_tokens);
self
}
#[cfg(test)]
pub(crate) fn fee_payer_allowed_fee_tokens(&self) -> Option<&[Address]> {
self.fee_payer_allowed_fee_tokens.as_deref()
}
pub fn with_store(mut self, store: Arc<dyn Store>) -> Self {
self.store = Some(store);
self
}
pub fn with_fee_payer(mut self, signer: alloy::signers::local::PrivateKeySigner) -> Self {
self.fee_payer_signer = Some(Arc::new(signer));
self
}
pub fn provider(&self) -> &P {
&self.provider
}
fn expected_transfers(charge: &ChargeRequest) -> Result<Vec<Transfer>, VerificationError> {
get_request_transfers(charge)
.map_err(|e| VerificationError::new(format!("Invalid charge request: {e}")))
}
async fn verify_hash(
&self,
tx_hash: &str,
charge: &ChargeRequest,
source: Option<&str>,
expected_chain_id: u64,
challenge_id: &str,
realm: &str,
) -> Result<Receipt, VerificationError> {
let source_address = parse_hash_credential_source(source, expected_chain_id)?;
let hash = tx_hash
.parse::<B256>()
.map_err(|e| VerificationError::new(format!("Invalid transaction hash: {}", e)))?;
let replay_key = format!("mpp:charge:{:#x}", hash);
let receipt = self
.provider
.get_transaction_receipt(hash)
.await
.map_err(|e| {
VerificationError::network_error(format!("Failed to fetch receipt: {}", e))
})?
.ok_or_else(|| {
VerificationError::pending(format!(
"Transaction {} not found or not yet mined",
tx_hash
))
})?;
if !receipt.status() {
return Err(VerificationError::transaction_failed(format!(
"Transaction {} reverted",
tx_hash
)));
}
let currency = charge.currency_address().map_err(|e| {
VerificationError::new(format!("Invalid currency address in request: {}", e))
})?;
let expected = Self::expected_transfers(charge)?;
let expected_sender = source_address.unwrap_or_else(|| receipt.from());
let matched_logs = self.verify_tip20_transfers(
&receipt,
expected_sender,
currency,
&expected,
source,
self.validate_sender.as_deref(),
)?;
if charge.memo().is_none() {
assert_challenge_bound_memo(&matched_logs, challenge_id, realm)?;
}
if let Some(store) = &self.store {
let claimed = store
.put_if_absent(&replay_key, serde_json::Value::Bool(true))
.await
.map_err(|e| VerificationError::new(format!("Failed to record tx hash: {e}")))?;
if !claimed {
return Err(VerificationError::new(
"Transaction hash has already been used.",
));
}
}
Ok(Receipt::success(METHOD_NAME, tx_hash))
}
fn verify_tip20_transfers(
&self,
receipt: &<TempoNetwork as alloy::network::Network>::ReceiptResponse,
expected_sender: Address,
currency: Address,
expected: &[Transfer],
source: Option<&str>,
validate_sender: Option<&ValidateSenderCallback>,
) -> Result<Vec<MatchedTransferLog>, VerificationError> {
let receipt_json = serde_json::to_value(receipt)
.map_err(|e| VerificationError::new(format!("Failed to serialize receipt: {}", e)))?;
let logs = receipt_json
.get("logs")
.and_then(|v| v.as_array())
.ok_or_else(|| VerificationError::new("Receipt has no logs".to_string()))?;
match_receipt_transfer_logs(
logs,
expected_sender,
currency,
expected,
source,
validate_sender,
)
}
fn validate_transaction_transfers(
&self,
tx_bytes: &[u8],
currency: Address,
expected: &[Transfer],
expected_chain_id: u64,
require_exact_calls: bool,
) -> Result<(), VerificationError> {
if currency.is_zero() {
return Err(VerificationError::new(
"Invalid currency: currency cannot be the zero address".to_string(),
));
}
let tx_data = if !tx_bytes.is_empty()
&& tx_bytes[0] == tempo_primitives::transaction::TEMPO_TX_TYPE_ID
{
&tx_bytes[1..]
} else {
tx_bytes
};
let signed = tempo_primitives::AASigned::rlp_decode(&mut &tx_data[..])
.map_err(|e| VerificationError::new(format!("Failed to decode transaction: {}", e)))?;
let tx = signed.tx();
if tx.chain_id != expected_chain_id {
return Err(VerificationError::new(format!(
"Transaction chain_id mismatch: expected {}, got {}",
expected_chain_id, tx.chain_id
)));
}
let policy =
FeePayerPolicy::resolve(expected_chain_id, self.fee_payer_policy_override.as_ref());
if require_exact_calls && tx.gas_limit > policy.max_gas {
return Err(VerificationError::new(format!(
"Fee-sponsored transaction gas limit {} exceeds maximum {}",
tx.gas_limit, policy.max_gas
)));
}
let transfer_calls = get_transfer_calls(&tx.calls)?;
if require_exact_calls {
validate_fee_payer_calls(&tx.calls)?;
}
let mut sorted_expected: Vec<(usize, &Transfer)> = expected.iter().enumerate().collect();
sorted_expected.sort_by_key(|(_, t)| if t.memo.is_some() { 0 } else { 1 });
let mut used_calls: Vec<bool> = vec![false; transfer_calls.len()];
if require_exact_calls && transfer_calls.len() != expected.len() {
return Err(VerificationError::new(format!(
"Invalid transaction: no matching payment call found (expected {} transfer calls, got {})",
expected.len(),
transfer_calls.len()
)));
}
for (_, transfer) in &sorted_expected {
if transfer.amount.is_zero() {
return Err(VerificationError::new(
"Invalid amount: expected_amount must be greater than zero".to_string(),
));
}
if transfer.recipient.is_zero() {
return Err(VerificationError::new(
"Invalid recipient: expected_recipient cannot be the zero address".to_string(),
));
}
let mut found = false;
for (call_idx, call) in transfer_calls.iter().enumerate() {
if used_calls[call_idx] {
continue;
}
let call_to = match &call.to {
TxKind::Call(addr) => addr,
TxKind::Create => continue,
};
if call_to != ¤cy {
continue;
}
let data = &call.input;
if data.len() < 4 {
continue;
}
let selector: [u8; 4] = data[..4].try_into().unwrap_or([0; 4]);
if let Some(exp_memo) = &transfer.memo {
if selector == TRANSFER_WITH_MEMO_SELECTOR && data.len() == 100 {
let to = Address::from_slice(&data[16..36]);
let amount = U256::from_be_slice(&data[36..68]);
let memo_bytes = B256::from_slice(&data[68..100]);
if to == transfer.recipient
&& amount == transfer.amount
&& memo_bytes == B256::from(*exp_memo)
{
used_calls[call_idx] = true;
found = true;
break;
}
}
} else {
if selector == TRANSFER_SELECTOR && data.len() == 68 {
let to = Address::from_slice(&data[16..36]);
let amount = U256::from_be_slice(&data[36..68]);
if to == transfer.recipient && amount == transfer.amount {
used_calls[call_idx] = true;
found = true;
break;
}
}
if !found && selector == TRANSFER_WITH_MEMO_SELECTOR && data.len() == 100 {
let to = Address::from_slice(&data[16..36]);
let amount = U256::from_be_slice(&data[36..68]);
if to == transfer.recipient && amount == transfer.amount {
used_calls[call_idx] = true;
found = true;
break;
}
}
}
}
if !found {
return Err(VerificationError::new(format!(
"Invalid transaction: no matching transfer call found for {} to {}{}",
transfer.amount,
transfer.recipient,
if transfer.memo.is_some() {
" with memo"
} else {
""
}
)));
}
}
if require_exact_calls && !used_calls.iter().all(|used| *used) {
return Err(VerificationError::new(
"Fee-sponsored transaction contains unexpected calls".to_string(),
));
}
Ok(())
}
async fn broadcast_transaction(
&self,
signed_tx: &str,
charge: &ChargeRequest,
expected_chain_id: u64,
challenge_id: &str,
realm: &str,
) -> Result<B256, VerificationError> {
let tx_bytes = signed_tx
.parse::<Bytes>()
.map_err(|e| VerificationError::new(format!("Invalid transaction bytes: {}", e)))?;
let currency = charge.currency_address().map_err(|e| {
VerificationError::new(format!("Invalid currency address in request: {}", e))
})?;
let expected = Self::expected_transfers(charge)?;
let final_tx_bytes = if charge.fee_payer() {
let fee_payer_signer = self.fee_payer_signer.as_ref().ok_or_else(|| {
VerificationError::new(
"feePayer requested but fee sponsorship is not configured on this server"
.to_string(),
)
})?;
self.cosign_fee_payer_transaction(&tx_bytes, fee_payer_signer, currency)?
} else {
tx_bytes.to_vec()
};
self.validate_transaction_transfers(
&final_tx_bytes,
currency,
&expected,
expected_chain_id,
charge.fee_payer(),
)?;
if charge.fee_payer() {
self.simulate_before_broadcast(&final_tx_bytes).await?;
}
if let Some(store) = &self.store {
let tx_hash_pre = keccak256(&final_tx_bytes);
let dedup_key = format!("mpp:charge:submission:{:#x}", tx_hash_pre);
let claimed = store
.put_if_absent(&dedup_key, serde_json::Value::Bool(true))
.await
.map_err(|e| VerificationError::new(format!("Failed to record tx: {e}")))?;
if !claimed {
return Err(VerificationError::new(
"Transaction has already been submitted.",
));
}
}
let raw_hex = alloy::hex::encode_prefixed(&final_tx_bytes);
let receipt: <TempoNetwork as alloy::network::Network>::ReceiptResponse = self
.provider
.raw_request("eth_sendRawTransactionSync".into(), [raw_hex])
.await
.map_err(|e| VerificationError::network_error(format!("Failed to broadcast: {}", e)))?;
if !receipt.status() {
return Err(VerificationError::transaction_failed(format!(
"Transaction {} reverted",
receipt.transaction_hash()
)));
}
let matched_logs =
self.verify_tip20_transfers(&receipt, receipt.from(), currency, &expected, None, None)?;
if charge.memo().is_none() {
assert_challenge_bound_memo(&matched_logs, challenge_id, realm)?;
}
if let Some(store) = &self.store {
let replay_key = format!("mpp:charge:{:#x}", receipt.transaction_hash());
let claimed = store
.put_if_absent(&replay_key, serde_json::Value::Bool(true))
.await
.map_err(|e| VerificationError::new(format!("Failed to record tx hash: {e}")))?;
if !claimed {
return Err(VerificationError::new(
"Transaction hash has already been used.",
));
}
}
Ok(receipt.transaction_hash())
}
fn build_simulate_payload(
final_tx_bytes: &[u8],
) -> Result<SimulatePayload<TempoTransactionRequest>, VerificationError> {
let signed =
tempo_primitives::AASigned::decode_2718(&mut &final_tx_bytes[..]).map_err(|e| {
VerificationError::new(format!("Failed to decode co-signed tx for simulation: {e}"))
})?;
let sender = signed.recover_signer().map_err(|e| {
VerificationError::new(format!("Failed to recover sender for simulation: {e}"))
})?;
let (key_id, key_type, key_data) = {
let (key_id, primitive_sig) = match signed.signature() {
TempoSignature::Keychain(keychain_sig) => {
let key_id = keychain_sig.key_id(&signed.signature_hash()).map_err(|e| {
VerificationError::new(format!(
"Failed to recover keychain access key for simulation: {e}"
))
})?;
(Some(key_id), &keychain_sig.signature)
}
TempoSignature::Primitive(primitive_sig) => (None, primitive_sig),
};
let key_data = match primitive_sig {
PrimitiveSignature::WebAuthn(webauthn) => Some(webauthn.webauthn_data.clone()),
_ => None,
};
(key_id, primitive_sig.signature_type(), key_data)
};
let mut req: TempoTransactionRequest = signed.into();
req.inner.from = Some(sender);
let tail = req.calls.pop().ok_or_else(|| {
VerificationError::new("Cannot simulate Tempo AA transaction with no calls")
})?;
req.inner.to = Some(tail.to);
req.inner.value = Some(tail.value);
req.inner.input = tail.input.into();
req.key_type = Some(key_type);
req.key_data = key_data;
if let Some(key_id) = key_id {
req.key_id = Some(key_id);
}
Ok(SimulatePayload {
block_state_calls: vec![SimBlock {
block_overrides: None,
state_overrides: None,
calls: vec![req],
}],
validation: false,
trace_transfers: false,
return_full_transactions: false,
})
}
async fn simulate_before_broadcast(
&self,
final_tx_bytes: &[u8],
) -> Result<(), VerificationError> {
const JSONRPC_METHOD_NOT_FOUND: i64 = -32601;
let payload = Self::build_simulate_payload(final_tx_bytes)?;
let response: TempoSimulateResponse = match self
.provider
.raw_request("tempo_simulateV1".into(), (payload,))
.await
{
Ok(response) => response,
Err(e) => {
if e.as_error_resp()
.is_some_and(|err| err.code == JSONRPC_METHOD_NOT_FOUND)
{
return Ok(());
}
return Err(VerificationError::network_error(format!(
"Pre-broadcast simulation failed: {e}"
)));
}
};
let call: &SimCallResult = response
.blocks
.first()
.and_then(|block| block.calls.first())
.ok_or_else(|| {
VerificationError::new("Pre-broadcast simulation returned no call results")
})?;
if !call.status {
let detail = match &call.error {
Some(err) => format!("{} (code {})", err.message, err.code),
None if !call.return_data.is_empty() => {
format!(
"revert data {}",
alloy::hex::encode_prefixed(&call.return_data)
)
}
None => "no revert reason returned".to_string(),
};
return Err(VerificationError::transaction_failed(format!(
"Sponsored transaction would revert in pre-broadcast simulation: {detail}"
)));
}
Ok(())
}
fn cosign_fee_payer_transaction(
&self,
tx_bytes: &[u8],
fee_payer_signer: &alloy::signers::local::PrivateKeySigner,
fee_token: Address,
) -> Result<Vec<u8>, VerificationError> {
use super::fee_payer_envelope::{FeePayerEnvelope78, TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID};
use alloy::eips::Encodable2718;
use alloy::signers::SignerSync;
use tempo_primitives::transaction::TEMPO_EXPIRING_NONCE_KEY;
if tx_bytes.is_empty() {
return Err(VerificationError::new("Empty transaction bytes"));
}
let type_byte = tx_bytes[0];
if type_byte != TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID {
return Err(VerificationError::new(format!(
"Expected fee payer envelope (0x78), got 0x{type_byte:02x}"
)));
}
let env = FeePayerEnvelope78::decode_envelope(tx_bytes)
.map_err(|e| VerificationError::new(format!("Failed to decode 0x78 envelope: {e}")))?;
let signed = env.to_recoverable_signed();
let sender = signed
.recover_signer()
.map_err(|e| VerificationError::new(format!("Failed to recover sender: {e}")))?;
if sender != env.sender {
return Err(VerificationError::new(format!(
"Sender mismatch in 0x78 envelope: envelope={:#x} recovered={:#x}",
env.sender, sender
)));
}
let tx = signed.tx();
if tx.fee_payer_signature.is_none() {
return Err(VerificationError::new(
"Transaction must include fee_payer_signature placeholder",
));
}
if tx.fee_token.is_some() {
return Err(VerificationError::new(
"Fee payer transaction must not include fee_token (server sets it)",
));
}
debug_assert!(tx.access_list.is_empty());
if tx.nonce_key != TEMPO_EXPIRING_NONCE_KEY {
return Err(VerificationError::new(
"Fee payer envelope must use expiring nonce key (U256::MAX)",
));
}
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_err(|e| VerificationError::new(format!("System clock error: {e}")))?
.as_secs();
let valid_before = match tx.valid_before {
None => {
return Err(VerificationError::new(
"Fee payer envelope must include valid_before",
));
}
Some(vb) => {
if vb.get() <= now {
return Err(VerificationError::new(format!(
"Fee payer envelope expired: valid_before ({vb}) is not in the future (now={now})"
)));
}
vb.get()
}
};
let policy = FeePayerPolicy::resolve(tx.chain_id, self.fee_payer_policy_override.as_ref());
let allowed_fee_tokens = self
.fee_payer_allowed_fee_tokens
.clone()
.unwrap_or_else(|| FeePayerPolicy::default_allowed_fee_tokens(tx.chain_id));
if !fee_token_allowed(&allowed_fee_tokens, fee_token) {
return Err(VerificationError::new(format!(
"Fee token {:#x} is not allowed by fee payer policy",
fee_token
)));
}
if tx.max_fee_per_gas > policy.max_fee_per_gas {
return Err(VerificationError::new(format!(
"max_fee_per_gas {} exceeds policy maximum {}",
tx.max_fee_per_gas, policy.max_fee_per_gas
)));
}
let total_fee = (tx.gas_limit as u128).saturating_mul(tx.max_fee_per_gas);
if total_fee > policy.max_total_fee {
return Err(VerificationError::new(format!(
"Total fee {} (gas_limit * max_fee_per_gas) exceeds policy maximum {}",
total_fee, policy.max_total_fee
)));
}
if tx.max_priority_fee_per_gas > tx.max_fee_per_gas {
return Err(VerificationError::new(format!(
"max_priority_fee_per_gas {} exceeds max_fee_per_gas {}",
tx.max_priority_fee_per_gas, tx.max_fee_per_gas
)));
}
if tx.max_priority_fee_per_gas > policy.max_priority_fee_per_gas {
return Err(VerificationError::new(format!(
"max_priority_fee_per_gas {} exceeds policy maximum {}",
tx.max_priority_fee_per_gas, policy.max_priority_fee_per_gas
)));
}
if valid_before.saturating_sub(now) > policy.max_validity_window_seconds {
return Err(VerificationError::new(format!(
"valid_before window {}s exceeds policy maximum {}s",
valid_before.saturating_sub(now),
policy.max_validity_window_seconds
)));
}
let (tx, client_signature, _hash) = signed.into_parts();
let mut tx = tx;
tx.fee_token = Some(fee_token);
tx.fee_payer_signature = None;
let fp_hash = tx.fee_payer_signature_hash(sender);
let fp_sig = fee_payer_signer
.sign_hash_sync(&fp_hash)
.map_err(|e| VerificationError::new(format!("Failed to co-sign transaction: {e}")))?;
tx.fee_payer_signature = Some(fp_sig);
let signed_tx = tx.into_signed(client_signature);
Ok(signed_tx.encoded_2718())
}
}
#[allow(clippy::manual_async_fn)]
impl<P> crate::protocol::traits::SessionMethod for ChargeMethod<P>
where
P: Provider<TempoNetwork> + Clone + Send + Sync + 'static,
{
fn method(&self) -> &str {
METHOD_NAME
}
fn verify_session(
&self,
_credential: &PaymentCredential,
_request: &crate::protocol::intents::SessionRequest,
) -> impl Future<Output = Result<Receipt, VerificationError>> + Send {
async {
Err(VerificationError::new(
"Session verification not yet implemented — requires on-chain channel state lookup",
))
}
}
}
impl<P> ChargeMethodTrait for ChargeMethod<P>
where
P: Provider<TempoNetwork> + Clone + Send + Sync + 'static,
{
fn method(&self) -> &str {
METHOD_NAME
}
fn verify(
&self,
credential: &PaymentCredential,
request: &ChargeRequest,
) -> impl Future<Output = Result<Receipt, VerificationError>> + Send {
let credential = credential.clone();
let request = request.clone();
let provider = Arc::clone(&self.provider);
let fee_payer_signer = self.fee_payer_signer.clone();
let store = self.store.clone();
let cached_chain_id = Arc::clone(&self.cached_chain_id);
let fee_payer_policy_override = self.fee_payer_policy_override.clone();
let validate_sender = self.validate_sender.clone();
let fee_payer_allowed_fee_tokens = self.fee_payer_allowed_fee_tokens.clone();
async move {
let this = ChargeMethod {
provider,
fee_payer_signer,
store,
cached_chain_id,
fee_payer_policy_override,
validate_sender,
fee_payer_allowed_fee_tokens,
};
if credential.challenge.method.as_str() != METHOD_NAME {
return Err(VerificationError::credential_mismatch(format!(
"Method mismatch: expected {}, got {}",
METHOD_NAME, credential.challenge.method
)));
}
if credential.challenge.intent.as_str() != INTENT_CHARGE {
return Err(VerificationError::credential_mismatch(format!(
"Intent mismatch: expected {}, got {}",
INTENT_CHARGE, credential.challenge.intent
)));
}
let expected_chain_id = request.chain_id().unwrap_or(CHAIN_ID);
let actual_chain_id = *this
.cached_chain_id
.get_or_try_init(|| async {
this.provider.get_chain_id().await.map_err(|e| {
VerificationError::network_error(format!("Failed to fetch chain ID: {}", e))
})
})
.await?;
if actual_chain_id != expected_chain_id {
return Err(VerificationError::chain_id_mismatch(format!(
"Chain ID mismatch: expected {}, got {}",
expected_chain_id, actual_chain_id
)));
}
let charge_payload = credential.charge_payload().map_err(|e| {
VerificationError::with_code(
format!("Expected charge payload: {}", e),
crate::protocol::traits::ErrorCode::InvalidCredential,
)
})?;
let is_zero_amount = request
.amount_u256()
.map_err(|e| VerificationError::new(format!("Invalid amount in request: {}", e)))?
.is_zero();
if is_zero_amount && !charge_payload.is_proof() {
return Err(VerificationError::new(
"Zero-amount challenges require a proof credential.",
));
}
if charge_payload.is_hash() {
this.verify_hash(
charge_payload.tx_hash().unwrap(),
&request,
credential.source.as_deref(),
expected_chain_id,
&credential.challenge.id,
&credential.challenge.realm,
)
.await
} else if charge_payload.is_proof() {
if !is_zero_amount {
return Err(VerificationError::new(
"Proof credentials are only valid for zero-amount challenges.",
));
}
let source = credential.source.as_deref().ok_or_else(|| {
VerificationError::new("Proof credential must include a source.")
})?;
let parsed_source = proof::parse_proof_source(source)
.map_err(|_| VerificationError::new("Proof credential source is invalid."))?;
if parsed_source.chain_id != expected_chain_id {
return Err(VerificationError::new(
"Proof credential source is invalid.",
));
}
let sig_hex = charge_payload.proof_signature().unwrap();
if !proof::verify_proof(
expected_chain_id,
&credential.challenge.id,
&credential.challenge.realm,
sig_hex,
parsed_source.address,
) {
let recovered = proof::recover_proof_signer(
expected_chain_id,
&credential.challenge.id,
&credential.challenge.realm,
sig_hex,
)
.map_err(|_| {
VerificationError::new("Proof signature does not match source.")
})?;
let keychain = IAccountKeychain::new(ACCOUNT_KEYCHAIN_ADDRESS, &*this.provider);
let key_info = keychain
.getKey(parsed_source.address, recovered)
.call()
.await
.map_err(|_| {
VerificationError::new("Proof signature does not match source.")
})?;
let now_secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
if key_info.expiry == 0 || key_info.isRevoked || key_info.expiry <= now_secs {
return Err(VerificationError::new(
"Proof signature does not match source.",
));
}
}
if let Some(store) = &this.store {
let fingerprint = proof::proof_fingerprint(
&credential.challenge.id,
parsed_source.address,
parsed_source.chain_id,
sig_hex,
)
.map_err(|e| VerificationError::new(format!("Failed to record proof: {e}")))?;
let replay_key = format!("mpp:proof:{:x}", fingerprint);
let reserved = store
.put_if_absent(&replay_key, serde_json::Value::Bool(true))
.await
.map_err(|e| {
VerificationError::new(format!("Failed to record proof: {e}"))
})?;
if !reserved {
return Err(VerificationError::new(
"Proof credential has already been used.",
));
}
}
Ok(Receipt::success(METHOD_NAME, &credential.challenge.id))
} else {
let tx_hash = this
.broadcast_transaction(
charge_payload.signed_tx().unwrap(),
&request,
expected_chain_id,
&credential.challenge.id,
&credential.challenge.realm,
)
.await?;
Ok(Receipt::success(METHOD_NAME, format!("{:#x}", tx_hash)))
}
}
}
}
#[cfg(test)]
mod tests {
use std::num::NonZeroU64;
use alloy::primitives::hex;
use super::{super::MODERATO_CHAIN_ID, *};
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
fn test_charge_request_with_amount(amount: &str) -> ChargeRequest {
ChargeRequest {
amount: amount.to_string(),
currency: "0x20c0000000000000000000000000000000000000".to_string(),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".to_string()),
method_details: Some(serde_json::json!({ "chainId": 42431 })),
..Default::default()
}
}
fn test_proof_challenge(request: &ChargeRequest) -> PaymentChallenge {
PaymentChallenge::new(
"proof-challenge-id",
"api.example.com",
"tempo",
"charge",
Base64UrlJson::from_typed(request).unwrap(),
)
}
#[test]
fn test_transfer_selector() {
assert_eq!(TRANSFER_SELECTOR, [0xa9, 0x05, 0x9c, 0xbb]);
}
#[test]
fn test_transfer_with_memo_selector() {
assert_eq!(TRANSFER_WITH_MEMO_SELECTOR, [0x95, 0x77, 0x7d, 0x59]);
}
#[test]
fn test_event_topics() {
assert_eq!(
TRANSFER_EVENT_TOPIC,
alloy::primitives::b256!(
"ddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef"
)
);
assert_eq!(
TRANSFER_WITH_MEMO_EVENT_TOPIC,
alloy::primitives::b256!(
"57bc7354aa85aed339e000bccffabbc529466af35f0772c8f8ee1145927de7f0"
)
);
}
#[test]
fn test_calldata_length_constants() {
const TRANSFER_CALLDATA_LEN: usize = 4 + 32 + 32;
const TRANSFER_WITH_MEMO_CALLDATA_LEN: usize = 4 + 32 + 32 + 32;
assert_eq!(TRANSFER_CALLDATA_LEN, 68);
assert_eq!(TRANSFER_WITH_MEMO_CALLDATA_LEN, 100);
}
#[test]
fn test_selector_parsing_short_input() {
let short_inputs: Vec<&[u8]> = vec![&[], &[0xa9], &[0xa9, 0x05], &[0xa9, 0x05, 0x9c]];
for input in short_inputs {
if input.len() >= 4 {
let _selector: [u8; 4] = input[..4].try_into().unwrap_or([0; 4]);
}
}
}
#[test]
fn test_zero_amount_rejected() {
let zero = U256::ZERO;
assert!(zero.is_zero());
let non_zero = U256::from(1u64);
assert!(!non_zero.is_zero());
}
#[test]
fn test_zero_address_detection() {
let zero_addr = Address::ZERO;
assert!(zero_addr.is_zero());
let valid_addr: Address = "0x742d35Cc6634C0532925a3b844Bc9e7595f3bB77"
.parse()
.unwrap();
assert!(!valid_addr.is_zero());
}
#[test]
fn test_chain_id_constant() {
assert_eq!(CHAIN_ID, 4217);
assert_eq!(MODERATO_CHAIN_ID, 42431);
}
#[test]
fn test_fee_payer_not_configured() {
let error = VerificationError::new(
"feePayer requested but fee sponsorship is not configured on this server",
);
assert!(error
.to_string()
.contains("fee sponsorship is not configured"));
}
#[tokio::test]
async fn test_zero_amount_proof_accepted() {
let signer = alloy::signers::local::PrivateKeySigner::random();
let request = test_charge_request_with_amount("0");
let challenge = test_proof_challenge(&request);
let signature = proof::sign_proof(&signer, 42431, &challenge.id, &challenge.realm)
.await
.unwrap();
let credential = PaymentCredential::with_source(
challenge.to_echo(),
proof::proof_source(signer.address(), 42431),
crate::protocol::core::PaymentPayload::proof(signature),
);
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
let receipt = method.verify(&credential, &request).await.unwrap_err();
assert!(receipt.to_string().contains("Failed to fetch chain ID") || receipt.retryable);
}
#[tokio::test]
async fn test_verify_proof_rejects_wrong_signer() {
let signer = alloy::signers::local::PrivateKeySigner::random();
let other = alloy::signers::local::PrivateKeySigner::random();
let request = test_charge_request_with_amount("0");
let challenge = test_proof_challenge(&request);
let signature = proof::sign_proof(&other, 42431, &challenge.id, &challenge.realm)
.await
.unwrap();
let payload = crate::protocol::core::PaymentPayload::proof(signature);
let credential = PaymentCredential::with_source(
challenge.to_echo(),
proof::proof_source(signer.address(), 42431),
payload.clone(),
);
let source = credential.source.as_deref().unwrap();
let parsed = proof::parse_proof_source(source).unwrap();
assert!(!proof::verify_proof(
42431,
&credential.challenge.id,
&credential.challenge.realm,
payload.proof_signature().unwrap(),
parsed.address,
));
}
#[test]
fn test_verify_zero_amount_requires_proof_payload() {
let request = test_charge_request_with_amount("0");
let challenge = test_proof_challenge(&request);
let credential = PaymentCredential::new(
challenge.to_echo(),
crate::protocol::core::PaymentPayload::transaction("0xdeadbeef"),
);
let payload = credential.charge_payload().unwrap();
assert!(!payload.is_proof());
assert!(request.amount_u256().unwrap().is_zero());
}
fn make_fee_payer_tx(valid_before_secs_from_now: u64) -> tempo_primitives::TempoTransaction {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
tempo_primitives::TempoTransaction {
chain_id: CHAIN_ID,
nonce: 0,
nonce_key: U256::MAX,
gas_limit: 1_000_000,
max_fee_per_gas: 1_000_000_000,
max_priority_fee_per_gas: 1_000_000_000,
fee_token: None,
fee_payer_signature: Some(alloy::primitives::Signature::new(
U256::ZERO,
U256::ZERO,
false,
)),
valid_before: NonZeroU64::new(now + valid_before_secs_from_now),
valid_after: None,
calls: vec![tempo_primitives::transaction::Call {
to: TxKind::Call(Address::repeat_byte(0x20)),
value: U256::ZERO,
input: Bytes::from(vec![0xa9, 0x05, 0x9c, 0xbb]), }],
access_list: Default::default(),
tempo_authorization_list: vec![],
key_authorization: None,
}
}
fn make_transfer_input(recipient: Address, amount: U256) -> Bytes {
let mut data = Vec::with_capacity(68);
data.extend_from_slice(&TRANSFER_SELECTOR);
data.extend_from_slice(&[0u8; 12]);
data.extend_from_slice(recipient.as_slice());
let mut amount_bytes = [0u8; 32];
amount.to_be_bytes::<32>().clone_into(&mut amount_bytes);
data.extend_from_slice(&amount_bytes);
Bytes::from(data)
}
fn make_approve_input(spender: Address, amount: U256) -> Bytes {
Bytes::from(ITIP20::approveCall { spender, amount }.abi_encode())
}
fn make_swap_input(token_in: Address, token_out: Address, amount_out: u128) -> Bytes {
Bytes::from(
IStablecoinDEX::swapExactAmountOutCall {
tokenIn: token_in,
tokenOut: token_out,
amountOut: amount_out,
maxAmountIn: amount_out,
}
.abi_encode(),
)
}
fn encode_signed_tx(
calls: Vec<tempo_primitives::transaction::Call>,
gas_limit: u64,
) -> Vec<u8> {
use alloy::eips::Encodable2718;
use alloy::signers::SignerSync;
let signer = alloy::signers::local::PrivateKeySigner::random();
let tx = tempo_primitives::TempoTransaction {
chain_id: CHAIN_ID,
nonce: 0,
nonce_key: U256::MAX,
gas_limit,
max_fee_per_gas: 1_000_000_000,
max_priority_fee_per_gas: 1_000_000_000,
fee_token: Some(Address::repeat_byte(0x20)),
fee_payer_signature: None,
valid_before: None,
valid_after: None,
calls,
access_list: Default::default(),
tempo_authorization_list: vec![],
key_authorization: None,
};
let signature: tempo_primitives::transaction::TempoSignature =
signer.sign_hash_sync(&tx.signature_hash()).unwrap().into();
tx.into_signed(signature).encoded_2718()
}
fn address_topic(address: Address) -> String {
format!("0x{:0>64}", hex::encode(address.as_slice()))
}
fn amount_data(amount: U256) -> String {
let mut amount_bytes = [0u8; 32];
amount.to_be_bytes::<32>().clone_into(&mut amount_bytes);
hex::encode(amount_bytes)
}
fn make_transfer_log(
currency: Address,
from: Address,
to: Address,
amount: U256,
) -> serde_json::Value {
serde_json::json!({
"address": format!("{:#x}", currency),
"topics": [
format!("{:#x}", TRANSFER_EVENT_TOPIC),
address_topic(from),
address_topic(to),
],
"data": format!("0x{}", amount_data(amount)),
})
}
fn make_transfer_with_memo_log(
currency: Address,
from: Address,
to: Address,
amount: U256,
memo: [u8; 32],
) -> serde_json::Value {
serde_json::json!({
"address": format!("{:#x}", currency),
"topics": [
format!("{:#x}", TRANSFER_WITH_MEMO_EVENT_TOPIC),
address_topic(from),
address_topic(to),
format!("0x{}", hex::encode(memo)),
],
"data": format!("0x{}", amount_data(amount)),
})
}
#[test]
fn test_match_receipt_transfer_logs_prefers_memo_logs() {
let currency = Address::repeat_byte(0x20);
let sender = Address::repeat_byte(0x11);
let recipient = Address::repeat_byte(0x33);
let amount = U256::from(100u64);
let memo = attribution::encode("challenge-123", "api.example.com", None);
let logs = vec![
make_transfer_log(currency, sender, recipient, amount),
make_transfer_with_memo_log(currency, sender, recipient, amount, memo),
];
let expected = vec![Transfer {
amount,
recipient,
memo: None,
}];
let matched =
match_receipt_transfer_logs(&logs, sender, currency, &expected, None, None).unwrap();
assert_eq!(matched, vec![MatchedTransferLog::Memo(memo)]);
}
#[test]
fn test_match_receipt_transfer_logs_with_split_preserves_bound_memo() {
let currency = Address::repeat_byte(0x20);
let sender = Address::repeat_byte(0x11);
let primary = Address::repeat_byte(0x33);
let split = Address::repeat_byte(0x44);
let memo = attribution::encode("challenge-123", "api.example.com", None);
let logs = vec![
make_transfer_log(currency, sender, split, U256::from(10u64)),
make_transfer_with_memo_log(currency, sender, primary, U256::from(90u64), memo),
];
let expected = vec![
Transfer {
amount: U256::from(90u64),
recipient: primary,
memo: None,
},
Transfer {
amount: U256::from(10u64),
recipient: split,
memo: None,
},
];
let matched =
match_receipt_transfer_logs(&logs, sender, currency, &expected, None, None).unwrap();
assert_eq!(matched.len(), 2);
assert!(matched.contains(&MatchedTransferLog::Memo(memo)));
assert!(matched.contains(&MatchedTransferLog::Transfer));
}
const HASH_SOURCE_INVALID: &str = "Hash credential source is invalid.";
fn did_pkh(chain_id: u64, address: Address) -> String {
format!("did:pkh:eip155:{chain_id}:{address}")
}
#[test]
fn test_parse_hash_credential_source_absent_is_none() {
assert_eq!(
parse_hash_credential_source(None, MODERATO_CHAIN_ID).unwrap(),
None
);
}
#[test]
fn test_parse_hash_credential_source_valid_returns_address() {
let address = Address::repeat_byte(0x11);
let source = did_pkh(MODERATO_CHAIN_ID, address);
let parsed = parse_hash_credential_source(Some(&source), MODERATO_CHAIN_ID).unwrap();
assert_eq!(parsed, Some(address));
}
#[test]
fn test_parse_hash_credential_source_chain_id_mismatch_is_rejected() {
let source = did_pkh(1, Address::repeat_byte(0x11));
let err = parse_hash_credential_source(Some(&source), MODERATO_CHAIN_ID).unwrap_err();
assert_eq!(err.to_string(), HASH_SOURCE_INVALID);
}
#[test]
fn test_parse_hash_credential_source_rejects_malformed_variants() {
let address = "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2";
let cases = [
"not-a-valid-did",
"did:pkh:solana:42431:0xa5cc3c03994db5b0d9ba5e4f6d2efbd9f213b141",
&format!("did:pkh:eip155:042431:{address}"),
&format!("did:pkh:eip155:not-a-number:{address}"),
&format!("did:pkh:eip155:42431:extra:{address}"),
"did:pkh:eip155:42431:not-an-address",
];
for case in cases {
let err = parse_hash_credential_source(Some(case), MODERATO_CHAIN_ID).unwrap_err();
assert_eq!(err.to_string(), HASH_SOURCE_INVALID, "case: {case}");
}
}
#[test]
fn test_match_transfer_logs_accepts_source_matching_transfer_sender() {
let currency = Address::repeat_byte(0x20);
let source = Address::repeat_byte(0x11);
let recipient = Address::repeat_byte(0x33);
let amount = U256::from(100u64);
let memo = attribution::encode("challenge-123", "api.example.com", None);
let logs = vec![make_transfer_with_memo_log(
currency, source, recipient, amount, memo,
)];
let expected = vec![Transfer {
amount,
recipient,
memo: None,
}];
let matched =
match_receipt_transfer_logs(&logs, source, currency, &expected, None, None).unwrap();
assert_eq!(matched, vec![MatchedTransferLog::Memo(memo)]);
}
#[test]
fn test_match_transfer_logs_rejects_source_differing_from_transfer_sender() {
let currency = Address::repeat_byte(0x20);
let declared_source = Address::repeat_byte(0x99);
let actual_sender = Address::repeat_byte(0x11);
let recipient = Address::repeat_byte(0x33);
let amount = U256::from(100u64);
let logs = vec![make_transfer_log(
currency,
actual_sender,
recipient,
amount,
)];
let expected = vec![Transfer {
amount,
recipient,
memo: None,
}];
let err =
match_receipt_transfer_logs(&logs, declared_source, currency, &expected, None, None)
.unwrap_err();
assert!(err.to_string().contains("No matching transfer event found"));
}
#[test]
fn test_match_transfer_logs_validate_sender_override_allows_mismatch() {
let currency = Address::repeat_byte(0x20);
let declared_source = Address::repeat_byte(0x99);
let actual_sender = Address::repeat_byte(0x11);
let recipient = Address::repeat_byte(0x33);
let amount = U256::from(100u64);
let source_did = did_pkh(MODERATO_CHAIN_ID, declared_source);
let logs = vec![make_transfer_log(
currency,
actual_sender,
recipient,
amount,
)];
let expected = vec![Transfer {
amount,
recipient,
memo: None,
}];
let cb_did = source_did.clone();
let cb: Box<ValidateSenderCallback> = Box::new(move |v: SenderValidation| {
assert_eq!(v.expected_sender, declared_source);
assert_eq!(v.sender, actual_sender);
assert_eq!(v.source, Some(cb_did.as_str()));
true
});
let matched = match_receipt_transfer_logs(
&logs,
declared_source,
currency,
&expected,
Some(&source_did),
Some(cb.as_ref()),
)
.unwrap();
assert_eq!(matched, vec![MatchedTransferLog::Transfer]);
}
#[test]
fn test_match_transfer_logs_validate_sender_returning_false_rejects() {
let currency = Address::repeat_byte(0x20);
let declared_source = Address::repeat_byte(0x99);
let actual_sender = Address::repeat_byte(0x11);
let recipient = Address::repeat_byte(0x33);
let amount = U256::from(100u64);
let logs = vec![make_transfer_log(
currency,
actual_sender,
recipient,
amount,
)];
let expected = vec![Transfer {
amount,
recipient,
memo: None,
}];
let cb: Box<ValidateSenderCallback> = Box::new(|_v: SenderValidation| false);
let err = match_receipt_transfer_logs(
&logs,
declared_source,
currency,
&expected,
None,
Some(cb.as_ref()),
)
.unwrap_err();
assert!(err.to_string().contains("No matching transfer event found"));
}
#[test]
fn test_match_transfer_logs_validate_sender_not_called_when_sender_matches() {
let currency = Address::repeat_byte(0x20);
let source = Address::repeat_byte(0x11);
let recipient = Address::repeat_byte(0x33);
let amount = U256::from(100u64);
let memo = attribution::encode("challenge-123", "api.example.com", None);
let logs = vec![make_transfer_with_memo_log(
currency, source, recipient, amount, memo,
)];
let expected = vec![Transfer {
amount,
recipient,
memo: None,
}];
let cb: Box<ValidateSenderCallback> = Box::new(|_v: SenderValidation| {
panic!("validate_sender must not run when sender already matches")
});
let matched = match_receipt_transfer_logs(
&logs,
source,
currency,
&expected,
None,
Some(cb.as_ref()),
)
.unwrap();
assert_eq!(matched, vec![MatchedTransferLog::Memo(memo)]);
}
#[test]
fn test_match_transfer_logs_validate_sender_not_called_for_memo_incompatible_logs() {
let currency = Address::repeat_byte(0x20);
let declared_source = Address::repeat_byte(0x99);
let wrong_sender = Address::repeat_byte(0x11);
let recipient = Address::repeat_byte(0x33);
let amount = U256::from(100u64);
let wanted_memo = attribution::encode("challenge-123", "api.example.com", None);
let other_memo = attribution::encode("challenge-999", "api.example.com", None);
let logs = vec![
make_transfer_with_memo_log(currency, wrong_sender, recipient, amount, other_memo),
make_transfer_with_memo_log(currency, declared_source, recipient, amount, wanted_memo),
];
let expected = vec![Transfer {
amount,
recipient,
memo: Some(wanted_memo),
}];
let cb: Box<ValidateSenderCallback> = Box::new(|_v: SenderValidation| {
panic!("validate_sender must not run for memo-incompatible logs")
});
let matched = match_receipt_transfer_logs(
&logs,
declared_source,
currency,
&expected,
None,
Some(cb.as_ref()),
)
.unwrap();
assert_eq!(matched, vec![MatchedTransferLog::Memo(wanted_memo)]);
}
#[test]
fn test_assert_challenge_bound_memo_accepts_bound_memo() {
let memo = attribution::encode("challenge-123", "api.example.com", None);
assert!(assert_challenge_bound_memo(
&[MatchedTransferLog::Memo(memo)],
"challenge-123",
"api.example.com",
)
.is_ok());
}
#[test]
fn test_assert_challenge_bound_memo_rejects_plain_transfer() {
let error = assert_challenge_bound_memo(
&[MatchedTransferLog::Transfer],
"challenge-123",
"api.example.com",
)
.unwrap_err();
assert!(error
.to_string()
.contains("memo is not bound to this challenge"));
}
#[test]
fn test_assert_challenge_bound_memo_rejects_wrong_challenge() {
let memo = attribution::encode("challenge-123", "api.example.com", None);
let error = assert_challenge_bound_memo(
&[MatchedTransferLog::Memo(memo)],
"challenge-456",
"api.example.com",
)
.unwrap_err();
assert!(error
.to_string()
.contains("memo is not bound to this challenge"));
}
#[test]
fn test_assert_challenge_bound_memo_rejects_non_mpp_memo() {
let error = assert_challenge_bound_memo(
&[MatchedTransferLog::Memo([0x11; 32])],
"challenge-123",
"api.example.com",
)
.unwrap_err();
assert!(error
.to_string()
.contains("memo is not bound to this challenge"));
}
#[test]
fn test_assert_challenge_bound_memo_rejects_wrong_realm() {
let memo = attribution::encode("challenge-123", "api.example.com", None);
let error = assert_challenge_bound_memo(
&[MatchedTransferLog::Memo(memo)],
"challenge-123",
"other.example.com",
)
.unwrap_err();
assert!(error
.to_string()
.contains("memo is not bound to this challenge"));
}
fn sign_and_encode_0x78(
tx: tempo_primitives::TempoTransaction,
signer: &alloy::signers::local::PrivateKeySigner,
) -> Vec<u8> {
use super::super::{FeePayerEnvelope78, TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID};
use alloy::signers::SignerSync;
let sig_hash = tx.signature_hash();
let sig = signer.sign_hash_sync(&sig_hash).unwrap();
let signature: tempo_primitives::transaction::TempoSignature = sig.into();
let encoded =
FeePayerEnvelope78::from_signing_tx(tx, signer.address(), signature).encoded_envelope();
assert_eq!(encoded[0], TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID);
encoded
}
#[test]
fn test_fee_payer_round_trip_0x78_envelope() {
use super::super::{FeePayerEnvelope78, TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID};
use alloy::signers::SignerSync;
let client_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let tx = make_fee_payer_tx(60);
let sig_hash = tx.signature_hash();
let sig = client_signer.sign_hash_sync(&sig_hash).unwrap();
let signature: tempo_primitives::transaction::TempoSignature = sig.into();
let encoded = FeePayerEnvelope78::from_signing_tx(tx, client_signer.address(), signature)
.encoded_envelope();
assert_eq!(encoded[0], TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID);
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);
let result = method.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
);
let co_signed = result.expect("cosign should succeed for valid 0x78 envelope");
assert_eq!(
co_signed[0],
tempo_primitives::transaction::TEMPO_TX_TYPE_ID,
"co-signed output should be 0x76"
);
let signed = tempo_primitives::AASigned::decode_2718(&mut &co_signed[..])
.expect("co-signed tx should be decodable as AASigned");
let decoded_tx = signed.tx();
assert_eq!(decoded_tx.chain_id, CHAIN_ID);
assert_eq!(decoded_tx.nonce_key, U256::MAX);
assert_eq!(decoded_tx.fee_token, Some(fee_token));
assert!(decoded_tx.fee_payer_signature.is_some());
assert!(decoded_tx.valid_before.is_some());
}
#[test]
fn test_validate_transaction_transfers_rejects_unexpected_fee_payer_calls() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
let currency = Address::repeat_byte(0x20);
let recipient = Address::repeat_byte(0x33);
let expected = vec![Transfer {
amount: U256::from(100u64),
recipient,
memo: None,
}];
let tx_bytes = encode_signed_tx(
vec![
tempo_primitives::transaction::Call {
to: TxKind::Call(currency),
value: U256::ZERO,
input: make_transfer_input(recipient, U256::from(100u64)),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(Address::repeat_byte(0x44)),
value: U256::ZERO,
input: Bytes::from(vec![0u8; 4]),
},
],
MAX_FEE_PAYER_GAS_LIMIT,
);
let error = method
.validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
.unwrap_err();
assert!(
error.to_string().contains("disallowed call pattern")
|| error.to_string().contains("no matching payment call")
);
}
#[test]
fn test_validate_transaction_transfers_accepts_fee_payer_approve_swap_prefix() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
let currency = Address::repeat_byte(0x20);
let recipient = Address::repeat_byte(0x33);
let token_in = Address::repeat_byte(0x11);
let expected = vec![Transfer {
amount: U256::from(100u64),
recipient,
memo: None,
}];
let tx_bytes = encode_signed_tx(
vec![
tempo_primitives::transaction::Call {
to: TxKind::Call(token_in),
value: U256::ZERO,
input: make_approve_input(STABLECOIN_DEX_ADDRESS, U256::from(100u64)),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(STABLECOIN_DEX_ADDRESS),
value: U256::ZERO,
input: make_swap_input(token_in, currency, 100),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(currency),
value: U256::ZERO,
input: make_transfer_input(recipient, U256::from(100u64)),
},
],
MAX_FEE_PAYER_GAS_LIMIT,
);
method
.validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
.unwrap();
}
#[test]
fn test_validate_transaction_transfers_accepts_fee_payer_approve_swap_prefix_with_splits() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
let currency = Address::repeat_byte(0x20);
let primary_recipient = Address::repeat_byte(0x33);
let split_recipient = Address::repeat_byte(0x34);
let token_in = Address::repeat_byte(0x11);
let expected = vec![
Transfer {
amount: U256::from(90u64),
recipient: primary_recipient,
memo: None,
},
Transfer {
amount: U256::from(10u64),
recipient: split_recipient,
memo: None,
},
];
let tx_bytes = encode_signed_tx(
vec![
tempo_primitives::transaction::Call {
to: TxKind::Call(token_in),
value: U256::ZERO,
input: make_approve_input(STABLECOIN_DEX_ADDRESS, U256::from(100u64)),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(STABLECOIN_DEX_ADDRESS),
value: U256::ZERO,
input: make_swap_input(token_in, currency, 100),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(currency),
value: U256::ZERO,
input: make_transfer_input(primary_recipient, U256::from(90u64)),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(currency),
value: U256::ZERO,
input: make_transfer_input(split_recipient, U256::from(10u64)),
},
],
MAX_FEE_PAYER_GAS_LIMIT,
);
method
.validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
.unwrap();
}
#[test]
fn test_validate_transaction_transfers_rejects_fee_payer_swap_without_approve() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
let currency = Address::repeat_byte(0x20);
let recipient = Address::repeat_byte(0x33);
let token_in = Address::repeat_byte(0x11);
let expected = vec![Transfer {
amount: U256::from(100u64),
recipient,
memo: None,
}];
let tx_bytes = encode_signed_tx(
vec![
tempo_primitives::transaction::Call {
to: TxKind::Call(STABLECOIN_DEX_ADDRESS),
value: U256::ZERO,
input: make_swap_input(token_in, currency, 100),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(currency),
value: U256::ZERO,
input: make_transfer_input(recipient, U256::from(100u64)),
},
],
MAX_FEE_PAYER_GAS_LIMIT,
);
let error = method
.validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
.unwrap_err();
assert!(
error.to_string().contains("disallowed call pattern")
|| error.to_string().contains("no matching payment call")
);
}
#[test]
fn test_validate_transaction_transfers_rejects_fee_payer_wrong_approve_spender() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
let currency = Address::repeat_byte(0x20);
let recipient = Address::repeat_byte(0x33);
let token_in = Address::repeat_byte(0x11);
let expected = vec![Transfer {
amount: U256::from(100u64),
recipient,
memo: None,
}];
let tx_bytes = encode_signed_tx(
vec![
tempo_primitives::transaction::Call {
to: TxKind::Call(token_in),
value: U256::ZERO,
input: make_approve_input(Address::repeat_byte(0x99), U256::from(100u64)),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(STABLECOIN_DEX_ADDRESS),
value: U256::ZERO,
input: make_swap_input(token_in, currency, 100),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(currency),
value: U256::ZERO,
input: make_transfer_input(recipient, U256::from(100u64)),
},
],
MAX_FEE_PAYER_GAS_LIMIT,
);
let error = method
.validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
.unwrap_err();
assert!(error.to_string().contains("approve spender is not the DEX"));
}
#[test]
fn test_validate_transaction_transfers_rejects_fee_payer_wrong_approve_target() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
let currency = Address::repeat_byte(0x20);
let recipient = Address::repeat_byte(0x33);
let token_in = Address::repeat_byte(0x11);
let expected = vec![Transfer {
amount: U256::from(100u64),
recipient,
memo: None,
}];
let tx_bytes = encode_signed_tx(
vec![
tempo_primitives::transaction::Call {
to: TxKind::Call(Address::repeat_byte(0x99)),
value: U256::ZERO,
input: make_approve_input(STABLECOIN_DEX_ADDRESS, U256::from(100u64)),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(STABLECOIN_DEX_ADDRESS),
value: U256::ZERO,
input: make_swap_input(token_in, currency, 100),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(currency),
value: U256::ZERO,
input: make_transfer_input(recipient, U256::from(100u64)),
},
],
MAX_FEE_PAYER_GAS_LIMIT,
);
let error = method
.validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
.unwrap_err();
assert!(error
.to_string()
.contains("approve target is not the swap input token"));
}
#[test]
fn test_validate_transaction_transfers_rejects_fee_payer_wrong_swap_target() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
let currency = Address::repeat_byte(0x20);
let recipient = Address::repeat_byte(0x33);
let token_in = Address::repeat_byte(0x11);
let expected = vec![Transfer {
amount: U256::from(100u64),
recipient,
memo: None,
}];
let tx_bytes = encode_signed_tx(
vec![
tempo_primitives::transaction::Call {
to: TxKind::Call(token_in),
value: U256::ZERO,
input: make_approve_input(STABLECOIN_DEX_ADDRESS, U256::from(100u64)),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(Address::repeat_byte(0x98)),
value: U256::ZERO,
input: make_swap_input(token_in, currency, 100),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(currency),
value: U256::ZERO,
input: make_transfer_input(recipient, U256::from(100u64)),
},
],
MAX_FEE_PAYER_GAS_LIMIT,
);
let error = method
.validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
.unwrap_err();
assert!(error.to_string().contains("swap target is not the DEX"));
}
#[test]
fn test_validate_transaction_transfers_rejects_fee_payer_gas_limit_above_max() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
let currency = Address::repeat_byte(0x20);
let recipient = Address::repeat_byte(0x33);
let expected = vec![Transfer {
amount: U256::from(100u64),
recipient,
memo: None,
}];
let tx_bytes = encode_signed_tx(
vec![tempo_primitives::transaction::Call {
to: TxKind::Call(currency),
value: U256::ZERO,
input: make_transfer_input(recipient, U256::from(100u64)),
}],
MAX_FEE_PAYER_GAS_LIMIT + 1,
);
let error = method
.validate_transaction_transfers(&tx_bytes, currency, &expected, CHAIN_ID, true)
.unwrap_err();
assert!(error.to_string().contains("exceeds maximum"));
}
#[test]
fn test_policy_override_adjusts_fee_payer_gas_limit() {
let currency = Address::repeat_byte(0x20);
let recipient = Address::repeat_byte(0x33);
let expected = vec![Transfer {
amount: U256::from(100u64),
recipient,
memo: None,
}];
let calls = vec![tempo_primitives::transaction::Call {
to: TxKind::Call(currency),
value: U256::ZERO,
input: make_transfer_input(recipient, U256::from(100u64)),
}];
let build_method = || {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
ChargeMethod::new(provider)
};
let lowered = build_method().with_fee_payer_policy_override(FeePayerPolicyOverride {
max_gas: Some(500_000),
..Default::default()
});
let tx_under_default_over_override = encode_signed_tx(calls.clone(), 500_001);
let error = lowered
.validate_transaction_transfers(
&tx_under_default_over_override,
currency,
&expected,
CHAIN_ID,
true,
)
.unwrap_err();
assert!(error.to_string().contains("exceeds maximum 500000"));
let raised = build_method().with_fee_payer_policy_override(FeePayerPolicyOverride {
max_gas: Some(3_000_000),
..Default::default()
});
let tx_over_default_under_override = encode_signed_tx(calls, 2_500_000);
raised
.validate_transaction_transfers(
&tx_over_default_under_override,
currency,
&expected,
CHAIN_ID,
true,
)
.expect("override should raise ceiling above default");
}
#[test]
fn test_cosign_rejects_wrong_nonce_key() {
let client_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let mut tx = make_fee_payer_tx(60);
tx.nonce_key = U256::ZERO;
let encoded = sign_and_encode_0x78(tx, &client_signer);
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);
let result = method.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
);
let err = result.expect_err("should reject wrong nonce_key");
assert!(
err.to_string().contains("expiring nonce key"),
"error should mention expiring nonce key, got: {err}"
);
}
#[test]
fn test_cosign_rejects_missing_valid_before() {
let client_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let mut tx = make_fee_payer_tx(60);
tx.valid_before = None;
let encoded = sign_and_encode_0x78(tx, &client_signer);
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);
let result = method.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
);
let err = result.expect_err("should reject missing valid_before");
assert!(
err.to_string().contains("must include valid_before"),
"error should mention valid_before, got: {err}"
);
}
#[test]
fn test_cosign_rejects_access_list_signed_by_client() {
let client_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let mut tx = make_fee_payer_tx(60);
tx.access_list =
alloy::eips::eip2930::AccessList(vec![alloy::eips::eip2930::AccessListItem {
address: Address::repeat_byte(0xaa),
storage_keys: vec![],
}]);
let encoded = sign_and_encode_0x78(tx, &client_signer);
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);
let result = method.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
);
let err = result.expect_err("malicious access-list signature must not cosign");
assert!(
err.to_string().to_lowercase().contains("sender mismatch"),
"expected sender mismatch, got: {err}"
);
}
#[test]
fn test_cosign_rejects_expired_valid_before() {
let client_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let past = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs()
- 10;
let mut tx = make_fee_payer_tx(60);
tx.valid_before = NonZeroU64::new(past);
let encoded = sign_and_encode_0x78(tx, &client_signer);
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);
let result = method.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
);
let err = result.expect_err("should reject expired valid_before");
assert!(
err.to_string().contains("expired"),
"error should mention expiration, got: {err}"
);
}
#[test]
fn test_cosign_rejects_empty_input() {
let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);
let result = method.cosign_fee_payer_transaction(
&[],
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
);
let err = result.expect_err("should reject empty input");
assert!(
err.to_string().contains("Empty transaction bytes"),
"error should mention empty, got: {err}"
);
}
#[test]
fn test_cosign_rejects_wrong_type_byte() {
let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);
let result = method.cosign_fee_payer_transaction(
&[0x79, 0xc0], method.fee_payer_signer.as_ref().unwrap(),
fee_token,
);
let err = result.expect_err("should reject wrong type");
assert!(
err.to_string()
.contains("Expected fee payer envelope (0x78)"),
"error should mention 0x78, got: {err}"
);
}
#[tokio::test]
async fn test_store_rejects_replayed_hash() {
use crate::store::{MemoryStore, Store};
let store = Arc::new(MemoryStore::new());
let hash = "0xabc123def456";
let key = format!("mpp:charge:{hash}");
store
.put(&key, serde_json::Value::Bool(true))
.await
.unwrap();
let seen = store.get(&key).await.unwrap();
assert!(seen.is_some(), "hash should be recorded after first use");
let seen_again = store.get(&key).await.unwrap();
assert!(
seen_again.is_some(),
"replayed hash should be detected via store"
);
}
#[tokio::test]
async fn test_store_allows_unseen_hash() {
use crate::store::{MemoryStore, Store};
let store = Arc::new(MemoryStore::new());
let key = "mpp:charge:0xnever_seen";
let seen = store.get(key).await.unwrap();
assert!(seen.is_none(), "unseen hash should not be in store");
}
#[tokio::test]
async fn test_store_dedup_case_insensitive() {
use crate::store::{MemoryStore, Store};
let store = Arc::new(MemoryStore::new());
let mixed_case = "0xABCdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
let hash = mixed_case.parse::<B256>().unwrap();
let key1 = format!("mpp:charge:{:#x}", hash);
store
.put(&key1, serde_json::Value::Bool(true))
.await
.unwrap();
let lower_case = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
let hash2 = lower_case.parse::<B256>().unwrap();
let key2 = format!("mpp:charge:{:#x}", hash2);
let seen = store.get(&key2).await.unwrap();
assert!(
seen.is_some(),
"same hash with different case should be detected as replay"
);
let no_prefix = "ABCdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
let hash3 = no_prefix.parse::<B256>().unwrap();
let key3 = format!("mpp:charge:{:#x}", hash3);
assert_eq!(
key1, key3,
"0x-prefixed and unprefixed should produce same key"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn test_concurrent_replay_rejected_via_put_if_absent() {
use crate::store::{MemoryStore, Store};
use std::sync::Arc;
let store: Arc<dyn Store> = Arc::new(MemoryStore::new());
let hash = "0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890";
let key = format!("mpp:charge:{}", hash.parse::<B256>().unwrap());
let start = Arc::new(tokio::sync::Barrier::new(8));
let mut handles = Vec::new();
for _ in 0..8 {
let store = store.clone();
let key = key.clone();
let start = start.clone();
handles.push(tokio::spawn(async move {
start.wait().await;
store
.put_if_absent(&key, serde_json::Value::Bool(true))
.await
.unwrap()
}));
}
let mut claims = 0;
for h in handles {
if h.await.unwrap() {
claims += 1;
}
}
assert_eq!(
claims, 1,
"exactly one concurrent verifier may claim the tx hash"
);
}
#[tokio::test]
async fn test_store_dedup_different_hashes_independent() {
use crate::store::{MemoryStore, Store};
let store = Arc::new(MemoryStore::new());
store
.put("mpp:charge:0xhash_a", serde_json::Value::Bool(true))
.await
.unwrap();
let seen = store.get("mpp:charge:0xhash_b").await.unwrap();
assert!(seen.is_none(), "different hash should not be blocked");
let seen = store.get("mpp:charge:0xhash_a").await.unwrap();
assert!(seen.is_some(), "original hash should still be recorded");
}
#[tokio::test]
async fn test_proof_credential_replay_rejected() {
use crate::store::MemoryStore;
let signer = alloy::signers::local::PrivateKeySigner::random();
let request = test_charge_request_with_amount("0");
let challenge = test_proof_challenge(&request);
let signature = proof::sign_proof(&signer, 42431, &challenge.id, &challenge.realm)
.await
.unwrap();
let credential = PaymentCredential::with_source(
challenge.to_echo(),
proof::proof_source(signer.address(), 42431),
crate::protocol::core::PaymentPayload::proof(signature.clone()),
);
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let store = Arc::new(MemoryStore::new());
let method = ChargeMethod::new(provider).with_store(store.clone());
method.cached_chain_id.set(42431).unwrap();
let receipt = method.verify(&credential, &request).await.unwrap();
assert_eq!(receipt.reference, challenge.id);
let fingerprint =
proof::proof_fingerprint(&challenge.id, signer.address(), 42431, &signature).unwrap();
let key = format!("mpp:proof:{:x}", fingerprint);
assert!(store.get(&key).await.unwrap().is_some());
let err = method.verify(&credential, &request).await.unwrap_err();
assert!(err.to_string().contains("already been used"));
}
#[tokio::test]
async fn test_proof_credential_replay_rejected_across_spellings() {
use crate::store::MemoryStore;
let signer = alloy::signers::local::PrivateKeySigner::random();
let request = test_charge_request_with_amount("0");
let challenge = test_proof_challenge(&request);
let signature = proof::sign_proof(&signer, 42431, &challenge.id, &challenge.realm)
.await
.unwrap();
let credential = PaymentCredential::with_source(
challenge.to_echo(),
proof::proof_source(signer.address(), 42431),
crate::protocol::core::PaymentPayload::proof(signature.clone()),
);
let upper_sig = signature.to_uppercase().replace("0X", "0x");
let lower_source = format!("did:pkh:eip155:42431:0x{:x}", signer.address());
assert_ne!(
signature, upper_sig,
"test must actually mutate the spelling"
);
let credential_variant = PaymentCredential::with_source(
challenge.to_echo(),
lower_source,
crate::protocol::core::PaymentPayload::proof(upper_sig),
);
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let store = Arc::new(MemoryStore::new());
let method = ChargeMethod::new(provider).with_store(store);
method.cached_chain_id.set(42431).unwrap();
method.verify(&credential, &request).await.unwrap();
let err = method
.verify(&credential_variant, &request)
.await
.unwrap_err();
assert!(
err.to_string().contains("already been used"),
"re-encoded proof must hit the same replay key, got: {err}"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn test_concurrent_proof_submissions_only_one_succeeds() {
use crate::store::{MemoryStore, Store, StoreError};
use std::future::Future;
use std::pin::Pin;
use std::time::Duration;
struct SlowStore {
inner: MemoryStore,
delay: Duration,
}
impl Store for SlowStore {
fn get(
&self,
key: &str,
) -> Pin<
Box<dyn Future<Output = Result<Option<serde_json::Value>, StoreError>> + Send + '_>,
> {
self.inner.get(key)
}
fn put(
&self,
key: &str,
value: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<(), StoreError>> + Send + '_>> {
self.inner.put(key, value)
}
fn delete(
&self,
key: &str,
) -> Pin<Box<dyn Future<Output = Result<(), StoreError>> + Send + '_>> {
self.inner.delete(key)
}
fn put_if_absent(
&self,
key: &str,
value: serde_json::Value,
) -> Pin<Box<dyn Future<Output = Result<bool, StoreError>> + Send + '_>> {
let key = key.to_string();
let delay = self.delay;
Box::pin(async move {
tokio::time::sleep(delay).await;
self.inner.put_if_absent(&key, value).await
})
}
}
let signer = alloy::signers::local::PrivateKeySigner::random();
let request = test_charge_request_with_amount("0");
let challenge = test_proof_challenge(&request);
let signature = proof::sign_proof(&signer, 42431, &challenge.id, &challenge.realm)
.await
.unwrap();
let credential = PaymentCredential::with_source(
challenge.to_echo(),
proof::proof_source(signer.address(), 42431),
crate::protocol::core::PaymentPayload::proof(signature),
);
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let store = Arc::new(SlowStore {
inner: MemoryStore::new(),
delay: Duration::from_millis(50),
});
let method = ChargeMethod::new(provider).with_store(store);
method.cached_chain_id.set(42431).unwrap();
let m1 = method.clone();
let c1 = credential.clone();
let r1 = request.clone();
let t1 = tokio::spawn(async move { m1.verify(&c1, &r1).await });
let m2 = method.clone();
let c2 = credential.clone();
let r2 = request.clone();
let t2 = tokio::spawn(async move { m2.verify(&c2, &r2).await });
let res1 = t1.await.unwrap();
let res2 = t2.await.unwrap();
let successes = [res1.is_ok(), res2.is_ok()]
.into_iter()
.filter(|ok| *ok)
.count();
assert_eq!(
successes, 1,
"exactly one concurrent proof submission must succeed"
);
let err = res1.err().or(res2.err()).unwrap();
assert!(err.to_string().contains("already been used"));
}
#[test]
fn test_charge_method_new_has_empty_chain_id_cache() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
assert!(
method.cached_chain_id.get().is_none(),
"cache should be empty on construction"
);
}
#[test]
fn test_charge_method_clone_shares_chain_id_cache() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
method.cached_chain_id.set(42431).unwrap();
let cloned = method.clone();
assert_eq!(
cloned.cached_chain_id.get(),
Some(&42431),
"clone should share the cached chain ID"
);
}
#[tokio::test]
async fn test_cached_chain_id_survives_across_verify_calls() {
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider);
let request = test_charge_request_with_amount("0");
let challenge = test_proof_challenge(&request);
let credential = PaymentCredential::new(
challenge.to_echo(),
crate::protocol::core::PaymentPayload::hash("0xdeadbeef"),
);
let _ = method.verify(&credential, &request).await;
assert!(
method.cached_chain_id.get().is_none(),
"failed RPC should not populate cache"
);
method.cached_chain_id.set(42431).unwrap();
assert_eq!(method.cached_chain_id.get(), Some(&42431));
}
#[tokio::test]
async fn test_cached_chain_id_oncecell_rejects_second_init() {
let cell = Arc::new(OnceCell::new());
cell.set(42431).unwrap();
let result = cell.set(9999);
assert!(result.is_err(), "OnceCell should reject second set");
assert_eq!(
cell.get(),
Some(&42431),
"original value should be retained"
);
}
fn make_cosign_method(
fee_payer_policy_override: Option<FeePayerPolicyOverride>,
) -> (
ChargeMethod<impl alloy::providers::Provider<TempoNetwork> + Clone + 'static>,
alloy::signers::local::PrivateKeySigner,
Address,
) {
let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let mut method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer.clone());
if let Some(overrides) = fee_payer_policy_override {
method = method.with_fee_payer_policy_override(overrides);
}
(method, fee_payer_signer, fee_token)
}
#[test]
fn test_cosign_rejects_excessive_max_fee_per_gas() {
let overrides = FeePayerPolicyOverride {
max_fee_per_gas: Some(500_000_000), ..Default::default()
};
let (method, client_signer, fee_token) = make_cosign_method(Some(overrides));
let mut tx = make_fee_payer_tx(60);
tx.max_fee_per_gas = 600_000_000; let encoded = sign_and_encode_0x78(tx, &client_signer);
let err = method
.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect_err("should reject excessive max_fee_per_gas");
assert!(err.to_string().contains("max_fee_per_gas"), "got: {err}");
}
#[test]
fn test_cosign_rejects_excessive_max_priority_fee_per_gas() {
let overrides = FeePayerPolicyOverride {
max_priority_fee_per_gas: Some(100_000_000), ..Default::default()
};
let (method, client_signer, fee_token) = make_cosign_method(Some(overrides));
let mut tx = make_fee_payer_tx(60);
tx.max_priority_fee_per_gas = 200_000_000; let encoded = sign_and_encode_0x78(tx, &client_signer);
let err = method
.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect_err("should reject excessive max_priority_fee_per_gas");
assert!(
err.to_string().contains("max_priority_fee_per_gas"),
"got: {err}"
);
}
#[test]
fn test_cosign_rejects_excessive_total_fee() {
let overrides = FeePayerPolicyOverride {
max_total_fee: Some(500_000_000_000_000), ..Default::default()
};
let (method, client_signer, fee_token) = make_cosign_method(Some(overrides));
let mut tx = make_fee_payer_tx(60);
tx.gas_limit = 1_000_000;
tx.max_fee_per_gas = 1_000_000_000;
let encoded = sign_and_encode_0x78(tx, &client_signer);
let err = method
.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect_err("should reject excessive total fee");
assert!(err.to_string().contains("Total fee"), "got: {err}");
}
#[test]
fn test_cosign_rejects_excessive_total_fee_under_gas_limit_and_fee_per_gas() {
let (method, client_signer, fee_token) = make_cosign_method(None);
let mut tx = make_fee_payer_tx(60);
tx.gas_limit = 1_999_999;
tx.max_fee_per_gas = 99_000_000_000;
let encoded = sign_and_encode_0x78(tx, &client_signer);
let err = method
.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect_err("should reject excessive total fee");
assert!(err.to_string().contains("Total fee"), "got: {err}");
}
#[test]
fn test_cosign_rejects_excessive_validity_window() {
let overrides = FeePayerPolicyOverride {
max_validity_window_seconds: Some(30), ..Default::default()
};
let (method, client_signer, fee_token) = make_cosign_method(Some(overrides));
let tx = make_fee_payer_tx(120);
let encoded = sign_and_encode_0x78(tx, &client_signer);
let err = method
.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect_err("should reject excessive validity window");
assert!(
err.to_string().contains("valid_before window"),
"got: {err}"
);
}
#[test]
fn test_policy_override_all_fields_applied() {
let overrides = FeePayerPolicyOverride {
max_gas: Some(2_000_000),
max_fee_per_gas: Some(20_000_000_000),
max_priority_fee_per_gas: Some(2_000_000_000),
max_total_fee: Some(40_000_000_000_000_000),
max_validity_window_seconds: Some(600),
};
let (method, client_signer, fee_token) = make_cosign_method(Some(overrides));
let mut tx = make_fee_payer_tx(60);
tx.gas_limit = 1_500_000; tx.max_fee_per_gas = 15_000_000_000; tx.max_priority_fee_per_gas = 1_500_000_000; let encoded = sign_and_encode_0x78(tx, &client_signer);
method
.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect("cosign should succeed when all fields within override limits");
}
#[test]
fn test_cosign_rejects_priority_fee_above_max_fee() {
let (method, client_signer, fee_token) = make_cosign_method(None);
let mut tx = make_fee_payer_tx(60);
tx.max_fee_per_gas = 1_000_000_000; tx.max_priority_fee_per_gas = 2_000_000_000; let encoded = sign_and_encode_0x78(tx, &client_signer);
let err = method
.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect_err("priority fee above max fee must be rejected");
assert!(
err.to_string()
.contains("max_priority_fee_per_gas 2000000000 exceeds max_fee_per_gas"),
"got: {err}"
);
}
#[test]
fn test_policy_moderato_default_raises_priority_fee() {
let tempo_mainnet = FeePayerPolicy::resolve(CHAIN_ID, None);
let moderato = FeePayerPolicy::resolve(MODERATO_CHAIN_ID, None);
assert_eq!(tempo_mainnet.max_priority_fee_per_gas, 10_000_000_000);
assert_eq!(moderato.max_priority_fee_per_gas, 50_000_000_000);
assert_eq!(moderato.max_gas, tempo_mainnet.max_gas);
assert_eq!(moderato.max_fee_per_gas, tempo_mainnet.max_fee_per_gas);
assert_eq!(moderato.max_total_fee, tempo_mainnet.max_total_fee);
assert_eq!(
moderato.max_validity_window_seconds,
tempo_mainnet.max_validity_window_seconds
);
assert!(FeePayerPolicy::default_allows_fee_token(
CHAIN_ID,
KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap()
));
assert!(!FeePayerPolicy::default_allows_fee_token(
CHAIN_ID,
KnownTempoNetwork::Moderato
.default_currency()
.parse::<Address>()
.unwrap()
));
assert!(FeePayerPolicy::default_allows_fee_token(
MODERATO_CHAIN_ID,
KnownTempoNetwork::Moderato
.default_currency()
.parse::<Address>()
.unwrap()
));
assert!(!FeePayerPolicy::default_allows_fee_token(
MODERATO_CHAIN_ID,
KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap()
));
assert!(FeePayerPolicy::default_allows_fee_token(
31337,
DEFAULT_CURRENCY_TESTNET.parse::<Address>().unwrap()
));
}
#[test]
fn test_cosign_rejects_non_allowlisted_fee_token() {
let allowed_fee_tokens = vec![KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap()];
let (method, client_signer, _) = make_cosign_method(None);
let method = method.with_fee_payer_allowed_fee_tokens(allowed_fee_tokens);
let tx = make_fee_payer_tx(60);
let encoded = sign_and_encode_0x78(tx, &client_signer);
let err = method
.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
KnownTempoNetwork::Moderato
.default_currency()
.parse::<Address>()
.unwrap(),
)
.expect_err("cosign should reject non-allowlisted fee token");
assert!(
err.to_string()
.contains("is not allowed by fee payer policy"),
"got: {err}"
);
}
#[test]
fn test_cosign_uses_custom_fee_token_allowlist() {
let allowed_fee_tokens = vec![KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap()];
let (method, client_signer, fee_token) = make_cosign_method(None);
let method = method.with_fee_payer_allowed_fee_tokens(allowed_fee_tokens);
let tx = make_fee_payer_tx(60);
let encoded = sign_and_encode_0x78(tx, &client_signer);
method
.cosign_fee_payer_transaction(
&encoded,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect("cosign should accept a custom allowlisted fee token");
}
#[test]
fn test_cosign_strips_tampered_access_list() {
use alloy::eips::eip2930::{AccessList, AccessListItem};
use alloy::primitives::B256;
use alloy::signers::local::PrivateKeySigner;
use alloy::signers::SignerSync;
use crate::protocol::methods::tempo::FeePayerEnvelope78;
let client_signer = PrivateKeySigner::random();
let fee_payer_signer = PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let tx = make_fee_payer_tx(60);
let signature: tempo_primitives::transaction::TempoSignature = client_signer
.sign_hash_sync(&tx.signature_hash())
.unwrap()
.into();
let envelope = FeePayerEnvelope78 {
chain_id: tx.chain_id,
max_priority_fee_per_gas: tx.max_priority_fee_per_gas,
max_fee_per_gas: tx.max_fee_per_gas,
gas_limit: tx.gas_limit,
calls: tx.calls.clone(),
access_list: AccessList(vec![AccessListItem {
address: Address::repeat_byte(0xaa),
storage_keys: vec![B256::ZERO],
}]),
nonce_key: tx.nonce_key,
nonce: tx.nonce,
valid_before: tx.valid_before.map(|v| v.get()),
valid_after: tx.valid_after.map(|v| v.get()),
fee_token: tx.fee_token,
sender: client_signer.address(),
tempo_authorization_list: tx.tempo_authorization_list.clone(),
key_authorization: tx.key_authorization.clone(),
signature,
};
let envelope_bytes = envelope.encoded_envelope();
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);
let cosigned = method
.cosign_fee_payer_transaction(
&envelope_bytes,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect("sponsor must cosign tampered envelope");
let signed = tempo_primitives::AASigned::decode_2718(&mut cosigned.as_slice()).unwrap();
assert!(
signed.tx().access_list.is_empty(),
"broadcast tx must have empty access list"
);
assert_eq!(signed.tx().fee_token, Some(fee_token));
}
fn make_cosigned_fee_payer_tx() -> Vec<u8> {
use super::super::FeePayerEnvelope78;
use alloy::signers::SignerSync;
let client_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let tx = make_fee_payer_tx(60);
let sig_hash = tx.signature_hash();
let sig = client_signer.sign_hash_sync(&sig_hash).unwrap();
let signature: tempo_primitives::transaction::TempoSignature = sig.into();
let envelope = FeePayerEnvelope78::from_signing_tx(tx, client_signer.address(), signature)
.encoded_envelope();
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);
method
.cosign_fee_payer_transaction(
&envelope,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect("cosign should succeed")
}
fn make_keychain_cosigned_fee_payer_tx() -> (Vec<u8>, Address, Address) {
use super::super::FeePayerEnvelope78;
use alloy::signers::SignerSync;
use tempo_primitives::transaction::{
KeychainSignature, PrimitiveSignature, TempoSignature,
};
let wallet = Address::repeat_byte(0xab);
let access_key_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_payer_signer = alloy::signers::local::PrivateKeySigner::random();
let fee_token = KnownTempoNetwork::Mainnet
.default_currency()
.parse::<Address>()
.unwrap();
let tx = make_fee_payer_tx(60);
let sig_hash = tx.signature_hash();
let inner = access_key_signer.sign_hash_sync(&sig_hash).unwrap();
let keychain_sig = KeychainSignature::new_v1(wallet, PrimitiveSignature::Secp256k1(inner));
let signature = TempoSignature::Keychain(keychain_sig);
let envelope =
FeePayerEnvelope78::from_signing_tx(tx, wallet, signature).encoded_envelope();
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_http("http://127.0.0.1:1".parse().unwrap());
let method = ChargeMethod::new(provider).with_fee_payer(fee_payer_signer);
let cosigned = method
.cosign_fee_payer_transaction(
&envelope,
method.fee_payer_signer.as_ref().unwrap(),
fee_token,
)
.expect("cosign should succeed");
(cosigned, wallet, access_key_signer.address())
}
#[test]
fn test_build_simulate_payload_includes_keychain_fields() {
let (cosigned, wallet, access_key) = make_keychain_cosigned_fee_payer_tx();
let payload =
ChargeMethod::<alloy::providers::RootProvider<tempo_alloy::TempoNetwork>>::build_simulate_payload(
&cosigned,
)
.expect("payload must build");
let call = &payload.block_state_calls[0].calls[0];
assert_eq!(call.inner.from, Some(wallet), "from must be the wallet");
assert_eq!(
call.key_id,
Some(access_key),
"keyId must be the access key, not the wallet"
);
assert_eq!(
call.key_type,
Some(tempo_primitives::SignatureType::Secp256k1)
);
assert_ne!(wallet, access_key);
let p = serde_json::to_value(&payload).unwrap();
let wire_call = &p["blockStateCalls"][0]["calls"][0];
assert_eq!(
wire_call["keyId"].as_str().unwrap().to_lowercase(),
format!("{:#x}", access_key),
);
assert!(wire_call["keyType"].is_string() || wire_call["keyType"].is_number());
}
#[test]
fn test_build_simulate_payload_omits_keychain_for_primitive_sig() {
let cosigned = make_cosigned_fee_payer_tx();
let payload =
ChargeMethod::<alloy::providers::RootProvider<tempo_alloy::TempoNetwork>>::build_simulate_payload(
&cosigned,
)
.expect("payload must build");
let call = &payload.block_state_calls[0].calls[0];
assert!(call.key_id.is_none(), "plain EOA tx must not set keyId");
assert_eq!(
call.key_type,
Some(tempo_primitives::SignatureType::Secp256k1),
"primitive tx must advertise its keyType for gas sizing"
);
assert!(
call.key_data.is_none(),
"secp256k1 tx has no WebAuthn auth data"
);
}
#[test]
fn test_build_simulate_payload_request_abi() {
let cosigned = make_cosigned_fee_payer_tx();
let signed = tempo_primitives::AASigned::decode_2718(&mut cosigned.as_slice()).unwrap();
let expected_from = signed.recover_signer().unwrap();
let expected_calls = signed.tx().calls.clone();
let payload =
ChargeMethod::<alloy::providers::RootProvider<tempo_alloy::TempoNetwork>>::build_simulate_payload(
&cosigned,
)
.expect("payload must build");
let rebuilt = payload.block_state_calls[0].calls[0]
.clone()
.build_aa()
.expect("request must rebuild into an AA tx");
assert_eq!(
rebuilt.calls, expected_calls,
"rebuilt batch must match the signed tx's calls"
);
let params = serde_json::to_value((payload,)).unwrap();
let arr = params.as_array().expect("params serialize to a JSON array");
assert_eq!(
arr.len(),
1,
"tempo_simulateV1 takes a single payload param"
);
let p = &arr[0];
assert_eq!(p["validation"], serde_json::json!(false));
let call = &p["blockStateCalls"][0]["calls"][0];
assert_eq!(
call["from"].as_str().unwrap().to_lowercase(),
format!("{:#x}", expected_from),
"request must set the recovered sender as `from`"
);
assert!(
call["to"].is_string(),
"payment call must be folded into `to`: {call}"
);
assert!(
call["calls"]
.as_array()
.map(|c| c.is_empty())
.unwrap_or(true),
"single-call request must leave `calls` empty: {call}"
);
assert!(call["feeToken"].is_string(), "feeToken must be present");
assert!(
call["feePayerSignature"].is_string() || call["feePayerSignature"].is_object(),
"feePayerSignature must be present: {call}"
);
assert!(call["nonceKey"].is_string(), "nonceKey must be present");
assert!(
call["validBefore"].is_string(),
"validBefore must be present"
);
}
#[test]
fn test_build_simulate_payload_preserves_multi_call_order() {
use alloy::eips::Encodable2718;
use alloy::signers::SignerSync;
let signer = alloy::signers::local::PrivateKeySigner::random();
let calls = vec![
tempo_primitives::transaction::Call {
to: TxKind::Call(Address::repeat_byte(0x11)),
value: U256::ZERO,
input: Bytes::from(vec![0xaa]),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(Address::repeat_byte(0x22)),
value: U256::from(7u64),
input: Bytes::from(vec![0xbb, 0xbb]),
},
tempo_primitives::transaction::Call {
to: TxKind::Call(Address::repeat_byte(0x33)),
value: U256::ZERO,
input: Bytes::from(vec![0xcc, 0xcc, 0xcc]),
},
];
let mut tx = make_fee_payer_tx(60);
tx.calls = calls.clone();
let signature: tempo_primitives::transaction::TempoSignature =
signer.sign_hash_sync(&tx.signature_hash()).unwrap().into();
let signed_bytes = tx.into_signed(signature).encoded_2718();
let payload =
ChargeMethod::<alloy::providers::RootProvider<tempo_alloy::TempoNetwork>>::build_simulate_payload(
&signed_bytes,
)
.expect("payload must build");
let req = &payload.block_state_calls[0].calls[0];
assert_eq!(req.calls, calls[..2], "first N-1 calls stay in `calls`");
assert_eq!(req.inner.to, Some(calls[2].to), "last call folds into `to`");
assert_eq!(req.inner.value, Some(calls[2].value));
let rebuilt = req.clone().build_aa().expect("must rebuild");
assert_eq!(
rebuilt.calls, calls,
"reconstructed batch must preserve the original order"
);
}
#[tokio::test]
async fn test_simulate_before_broadcast_rejects_revert() {
use alloy::providers::mock::Asserter;
let cosigned = make_cosigned_fee_payer_tx();
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!({
"blocks": [{
"calls": [{
"returnData": "0x",
"gasUsed": "0x5208",
"status": "0x0",
"error": { "code": 3, "message": "execution reverted" }
}]
}]
}));
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_mocked_client(asserter);
let method = ChargeMethod::new(provider);
let err = method
.simulate_before_broadcast(&cosigned)
.await
.expect_err("reverting simulation must be rejected");
assert!(
err.to_string().contains("would revert"),
"unexpected error: {err}"
);
assert!(err.to_string().contains("execution reverted"));
}
#[tokio::test]
async fn test_simulate_before_broadcast_accepts_success() {
use alloy::providers::mock::Asserter;
let cosigned = make_cosigned_fee_payer_tx();
let asserter = Asserter::new();
asserter.push_success(&serde_json::json!({
"blocks": [{
"calls": [{
"returnData": "0x",
"gasUsed": "0x5208",
"status": "0x1"
}]
}]
}));
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_mocked_client(asserter);
let method = ChargeMethod::new(provider);
method
.simulate_before_broadcast(&cosigned)
.await
.expect("successful simulation must pass");
}
#[tokio::test]
async fn test_simulate_before_broadcast_fails_closed_on_rpc_error() {
use alloy::providers::mock::Asserter;
let cosigned = make_cosigned_fee_payer_tx();
let asserter = Asserter::new();
asserter.push_failure_msg("tempo_simulateV1 unavailable");
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_mocked_client(asserter);
let method = ChargeMethod::new(provider);
let err = method
.simulate_before_broadcast(&cosigned)
.await
.expect_err("RPC failure must fail closed");
assert!(
err.to_string().contains("Pre-broadcast simulation failed"),
"unexpected error: {err}"
);
}
#[tokio::test]
async fn test_simulate_before_broadcast_skips_when_method_not_found() {
use alloy::providers::mock::Asserter;
let cosigned = make_cosigned_fee_payer_tx();
let asserter = Asserter::new();
asserter.push_failure(alloy_json_rpc::ErrorPayload {
code: -32601,
message: "the method tempo_simulateV1 does not exist/is not available".into(),
data: None,
});
let provider =
alloy::providers::ProviderBuilder::new_with_network::<tempo_alloy::TempoNetwork>()
.connect_mocked_client(asserter);
let method = ChargeMethod::new(provider);
method
.simulate_before_broadcast(&cosigned)
.await
.expect("method-not-found must skip the check, not fail");
}
}