pub mod tx_builder;
use std::num::NonZeroU64;
use alloy::primitives::{Address, TxKind, U256};
use tempo_primitives::transaction::{Call, SignedKeyAuthorization};
use self::tx_builder::{build_charge_credential, build_tempo_tx, estimate_gas, TempoTxOptions};
use crate::client::tempo::signing::{
sign_and_encode_async, sign_and_encode_fee_payer_envelope_async, TempoSigningMode,
};
use crate::error::{MppError, ResultExt};
use crate::protocol::core::{PaymentChallenge, PaymentCredential, PaymentPayload};
use crate::protocol::intents::ChargeRequest;
use crate::protocol::methods::tempo::charge::{parse_memo_bytes_checked, TempoChargeExt};
use crate::protocol::methods::tempo::network::TempoNetwork;
use crate::protocol::methods::tempo::proof;
use crate::protocol::methods::tempo::transfers::get_transfers;
use crate::protocol::methods::tempo::types::Split;
use crate::protocol::methods::tempo::CHAIN_ID;
use alloy::sol_types::SolCall;
use tempo_alloy::contracts::precompiles::ITIP20;
use tempo_alloy::rpc::TempoTransactionRequest;
const EXPIRING_NONCE_KEY: U256 = U256::MAX;
const FEE_PAYER_VALID_BEFORE_SECS: u64 = 25;
fn encode_transfer(
recipient: Address,
amount: U256,
memo: Option<[u8; 32]>,
) -> alloy::primitives::Bytes {
if let Some(memo_bytes) = memo {
alloy::primitives::Bytes::from(
ITIP20::transferWithMemoCall {
to: recipient,
amount,
memo: memo_bytes.into(),
}
.abi_encode(),
)
} else {
alloy::primitives::Bytes::from(
ITIP20::transferCall {
to: recipient,
amount,
}
.abi_encode(),
)
}
}
#[derive(Debug, Clone)]
pub struct TempoCharge {
challenge: PaymentChallenge,
recipient: Address,
currency: Address,
amount: U256,
memo: Option<[u8; 32]>,
chain_id: u64,
fee_payer: bool,
splits: Option<Vec<Split>>,
calls: Option<Vec<Call>>,
}
impl TempoCharge {
pub fn from_challenge(challenge: &PaymentChallenge) -> Result<Self, MppError> {
challenge.validate_for_charge("tempo")?;
let charge_req: ChargeRequest = challenge.request.decode()?;
let details = charge_req.tempo_method_details()?;
let recipient = charge_req.recipient_address()?;
let currency = charge_req.currency_address()?;
let amount = charge_req.amount_u256()?;
let memo = parse_memo_bytes_checked(details.memo.as_deref())?;
let chain_id = details.chain_id.unwrap_or(CHAIN_ID);
let fee_payer = details.fee_payer();
let splits = details.splits;
get_transfers(amount, recipient, memo, splits.as_deref())?;
Ok(Self {
challenge: challenge.clone(),
recipient,
currency,
amount,
memo,
chain_id,
fee_payer,
splits,
calls: None,
})
}
pub fn chain_id(&self) -> u64 {
self.chain_id
}
pub fn currency(&self) -> Address {
self.currency
}
pub fn recipient(&self) -> Address {
self.recipient
}
pub fn amount(&self) -> U256 {
self.amount
}
pub fn memo(&self) -> Option<[u8; 32]> {
self.memo
}
pub fn with_memo(mut self, memo: [u8; 32]) -> Self {
self.memo = Some(memo);
self
}
pub fn fee_payer(&self) -> bool {
self.fee_payer
}
pub fn splits(&self) -> Option<&[Split]> {
self.splits.as_deref()
}
fn build_transfer_calls(&self) -> Result<Vec<Call>, MppError> {
let transfers = get_transfers(
self.amount,
self.recipient,
self.memo,
self.splits.as_deref(),
)?;
Ok(transfers
.into_iter()
.map(|t| Call {
to: TxKind::Call(self.currency),
value: U256::ZERO,
input: encode_transfer(t.recipient, t.amount, t.memo),
})
.collect())
}
pub fn with_prepended_call(mut self, call: Call) -> Result<Self, MppError> {
if self.calls.is_none() {
self.calls = Some(self.build_transfer_calls()?);
}
self.calls.as_mut().unwrap().insert(0, call);
Ok(self)
}
pub async fn sign(
self,
signer: &(impl alloy::signers::Signer + Clone),
) -> Result<SignedTempoCharge, MppError> {
self.sign_with_options(signer, SignOptions::default()).await
}
pub async fn sign_with_options(
self,
signer: &(impl alloy::signers::Signer + Clone),
options: SignOptions,
) -> Result<SignedTempoCharge, MppError> {
if self.amount.is_zero() {
let signing_mode = options.signing_mode.unwrap_or_default();
let from = signing_mode.from_address(signer.address());
let credential = PaymentCredential::with_source(
self.challenge.to_echo(),
proof::proof_source(from, self.chain_id),
PaymentPayload::proof(
proof::sign_proof(signer, self.chain_id, &self.challenge.id, from).await?,
),
);
return Ok(SignedTempoCharge {
credential,
tx_bytes: None,
chain_id: self.chain_id,
from,
});
}
let signing_mode = options.signing_mode.unwrap_or_default();
let from = signing_mode.from_address(signer.address());
let rpc_url = match options.rpc_url {
Some(url) => url.parse().mpp_config("invalid RPC URL")?,
None => {
let network = TempoNetwork::from_chain_id(self.chain_id).ok_or_else(|| {
MppError::InvalidConfig(format!(
"unknown chain ID {}: provide rpc_url in SignOptions",
self.chain_id
))
})?;
network
.default_rpc_url()
.parse()
.mpp_config("invalid RPC URL")?
}
};
let provider =
alloy::providers::RootProvider::<tempo_alloy::TempoNetwork>::new_http(rpc_url);
let calls = match self.calls {
Some(c) => c,
None => self.build_transfer_calls()?,
};
let fee_token = options.fee_token.unwrap_or(self.currency);
let max_fee_per_gas = options
.max_fee_per_gas
.unwrap_or(crate::client::tempo::MAX_FEE_PER_GAS);
let max_priority_fee_per_gas = options
.max_priority_fee_per_gas
.unwrap_or(crate::client::tempo::MAX_PRIORITY_FEE_PER_GAS);
let nonce = options.nonce.unwrap_or(0);
let nonce_key = options.nonce_key.unwrap_or(EXPIRING_NONCE_KEY);
let valid_before = options.valid_before.or_else(|| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Some(now.saturating_add(FEE_PAYER_VALID_BEFORE_SECS))
});
let gas_limit = if let Some(gas) = options.gas_limit {
gas
} else if self.fee_payer {
1_000_000
} else {
let key_auth = options
.key_authorization
.as_deref()
.or_else(|| signing_mode.key_authorization());
let mut req = TempoTransactionRequest {
calls: calls.clone(),
key_authorization: key_auth.cloned(),
..Default::default()
}
.with_fee_token(fee_token)
.with_nonce_key(nonce_key);
if let Some(vb) = valid_before.and_then(NonZeroU64::new) {
req = req.with_valid_before(vb);
}
req.inner.from = Some(from);
req.inner.chain_id = Some(self.chain_id);
req.inner.nonce = Some(nonce);
req.inner.max_fee_per_gas = Some(max_fee_per_gas);
req.inner.max_priority_fee_per_gas = Some(max_priority_fee_per_gas);
estimate_gas(&provider, req).await?
};
let tx_key_authorization = options
.key_authorization
.as_deref()
.or_else(|| signing_mode.key_authorization())
.cloned();
let tx = build_tempo_tx(TempoTxOptions {
calls,
chain_id: self.chain_id,
fee_token,
nonce,
nonce_key,
gas_limit,
max_fee_per_gas,
max_priority_fee_per_gas,
fee_payer: self.fee_payer,
valid_before,
key_authorization: tx_key_authorization,
});
let tx_bytes = if self.fee_payer {
sign_and_encode_fee_payer_envelope_async(tx, signer, &signing_mode).await?
} else {
sign_and_encode_async(tx, signer, &signing_mode).await?
};
let credential = build_charge_credential(&self.challenge, &tx_bytes, self.chain_id, from);
Ok(SignedTempoCharge {
credential,
tx_bytes: Some(tx_bytes),
chain_id: self.chain_id,
from,
})
}
}
#[derive(Debug, Clone, Default)]
pub struct SignOptions {
pub rpc_url: Option<String>,
pub nonce: Option<u64>,
pub nonce_key: Option<U256>,
pub gas_limit: Option<u64>,
pub max_fee_per_gas: Option<u128>,
pub max_priority_fee_per_gas: Option<u128>,
pub fee_token: Option<Address>,
pub signing_mode: Option<TempoSigningMode>,
pub key_authorization: Option<Box<SignedKeyAuthorization>>,
pub valid_before: Option<u64>,
}
#[derive(Debug)]
pub struct SignedTempoCharge {
credential: PaymentCredential,
tx_bytes: Option<Vec<u8>>,
chain_id: u64,
from: Address,
}
impl SignedTempoCharge {
pub fn into_credential(self) -> PaymentCredential {
self.credential
}
pub fn tx_bytes(&self) -> &[u8] {
self.tx_bytes.as_deref().unwrap_or(&[])
}
pub fn chain_id(&self) -> u64 {
self.chain_id
}
pub fn from_address(&self) -> Address {
self.from
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::protocol::core::Base64UrlJson;
fn test_challenge() -> PaymentChallenge {
let request_json = serde_json::json!({
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
"methodDetails": {
"chainId": 42431
}
});
let request = Base64UrlJson::from_value(&request_json).unwrap();
PaymentChallenge::new("test-id", "api.example.com", "tempo", "charge", request)
}
#[test]
fn test_from_challenge_parses_fields() {
let challenge = test_challenge();
let charge = TempoCharge::from_challenge(&challenge).unwrap();
assert_eq!(charge.chain_id(), 42431);
assert_eq!(
charge.currency(),
"0x20c0000000000000000000000000000000000000"
.parse::<Address>()
.unwrap()
);
assert_eq!(
charge.recipient(),
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2"
.parse::<Address>()
.unwrap()
);
assert_eq!(charge.amount(), U256::from(1_000_000u64));
assert!(!charge.fee_payer());
}
#[test]
fn test_from_challenge_wrong_method() {
let request = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "stripe", "charge", request);
assert!(TempoCharge::from_challenge(&challenge).is_err());
}
#[test]
fn test_from_challenge_wrong_intent() {
let request = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "session", request);
assert!(TempoCharge::from_challenge(&challenge).is_err());
}
#[test]
fn test_from_challenge_with_fee_payer() {
let request_json = serde_json::json!({
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
"methodDetails": {
"chainId": 42431,
"feePayer": true
}
});
let request = Base64UrlJson::from_value(&request_json).unwrap();
let challenge =
PaymentChallenge::new("test-id", "api.example.com", "tempo", "charge", request);
let charge = TempoCharge::from_challenge(&challenge).unwrap();
assert!(charge.fee_payer());
}
#[test]
fn test_from_challenge_default_chain_id() {
let request_json = serde_json::json!({
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
});
let request = Base64UrlJson::from_value(&request_json).unwrap();
let challenge =
PaymentChallenge::new("test-id", "api.example.com", "tempo", "charge", request);
let charge = TempoCharge::from_challenge(&challenge).unwrap();
assert_eq!(charge.chain_id(), CHAIN_ID);
}
#[test]
fn test_from_challenge_with_memo() {
let request_json = serde_json::json!({
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
"methodDetails": {
"chainId": 42431,
"memo": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
}
});
let request = Base64UrlJson::from_value(&request_json).unwrap();
let challenge =
PaymentChallenge::new("test-id", "api.example.com", "tempo", "charge", request);
let charge = TempoCharge::from_challenge(&challenge).unwrap();
assert!(charge.memo.is_some());
}
#[test]
fn test_sign_options_default() {
let opts = SignOptions::default();
assert!(opts.rpc_url.is_none());
assert!(opts.nonce.is_none());
assert!(opts.gas_limit.is_none());
assert!(opts.max_fee_per_gas.is_none());
assert!(opts.max_priority_fee_per_gas.is_none());
assert!(opts.fee_token.is_none());
assert!(opts.signing_mode.is_none());
assert!(opts.key_authorization.is_none());
assert!(opts.valid_before.is_none());
assert!(opts.nonce_key.is_none());
}
#[test]
fn test_signed_charge_into_credential() {
let challenge = test_challenge();
let from: Address = "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2"
.parse()
.unwrap();
let credential = build_charge_credential(&challenge, &[0x76, 0xab, 0xcd], 42431, from);
let signed = SignedTempoCharge {
credential,
tx_bytes: Some(vec![0x76, 0xab, 0xcd]),
chain_id: 42431,
from,
};
let credential = signed.into_credential();
let tx_hex = credential
.payload
.get("signature")
.and_then(|v| v.as_str())
.unwrap();
assert_eq!(tx_hex, "0x76abcd");
let did = credential.source.as_ref().unwrap();
assert!(did.starts_with("did:pkh:eip155:42431:"));
}
#[test]
fn test_signed_charge_accessors() {
let challenge = test_challenge();
let from = Address::repeat_byte(0x11);
let credential = build_charge_credential(&challenge, &[0x76], 4217, from);
let signed = SignedTempoCharge {
credential,
tx_bytes: Some(vec![0x76]),
chain_id: 4217,
from,
};
assert_eq!(signed.tx_bytes(), &[0x76]);
assert_eq!(signed.chain_id(), 4217);
assert_eq!(signed.from_address(), from);
}
#[test]
fn test_from_challenge_with_splits() {
let request_json = serde_json::json!({
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
"methodDetails": {
"chainId": 42431,
"splits": [
{
"amount": "300000",
"recipient": "0x1111111111111111111111111111111111111111"
},
{
"amount": "200000",
"recipient": "0x2222222222222222222222222222222222222222",
"memo": "0x1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
}
]
}
});
let request = Base64UrlJson::from_value(&request_json).unwrap();
let challenge =
PaymentChallenge::new("test-id", "api.example.com", "tempo", "charge", request);
let charge = TempoCharge::from_challenge(&challenge).unwrap();
assert!(charge.splits().is_some());
assert_eq!(charge.splits().unwrap().len(), 2);
assert_eq!(charge.amount(), U256::from(1_000_000u64));
}
#[test]
fn test_build_transfer_calls_with_splits() {
let request_json = serde_json::json!({
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
"methodDetails": {
"chainId": 42431,
"splits": [
{
"amount": "300000",
"recipient": "0x1111111111111111111111111111111111111111"
}
]
}
});
let request = Base64UrlJson::from_value(&request_json).unwrap();
let challenge =
PaymentChallenge::new("test-id", "api.example.com", "tempo", "charge", request);
let charge = TempoCharge::from_challenge(&challenge).unwrap();
let calls = charge.build_transfer_calls().unwrap();
assert_eq!(calls.len(), 2);
}
#[test]
fn test_build_transfer_calls_no_splits() {
let challenge = test_challenge();
let charge = TempoCharge::from_challenge(&challenge).unwrap();
let calls = charge.build_transfer_calls().unwrap();
assert_eq!(calls.len(), 1);
}
#[tokio::test]
async fn test_zero_amount_sign_returns_proof_credential() {
let request_json = serde_json::json!({
"amount": "0",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
"methodDetails": {
"chainId": 42431
}
});
let request = Base64UrlJson::from_value(&request_json).unwrap();
let challenge =
PaymentChallenge::new("proof-id", "api.example.com", "tempo", "charge", request);
let signer: alloy::signers::local::PrivateKeySigner =
"0x1234567890123456789012345678901234567890123456789012345678901234"
.parse()
.unwrap();
let charge = TempoCharge::from_challenge(&challenge).unwrap();
let signed = charge
.sign_with_options(&signer, SignOptions::default())
.await
.unwrap();
let credential = signed.into_credential();
let payload: PaymentPayload = credential.payload_as().unwrap();
let expected_did = PaymentCredential::evm_did(42431, &signer.address().to_string());
assert!(payload.is_proof());
assert!(payload.proof_signature().unwrap().starts_with("0x"));
assert_eq!(credential.source.as_deref(), Some(expected_did.as_str()));
}
}