pub mod charge;
pub mod transaction;
pub mod types;
#[cfg(feature = "server")]
pub mod method;
pub use charge::TempoChargeExt;
pub use transaction::{
Call, SignatureType, TempoTransaction, TempoTransactionRequest, TEMPO_SEND_TRANSACTION_METHOD,
TEMPO_TX_TYPE_ID,
};
pub use types::TempoMethodDetails;
#[cfg(feature = "server")]
pub use method::ChargeMethod;
pub const CHAIN_ID: u64 = 42431;
pub const METHOD_NAME: &str = "tempo";
pub const INTENT_CHARGE: &str = "charge";
#[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};
let encoded_request = Base64UrlJson::from_typed(request)?;
let id = generate_challenge_id(
secret_key,
realm,
METHOD_NAME,
INTENT_CHARGE,
encoded_request.raw(),
expires,
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,
})
}
pub fn generate_challenge_id(
secret_key: &str,
realm: &str,
method: &str,
intent: &str,
request: &str,
expires: Option<&str>,
digest: Option<&str>,
) -> String {
use crate::protocol::core::base64url_encode;
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let hmac_input = format!(
"{}|{}|{}|{}|{}|{}",
realm,
method,
intent,
request,
expires.unwrap_or(""),
digest.unwrap_or("")
);
let mut mac =
HmacSha256::new_from_slice(secret_key.as_bytes()).expect("HMAC can take key of any size");
mac.update(hmac_input.as_bytes());
let result = mac.finalize();
base64url_encode(&result.into_bytes())
}
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>,
) -> 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,
))
}
#[cfg(feature = "server")]
pub(crate) fn parse_iso8601_timestamp(s: &str) -> Option<u64> {
use time::format_description::well_known::Iso8601;
use time::OffsetDateTime;
OffsetDateTime::parse(s.trim(), &Iso8601::DEFAULT)
.ok()
.map(|dt| dt.unix_timestamp() as u64)
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_SECRET: &str = "test-secret-key";
#[test]
fn test_challenge_id_is_deterministic() {
let challenge1 = charge_challenge(
TEST_SECRET,
"api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000001",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
let challenge2 = charge_challenge(
TEST_SECRET,
"api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000001",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
assert_eq!(
challenge1.id, challenge2.id,
"Same parameters should produce same challenge ID"
);
}
#[test]
fn test_challenge_id_differs_for_different_params() {
let challenge1 = charge_challenge(
TEST_SECRET,
"api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000001",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
let challenge2 = charge_challenge(
TEST_SECRET,
"api.example.com",
"2000000", "0x20c0000000000000000000000000000000000001",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
assert_ne!(
challenge1.id, challenge2.id,
"Different parameters should produce different challenge IDs"
);
}
#[test]
fn test_challenge_id_differs_for_different_realm() {
let challenge1 = charge_challenge(
TEST_SECRET,
"api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000001",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
let challenge2 = charge_challenge(
TEST_SECRET,
"api.other.com", "1000000",
"0x20c0000000000000000000000000000000000001",
"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",
"0x20c0000000000000000000000000000000000001",
"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",
"0x20c0000000000000000000000000000000000001",
"0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
)
.unwrap();
let challenge2 = charge_challenge(
"secret-two", "api.example.com",
"1000000",
"0x20c0000000000000000000000000000000000001",
"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": "0x20c0000000000000000000000000000000000001",
"recipient": "0x1234567890abcdef1234567890abcdef12345678"
}),
None,
None,
)
.unwrap();
assert_eq!(id, "4Y_7cCtNrnPq0ujXFLOPsk4DRMctIFYxijKxrY5uob0");
}
#[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": "0x20c0000000000000000000000000000000000001",
"recipient": "0xabcdef1234567890abcdef1234567890abcdef12"
}),
Some("2026-01-29T12:00:00Z"),
None,
)
.unwrap();
assert_eq!(id, "02h24ab0XjVsKFwbyhz8HU9FacoT-21zV4FokI2U4YI");
}
#[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="),
)
.unwrap();
assert_eq!(id, "EAX2sqwdeg8Km8LIKRBFhM5xDQvEgIlbTif9FKBsOiU");
}
#[test]
fn test_full_challenge() {
let id = generate_challenge_id_from_request(
"production-secret-abc123",
"api.tempo.xyz",
"tempo",
"charge",
&json!({
"amount": "10000000",
"currency": "0x20c0000000000000000000000000000000000001",
"recipient": "0x742d35Cc6634C0532925a3b844Bc9e7595f1B0F2",
"description": "API access fee",
"externalId": "order-12345"
}),
Some("2026-02-01T00:00:00Z"),
Some("sha-256=abc123def456"),
)
.unwrap();
assert_eq!(id, "9uqa-bDFwDBiMIgJF-hytstRW_YgjpBUDCo5_SMSqG4");
}
#[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": "0x20c0000000000000000000000000000000000001",
"recipient": "0x1234567890abcdef1234567890abcdef12345678"
}),
None,
None,
)
.unwrap();
assert_eq!(id, "GaC7Gn_Fbbq98Tw-Eb7z4FadriU7GzNrAyC7ZcY3VRI");
}
#[test]
fn test_empty_request() {
let id = generate_challenge_id_from_request(
"test-key",
"test.example.com",
"tempo",
"authorize",
&json!({}),
None,
None,
)
.unwrap();
assert_eq!(id, "jUTqTVe3kCv5rVizv1XBCs9qKCLg4AZLwBUnk4N3MR8");
}
#[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,
)
.unwrap();
assert_eq!(id, "76lyru2p7i7Xw6fGTJtWzd9c7Z6mt33LIW7968Mlkz8");
}
#[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": "0x20c0000000000000000000000000000000000001",
"recipient": "0x2222222222222222222222222222222222222222",
"methodDetails": {
"chainId": 42431,
"feePayer": true
}
}),
None,
None,
)
.unwrap();
assert_eq!(id, "dyItTtUU31Gp2ckWrYXoeB2wZtS1OTVpXw81D_blwuk");
}
}
}