use alloy::consensus::SignableTransaction;
use alloy::primitives::{keccak256, Address, Bytes, TxKind, Uint, B256, U256};
use alloy::providers::Provider;
use alloy::signers::Signer;
use alloy::sol_types::{SolCall, SolValue};
use tempo_alloy::contracts::precompiles::{ITIP20ChannelReserve, TIP20_CHANNEL_RESERVE_ADDRESS};
use tempo_alloy::primitives::transaction::{Call, SignatureType, TempoTransaction};
use tempo_alloy::rpc::TempoTransactionRequest;
use tempo_alloy::TempoNetwork;
use crate::client::tempo::charge::tx_builder::{build_tempo_tx, estimate_gas, TempoTxOptions};
use crate::error::{MppError, ResultExt};
use crate::protocol::core::{PaymentChallenge, PaymentCredential};
use crate::protocol::intents::SessionRequest;
use crate::protocol::methods::tempo::precompile_voucher::{
compute_precompile_channel_id, compute_precompile_channel_id_with_escrow,
sign_precompile_voucher, sign_precompile_voucher_primitive,
sign_precompile_voucher_primitive_with_escrow, sign_precompile_voucher_with_escrow,
PRECOMPILE_MAX_CUMULATIVE_AMOUNT,
};
use crate::protocol::methods::tempo::session::{
ChannelDescriptor, SessionCredentialPayload, TempoSessionExt,
};
use crate::protocol::methods::tempo::voucher::{compute_channel_id, sign_voucher};
use crate::protocol::methods::tempo::CHAIN_ID;
#[cfg(feature = "tempo")]
const EXPIRING_NONCE_VALID_BEFORE_SECS: u64 = 25;
pub fn default_escrow_contract(chain_id: u64) -> Option<Address> {
match chain_id {
4217 => Some(
"0x33b901018174DDabE4841042ab76ba85D4e24f25"
.parse()
.unwrap(),
),
42431 => Some(
"0xe1c4d3dce17bc111181ddf716f75bae49e61a336"
.parse()
.unwrap(),
),
_ => None,
}
}
#[derive(Debug, Clone)]
pub struct ChannelEntry {
pub channel_id: B256,
pub salt: B256,
pub cumulative_amount: u128,
pub deposit: u128,
pub descriptor: Option<ChannelDescriptor>,
pub settlement_route: Option<crate::protocol::methods::tempo::session::SettlementRoute>,
pub escrow_contract: Address,
pub chain_id: u64,
pub opened: bool,
}
pub fn resolve_chain_id(challenge: &PaymentChallenge) -> u64 {
let session: Result<SessionRequest, _> = challenge.request.decode();
session.ok().and_then(|r| r.chain_id()).unwrap_or(CHAIN_ID)
}
pub fn resolve_escrow(
challenge: &PaymentChallenge,
chain_id: u64,
escrow_override: Option<Address>,
) -> Result<Address, MppError> {
if let Some(addr) = escrow_override {
return Ok(addr);
}
if let Ok(req) = challenge.request.decode::<SessionRequest>() {
if let Some(details) = req.method_details.as_ref() {
for key in ["escrowContract", "escrow"] {
if let Some(addr) = details
.get(key)
.and_then(serde_json::Value::as_str)
.and_then(|value| value.parse::<Address>().ok())
{
return Ok(addr);
}
}
}
if req.is_tip1034_session() {
return Ok(TIP20_CHANNEL_RESERVE_ADDRESS);
}
}
default_escrow_contract(chain_id).ok_or_else(|| {
MppError::InvalidConfig(
"No escrowContract available. Provide it in parameters or ensure the server challenge includes it.".to_string(),
)
})
}
pub fn build_credential(
challenge: &PaymentChallenge,
payload: SessionCredentialPayload,
chain_id: u64,
signer_address: Address,
) -> PaymentCredential {
let echo = challenge.to_echo();
let source = PaymentCredential::evm_did(chain_id, &signer_address.to_string());
PaymentCredential::with_source(echo, source, payload)
}
pub async fn create_voucher_payload(
signer: &impl Signer,
channel_id: B256,
cumulative_amount: u128,
escrow_contract: Address,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
let sig = sign_voucher(
signer,
channel_id,
cumulative_amount,
escrow_contract,
chain_id,
)
.await?;
Ok(SessionCredentialPayload::Voucher {
channel_id: channel_id.to_string(),
descriptor: None,
settlement_route: None,
cumulative_amount: cumulative_amount.to_string(),
signature: alloy::hex::encode_prefixed(&sig),
})
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_voucher_payload(
signer: &impl Signer,
channel_id: B256,
cumulative_amount: u128,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
let sig = sign_precompile_voucher(signer, channel_id, cumulative_amount, chain_id).await?;
Ok(SessionCredentialPayload::Voucher {
channel_id: channel_id.to_string(),
descriptor: None,
settlement_route: None,
cumulative_amount: cumulative_amount.to_string(),
signature: alloy::hex::encode_prefixed(&sig),
})
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_voucher_payload_primitive(
signer: &impl Signer<tempo_alloy::primitives::transaction::PrimitiveSignature>,
channel_id: B256,
cumulative_amount: u128,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
let signature =
sign_precompile_voucher_primitive(signer, channel_id, cumulative_amount, chain_id).await?;
Ok(SessionCredentialPayload::Voucher {
channel_id: channel_id.to_string(),
descriptor: None,
settlement_route: None,
cumulative_amount: cumulative_amount.to_string(),
signature: alloy::hex::encode_prefixed(&signature),
})
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_voucher_payload_with_descriptor(
signer: &impl Signer,
descriptor: ChannelDescriptor,
cumulative_amount: u128,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
create_precompile_voucher_payload_with_descriptor_and_escrow(
signer,
descriptor,
cumulative_amount,
TIP20_CHANNEL_RESERVE_ADDRESS,
chain_id,
)
.await
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_voucher_payload_with_descriptor_primitive(
signer: &impl Signer<tempo_alloy::primitives::transaction::PrimitiveSignature>,
descriptor: ChannelDescriptor,
cumulative_amount: u128,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
let channel_id = compute_precompile_channel_id_from_descriptor_with_escrow(
&descriptor,
TIP20_CHANNEL_RESERVE_ADDRESS,
chain_id,
)?;
let signature = sign_precompile_voucher_primitive_with_escrow(
signer,
channel_id,
cumulative_amount,
TIP20_CHANNEL_RESERVE_ADDRESS,
chain_id,
)
.await?;
Ok(SessionCredentialPayload::Voucher {
channel_id: channel_id.to_string(),
descriptor: Some(descriptor),
settlement_route: None,
cumulative_amount: cumulative_amount.to_string(),
signature: alloy::hex::encode_prefixed(&signature),
})
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_voucher_payload_with_descriptor_and_escrow(
signer: &impl Signer,
descriptor: ChannelDescriptor,
cumulative_amount: u128,
escrow_contract: Address,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
let channel_id = compute_precompile_channel_id_from_descriptor_with_escrow(
&descriptor,
escrow_contract,
chain_id,
)?;
let sig = sign_precompile_voucher_with_escrow(
signer,
channel_id,
cumulative_amount,
escrow_contract,
chain_id,
)
.await?;
Ok(SessionCredentialPayload::Voucher {
channel_id: channel_id.to_string(),
descriptor: Some(descriptor),
settlement_route: None,
cumulative_amount: cumulative_amount.to_string(),
signature: alloy::hex::encode_prefixed(&sig),
})
}
pub async fn create_close_payload(
signer: &impl Signer,
channel_id: B256,
cumulative_amount: u128,
escrow_contract: Address,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
let sig = sign_voucher(
signer,
channel_id,
cumulative_amount,
escrow_contract,
chain_id,
)
.await?;
Ok(SessionCredentialPayload::Close {
channel_id: channel_id.to_string(),
descriptor: None,
settlement_route: None,
cumulative_amount: cumulative_amount.to_string(),
signature: alloy::hex::encode_prefixed(&sig),
})
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_close_payload(
signer: &impl Signer,
channel_id: B256,
cumulative_amount: u128,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
let sig = sign_precompile_voucher(signer, channel_id, cumulative_amount, chain_id).await?;
Ok(SessionCredentialPayload::Close {
channel_id: channel_id.to_string(),
descriptor: None,
settlement_route: None,
cumulative_amount: cumulative_amount.to_string(),
signature: alloy::hex::encode_prefixed(&sig),
})
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_close_payload_primitive(
signer: &impl Signer<tempo_alloy::primitives::transaction::PrimitiveSignature>,
channel_id: B256,
cumulative_amount: u128,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
let signature =
sign_precompile_voucher_primitive(signer, channel_id, cumulative_amount, chain_id).await?;
Ok(SessionCredentialPayload::Close {
channel_id: channel_id.to_string(),
descriptor: None,
settlement_route: None,
cumulative_amount: cumulative_amount.to_string(),
signature: alloy::hex::encode_prefixed(&signature),
})
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_close_payload_with_descriptor(
signer: &impl Signer,
channel_id: B256,
descriptor: ChannelDescriptor,
cumulative_amount: u128,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
create_precompile_close_payload_with_descriptor_and_escrow(
signer,
channel_id,
descriptor,
cumulative_amount,
TIP20_CHANNEL_RESERVE_ADDRESS,
chain_id,
)
.await
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_close_payload_with_descriptor_primitive(
signer: &impl Signer<tempo_alloy::primitives::transaction::PrimitiveSignature>,
channel_id: B256,
descriptor: ChannelDescriptor,
cumulative_amount: u128,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
let expected_channel_id = compute_precompile_channel_id_from_descriptor_with_escrow(
&descriptor,
TIP20_CHANNEL_RESERVE_ADDRESS,
chain_id,
)?;
if expected_channel_id != channel_id {
return Err(MppError::InvalidConfig(
"TIP-1034 close descriptor does not match channel_id".to_string(),
));
}
let signature = sign_precompile_voucher_primitive_with_escrow(
signer,
channel_id,
cumulative_amount,
TIP20_CHANNEL_RESERVE_ADDRESS,
chain_id,
)
.await?;
Ok(SessionCredentialPayload::Close {
channel_id: channel_id.to_string(),
descriptor: Some(descriptor),
settlement_route: None,
cumulative_amount: cumulative_amount.to_string(),
signature: alloy::hex::encode_prefixed(&signature),
})
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_close_payload_with_descriptor_and_escrow(
signer: &impl Signer,
channel_id: B256,
descriptor: ChannelDescriptor,
cumulative_amount: u128,
escrow_contract: Address,
chain_id: u64,
) -> Result<SessionCredentialPayload, MppError> {
let expected_channel_id = compute_precompile_channel_id_from_descriptor_with_escrow(
&descriptor,
escrow_contract,
chain_id,
)?;
if expected_channel_id != channel_id {
return Err(MppError::InvalidConfig(
"TIP-1034 close descriptor does not match channel_id".to_string(),
));
}
let sig = sign_precompile_voucher_with_escrow(
signer,
channel_id,
cumulative_amount,
escrow_contract,
chain_id,
)
.await?;
Ok(SessionCredentialPayload::Close {
channel_id: channel_id.to_string(),
descriptor: Some(descriptor),
settlement_route: None,
cumulative_amount: cumulative_amount.to_string(),
signature: alloy::hex::encode_prefixed(&sig),
})
}
pub struct OpenPayloadOptions {
pub authorized_signer: Option<Address>,
pub escrow_contract: Address,
pub payee: Address,
pub currency: Address,
pub deposit: u128,
pub initial_amount: u128,
pub chain_id: u64,
pub fee_payer: bool,
}
pub async fn create_open_payload<P, S>(
provider: &P,
signer: &S,
signing_mode: Option<&crate::client::tempo::signing::TempoSigningMode>,
payer: Address,
options: OpenPayloadOptions,
) -> Result<(ChannelEntry, SessionCredentialPayload), MppError>
where
P: Provider<TempoNetwork>,
S: Signer + Clone,
{
use alloy::sol;
use tempo_alloy::primitives::transaction::Call;
let default_mode = crate::client::tempo::signing::TempoSigningMode::Direct;
let signing_mode = signing_mode.unwrap_or(&default_mode);
let authorized_signer = options.authorized_signer.unwrap_or(payer);
let salt = B256::random();
let channel_id = compute_channel_id(
payer,
options.payee,
options.currency,
salt,
authorized_signer,
options.escrow_contract,
options.chain_id,
);
use tempo_alloy::contracts::precompiles::ITIP20;
sol! {
interface IEscrow {
function open(
address payee,
address token,
uint128 deposit,
bytes32 salt,
address authorizedSigner
) external;
}
}
let approve_data =
ITIP20::approveCall::new((options.escrow_contract, U256::from(options.deposit)))
.abi_encode();
let open_data = IEscrow::openCall::new((
options.payee,
options.currency,
options.deposit,
salt,
authorized_signer,
))
.abi_encode();
let calls = vec![
Call {
to: TxKind::Call(options.currency),
value: U256::ZERO,
input: Bytes::from(approve_data),
},
Call {
to: TxKind::Call(options.escrow_contract),
value: U256::ZERO,
input: Bytes::from(open_data),
},
];
let gas_price = provider
.get_gas_price()
.await
.mpp_http("failed to get gas price")?;
let (nonce, nonce_key, valid_before) = session_transaction_nonce_fields();
let key_authorization = crate::client::tempo::signing::keychain::resolve_key_authorization(
provider,
signing_mode,
signer.address(),
)
.await?;
let tempo_tx = build_tempo_tx(TempoTxOptions {
calls,
chain_id: options.chain_id,
fee_token: options.currency,
nonce,
nonce_key,
gas_limit: 2_000_000,
max_fee_per_gas: gas_price,
max_priority_fee_per_gas: gas_price,
fee_payer: options.fee_payer,
valid_before,
key_authorization,
});
let tx_bytes =
crate::client::tempo::signing::sign_and_encode_async(tempo_tx, signer, signing_mode)
.await?;
let signed_tx_hex = alloy::hex::encode_prefixed(&tx_bytes);
let voucher_sig = sign_voucher(
signer,
channel_id,
options.initial_amount,
options.escrow_contract,
options.chain_id,
)
.await?;
let entry = ChannelEntry {
channel_id,
salt,
cumulative_amount: options.initial_amount,
deposit: options.deposit,
descriptor: None,
settlement_route: None,
escrow_contract: options.escrow_contract,
chain_id: options.chain_id,
opened: true,
};
let payload = SessionCredentialPayload::Open {
payload_type: "transaction".to_string(),
channel_id: channel_id.to_string(),
transaction: signed_tx_hex,
descriptor: None,
settlement_route: None,
authorized_signer: Some(authorized_signer.to_string()),
cumulative_amount: options.initial_amount.to_string(),
signature: alloy::hex::encode_prefixed(&voucher_sig),
};
Ok((entry, payload))
}
pub fn is_precompile_escrow(addr: Address) -> bool {
addr == TIP20_CHANNEL_RESERVE_ADDRESS
}
#[cfg(feature = "tempo")]
pub fn compute_expiring_nonce_hash(unsigned_tx: &TempoTransaction, sender: Address) -> B256 {
let mut buf = Vec::with_capacity(unsigned_tx.payload_len_for_signature() + 20);
unsigned_tx.encode_for_signing(&mut buf);
buf.extend_from_slice(sender.as_slice());
keccak256(buf)
}
#[cfg(feature = "tempo")]
pub fn compute_fee_payer_expiring_nonce_hash(
unsigned_tx: &TempoTransaction,
sender: Address,
) -> B256 {
let mut tx = unsigned_tx.clone();
tx.fee_payer_signature = Some(alloy::primitives::Signature::new(
U256::ZERO,
U256::ZERO,
false,
));
compute_expiring_nonce_hash(&tx, sender)
}
#[cfg(feature = "tempo")]
#[allow(clippy::too_many_arguments)]
pub fn build_channel_descriptor(
payer: Address,
payee: Address,
operator: Address,
token: Address,
salt: B256,
authorized_signer: Address,
expiring_nonce_hash: B256,
) -> ChannelDescriptor {
ChannelDescriptor {
payer: payer.to_string(),
payee: payee.to_string(),
operator: operator.to_string(),
token: token.to_string(),
salt: salt.to_string(),
authorized_signer: authorized_signer.to_string(),
expiring_nonce_hash: expiring_nonce_hash.to_string(),
}
}
#[cfg(feature = "tempo")]
fn parse_precompile_amount(value: u128, label: &str) -> Result<Uint<96, 2>, MppError> {
if value > PRECOMPILE_MAX_CUMULATIVE_AMOUNT {
return Err(MppError::InvalidConfig(format!(
"{label} {value} exceeds precompile uint96 max"
)));
}
Ok(Uint::<96, 2>::from(value))
}
#[cfg(feature = "tempo")]
fn session_transaction_nonce_fields() -> (u64, U256, Option<u64>) {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
(
0,
U256::MAX,
Some(now.saturating_add(EXPIRING_NONCE_VALID_BEFORE_SECS)),
)
}
#[cfg(feature = "tempo")]
fn random_past_valid_after() -> Option<std::num::NonZeroU64> {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let latest = now.saturating_sub(60);
if latest == 0 {
return None;
}
let random = B256::random();
let mut prefix = [0u8; 8];
prefix.copy_from_slice(&random[..8]);
let timestamp = (u64::from_be_bytes(prefix) % latest).max(1);
std::num::NonZeroU64::new(timestamp)
}
#[cfg(feature = "tempo")]
fn session_estimation_request(
options: &TempoTxOptions,
valid_after: Option<std::num::NonZeroU64>,
payer: Address,
signer: Address,
signature_type: SignatureType,
keychain: bool,
) -> TempoTransactionRequest {
let mut request = TempoTransactionRequest {
calls: options.calls.clone(),
key_authorization: options.key_authorization.clone(),
valid_before: options.valid_before.and_then(std::num::NonZeroU64::new),
valid_after,
..Default::default()
}
.with_fee_token(options.fee_token)
.with_nonce_key(options.nonce_key);
request.inner.from = Some(payer);
request.inner.chain_id = Some(options.chain_id);
request.inner.nonce = Some(options.nonce);
request.inner.max_fee_per_gas = Some(options.max_fee_per_gas);
request.inner.max_priority_fee_per_gas = Some(options.max_priority_fee_per_gas);
request.key_type = Some(signature_type);
if keychain {
request.key_id = Some(signer);
}
request
}
#[cfg(feature = "tempo")]
pub fn precompile_descriptor_from_wire(
descriptor: &ChannelDescriptor,
) -> Result<ITIP20ChannelReserve::ChannelDescriptor, MppError> {
Ok(ITIP20ChannelReserve::ChannelDescriptor {
payer: descriptor.payer.parse().map_err(|e| {
MppError::InvalidConfig(format!("invalid TIP-1034 descriptor payer: {e}"))
})?,
payee: descriptor.payee.parse().map_err(|e| {
MppError::InvalidConfig(format!("invalid TIP-1034 descriptor payee: {e}"))
})?,
operator: descriptor.operator.parse().map_err(|e| {
MppError::InvalidConfig(format!("invalid TIP-1034 descriptor operator: {e}"))
})?,
token: descriptor.token.parse().map_err(|e| {
MppError::InvalidConfig(format!("invalid TIP-1034 descriptor token: {e}"))
})?,
salt: descriptor.salt.parse().map_err(|e| {
MppError::InvalidConfig(format!("invalid TIP-1034 descriptor salt: {e}"))
})?,
authorizedSigner: descriptor.authorized_signer.parse().map_err(|e| {
MppError::InvalidConfig(format!("invalid TIP-1034 descriptor authorizedSigner: {e}"))
})?,
expiringNonceHash: descriptor.expiring_nonce_hash.parse().map_err(|e| {
MppError::InvalidConfig(format!(
"invalid TIP-1034 descriptor expiringNonceHash: {e}"
))
})?,
})
}
#[cfg(feature = "tempo")]
pub fn compute_precompile_channel_id_from_descriptor(
descriptor: &ChannelDescriptor,
chain_id: u64,
) -> Result<B256, MppError> {
compute_precompile_channel_id_from_descriptor_with_escrow(
descriptor,
TIP20_CHANNEL_RESERVE_ADDRESS,
chain_id,
)
}
#[cfg(feature = "tempo")]
pub fn compute_precompile_channel_id_from_descriptor_with_escrow(
descriptor: &ChannelDescriptor,
escrow_contract: Address,
chain_id: u64,
) -> Result<B256, MppError> {
let descriptor = precompile_descriptor_from_wire(descriptor)?;
Ok(compute_precompile_channel_id_with_escrow(
descriptor.payer,
descriptor.payee,
descriptor.operator,
descriptor.token,
descriptor.salt,
descriptor.authorizedSigner,
descriptor.expiringNonceHash,
escrow_contract,
chain_id,
))
}
#[cfg(feature = "tempo")]
pub fn encode_precompile_open_call(
payee: Address,
operator: Address,
token: Address,
deposit: u128,
salt: B256,
authorized_signer: Address,
) -> Result<Bytes, MppError> {
Ok(Bytes::from(
ITIP20ChannelReserve::openCall::new((
payee,
operator,
token,
parse_precompile_amount(deposit, "deposit")?,
salt,
authorized_signer,
))
.abi_encode(),
))
}
#[cfg(feature = "tempo")]
pub fn encode_precompile_top_up_call(
descriptor: &ChannelDescriptor,
additional_deposit: u128,
) -> Result<Bytes, MppError> {
Ok(Bytes::from(
ITIP20ChannelReserve::topUpCall::new((
precompile_descriptor_from_wire(descriptor)?,
parse_precompile_amount(additional_deposit, "additional_deposit")?,
))
.abi_encode(),
))
}
#[cfg(feature = "tempo")]
pub fn encode_precompile_get_channel_call(
descriptor: &ChannelDescriptor,
) -> Result<Bytes, MppError> {
Ok(Bytes::from(
ITIP20ChannelReserve::getChannelCall::new((precompile_descriptor_from_wire(descriptor)?,))
.abi_encode(),
))
}
#[cfg(feature = "tempo")]
pub fn encode_precompile_get_channel_state_call(channel_id: B256) -> Bytes {
Bytes::from(ITIP20ChannelReserve::getChannelStateCall::new((channel_id,)).abi_encode())
}
#[cfg(feature = "tempo")]
pub fn encode_precompile_request_close_call(
descriptor: &ChannelDescriptor,
) -> Result<Bytes, MppError> {
Ok(Bytes::from(
ITIP20ChannelReserve::requestCloseCall::new(
(precompile_descriptor_from_wire(descriptor)?,),
)
.abi_encode(),
))
}
#[cfg(feature = "tempo")]
pub fn encode_precompile_withdraw_call(descriptor: &ChannelDescriptor) -> Result<Bytes, MppError> {
Ok(Bytes::from(
ITIP20ChannelReserve::withdrawCall::new((precompile_descriptor_from_wire(descriptor)?,))
.abi_encode(),
))
}
#[cfg(feature = "tempo")]
pub fn create_precompile_top_up_payload(
channel_id: B256,
descriptor: ChannelDescriptor,
transaction: String,
additional_deposit: u128,
) -> SessionCredentialPayload {
SessionCredentialPayload::TopUp {
payload_type: "transaction".to_string(),
channel_id: channel_id.to_string(),
transaction,
descriptor: Some(descriptor),
settlement_route: None,
additional_deposit: additional_deposit.to_string(),
}
}
pub struct OpenPrecompilePayloadOptions {
pub prefix_calls: Vec<Call>,
pub operator: Address,
pub authorized_signer: Option<Address>,
pub payee: Address,
pub currency: Address,
pub fee_token: Address,
pub deposit: u128,
pub initial_amount: u128,
pub chain_id: u64,
pub fee_payer: bool,
pub salt: Option<B256>,
}
pub struct TopUpPrecompilePayloadOptions<'a> {
pub prefix_calls: Vec<Call>,
pub descriptor: &'a ChannelDescriptor,
pub fee_token: Address,
pub additional_deposit: u128,
pub chain_id: u64,
pub fee_payer: bool,
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_open_payload<P, S>(
provider: &P,
signer: &S,
signing_mode: Option<&crate::client::tempo::signing::TempoSigningMode>,
payer: Address,
options: OpenPrecompilePayloadOptions,
) -> Result<(ChannelEntry, SessionCredentialPayload), MppError>
where
P: Provider<TempoNetwork>,
S: Clone + Into<crate::client::tempo::signing::TempoPrimitiveSigner>,
{
if options.deposit > PRECOMPILE_MAX_CUMULATIVE_AMOUNT {
return Err(MppError::InvalidConfig(format!(
"deposit {} exceeds precompile uint96 max",
options.deposit
)));
}
if options.initial_amount > PRECOMPILE_MAX_CUMULATIVE_AMOUNT {
return Err(MppError::InvalidConfig(format!(
"initial_amount {} exceeds precompile uint96 max",
options.initial_amount
)));
}
let default_mode = crate::client::tempo::signing::TempoSigningMode::Direct;
let signing_mode = signing_mode.unwrap_or(&default_mode);
let primitive_signer = signer.clone().into();
let signature_type = primitive_signer.signature_type();
let authorized_signer = options.authorized_signer.unwrap_or(payer);
let salt = options.salt.unwrap_or_else(B256::random);
let open_data = ITIP20ChannelReserve::openCall::new((
options.payee,
options.operator,
options.currency,
Uint::<96, 2>::from(options.deposit),
salt,
authorized_signer,
))
.abi_encode();
let mut calls = options.prefix_calls;
calls.push(Call {
to: TxKind::Call(TIP20_CHANNEL_RESERVE_ADDRESS),
value: U256::ZERO,
input: Bytes::from(open_data),
});
let gas_price = provider
.get_gas_price()
.await
.mpp_http("failed to get gas price")?;
let (nonce, nonce_key, valid_before) = session_transaction_nonce_fields();
let key_authorization = crate::client::tempo::signing::keychain::resolve_key_authorization(
provider,
signing_mode,
primitive_signer.address(),
)
.await?;
let mut transaction_options = TempoTxOptions {
calls,
chain_id: options.chain_id,
fee_token: options.fee_token,
nonce,
nonce_key,
gas_limit: 2_000_000,
max_fee_per_gas: gas_price,
max_priority_fee_per_gas: gas_price,
fee_payer: options.fee_payer,
valid_before,
key_authorization,
};
if !options.fee_payer {
let request = session_estimation_request(
&transaction_options,
None,
payer,
primitive_signer.address(),
signature_type,
matches!(
signing_mode,
crate::client::tempo::signing::TempoSigningMode::Keychain { .. }
),
);
transaction_options.gas_limit = estimate_gas(provider, request).await?;
}
let unsigned_tx = build_tempo_tx(transaction_options);
let expiring_nonce_hash = if options.fee_payer {
compute_fee_payer_expiring_nonce_hash(&unsigned_tx, payer)
} else {
compute_expiring_nonce_hash(&unsigned_tx, payer)
};
let descriptor = build_channel_descriptor(
payer,
options.payee,
options.operator,
options.currency,
salt,
authorized_signer,
expiring_nonce_hash,
);
let channel_id = compute_precompile_channel_id(
payer,
options.payee,
options.operator,
options.currency,
salt,
authorized_signer,
expiring_nonce_hash,
options.chain_id,
);
let tx_bytes = if options.fee_payer {
crate::client::tempo::signing::sign_and_encode_fee_payer_envelope_primitive_async(
unsigned_tx,
&primitive_signer,
signing_mode,
)
.await?
} else {
crate::client::tempo::signing::sign_and_encode_primitive_async(
unsigned_tx,
&primitive_signer,
signing_mode,
)
.await?
};
let signed_tx_hex = alloy::hex::encode_prefixed(&tx_bytes);
let voucher_sig = sign_precompile_voucher_primitive(
&primitive_signer,
channel_id,
options.initial_amount,
options.chain_id,
)
.await?;
let entry = ChannelEntry {
channel_id,
salt,
cumulative_amount: options.initial_amount,
deposit: options.deposit,
descriptor: Some(descriptor.clone()),
settlement_route: None,
escrow_contract: TIP20_CHANNEL_RESERVE_ADDRESS,
chain_id: options.chain_id,
opened: true,
};
let payload = SessionCredentialPayload::Open {
payload_type: "transaction".to_string(),
channel_id: channel_id.to_string(),
transaction: signed_tx_hex,
descriptor: Some(descriptor),
settlement_route: None,
authorized_signer: Some(authorized_signer.to_string()),
cumulative_amount: options.initial_amount.to_string(),
signature: alloy::hex::encode_prefixed(&voucher_sig),
};
Ok((entry, payload))
}
#[cfg(feature = "tempo")]
pub async fn create_precompile_top_up_transaction_payload<P, S>(
provider: &P,
signer: &S,
signing_mode: Option<&crate::client::tempo::signing::TempoSigningMode>,
payer: Address,
options: TopUpPrecompilePayloadOptions<'_>,
) -> Result<SessionCredentialPayload, MppError>
where
P: Provider<TempoNetwork>,
S: Clone + Into<crate::client::tempo::signing::TempoPrimitiveSigner>,
{
let additional_deposit =
parse_precompile_amount(options.additional_deposit, "additional_deposit")?;
if additional_deposit.is_zero() {
return Err(MppError::InvalidConfig(
"top-up amount must be greater than zero".into(),
));
}
let default_mode = crate::client::tempo::signing::TempoSigningMode::Direct;
let signing_mode = signing_mode.unwrap_or(&default_mode);
let primitive_signer = signer.clone().into();
let signature_type = primitive_signer.signature_type();
let descriptor = precompile_descriptor_from_wire(options.descriptor)?;
let mut calls = options.prefix_calls;
calls.push(Call {
to: TxKind::Call(TIP20_CHANNEL_RESERVE_ADDRESS),
value: U256::ZERO,
input: Bytes::from(
ITIP20ChannelReserve::topUpCall::new((descriptor, additional_deposit)).abi_encode(),
),
});
let gas_price = provider
.get_gas_price()
.await
.mpp_http("failed to get gas price")?;
let (nonce, nonce_key, valid_before) = session_transaction_nonce_fields();
let key_authorization = crate::client::tempo::signing::keychain::resolve_key_authorization(
provider,
signing_mode,
primitive_signer.address(),
)
.await?;
let valid_after = random_past_valid_after();
let mut transaction_options = TempoTxOptions {
calls,
chain_id: options.chain_id,
fee_token: options.fee_token,
nonce,
nonce_key,
gas_limit: 2_000_000,
max_fee_per_gas: gas_price,
max_priority_fee_per_gas: gas_price,
fee_payer: options.fee_payer,
valid_before,
key_authorization,
};
if !options.fee_payer {
let request = session_estimation_request(
&transaction_options,
valid_after,
payer,
primitive_signer.address(),
signature_type,
matches!(
signing_mode,
crate::client::tempo::signing::TempoSigningMode::Keychain { .. }
),
);
transaction_options.gas_limit = estimate_gas(provider, request).await?;
}
let mut unsigned_tx = build_tempo_tx(transaction_options);
unsigned_tx.valid_after = valid_after;
let tx_bytes = if options.fee_payer {
crate::client::tempo::signing::sign_and_encode_fee_payer_envelope_primitive_async(
unsigned_tx,
&primitive_signer,
signing_mode,
)
.await?
} else {
crate::client::tempo::signing::sign_and_encode_primitive_async(
unsigned_tx,
&primitive_signer,
signing_mode,
)
.await?
};
let channel_id =
compute_precompile_channel_id_from_descriptor(options.descriptor, options.chain_id)?;
Ok(create_precompile_top_up_payload(
channel_id,
options.descriptor.clone(),
alloy::hex::encode_prefixed(&tx_bytes),
options.additional_deposit,
))
}
#[derive(Debug, Clone)]
pub struct OnChainChannel {
pub payer: Address,
pub payee: Address,
pub token: Address,
pub authorized_signer: Address,
pub deposit: u128,
pub settled: u128,
pub close_requested_at: u64,
pub finalized: bool,
}
pub async fn get_on_chain_channel<P: Provider<TempoNetwork>>(
provider: &P,
escrow_contract: Address,
channel_id: B256,
) -> Result<OnChainChannel, MppError> {
use alloy::sol;
sol! {
interface IEscrowRead {
function getChannel(bytes32 channelId) external view returns (
bool finalized,
uint64 closeRequestedAt,
address payer,
address payee,
address token,
address authorizedSigner,
uint128 deposit,
uint128 settled
);
}
}
let call_data = IEscrowRead::getChannelCall::new((channel_id,)).abi_encode();
use tempo_alloy::rpc::TempoTransactionRequest;
let mut tx_req = TempoTransactionRequest::default();
tx_req.inner =
tx_req
.inner
.to(escrow_contract)
.input(alloy::rpc::types::TransactionInput::new(Bytes::from(
call_data,
)));
let result = provider
.call(tx_req)
.await
.mpp_http("failed to read channel")?;
let decoded =
<(bool, u64, Address, Address, Address, Address, u128, u128)>::abi_decode(&result)
.mpp_http("failed to decode channel data")?;
Ok(OnChainChannel {
finalized: decoded.0,
close_requested_at: decoded.1,
payer: decoded.2,
payee: decoded.3,
token: decoded.4,
authorized_signer: decoded.5,
deposit: decoded.6,
settled: decoded.7,
})
}
#[allow(clippy::too_many_arguments)]
pub async fn try_recover_channel<P: Provider<TempoNetwork>>(
provider: &P,
escrow_contract: Address,
channel_id: B256,
chain_id: u64,
expected_payer: Address,
expected_payee: Address,
expected_token: Address,
expected_authorized_signer: Address,
) -> Option<ChannelEntry> {
let on_chain = get_on_chain_channel(provider, escrow_contract, channel_id)
.await
.ok()?;
let actual_authorized_signer = if on_chain.authorized_signer == Address::ZERO {
on_chain.payer
} else {
on_chain.authorized_signer
};
if on_chain.deposit > 0
&& !on_chain.finalized
&& on_chain.close_requested_at == 0
&& on_chain.payer == expected_payer
&& on_chain.payee == expected_payee
&& on_chain.token == expected_token
&& actual_authorized_signer == expected_authorized_signer
{
Some(ChannelEntry {
channel_id,
salt: B256::ZERO,
cumulative_amount: on_chain.settled,
deposit: on_chain.deposit,
descriptor: None,
settlement_route: None,
escrow_contract,
chain_id,
opened: true,
})
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_escrow_contract() {
assert!(default_escrow_contract(4217).is_some());
assert!(default_escrow_contract(42431).is_some());
assert!(default_escrow_contract(1).is_none());
}
#[cfg(feature = "tempo")]
#[test]
fn test_is_precompile_escrow() {
use tempo_alloy::contracts::precompiles::TIP20_CHANNEL_RESERVE_ADDRESS;
assert!(is_precompile_escrow(TIP20_CHANNEL_RESERVE_ADDRESS));
assert!(is_precompile_escrow(
"0x4D50500000000000000000000000000000000000"
.parse()
.unwrap()
));
assert!(!is_precompile_escrow(
default_escrow_contract(4217).unwrap()
));
assert!(!is_precompile_escrow(
default_escrow_contract(42431).unwrap()
));
assert!(!is_precompile_escrow(Address::ZERO));
}
#[cfg(feature = "tempo")]
#[test]
fn test_compute_expiring_nonce_hash_matches_on_chain_formula() {
use alloy::consensus::SignableTransaction;
use alloy::primitives::keccak256;
use tempo_alloy::primitives::transaction::TempoTransaction;
let payer = Address::repeat_byte(0xAA);
let calls = vec![tempo_alloy::primitives::transaction::Call {
to: TxKind::Call(Address::repeat_byte(0x11)),
value: U256::ZERO,
input: alloy::primitives::Bytes::new(),
}];
let tx: TempoTransaction = build_tempo_tx(TempoTxOptions {
calls,
chain_id: 4217,
fee_token: Address::repeat_byte(0x22),
nonce: 7,
nonce_key: U256::ZERO,
gas_limit: 500_000,
max_fee_per_gas: 1_000_000_000,
max_priority_fee_per_gas: 100_000_000,
fee_payer: false,
valid_before: None,
key_authorization: None,
});
let got = compute_expiring_nonce_hash(&tx, payer);
let mut expected_buf = Vec::with_capacity(tx.payload_len_for_signature() + 20);
tx.encode_for_signing(&mut expected_buf);
expected_buf.extend_from_slice(payer.as_slice());
assert_eq!(got, keccak256(expected_buf));
let other_sender = Address::repeat_byte(0xBB);
assert_ne!(compute_expiring_nonce_hash(&tx, other_sender), got);
}
#[cfg(feature = "tempo")]
#[test]
fn session_transactions_use_expiring_nonces_without_sponsorship() {
let before = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let (nonce, nonce_key, valid_before) = session_transaction_nonce_fields();
assert_eq!(nonce, 0);
assert_eq!(nonce_key, U256::MAX);
let valid_before = valid_before.expect("session transaction sets valid_before");
assert!(valid_before >= before);
assert!(valid_before <= before + EXPIRING_NONCE_VALID_BEFORE_SECS + 1);
}
#[cfg(feature = "tempo")]
#[test]
fn repeatable_top_ups_get_past_valid_after_entropy() {
let mut transaction = build_tempo_tx(TempoTxOptions {
calls: vec![],
chain_id: 4217,
fee_token: Address::repeat_byte(0x22),
nonce: 0,
nonce_key: U256::MAX,
gas_limit: 500_000,
max_fee_per_gas: 1_000_000_000,
max_priority_fee_per_gas: 100_000_000,
fee_payer: false,
valid_before: None,
key_authorization: None,
});
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
transaction.valid_after = random_past_valid_after();
let valid_after = transaction
.valid_after
.expect("top-ups get transaction entropy");
assert!(valid_after.get() <= now.saturating_sub(60));
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_create_precompile_open_payload_rejects_uint96_overflow() {
use crate::protocol::methods::tempo::precompile_voucher::PRECOMPILE_MAX_CUMULATIVE_AMOUNT;
use alloy::providers::ProviderBuilder;
use alloy::signers::local::PrivateKeySigner;
let signer = PrivateKeySigner::random();
let payer = signer.address();
let provider = ProviderBuilder::<_, _, TempoNetwork>::default()
.connect_http("http://localhost:1".parse().unwrap());
let opts = OpenPrecompilePayloadOptions {
prefix_calls: Vec::new(),
operator: Address::ZERO,
authorized_signer: None,
payee: Address::repeat_byte(0x11),
currency: Address::repeat_byte(0x22),
fee_token: Address::repeat_byte(0x22),
deposit: PRECOMPILE_MAX_CUMULATIVE_AMOUNT + 1,
initial_amount: 1,
chain_id: 4217,
fee_payer: false,
salt: None,
};
let err = create_precompile_open_payload(&provider, &signer, None, payer, opts)
.await
.expect_err("deposit > uint96 must be rejected");
assert!(matches!(err, MppError::InvalidConfig(_)));
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn top_up_stops_when_preflight_reverts() {
use alloy::{
providers::{mock::Asserter, ProviderBuilder},
signers::local::PrivateKeySigner,
};
let asserter = Asserter::new();
asserter.push_success(&"0x4a817c800");
asserter.push_failure_msg("execution reverted: insufficient balance");
let provider =
ProviderBuilder::new_with_network::<TempoNetwork>().connect_mocked_client(asserter);
let signer = PrivateKeySigner::random();
let payer = signer.address();
let descriptor = ChannelDescriptor {
payer: payer.to_string(),
payee: Address::repeat_byte(0x11).to_string(),
operator: Address::ZERO.to_string(),
token: Address::repeat_byte(0x22).to_string(),
salt: B256::repeat_byte(0x33).to_string(),
authorized_signer: payer.to_string(),
expiring_nonce_hash: B256::repeat_byte(0x44).to_string(),
};
let error = create_precompile_top_up_transaction_payload(
&provider,
&signer,
None,
payer,
TopUpPrecompilePayloadOptions {
prefix_calls: vec![],
descriptor: &descriptor,
fee_token: Address::repeat_byte(0x22),
additional_deposit: 5_000_000,
chain_id: 4217,
fee_payer: false,
},
)
.await
.expect_err("a reverting top-up must fail before it is signed");
assert!(matches!(
error,
MppError::Tempo(crate::client::tempo::TempoClientError::InsufficientBalance { .. })
));
}
#[test]
fn test_channel_entry_clone() {
let entry = ChannelEntry {
channel_id: B256::ZERO,
salt: B256::ZERO,
cumulative_amount: 1000,
deposit: 0,
descriptor: None,
settlement_route: None,
escrow_contract: Address::ZERO,
chain_id: 42431,
opened: true,
};
let cloned = entry.clone();
assert_eq!(cloned.cumulative_amount, 1000);
assert!(cloned.opened);
}
#[test]
fn test_resolve_escrow_from_override() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123"
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
let override_addr: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let result = resolve_escrow(&challenge, 42431, Some(override_addr)).unwrap();
assert_eq!(result, override_addr);
}
#[test]
fn test_resolve_escrow_from_default() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123"
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
let result = resolve_escrow(&challenge, 42431, None).unwrap();
assert_eq!(result, default_escrow_contract(42431).unwrap());
}
#[test]
fn test_resolve_escrow_from_challenge() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let escrow_addr = "0x2222222222222222222222222222222222222222";
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123",
"methodDetails": {
"escrowContract": escrow_addr
}
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
let result = resolve_escrow(&challenge, 42431, None).unwrap();
assert_eq!(result, escrow_addr.parse::<Address>().unwrap());
}
#[cfg(feature = "tempo")]
#[test]
fn tip1034_challenge_without_escrow_uses_canonical_precompile() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"currency": Address::repeat_byte(0x44).to_string(),
"methodDetails": { "sessionProtocol": "v2" }
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
assert_eq!(
resolve_escrow(&challenge, 4217, None).unwrap(),
TIP20_CHANNEL_RESERVE_ADDRESS
);
}
#[test]
fn test_resolve_escrow_accepts_legacy_alias_hint() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let hinted = Address::repeat_byte(0x22);
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"currency": Address::repeat_byte(0x44).to_string(),
"methodDetails": { "escrow": hinted.to_string() }
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
assert_eq!(resolve_escrow(&challenge, 4217, None).unwrap(), hinted);
}
#[test]
fn test_resolve_escrow_no_source() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123"
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
let result = resolve_escrow(&challenge, 9999, None);
assert!(result.is_err());
}
#[test]
fn test_build_credential() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test-id".to_string(),
realm: "api.example.com".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123"
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
let payload = SessionCredentialPayload::Voucher {
channel_id: "0xabc".to_string(),
descriptor: None,
settlement_route: None,
cumulative_amount: "5000".to_string(),
signature: "0xdef".to_string(),
};
let addr: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let cred = build_credential(&challenge, payload, 42431, addr);
assert!(cred.source.is_some());
assert!(cred.source.unwrap().contains("42431"));
}
#[cfg(feature = "evm")]
#[tokio::test]
async fn test_create_voucher_payload() {
use alloy::signers::local::PrivateKeySigner;
let signer = PrivateKeySigner::random();
let channel_id = B256::repeat_byte(0xAB);
let escrow: Address = "0x5555555555555555555555555555555555555555"
.parse()
.unwrap();
let payload = create_voucher_payload(&signer, channel_id, 1000, escrow, 42431)
.await
.unwrap();
match payload {
SessionCredentialPayload::Voucher {
channel_id: cid,
descriptor,
cumulative_amount,
signature,
..
} => {
assert!(cid.starts_with("0x"));
assert!(descriptor.is_none());
assert_eq!(cumulative_amount, "1000");
assert!(signature.starts_with("0x"));
}
_ => panic!("Expected Voucher variant"),
}
}
#[cfg(feature = "evm")]
#[tokio::test]
async fn test_create_close_payload() {
use alloy::signers::local::PrivateKeySigner;
let signer = PrivateKeySigner::random();
let channel_id = B256::repeat_byte(0xCD);
let escrow: Address = "0x5555555555555555555555555555555555555555"
.parse()
.unwrap();
let payload = create_close_payload(&signer, channel_id, 2000, escrow, 42431)
.await
.unwrap();
match payload {
SessionCredentialPayload::Close {
channel_id: cid,
descriptor,
cumulative_amount,
signature,
..
} => {
assert!(cid.starts_with("0x"));
assert!(descriptor.is_none());
assert_eq!(cumulative_amount, "2000");
assert!(signature.starts_with("0x"));
}
_ => panic!("Expected Close variant"),
}
}
#[cfg(feature = "tempo")]
#[tokio::test]
async fn test_create_precompile_close_payload_rejects_descriptor_mismatch() {
use alloy::signers::local::PrivateKeySigner;
let signer = PrivateKeySigner::random();
let descriptor = build_channel_descriptor(
Address::repeat_byte(0x11),
Address::repeat_byte(0x22),
Address::ZERO,
Address::repeat_byte(0x33),
B256::repeat_byte(0x44),
Address::repeat_byte(0x55),
B256::repeat_byte(0x66),
);
let err = create_precompile_close_payload_with_descriptor(
&signer,
B256::repeat_byte(0x77),
descriptor,
2000,
42431,
)
.await
.expect_err("descriptor/channel mismatch should fail");
assert!(err.to_string().contains("does not match channel_id"));
}
#[test]
fn test_resolve_chain_id_from_challenge() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123",
"methodDetails": { "escrowContract": "0xabc", "chainId": 4217 }
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
assert_eq!(resolve_chain_id(&challenge), 4217);
}
#[test]
fn test_resolve_chain_id_default() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123"
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
assert_eq!(resolve_chain_id(&challenge), CHAIN_ID);
}
#[test]
fn test_resolve_chain_id_malformed_request_falls_back() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"not_a_valid_field": true
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
assert_eq!(resolve_chain_id(&challenge), CHAIN_ID);
}
#[test]
fn test_resolve_escrow_challenge_has_invalid_address() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123",
"methodDetails": {
"escrowContract": "not-an-address"
}
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
let result = resolve_escrow(&challenge, 42431, None).unwrap();
assert_eq!(result, default_escrow_contract(42431).unwrap());
}
#[test]
fn test_resolve_escrow_override_takes_precedence_over_default() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123"
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
let override_addr: Address = "0x3333333333333333333333333333333333333333"
.parse()
.unwrap();
let result = resolve_escrow(&challenge, 42431, Some(override_addr)).unwrap();
assert_eq!(
result, override_addr,
"override should take precedence over default"
);
assert_ne!(result, default_escrow_contract(42431).unwrap());
}
#[test]
fn test_default_escrow_contract_known_chains() {
let mainnet = default_escrow_contract(4217).unwrap();
assert_eq!(
mainnet,
"0x33b901018174DDabE4841042ab76ba85D4e24f25"
.parse::<Address>()
.unwrap()
);
let moderato = default_escrow_contract(42431).unwrap();
assert_eq!(
moderato,
"0xe1c4d3dce17bc111181ddf716f75bae49e61a336"
.parse::<Address>()
.unwrap()
);
}
#[test]
fn test_default_escrow_contract_unknown_chain() {
assert!(default_escrow_contract(0).is_none());
assert!(default_escrow_contract(999999).is_none());
}
#[test]
fn test_build_credential_did_format() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let challenge = PaymentChallenge {
id: "test-id".to_string(),
realm: "api.example.com".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123"
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
let payload = SessionCredentialPayload::Voucher {
channel_id: "0xabc".to_string(),
descriptor: None,
settlement_route: None,
cumulative_amount: "5000".to_string(),
signature: "0xdef".to_string(),
};
let addr: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let cred = build_credential(&challenge, payload, 4217, addr);
let did = cred.source.as_ref().unwrap();
let expected = format!("did:pkh:eip155:4217:{}", addr);
assert_eq!(did, &expected, "DID should match exact pkh format");
}
#[cfg(feature = "evm")]
#[tokio::test]
async fn test_create_voucher_payload_zero_amount() {
use alloy::signers::local::PrivateKeySigner;
let signer = PrivateKeySigner::random();
let channel_id = B256::repeat_byte(0xAB);
let escrow: Address = "0x5555555555555555555555555555555555555555"
.parse()
.unwrap();
let payload = create_voucher_payload(&signer, channel_id, 0, escrow, 42431)
.await
.unwrap();
match payload {
SessionCredentialPayload::Voucher {
cumulative_amount, ..
} => {
assert_eq!(cumulative_amount, "0");
}
_ => panic!("Expected Voucher variant"),
}
}
#[cfg(feature = "evm")]
#[tokio::test]
async fn test_create_close_payload_zero_amount() {
use alloy::signers::local::PrivateKeySigner;
let signer = PrivateKeySigner::random();
let channel_id = B256::repeat_byte(0xCD);
let escrow: Address = "0x5555555555555555555555555555555555555555"
.parse()
.unwrap();
let payload = create_close_payload(&signer, channel_id, 0, escrow, 42431)
.await
.unwrap();
match payload {
SessionCredentialPayload::Close {
cumulative_amount, ..
} => {
assert_eq!(cumulative_amount, "0");
}
_ => panic!("Expected Close variant"),
}
}
#[cfg(feature = "evm")]
#[tokio::test]
async fn test_create_voucher_payload_large_amount() {
use alloy::signers::local::PrivateKeySigner;
let signer = PrivateKeySigner::random();
let channel_id = B256::repeat_byte(0xAB);
let escrow: Address = "0x5555555555555555555555555555555555555555"
.parse()
.unwrap();
let large_amount = u128::MAX;
let payload = create_voucher_payload(&signer, channel_id, large_amount, escrow, 42431)
.await
.unwrap();
match payload {
SessionCredentialPayload::Voucher {
cumulative_amount, ..
} => {
assert_eq!(cumulative_amount, u128::MAX.to_string());
}
_ => panic!("Expected Voucher variant"),
}
}
#[test]
fn test_channel_entry_debug() {
let entry = ChannelEntry {
channel_id: B256::ZERO,
salt: B256::ZERO,
cumulative_amount: 0,
deposit: 0,
descriptor: None,
settlement_route: None,
escrow_contract: Address::ZERO,
chain_id: 42431,
opened: false,
};
let debug = format!("{:?}", entry);
assert!(debug.contains("ChannelEntry"));
assert!(debug.contains("42431"));
}
#[test]
fn test_resolve_escrow_override_priority_order() {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
let escrow_addr = "0x2222222222222222222222222222222222222222";
let override_addr: Address = "0x3333333333333333333333333333333333333333"
.parse()
.unwrap();
let challenge = PaymentChallenge {
id: "test".to_string(),
realm: "test".to_string(),
method: "tempo".into(),
intent: "session".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000",
"unitType": "second",
"currency": "0x123",
"methodDetails": {
"escrowContract": escrow_addr
}
}))
.unwrap(),
expires: None,
description: None,
digest: None,
opaque: None,
};
let result = resolve_escrow(&challenge, 42431, Some(override_addr)).unwrap();
assert_eq!(
result, override_addr,
"override should take priority over challenge escrow"
);
}
fn recovery_accepts(
on_chain: &OnChainChannel,
expected_payer: Address,
expected_payee: Address,
expected_token: Address,
expected_authorized_signer: Address,
) -> bool {
let actual_authorized_signer = if on_chain.authorized_signer == Address::ZERO {
on_chain.payer
} else {
on_chain.authorized_signer
};
on_chain.deposit > 0
&& !on_chain.finalized
&& on_chain.close_requested_at == 0
&& on_chain.payer == expected_payer
&& on_chain.payee == expected_payee
&& on_chain.token == expected_token
&& actual_authorized_signer == expected_authorized_signer
}
#[test]
fn test_recovery_rejects_wrong_payer() {
let payer: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let payee: Address = "0x2222222222222222222222222222222222222222"
.parse()
.unwrap();
let token: Address = "0x3333333333333333333333333333333333333333"
.parse()
.unwrap();
let wrong_payer: Address = "0x9999999999999999999999999999999999999999"
.parse()
.unwrap();
let on_chain = OnChainChannel {
payer,
payee,
token,
authorized_signer: Address::ZERO,
deposit: 1_000,
settled: 0,
close_requested_at: 0,
finalized: false,
};
assert!(
recovery_accepts(&on_chain, payer, payee, token, payer),
"should accept when all fields match"
);
assert!(
!recovery_accepts(&on_chain, wrong_payer, payee, token, payer),
"should reject wrong payer"
);
}
#[test]
fn test_recovery_rejects_wrong_payee() {
let payer: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let payee: Address = "0x2222222222222222222222222222222222222222"
.parse()
.unwrap();
let token: Address = "0x3333333333333333333333333333333333333333"
.parse()
.unwrap();
let wrong_payee: Address = "0x9999999999999999999999999999999999999999"
.parse()
.unwrap();
let on_chain = OnChainChannel {
payer,
payee,
token,
authorized_signer: Address::ZERO,
deposit: 1_000,
settled: 0,
close_requested_at: 0,
finalized: false,
};
assert!(
!recovery_accepts(&on_chain, payer, wrong_payee, token, payer),
"should reject wrong payee"
);
}
#[test]
fn test_recovery_rejects_wrong_token() {
let payer: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let payee: Address = "0x2222222222222222222222222222222222222222"
.parse()
.unwrap();
let token: Address = "0x3333333333333333333333333333333333333333"
.parse()
.unwrap();
let wrong_token: Address = "0x9999999999999999999999999999999999999999"
.parse()
.unwrap();
let on_chain = OnChainChannel {
payer,
payee,
token,
authorized_signer: Address::ZERO,
deposit: 1_000,
settled: 0,
close_requested_at: 0,
finalized: false,
};
assert!(
!recovery_accepts(&on_chain, payer, payee, wrong_token, payer),
"should reject wrong token"
);
}
#[test]
fn test_recovery_rejects_wrong_authorized_signer() {
let payer: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let payee: Address = "0x2222222222222222222222222222222222222222"
.parse()
.unwrap();
let token: Address = "0x3333333333333333333333333333333333333333"
.parse()
.unwrap();
let client_signer: Address = "0x4444444444444444444444444444444444444444"
.parse()
.unwrap();
let wrong_signer: Address = "0x9999999999999999999999999999999999999999"
.parse()
.unwrap();
let on_chain = OnChainChannel {
payer,
payee,
token,
authorized_signer: wrong_signer,
deposit: 1_000,
settled: 0,
close_requested_at: 0,
finalized: false,
};
assert!(
!recovery_accepts(&on_chain, payer, payee, token, client_signer),
"should reject channel with wrong authorized_signer"
);
}
#[test]
fn test_recovery_accepts_zero_authorized_signer_when_payer_matches() {
let payer: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let payee: Address = "0x2222222222222222222222222222222222222222"
.parse()
.unwrap();
let token: Address = "0x3333333333333333333333333333333333333333"
.parse()
.unwrap();
let on_chain = OnChainChannel {
payer,
payee,
token,
authorized_signer: Address::ZERO,
deposit: 1_000,
settled: 0,
close_requested_at: 0,
finalized: false,
};
assert!(
recovery_accepts(&on_chain, payer, payee, token, payer),
"Address::ZERO authorized_signer should normalize to payer"
);
}
#[test]
fn test_recovery_rejects_pending_close() {
let payer: Address = "0x1111111111111111111111111111111111111111"
.parse()
.unwrap();
let payee: Address = "0x2222222222222222222222222222222222222222"
.parse()
.unwrap();
let token: Address = "0x3333333333333333333333333333333333333333"
.parse()
.unwrap();
let on_chain = OnChainChannel {
payer,
payee,
token,
authorized_signer: Address::ZERO,
deposit: 1_000,
settled: 0,
close_requested_at: 1_700_000_000,
finalized: false,
};
assert!(
!recovery_accepts(&on_chain, payer, payee, token, payer),
"should reject channel pending close"
);
}
}