pub mod charge;
pub mod fee_payer_envelope;
pub mod network;
pub(crate) mod proof;
pub mod session;
pub mod session_receipt;
pub mod transaction;
pub mod transfers;
pub mod types;
pub mod voucher;
#[cfg(feature = "server")]
pub mod method;
#[cfg(feature = "server")]
pub mod session_method;
pub use charge::TempoChargeExt;
pub use fee_payer_envelope::{FeePayerEnvelope78, TEMPO_FEE_PAYER_ENVELOPE_TYPE_ID};
pub use network::TempoNetwork;
pub use session::{SessionCredentialPayload, TempoSessionExt, TempoSessionMethodDetails};
pub use session_receipt::SessionReceipt;
#[deprecated(since = "0.5.0", note = "renamed to session_receipt")]
pub use session_receipt as stream_receipt;
#[deprecated(since = "0.5.0", note = "renamed to SessionReceipt")]
pub type StreamReceipt = SessionReceipt;
pub use transaction::{
Call, SignatureType, TempoTransaction, TempoTransactionRequest, TEMPO_SEND_TRANSACTION_METHOD,
TEMPO_TX_TYPE_ID,
};
pub use transfers::{get_transfers, Transfer};
pub use types::{Split, TempoMethodDetails};
#[cfg(feature = "evm")]
pub use voucher::{compute_channel_id, sign_voucher, DOMAIN_NAME, DOMAIN_VERSION};
#[cfg(feature = "server")]
pub use method::ChargeMethod;
#[cfg(feature = "server")]
pub use session_method::{
ChannelState, ChannelStore, InMemoryChannelStore, SessionMethod, SessionMethodConfig,
};
pub const CHAIN_ID: u64 = 4217;
pub const MODERATO_CHAIN_ID: u64 = 42431;
pub const DEFAULT_RPC_URL: &str = "https://rpc.tempo.xyz";
pub const USDC: &str = "0x20C000000000000000000000b9537d11c60E8b50";
pub const PATH_USD: &str = "0x20c0000000000000000000000000000000000000";
pub const DEFAULT_CURRENCY_MAINNET: &str = USDC;
pub const DEFAULT_CURRENCY_TESTNET: &str = PATH_USD;
#[deprecated(
since = "0.6.0",
note = "use DEFAULT_CURRENCY_MAINNET or DEFAULT_CURRENCY_TESTNET"
)]
pub const DEFAULT_CURRENCY: &str = USDC;
pub const DEFAULT_EXPIRES_MINUTES: u64 = 5;
pub const METHOD_NAME: &str = "tempo";
pub use crate::protocol::intents::INTENT_CHARGE;
pub use crate::protocol::intents::INTENT_SESSION;
#[must_use = "this returns a new PaymentChallenge and does not have side effects"]
pub fn charge_challenge(
secret_key: &str,
realm: &str,
amount: &str,
currency: &str,
recipient: &str,
) -> crate::error::Result<crate::protocol::core::PaymentChallenge> {
let request = crate::protocol::intents::ChargeRequest {
amount: amount.to_string(),
currency: currency.to_string(),
recipient: Some(recipient.to_string()),
..Default::default()
};
charge_challenge_with_options(secret_key, realm, &request, None, None)
}
pub fn charge_challenge_with_options(
secret_key: &str,
realm: &str,
request: &crate::protocol::intents::ChargeRequest,
expires: Option<&str>,
description: Option<&str>,
) -> crate::error::Result<crate::protocol::core::PaymentChallenge> {
use crate::protocol::core::{Base64UrlJson, PaymentChallenge};
use crate::protocol::methods::tempo::transfers::get_request_transfers;
use time::{Duration, OffsetDateTime};
let request = request.clone().with_base_units()?;
get_request_transfers(&request)?;
let encoded_request = Base64UrlJson::from_typed(&request)?;
let default_expires;
let expires = match expires {
Some(e) => Some(e),
None => {
let expiry_time =
OffsetDateTime::now_utc() + Duration::minutes(DEFAULT_EXPIRES_MINUTES as i64);
default_expires = expiry_time
.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| {
crate::error::MppError::InvalidConfig(format!("failed to format expires: {e}"))
})?;
Some(default_expires.as_str())
}
};
let id = generate_challenge_id(
secret_key,
realm,
METHOD_NAME,
INTENT_CHARGE,
encoded_request.raw(),
expires,
None,
None,
);
Ok(PaymentChallenge {
id,
realm: realm.to_string(),
method: METHOD_NAME.into(),
intent: INTENT_CHARGE.into(),
request: encoded_request,
expires: expires.map(|s| s.to_string()),
description: description.map(|s| s.to_string()),
digest: None,
opaque: None,
})
}
#[allow(clippy::too_many_arguments)]
pub fn generate_challenge_id(
secret_key: &str,
realm: &str,
method: &str,
intent: &str,
request: &str,
expires: Option<&str>,
digest: Option<&str>,
opaque: Option<&str>,
) -> String {
crate::protocol::core::compute_challenge_id(
secret_key, realm, method, intent, request, expires, digest, opaque,
)
}
#[allow(clippy::too_many_arguments)]
pub fn generate_challenge_id_from_request(
secret_key: &str,
realm: &str,
method: &str,
intent: &str,
request: &serde_json::Value,
expires: Option<&str>,
digest: Option<&str>,
opaque: Option<&str>,
) -> crate::error::Result<String> {
use crate::protocol::core::base64url_encode;
let request_json = serde_json::to_string(request)?;
let request_b64 = base64url_encode(request_json.as_bytes());
Ok(generate_challenge_id(
secret_key,
realm,
method,
intent,
&request_b64,
expires,
digest,
opaque,
))
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &str = "test-secret-key";
#[test]
fn test_challenge_id_is_deterministic() {
use crate::protocol::intents::ChargeRequest;
let request = ChargeRequest {
amount: "1000000".into(),
currency: "0x20c0000000000000000000000000000000000000".into(),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
..Default::default()
};
let challenge1 = charge_challenge_with_options(
TEST_SECRET,
"api.example.com",
&request,
Some("2026-01-01T00:00:00Z"),
None,
)
.unwrap();
let challenge2 = charge_challenge_with_options(
TEST_SECRET,
"api.example.com",
&request,
Some("2026-01-01T00:00:00Z"),
None,
)
.unwrap();
assert_eq!(
challenge1.id, challenge2.id,
"Same parameters should produce same challenge ID"
);
}
#[test]
fn test_challenge_default_expires() {
let challenge = charge_challenge(
TEST_SECRET,
"api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
assert!(
challenge.expires.is_some(),
"Challenge should have default expires"
);
}
#[test]
fn test_challenge_id_differs_for_different_params() {
let challenge1 = charge_challenge(
TEST_SECRET,
"api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
let challenge2 = charge_challenge(
TEST_SECRET,
"api.example.com",
"2000000", "0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
assert_ne!(
challenge1.id, challenge2.id,
"Different parameters should produce different challenge IDs"
);
}
#[test]
fn test_charge_challenge_with_options_rejects_empty_splits() {
use crate::protocol::intents::ChargeRequest;
let request = ChargeRequest {
amount: "1000000".into(),
currency: "0x20c0000000000000000000000000000000000000".into(),
recipient: Some("0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2".into()),
method_details: Some(serde_json::json!({
"splits": []
})),
..Default::default()
};
let error = charge_challenge_with_options(
TEST_SECRET,
"api.example.com",
&request,
Some("2026-01-01T00:00:00Z"),
None,
)
.unwrap_err();
assert!(error.to_string().contains("Splits must not be empty"));
}
#[test]
fn test_challenge_id_differs_for_different_realm() {
let challenge1 = charge_challenge(
TEST_SECRET,
"api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
let challenge2 = charge_challenge(
TEST_SECRET,
"api.other.com", "1000000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
assert_ne!(
challenge1.id, challenge2.id,
"Different realms should produce different challenge IDs"
);
}
#[test]
fn test_challenge_id_format() {
let challenge = charge_challenge(
TEST_SECRET,
"api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
assert_eq!(
challenge.id.len(),
43,
"HMAC ID should be 43 base64url characters"
);
assert!(
challenge
.id
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
"ID should only contain base64url characters"
);
}
#[test]
fn test_challenge_id_differs_for_different_secret() {
let challenge1 = charge_challenge(
"secret-one",
"api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
let challenge2 = charge_challenge(
"secret-two", "api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000000",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
assert_ne!(
challenge1.id, challenge2.id,
"Different secrets should produce different challenge IDs"
);
}
mod cross_sdk_compatibility {
use super::*;
use serde_json::json;
#[test]
fn test_basic_charge() {
let id = generate_challenge_id_from_request(
"test-secret-key-12345",
"api.example.com",
"tempo",
"charge",
&json!({
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x1234567890abcdef1234567890abcdef12345678"
}),
None,
None,
None,
)
.unwrap();
assert_eq!(id, "XmJ98SdsAdzwP9Oa-8In322Uh6yweMO6rywdomWk_V4");
}
#[test]
fn test_with_expires() {
let id = generate_challenge_id_from_request(
"test-secret-key-12345",
"api.example.com",
"tempo",
"charge",
&json!({
"amount": "5000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0xabcdef1234567890abcdef1234567890abcdef12"
}),
Some("2026-01-29T12:00:00Z"),
None,
None,
)
.unwrap();
assert_eq!(id, "EvqUWMPJjqhoVJVG3mhTYVqCa3Mk7bUVd_OjeJGek1A");
}
#[test]
fn test_with_digest() {
let id = generate_challenge_id_from_request(
"my-server-secret",
"payments.example.org",
"tempo",
"charge",
&json!({
"amount": "250000",
"currency": "USD",
"recipient": "0x9999999999999999999999999999999999999999"
}),
None,
Some("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE="),
None,
)
.unwrap();
assert_eq!(id, "qcJUPoapy4bFLznQjQUutwPLyXW7FvALrWA_sMENgAY");
}
#[test]
fn test_full_challenge() {
let id = generate_challenge_id_from_request(
"production-secret-abc123",
"api.tempo.xyz",
"tempo",
"charge",
&json!({
"amount": "10000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
"description": "API access fee",
"externalId": "order-12345"
}),
Some("2026-02-01T00:00:00Z"),
Some("sha-256=abc123def456"),
None,
)
.unwrap();
assert_eq!(id, "JYtuAXY3wf1gGVFa4L0MsUOwQ90BQISm7wkepNiHbeM");
}
#[test]
fn test_different_secret_different_id() {
let id = generate_challenge_id_from_request(
"different-secret-key",
"api.example.com",
"tempo",
"charge",
&json!({
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x1234567890abcdef1234567890abcdef12345678"
}),
None,
None,
None,
)
.unwrap();
assert_eq!(id, "_o55RP0duNvJYtw9PXnf44mGyY5ajV_wwGzoGdTFuNs");
}
#[test]
fn test_empty_request() {
let id = generate_challenge_id_from_request(
"test-key",
"test.example.com",
"tempo",
"authorize",
&json!({}),
None,
None,
None,
)
.unwrap();
assert_eq!(id, "MYEC2oq3_B3cHa_My1Lx3NQKn_iUiMfsns6361N0SX0");
}
#[test]
fn test_unicode_in_description() {
let id = generate_challenge_id_from_request(
"unicode-test-key",
"api.example.com",
"tempo",
"charge",
&json!({
"amount": "100",
"currency": "EUR",
"recipient": "0x1111111111111111111111111111111111111111",
"description": "Payment for caf\u{00e9} \u{2615}"
}),
None,
None,
None,
)
.unwrap();
assert_eq!(id, "54NwtlhzrjHd2Qox2EaElEA9dl73bZ1Y-rig6Ri8Xy8");
}
#[test]
fn test_nested_method_details() {
let id = generate_challenge_id_from_request(
"nested-test-key",
"api.tempo.xyz",
"tempo",
"charge",
&json!({
"amount": "5000000",
"currency": "0x20c0000000000000000000000000000000000000",
"recipient": "0x2222222222222222222222222222222222222222",
"methodDetails": {
"chainId": 42431,
"feePayer": true
}
}),
None,
None,
None,
)
.unwrap();
assert_eq!(id, "BdPQxVJ56pvRJnM-r7wh_ppka5hOJH_6XpRK4gM9paI");
}
}
}