use serde::{Deserialize, Serialize};
use super::types::{Base64UrlJson, IntentName, MethodName, PayloadType, ReceiptStatus};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentChallenge {
pub id: String,
pub realm: String,
pub method: MethodName,
pub intent: IntentName,
pub request: Base64UrlJson,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub opaque: Option<Base64UrlJson>,
}
impl PaymentChallenge {
pub fn new(
id: impl Into<String>,
realm: impl Into<String>,
method: impl Into<MethodName>,
intent: impl Into<IntentName>,
request: Base64UrlJson,
) -> Self {
Self {
id: id.into(),
realm: realm.into(),
method: method.into(),
intent: intent.into(),
request,
expires: None,
description: None,
digest: None,
opaque: None,
}
}
pub fn with_secret_key(
secret_key: &str,
realm: impl Into<String>,
method: impl Into<MethodName>,
intent: impl Into<IntentName>,
request: Base64UrlJson,
) -> Self {
let realm = realm.into();
let method = method.into();
let intent = intent.into();
let id = compute_challenge_id(
secret_key,
&realm,
method.as_str(),
intent.as_str(),
request.raw(),
None,
None,
None,
);
Self {
id,
realm,
method,
intent,
request,
expires: None,
description: None,
digest: None,
opaque: None,
}
}
#[allow(clippy::too_many_arguments)]
pub fn with_secret_key_full(
secret_key: &str,
realm: impl Into<String>,
method: impl Into<MethodName>,
intent: impl Into<IntentName>,
request: Base64UrlJson,
expires: Option<&str>,
digest: Option<&str>,
description: Option<&str>,
opaque: Option<Base64UrlJson>,
) -> Self {
let realm = realm.into();
let method = method.into();
let intent = intent.into();
let id = compute_challenge_id(
secret_key,
&realm,
method.as_str(),
intent.as_str(),
request.raw(),
expires,
digest,
opaque.as_ref().map(|o| o.raw()),
);
Self {
id,
realm,
method,
intent,
request,
expires: expires.map(String::from),
description: description.map(String::from),
digest: digest.map(String::from),
opaque,
}
}
pub fn with_expires(mut self, expires: impl Into<String>) -> Self {
self.expires = Some(expires.into());
self
}
pub fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
pub fn with_digest(mut self, digest: impl Into<String>) -> Self {
self.digest = Some(digest.into());
self
}
pub fn with_opaque(mut self, opaque: Base64UrlJson) -> Self {
self.opaque = Some(opaque);
self
}
pub fn effective_expires(&self) -> Option<&str> {
self.expires.as_deref()
}
pub fn to_echo(&self) -> ChallengeEcho {
ChallengeEcho {
id: self.id.clone(),
realm: self.realm.clone(),
method: self.method.clone(),
intent: self.intent.clone(),
request: self.request.clone(),
expires: self.expires.clone(),
digest: self.digest.clone(),
opaque: self.opaque.clone(),
}
}
pub fn to_header(&self) -> crate::error::Result<String> {
super::format_www_authenticate(self)
}
pub fn from_header(header: &str) -> crate::error::Result<Self> {
super::parse_www_authenticate(header)
}
pub fn from_headers<'a>(
headers: impl IntoIterator<Item = &'a str>,
) -> Vec<crate::error::Result<Self>> {
super::parse_www_authenticate_all(headers)
}
pub fn from_response(status_code: u16, www_authenticate: &str) -> crate::error::Result<Self> {
if status_code != 402 {
return Err(crate::error::MppError::invalid_challenge_reason(format!(
"Expected 402 status, got {}",
status_code
)));
}
Self::from_header(www_authenticate)
}
pub fn verify(&self, secret_key: &str) -> bool {
let expected_id = compute_challenge_id(
secret_key,
&self.realm,
self.method.as_str(),
self.intent.as_str(),
self.request.raw(),
self.expires.as_deref(),
self.digest.as_deref(),
self.opaque.as_ref().map(|o| o.raw()),
);
constant_time_eq(&self.id, &expected_id)
}
pub fn is_expired(&self) -> bool {
match &self.expires {
None => false,
Some(s) => {
match time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339)
{
Ok(expires) => expires <= time::OffsetDateTime::now_utc(),
Err(_) => true, }
}
}
}
pub fn expires_at(&self) -> Option<time::OffsetDateTime> {
self.expires.as_ref().and_then(|s| {
time::OffsetDateTime::parse(s, &time::format_description::well_known::Rfc3339).ok()
})
}
pub fn validate_for_charge(&self, method: &str) -> crate::error::Result<()> {
if !self.method.eq_ignore_ascii_case(method) {
return Err(crate::error::MppError::UnsupportedPaymentMethod(format!(
"Payment method '{}' is not supported. Supported methods: {}",
self.method, method
)));
}
if !self.intent.is_charge() {
return Err(crate::error::MppError::InvalidChallenge {
id: Some(self.id.clone()),
reason: Some(format!(
"Only 'charge' intent is supported, got: {}",
self.intent
)),
});
}
if self.is_expired() {
return Err(crate::error::MppError::PaymentExpired(self.expires.clone()));
}
Ok(())
}
pub fn validate_for_session(&self, method: &str) -> crate::error::Result<()> {
if !self.method.eq_ignore_ascii_case(method) {
return Err(crate::error::MppError::UnsupportedPaymentMethod(format!(
"Payment method '{}' is not supported. Supported methods: {}",
self.method, method
)));
}
if !self.intent.is_session() {
return Err(crate::error::MppError::InvalidChallenge {
id: Some(self.id.clone()),
reason: Some(format!("Expected 'session' intent, got: {}", self.intent)),
});
}
if self.is_expired() {
return Err(crate::error::MppError::PaymentExpired(self.expires.clone()));
}
Ok(())
}
}
#[allow(clippy::too_many_arguments)]
pub fn compute_challenge_id(
secret_key: &str,
realm: &str,
method: &str,
intent: &str,
request: &str,
expires: Option<&str>,
digest: Option<&str>,
opaque: Option<&str>,
) -> String {
use hmac::{Hmac, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let hmac_input = [
realm,
method,
intent,
request,
expires.unwrap_or(""),
digest.unwrap_or(""),
opaque.unwrap_or(""),
]
.join("|");
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();
super::base64url_encode(&result.into_bytes())
}
fn constant_time_eq(a: &str, b: &str) -> bool {
if a.len() != b.len() {
return false;
}
let mut result = 0u8;
for (x, y) in a.bytes().zip(b.bytes()) {
result |= x ^ y;
}
result == 0
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChallengeEcho {
pub id: String,
pub realm: String,
pub method: MethodName,
pub intent: IntentName,
pub request: Base64UrlJson,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub opaque: Option<Base64UrlJson>,
}
#[derive(Debug, Clone)]
pub struct PaymentPayload {
pub payload_type: PayloadType,
data: String,
}
impl serde::Serialize for PaymentPayload {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
let mut state = serializer.serialize_struct("PaymentPayload", 2)?;
state.serialize_field("type", &self.payload_type)?;
match self.payload_type {
PayloadType::Transaction => state.serialize_field("signature", &self.data)?,
PayloadType::Hash => state.serialize_field("hash", &self.data)?,
}
state.end()
}
}
impl<'de> serde::Deserialize<'de> for PaymentPayload {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct RawPayload {
#[serde(rename = "type")]
payload_type: PayloadType,
signature: Option<String>,
hash: Option<String>,
}
let raw = RawPayload::deserialize(deserializer)?;
let data = match raw.payload_type {
PayloadType::Transaction => raw.signature.ok_or_else(|| {
serde::de::Error::custom("transaction payload requires 'signature' field")
})?,
PayloadType::Hash => raw
.hash
.ok_or_else(|| serde::de::Error::custom("hash payload requires 'hash' field"))?,
};
Ok(PaymentPayload {
payload_type: raw.payload_type,
data,
})
}
}
impl PaymentPayload {
pub fn transaction(signature: impl Into<String>) -> Self {
Self {
payload_type: PayloadType::Transaction,
data: signature.into(),
}
}
pub fn hash(tx_hash: impl Into<String>) -> Self {
Self {
payload_type: PayloadType::Hash,
data: tx_hash.into(),
}
}
pub fn payload_type(&self) -> PayloadType {
self.payload_type.clone()
}
pub fn data(&self) -> &str {
&self.data
}
pub fn tx_hash(&self) -> Option<&str> {
if self.payload_type == PayloadType::Hash {
Some(&self.data)
} else {
None
}
}
pub fn signed_tx(&self) -> Option<&str> {
if self.payload_type == PayloadType::Transaction {
Some(&self.data)
} else {
None
}
}
pub fn is_transaction(&self) -> bool {
self.payload_type == PayloadType::Transaction
}
pub fn is_hash(&self) -> bool {
self.payload_type == PayloadType::Hash
}
pub fn reference(&self) -> &str {
&self.data
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PaymentCredential {
pub challenge: ChallengeEcho,
#[serde(skip_serializing_if = "Option::is_none")]
pub source: Option<String>,
pub payload: serde_json::Value,
}
impl PaymentCredential {
pub fn new(challenge: ChallengeEcho, payload: impl Serialize) -> Self {
Self {
challenge,
source: None,
payload: serde_json::to_value(payload).expect("payload must be serializable"),
}
}
pub fn with_source(
challenge: ChallengeEcho,
source: impl Into<String>,
payload: impl Serialize,
) -> Self {
Self {
challenge,
source: Some(source.into()),
payload: serde_json::to_value(payload).expect("payload must be serializable"),
}
}
pub fn charge_payload(&self) -> crate::error::Result<PaymentPayload> {
serde_json::from_value(self.payload.clone()).map_err(|e| {
crate::error::MppError::invalid_payload(format!("not a charge payload: {}", e))
})
}
pub fn from_header(header: &str) -> crate::error::Result<Self> {
super::parse_authorization(header)
}
pub fn payload_as<T: serde::de::DeserializeOwned>(&self) -> crate::error::Result<T> {
serde_json::from_value(self.payload.clone()).map_err(|e| {
crate::error::MppError::invalid_payload(format!(
"payload deserialization failed: {}",
e
))
})
}
pub fn evm_did(chain_id: u64, address: &str) -> String {
format!("did:pkh:eip155:{}:{}", chain_id, address)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Receipt {
pub status: ReceiptStatus,
pub method: MethodName,
pub timestamp: String,
pub reference: String,
}
impl Receipt {
#[must_use]
pub fn success(method: impl Into<MethodName>, reference: impl Into<String>) -> Self {
Self {
status: ReceiptStatus::Success,
method: method.into(),
timestamp: now_iso8601(),
reference: reference.into(),
}
}
pub fn is_success(&self) -> bool {
self.status == ReceiptStatus::Success
}
pub fn to_header(&self) -> crate::error::Result<String> {
super::format_receipt(self)
}
pub fn from_header(header: &str) -> crate::error::Result<Self> {
super::parse_receipt(header)
}
pub fn from_response(receipt_header: &str) -> crate::error::Result<Self> {
Self::from_header(receipt_header)
}
}
fn now_iso8601() -> String {
use time::format_description::well_known::Iso8601;
use time::OffsetDateTime;
OffsetDateTime::now_utc()
.format(&Iso8601::DEFAULT)
.unwrap_or_else(|_| "1970-01-01T00:00:00Z".to_string())
}
pub fn extract_tx_hash(receipt_b64: &str) -> Option<String> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let decoded = URL_SAFE_NO_PAD.decode(receipt_b64.trim()).ok()?;
let json: serde_json::Value = serde_json::from_slice(&decoded).ok()?;
json.get("txHash")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn test_challenge() -> PaymentChallenge {
PaymentChallenge {
id: "abc123".to_string(),
realm: "api".to_string(),
method: "tempo".into(),
intent: "charge".into(),
request: Base64UrlJson::from_value(&serde_json::json!({
"amount": "10000",
"currency": "0x123"
}))
.unwrap(),
expires: Some("2024-01-01T00:00:00Z".to_string()),
description: None,
digest: None,
opaque: None,
}
}
#[test]
fn test_challenge_to_echo() {
let challenge = test_challenge();
let echo = challenge.to_echo();
assert_eq!(echo.id, "abc123");
assert_eq!(echo.realm, "api");
assert_eq!(echo.method.as_str(), "tempo");
assert_eq!(echo.intent.as_str(), "charge");
assert_eq!(echo.request.raw(), challenge.request.raw());
}
#[test]
fn test_payment_payload_constructors() {
let tx = PaymentPayload::transaction("0xabc");
assert_eq!(tx.payload_type(), PayloadType::Transaction);
assert!(tx.is_transaction());
assert_eq!(tx.data(), "0xabc");
assert_eq!(tx.signed_tx(), Some("0xabc"));
assert_eq!(tx.tx_hash(), None);
let hash = PaymentPayload::hash("0xdef");
assert_eq!(hash.payload_type(), PayloadType::Hash);
assert!(hash.is_hash());
assert_eq!(hash.tx_hash(), Some("0xdef"));
assert_eq!(hash.data(), "0xdef");
assert_eq!(hash.signed_tx(), None);
}
#[test]
fn test_payment_payload_serialization() {
let tx = PaymentPayload::transaction("0xabc");
let json = serde_json::to_string(&tx).unwrap();
assert!(json.contains("\"signature\":\"0xabc\""));
assert!(json.contains("\"type\":\"transaction\""));
assert!(!json.contains("\"hash\""));
let hash = PaymentPayload::hash("0xdef");
let json = serde_json::to_string(&hash).unwrap();
assert!(json.contains("\"hash\":\"0xdef\""));
assert!(json.contains("\"type\":\"hash\""));
assert!(!json.contains("\"signature\""));
}
#[test]
fn test_payment_payload_deserialization() {
let hash_json = r#"{"type":"hash","hash":"0xdef123"}"#;
let payload: PaymentPayload = serde_json::from_str(hash_json).unwrap();
assert!(payload.is_hash());
assert_eq!(payload.tx_hash(), Some("0xdef123"));
let tx_json = r#"{"type":"transaction","signature":"0xabc456"}"#;
let payload: PaymentPayload = serde_json::from_str(tx_json).unwrap();
assert!(payload.is_transaction());
assert_eq!(payload.signed_tx(), Some("0xabc456"));
}
#[test]
fn test_payment_payload_strict_field_enforcement() {
let bad_hash = r#"{"type":"hash","signature":"0xdef123"}"#;
let result: Result<PaymentPayload, _> = serde_json::from_str(bad_hash);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("hash"));
let bad_tx = r#"{"type":"transaction","hash":"0xabc456"}"#;
let result: Result<PaymentPayload, _> = serde_json::from_str(bad_tx);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("signature"));
}
#[test]
fn test_payment_credential_serialization() {
let challenge = test_challenge();
let credential = PaymentCredential::with_source(
challenge.to_echo(),
"did:pkh:eip155:42431:0x123",
PaymentPayload::transaction("0xabc"),
);
let json = serde_json::to_string(&credential).unwrap();
assert!(json.contains("\"id\":\"abc123\""));
assert!(json.contains("did:pkh:eip155:42431:0x123"));
assert!(json.contains("\"type\":\"transaction\""));
}
#[test]
fn test_payment_credential_charge_payload() {
let challenge = test_challenge();
let credential = PaymentCredential::new(challenge.to_echo(), PaymentPayload::hash("0xdef"));
let payload = credential.charge_payload().unwrap();
assert!(payload.is_hash());
assert_eq!(payload.tx_hash(), Some("0xdef"));
}
#[test]
fn test_payment_credential_arbitrary_json_payload() {
let challenge = test_challenge();
let payload_json = serde_json::json!({
"action": "voucher",
"channelId": "0xabc",
"cumulativeAmount": "5000",
"signature": "0xdef"
});
let credential = PaymentCredential::new(challenge.to_echo(), payload_json.clone());
assert_eq!(credential.payload, payload_json);
assert!(credential.charge_payload().is_err());
}
#[test]
fn test_payment_credential_payload_as() {
let challenge = test_challenge();
let credential =
PaymentCredential::new(challenge.to_echo(), PaymentPayload::transaction("0xabc"));
let payload: PaymentPayload = credential.payload_as().unwrap();
assert!(payload.is_transaction());
assert_eq!(payload.signed_tx(), Some("0xabc"));
}
#[test]
fn test_evm_did() {
let did = PaymentCredential::evm_did(42431, "0x1234abcd");
assert_eq!(did, "did:pkh:eip155:42431:0x1234abcd");
}
#[test]
fn test_payment_receipt_status() {
let success = Receipt {
status: ReceiptStatus::Success,
method: "tempo".into(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
reference: "0xabc".to_string(),
};
assert!(success.is_success());
}
#[test]
fn test_challenge_from_header() {
let header = r#"Payment id="abc123", realm="api", method="tempo", intent="charge", request="eyJhbW91bnQiOiIxMDAwIn0""#;
let challenge = PaymentChallenge::from_header(header).unwrap();
assert_eq!(challenge.id, "abc123");
assert_eq!(challenge.method.as_str(), "tempo");
}
#[test]
fn test_challenge_from_headers() {
let headers = vec![
"Bearer token",
r#"Payment id="a", realm="api", method="tempo", intent="charge", request="e30""#,
r#"Payment id="b", realm="api", method="base", intent="charge", request="e30""#,
];
let results = PaymentChallenge::from_headers(headers);
assert_eq!(results.len(), 2);
}
#[test]
fn test_credential_from_header() {
let challenge = test_challenge();
let credential = PaymentCredential::with_source(
challenge.to_echo(),
"did:pkh:eip155:42431:0x123",
PaymentPayload::transaction("0xabc"),
);
let header = crate::protocol::core::format_authorization(&credential).unwrap();
let parsed = PaymentCredential::from_header(&header).unwrap();
assert_eq!(parsed.challenge.id, "abc123");
}
#[test]
fn test_receipt_from_header() {
let receipt = Receipt::success("tempo", "0xabc123");
let header = receipt.to_header().unwrap();
let parsed = Receipt::from_header(&header).unwrap();
assert!(parsed.is_success());
assert_eq!(parsed.reference, "0xabc123");
}
#[test]
fn test_challenge_verify_valid() {
let secret = "test-secret";
let request = Base64UrlJson::from_value(&serde_json::json!({
"amount": "1000000",
"currency": "0x20c0000000000000000000000000000000000000"
}))
.unwrap();
let id = compute_challenge_id(
secret,
"api.example.com",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
let challenge = PaymentChallenge {
id,
realm: "api.example.com".to_string(),
method: "tempo".into(),
intent: "charge".into(),
request,
expires: None,
description: None,
digest: None,
opaque: None,
};
assert!(challenge.verify(secret));
}
#[test]
fn test_challenge_verify_wrong_secret() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let id = compute_challenge_id(
"correct-secret",
"api",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
let challenge = PaymentChallenge {
id,
realm: "api".to_string(),
method: "tempo".into(),
intent: "charge".into(),
request,
expires: None,
description: None,
digest: None,
opaque: None,
};
assert!(!challenge.verify("wrong-secret"));
}
#[test]
fn test_challenge_verify_tampered_id() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge {
id: "tampered-id".to_string(),
realm: "api".to_string(),
method: "tempo".into(),
intent: "charge".into(),
request,
expires: None,
description: None,
digest: None,
opaque: None,
};
assert!(!challenge.verify("any-secret"));
}
#[test]
fn test_challenge_verify_with_expires_and_digest() {
let secret = "my-secret";
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "500"})).unwrap();
let expires = Some("2026-01-01T00:00:00Z");
let digest = Some("sha-256=abc123");
let id = compute_challenge_id(
secret,
"payments.example.org",
"tempo",
"charge",
request.raw(),
expires,
digest,
None,
);
let challenge = PaymentChallenge {
id,
realm: "payments.example.org".to_string(),
method: "tempo".into(),
intent: "charge".into(),
request,
expires: expires.map(String::from),
description: Some("test payment".to_string()),
digest: digest.map(String::from),
opaque: None,
};
assert!(challenge.verify(secret));
}
#[test]
fn test_compute_challenge_id_cross_sdk() {
let id = compute_challenge_id(
"test-secret-key-12345",
"api.example.com",
"tempo",
"charge",
&crate::protocol::core::base64url_encode(
br#"{"amount":"1000000","currency":"0x20c0000000000000000000000000000000000000","recipient":"0x1234567890abcdef1234567890abcdef12345678"}"#,
),
None,
None,
None,
);
assert_eq!(id, "XmJ98SdsAdzwP9Oa-8In322Uh6yweMO6rywdomWk_V4");
}
#[test]
fn test_golden_vectors() {
use crate::protocol::core::base64url_encode as b64;
let secret = "test-vector-secret";
let req_amount = b64(br#"{"amount":"1000000"}"#);
let req_multi = b64(br#"{"amount":"1000000","currency":"0x1234","recipient":"0xabcd"}"#);
let req_nested =
b64(br#"{"amount":"1000000","currency":"0x1234","methodDetails":{"chainId":42431}}"#);
let req_empty = b64(br#"{}"#);
#[allow(clippy::type_complexity)]
let vectors: Vec<(
&str,
&str,
&str,
&str,
&str,
Option<&str>,
Option<&str>,
&str,
)> = vec![
(
"required fields only",
"api.example.com",
"tempo",
"charge",
&req_amount,
None,
None,
"X6v1eo7fJ76gAxqY0xN9Jd__4lUyDDYmriryOM-5FO4",
),
(
"with expires",
"api.example.com",
"tempo",
"charge",
&req_amount,
Some("2025-01-06T12:00:00Z"),
None,
"ChPX33RkKSZoSUyZcu8ai4hhkvjZJFkZVnvWs5s0iXI",
),
(
"with digest",
"api.example.com",
"tempo",
"charge",
&req_amount,
None,
Some("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE"),
"JHB7EFsPVb-xsYCo8LHcOzeX1gfXWVoUSzQsZhKAfKM",
),
(
"with expires and digest",
"api.example.com",
"tempo",
"charge",
&req_amount,
Some("2025-01-06T12:00:00Z"),
Some("sha-256=X48E9qOokqqrvdts8nOJRJN3OWDUoyWxBf7kbu9DBPE"),
"m39jbWWCIfmfJZSwCfvKFFtBl0Qwf9X4nOmDb21peLA",
),
(
"multi-field request",
"api.example.com",
"tempo",
"charge",
&req_multi,
None,
None,
"_H5TOnnlW0zduQ5OhQ3EyLVze_TqxLDPda2CGZPZxOc",
),
(
"nested methodDetails",
"api.example.com",
"tempo",
"charge",
&req_nested,
None,
None,
"TqujwpuDDg_zsWGINAd5XObO2rRe6uYufpqvtDmr6N8",
),
(
"empty request",
"api.example.com",
"tempo",
"charge",
&req_empty,
None,
None,
"yLN7yChAejW9WNmb54HpJIWpdb1WWXeA3_aCx4dxmkU",
),
(
"different realm",
"payments.other.com",
"tempo",
"charge",
&req_amount,
None,
None,
"3F5bOo2a9RUihdwKk4hGRvBvzQmVPBMDvW0YM-8GD00",
),
(
"different method",
"api.example.com",
"stripe",
"charge",
&req_amount,
None,
None,
"o0ra2sd7HcB4Ph0Vns69gRDUhSj5WNOnUopcDqKPLz4",
),
(
"different intent",
"api.example.com",
"tempo",
"session",
&req_amount,
None,
None,
"aAY7_IEDzsznNYplhOSE8cERQxvjFcT4Lcn-7FHjLVE",
),
];
for (label, realm, method, intent, request, expires, digest, expected) in &vectors {
let id = compute_challenge_id(
secret, realm, method, intent, request, *expires, *digest, None,
);
assert_eq!(&id, expected, "golden vector failed: {}", label);
}
}
#[test]
fn test_challenge_serialize_includes_digest() {
let mut challenge = test_challenge();
challenge.digest = Some("sha-256=abc".to_string());
let header = challenge.to_header().unwrap();
assert!(header.contains(r#"digest="sha-256=abc""#));
assert!(header.contains("expires="));
}
#[test]
fn test_challenge_roundtrip_with_digest() {
let mut challenge = test_challenge();
challenge.digest = Some("sha-256=abc".to_string());
let header = challenge.to_header().unwrap();
let parsed = PaymentChallenge::from_header(&header).unwrap();
assert_eq!(parsed.digest.as_deref(), Some("sha-256=abc"));
assert_eq!(parsed.expires, challenge.expires);
}
#[test]
fn test_challenge_from_response_402() {
let challenge = test_challenge();
let header = challenge.to_header().unwrap();
let parsed = PaymentChallenge::from_response(402, &header).unwrap();
assert_eq!(parsed.id, challenge.id);
assert_eq!(parsed.realm, challenge.realm);
assert_eq!(parsed.method.as_str(), challenge.method.as_str());
assert_eq!(parsed.intent.as_str(), challenge.intent.as_str());
}
#[test]
fn test_challenge_from_response_non_402() {
let challenge = test_challenge();
let header = challenge.to_header().unwrap();
let result = PaymentChallenge::from_response(401, &header);
assert!(result.is_err());
}
#[test]
fn test_compute_challenge_id_deterministic() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let id1 = compute_challenge_id(
"secret",
"api",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
let id2 = compute_challenge_id(
"secret",
"api",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
assert_eq!(id1, id2);
}
#[test]
fn test_compute_challenge_id_different_secrets() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let id1 = compute_challenge_id(
"secret-a",
"api",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
let id2 = compute_challenge_id(
"secret-b",
"api",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
assert_ne!(id1, id2);
}
#[test]
fn test_challenge_verify_tampered_request() {
let secret = "test-secret";
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let id = compute_challenge_id(
secret,
"api",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
let tampered_request =
Base64UrlJson::from_value(&serde_json::json!({"amount": "9999"})).unwrap();
let challenge = PaymentChallenge {
id,
realm: "api".to_string(),
method: "tempo".into(),
intent: "charge".into(),
request: tampered_request,
expires: None,
description: None,
digest: None,
opaque: None,
};
assert!(!challenge.verify(secret));
}
#[test]
fn test_challenge_new() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge =
PaymentChallenge::new("my-id", "api.example.com", "tempo", "charge", request);
assert_eq!(challenge.id, "my-id");
assert_eq!(challenge.realm, "api.example.com");
assert_eq!(challenge.method.as_str(), "tempo");
assert_eq!(challenge.intent.as_str(), "charge");
assert!(challenge.expires.is_none());
assert!(challenge.description.is_none());
assert!(challenge.digest.is_none());
}
#[test]
fn test_challenge_with_secret_key() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::with_secret_key(
"my-secret",
"api.example.com",
"tempo",
"charge",
request,
);
assert!(challenge.verify("my-secret"));
assert!(!challenge.verify("wrong-secret"));
}
#[test]
fn test_challenge_with_secret_key_full() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::with_secret_key_full(
"my-secret",
"api.example.com",
"tempo",
"charge",
request,
Some("2026-01-01T00:00:00Z"),
Some("sha-256=abc"),
Some("test payment"),
None,
);
assert!(challenge.verify("my-secret"));
assert_eq!(challenge.expires.as_deref(), Some("2026-01-01T00:00:00Z"));
assert_eq!(challenge.digest.as_deref(), Some("sha-256=abc"));
assert_eq!(challenge.description.as_deref(), Some("test payment"));
}
#[test]
fn test_opaque_affects_challenge_id() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000000"})).unwrap();
let id_without = compute_challenge_id(
"test-secret",
"api.example.com",
"tempo",
"charge",
request.raw(),
None,
None,
None,
);
let opaque =
Base64UrlJson::from_value(&serde_json::json!({"pi": "pi_3abc123XYZ"})).unwrap();
let id_with = compute_challenge_id(
"test-secret",
"api.example.com",
"tempo",
"charge",
request.raw(),
None,
None,
Some(opaque.raw()),
);
assert_ne!(id_without, id_with);
}
#[test]
fn test_opaque_verify_roundtrip() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000000"})).unwrap();
let opaque =
Base64UrlJson::from_value(&serde_json::json!({"pi": "pi_3abc123XYZ"})).unwrap();
let opaque_raw = opaque.raw().to_string();
let challenge = PaymentChallenge::with_secret_key_full(
"my-secret",
"api.example.com",
"tempo",
"charge",
request,
None,
None,
None,
Some(opaque),
);
assert_eq!(challenge.opaque.as_ref().unwrap().raw(), opaque_raw);
assert!(challenge.verify("my-secret"));
}
#[test]
fn test_opaque_tamper_fails_verify() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000000"})).unwrap();
let opaque =
Base64UrlJson::from_value(&serde_json::json!({"pi": "pi_3abc123XYZ"})).unwrap();
let mut challenge = PaymentChallenge::with_secret_key_full(
"my-secret",
"api.example.com",
"tempo",
"charge",
request,
None,
None,
None,
Some(opaque),
);
let tampered =
Base64UrlJson::from_value(&serde_json::json!({"pi": "pi_TAMPERED"})).unwrap();
challenge.opaque = Some(tampered);
assert!(!challenge.verify("my-secret"));
}
#[test]
fn test_opaque_echo_roundtrip() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000000"})).unwrap();
let opaque =
Base64UrlJson::from_value(&serde_json::json!({"pi": "pi_3abc123XYZ"})).unwrap();
let opaque_raw = opaque.raw().to_string();
let challenge = PaymentChallenge::with_secret_key_full(
"my-secret",
"api.example.com",
"tempo",
"charge",
request,
None,
None,
None,
Some(opaque),
);
let echo = challenge.to_echo();
assert_eq!(
echo.opaque.as_ref().map(|o| o.raw()),
Some(opaque_raw.as_str())
);
}
#[test]
fn test_opaque_golden_vectors() {
let secret = "test-vector-secret";
let req = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000000"})).unwrap();
let opaque1 =
Base64UrlJson::from_value(&serde_json::json!({"pi": "pi_3abc123XYZ"})).unwrap();
let id1 = compute_challenge_id(
secret,
"api.example.com",
"tempo",
"charge",
req.raw(),
None,
None,
Some(opaque1.raw()),
);
assert_eq!(
id1, "rxzKZ2qjXvinqCH96RORTZEPs1KXsA-0AUjrCAPFOWc",
"opaque golden vector failed: with opaque"
);
let id2 = compute_challenge_id(
secret,
"api.example.com",
"tempo",
"charge",
req.raw(),
Some("2025-01-06T12:00:00Z"),
None,
Some(opaque1.raw()),
);
assert_eq!(
id2, "KAfoMrA4fnzS1DPWN_cUv_b3_yHxCizdp6OhH7gluMY",
"opaque golden vector failed: with opaque and expires"
);
let opaque_empty = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let id3 = compute_challenge_id(
secret,
"api.example.com",
"tempo",
"charge",
req.raw(),
None,
None,
Some(opaque_empty.raw()),
);
assert_eq!(
id3, "vb4IyH-0LdJ3s7L0QAw8jIzcZkyxksPhIvEfmHmzA9k",
"opaque golden vector failed: with empty opaque"
);
let opaque_multi = Base64UrlJson::from_value(
&serde_json::json!({"deposit": "dep_456", "pi": "pi_3abc123XYZ"}),
)
.unwrap();
let id4 = compute_challenge_id(
secret,
"api.example.com",
"tempo",
"charge",
req.raw(),
None,
None,
Some(opaque_multi.raw()),
);
assert_eq!(
id4, "aKskU8sadR5ZuFbUCsIwhO-ENxuVpTw17FdwHEXsJDk",
"opaque golden vector failed: with multi-key opaque"
);
}
#[test]
fn test_opaque_header_roundtrip_with_hmac() {
let opaque =
Base64UrlJson::from_value(&serde_json::json!({"pi": "pi_3abc123XYZ"})).unwrap();
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000000"})).unwrap();
let challenge = PaymentChallenge::with_secret_key_full(
"test-secret",
"api.example.com",
"tempo",
"charge",
request,
Some("2025-01-06T12:00:00Z"),
None,
None,
Some(opaque),
);
assert!(challenge.verify("test-secret"));
let header = challenge.to_header().unwrap();
assert!(header.contains("opaque="));
let parsed = PaymentChallenge::from_header(&header).unwrap();
assert!(parsed.opaque.is_some());
assert_eq!(
parsed.opaque.as_ref().unwrap().raw(),
challenge.opaque.as_ref().unwrap().raw()
);
let decoded: std::collections::HashMap<String, String> =
parsed.opaque.unwrap().decode().unwrap();
assert_eq!(decoded.get("pi").unwrap(), "pi_3abc123XYZ");
}
#[test]
fn test_opaque_decode_to_hashmap() {
let opaque = Base64UrlJson::from_value(
&serde_json::json!({"deposit": "dep_456", "pi": "pi_3abc123XYZ"}),
)
.unwrap();
let decoded: std::collections::HashMap<String, String> = opaque.decode().unwrap();
assert_eq!(decoded.len(), 2);
assert_eq!(decoded.get("pi").unwrap(), "pi_3abc123XYZ");
assert_eq!(decoded.get("deposit").unwrap(), "dep_456");
}
#[test]
fn test_with_opaque_builder() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let opaque = Base64UrlJson::from_value(&serde_json::json!({"key": "val"})).unwrap();
let challenge =
PaymentChallenge::new("id", "api", "tempo", "charge", request).with_opaque(opaque);
assert!(challenge.opaque.is_some());
let decoded: std::collections::HashMap<String, String> =
challenge.opaque.unwrap().decode().unwrap();
assert_eq!(decoded.get("key").unwrap(), "val");
}
#[test]
fn test_challenge_builder_methods() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2026-01-01T00:00:00Z")
.with_description("test")
.with_digest("sha-256=abc");
assert_eq!(challenge.expires.as_deref(), Some("2026-01-01T00:00:00Z"));
assert_eq!(challenge.description.as_deref(), Some("test"));
assert_eq!(challenge.digest.as_deref(), Some("sha-256=abc"));
}
#[test]
fn test_is_expired_no_expires() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request);
assert!(!challenge.is_expired());
}
#[test]
fn test_is_expired_future() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2099-01-01T00:00:00Z");
assert!(!challenge.is_expired());
}
#[test]
fn test_is_expired_past() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2020-01-01T00:00:00Z");
assert!(challenge.is_expired());
}
#[test]
fn test_is_expired_unparseable() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("not-a-date");
assert!(challenge.is_expired()); }
#[test]
fn test_expires_at_valid() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2099-01-01T00:00:00Z");
assert!(challenge.expires_at().is_some());
}
#[test]
fn test_expires_at_missing() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request);
assert!(challenge.expires_at().is_none());
}
#[test]
fn test_expires_at_invalid() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge =
PaymentChallenge::new("id", "api", "tempo", "charge", request).with_expires("garbage");
assert!(challenge.expires_at().is_none());
}
#[test]
fn test_is_expired_positive_timezone_offset_future() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2099-01-01T00:00:00+05:00");
assert!(!challenge.is_expired());
assert!(challenge.expires_at().is_some());
}
#[test]
fn test_is_expired_negative_timezone_offset_past() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2020-01-01T00:00:00-07:00");
assert!(challenge.is_expired());
}
#[test]
fn test_is_expired_fractional_seconds_millis() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2099-01-01T00:00:00.123Z");
assert!(!challenge.is_expired());
assert!(challenge.expires_at().is_some());
}
#[test]
fn test_is_expired_fractional_seconds_micros() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2099-01-01T00:00:00.123456Z");
assert!(!challenge.is_expired());
}
#[test]
fn test_is_expired_empty_string() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge =
PaymentChallenge::new("id", "api", "tempo", "charge", request).with_expires("");
assert!(challenge.is_expired()); assert!(challenge.expires_at().is_none());
}
#[test]
fn test_is_expired_whitespace_only() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge =
PaymentChallenge::new("id", "api", "tempo", "charge", request).with_expires(" ");
assert!(challenge.is_expired()); assert!(challenge.expires_at().is_none());
}
#[test]
fn test_is_expired_unix_epoch() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("1970-01-01T00:00:00Z");
assert!(challenge.is_expired());
}
#[test]
fn test_is_expired_invalid_month() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2099-13-01T00:00:00Z");
assert!(challenge.is_expired()); }
#[test]
fn test_is_expired_invalid_day() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2099-01-32T00:00:00Z");
assert!(challenge.is_expired()); }
#[test]
fn test_is_expired_plain_text() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("just some text");
assert!(challenge.is_expired()); }
#[test]
fn test_is_expired_numeric_string() {
let request = Base64UrlJson::from_value(&serde_json::json!({"amount": "1000"})).unwrap();
let challenge =
PaymentChallenge::new("id", "api", "tempo", "charge", request).with_expires("12345");
assert!(challenge.is_expired()); }
#[test]
fn test_validate_for_charge_valid() {
let request = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request);
assert!(challenge.validate_for_charge("tempo").is_ok());
}
#[test]
fn test_validate_for_charge_case_insensitive() {
let request = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request);
assert!(challenge.validate_for_charge("TEMPO").is_ok());
assert!(challenge.validate_for_charge("Tempo").is_ok());
}
#[test]
fn test_validate_for_charge_wrong_method() {
let request = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request);
assert!(challenge.validate_for_charge("stripe").is_err());
}
#[test]
fn test_validate_for_charge_wrong_intent() {
let request = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "session", request);
assert!(challenge.validate_for_charge("tempo").is_err());
}
#[test]
fn test_validate_for_charge_expired() {
let request = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request)
.with_expires("2020-01-01T00:00:00Z");
assert!(challenge.validate_for_charge("tempo").is_err());
}
#[test]
fn test_validate_for_session_valid() {
let request = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "session", request);
assert!(challenge.validate_for_session("tempo").is_ok());
}
#[test]
fn test_validate_for_session_wrong_intent() {
let request = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "charge", request);
assert!(challenge.validate_for_session("tempo").is_err());
}
#[test]
fn test_validate_for_session_expired() {
let request = Base64UrlJson::from_value(&serde_json::json!({})).unwrap();
let challenge = PaymentChallenge::new("id", "api", "tempo", "session", request)
.with_expires("2020-01-01T00:00:00Z");
assert!(challenge.validate_for_session("tempo").is_err());
}
#[test]
fn test_extract_tx_hash_valid() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let json = serde_json::json!({"txHash": "0xabc123", "status": "success"});
let encoded = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap());
assert_eq!(extract_tx_hash(&encoded), Some("0xabc123".to_string()));
}
#[test]
fn test_extract_tx_hash_missing() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let json = serde_json::json!({"status": "success"});
let encoded = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap());
assert_eq!(extract_tx_hash(&encoded), None);
}
#[test]
fn test_extract_tx_hash_empty() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let json = serde_json::json!({"txHash": ""});
let encoded = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&json).unwrap());
assert_eq!(extract_tx_hash(&encoded), None);
}
#[test]
fn test_extract_tx_hash_invalid_base64() {
assert_eq!(extract_tx_hash("not-valid-base64!!!"), None);
}
#[test]
fn test_extract_tx_hash_invalid_json() {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
let encoded = URL_SAFE_NO_PAD.encode(b"not json");
assert_eq!(extract_tx_hash(&encoded), None);
}
}