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>,
}
impl PaymentChallenge {
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.raw().to_string(),
expires: self.expires.clone(),
digest: self.digest.clone(),
}
}
pub fn to_header(&self) -> crate::error::Result<String> {
super::format_www_authenticate(self)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChallengeEcho {
pub id: String,
pub realm: String,
pub method: MethodName,
pub intent: IntentName,
pub request: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub digest: Option<String>,
}
#[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: PaymentPayload,
}
impl PaymentCredential {
pub fn new(challenge: ChallengeEcho, payload: PaymentPayload) -> Self {
Self {
challenge,
source: None,
payload,
}
}
pub fn with_source(
challenge: ChallengeEcho,
source: impl Into<String>,
payload: PaymentPayload,
) -> Self {
Self {
challenge,
source: Some(source.into()),
payload,
}
}
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,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<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(),
error: None,
}
}
pub fn failed(method: impl Into<MethodName>, error_msg: &str) -> Self {
Self {
status: ReceiptStatus::Failed,
method: method.into(),
timestamp: now_iso8601(),
reference: String::new(),
error: Some(error_msg.to_string()),
}
}
pub fn is_success(&self) -> bool {
self.status == ReceiptStatus::Success
}
pub fn is_failed(&self) -> bool {
self.status == ReceiptStatus::Failed
}
pub fn to_header(&self) -> crate::error::Result<String> {
super::format_receipt(self)
}
}
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())
}
#[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,
}
}
#[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, 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_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(),
error: None,
};
assert!(success.is_success());
assert!(!success.is_failed());
let failed = Receipt {
status: ReceiptStatus::Failed,
method: "tempo".into(),
timestamp: "2024-01-01T00:00:00Z".to_string(),
reference: "".to_string(),
error: Some("Payment failed".to_string()),
};
assert!(!failed.is_success());
assert!(failed.is_failed());
}
}