Skip to main content

fly402_core/
models.rs

1use base64::{engine::general_purpose, Engine as _};
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4
5use crate::errors::{X402Error, X402Result};
6
7/// Payment request received from server in 402 response
8#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
9pub struct PaymentRequest {
10    /// Maximum amount required in USDC (e.g., "0.10")
11    pub max_amount_required: String,
12
13    /// Type of asset (e.g., "SPL" for Solana Program Library tokens)
14    pub asset_type: String,
15
16    /// Token mint address (USDC address on Solana)
17    pub asset_address: String,
18
19    /// Recipient wallet address for payment
20    pub payment_address: String,
21
22    /// Network identifier (e.g., "solana-devnet", "solana-mainnet")
23    pub network: String,
24
25    /// Expiration timestamp (ISO 8601 format)
26    pub expires_at: DateTime<Utc>,
27
28    /// Nonce for replay protection
29    pub nonce: String,
30
31    /// Unique payment identifier
32    pub payment_id: String,
33
34    /// API endpoint/resource being accessed
35    pub resource: String,
36
37    /// Human-readable description (optional)
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub description: Option<String>,
40}
41
42impl PaymentRequest {
43    /// Create a new payment request
44    pub fn new(
45        max_amount_required: String,
46        asset_address: String,
47        payment_address: String,
48        network: String,
49        expires_at: DateTime<Utc>,
50        nonce: String,
51        payment_id: String,
52        resource: String,
53    ) -> Self {
54        Self {
55            max_amount_required,
56            asset_type: "SPL".to_string(),
57            asset_address,
58            payment_address,
59            network,
60            expires_at,
61            nonce,
62            payment_id,
63            resource,
64            description: None,
65        }
66    }
67
68    /// Set the description
69    pub fn with_description(mut self, description: String) -> Self {
70        self.description = Some(description);
71        self
72    }
73
74    /// Check if the payment request has expired
75    pub fn is_expired(&self) -> bool {
76        Utc::now() > self.expires_at
77    }
78
79    /// Parse payment request from JSON string
80    pub fn from_json(json: &str) -> X402Result<Self> {
81        serde_json::from_str(json).map_err(|e| {
82            X402Error::InvalidPaymentRequest(format!("Failed to parse payment request: {}", e))
83        })
84    }
85
86    /// Convert payment request to JSON string
87    pub fn to_json(&self) -> X402Result<String> {
88        serde_json::to_string(self).map_err(|e| {
89            X402Error::Serialization(format!("Failed to serialize payment request: {}", e))
90        })
91    }
92
93    /// Encode payment request as base64 JSON
94    pub fn to_base64(&self) -> X402Result<String> {
95        let json = self.to_json()?;
96        Ok(general_purpose::STANDARD.encode(json.as_bytes()))
97    }
98
99    /// Decode payment request from base64 JSON
100    pub fn from_base64(encoded: &str) -> X402Result<Self> {
101        let decoded = general_purpose::STANDARD.decode(encoded)?;
102        let json = String::from_utf8(decoded).map_err(|e| {
103            X402Error::InvalidPaymentRequest(format!("Invalid UTF-8 in base64 data: {}", e))
104        })?;
105        Self::from_json(&json)
106    }
107}
108
109/// Payment authorization sent with retry request
110#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
111pub struct PaymentAuthorization {
112    /// Payment ID from the original request
113    pub payment_id: String,
114
115    /// Actual amount paid in USDC (e.g., "0.10")
116    pub actual_amount: String,
117
118    /// Recipient wallet address
119    pub payment_address: String,
120
121    /// Token mint address
122    pub asset_address: String,
123
124    /// Network identifier
125    pub network: String,
126
127    /// Timestamp when payment was authorized
128    pub timestamp: DateTime<Utc>,
129
130    /// Solana transaction signature
131    pub signature: String,
132
133    /// Payer's public key
134    pub public_key: String,
135
136    /// On-chain transaction hash (optional, may be same as signature)
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub transaction_hash: Option<String>,
139}
140
141impl PaymentAuthorization {
142    /// Create a new payment authorization
143    pub fn new(
144        payment_id: String,
145        actual_amount: String,
146        payment_address: String,
147        asset_address: String,
148        network: String,
149        signature: String,
150        public_key: String,
151    ) -> Self {
152        Self {
153            payment_id,
154            actual_amount,
155            payment_address,
156            asset_address,
157            network,
158            timestamp: Utc::now(),
159            signature: signature.clone(),
160            public_key,
161            transaction_hash: Some(signature),
162        }
163    }
164
165    /// Parse payment authorization from JSON string
166    pub fn from_json(json: &str) -> X402Result<Self> {
167        serde_json::from_str(json).map_err(|e| {
168            X402Error::InvalidPaymentAuthorization(format!(
169                "Failed to parse payment authorization: {}",
170                e
171            ))
172        })
173    }
174
175    /// Convert payment authorization to JSON string
176    pub fn to_json(&self) -> X402Result<String> {
177        serde_json::to_string(self).map_err(|e| {
178            X402Error::Serialization(format!("Failed to serialize payment authorization: {}", e))
179        })
180    }
181
182    /// Encode payment authorization as base64 JSON for X-Payment-Authorization header
183    pub fn to_header_value(&self) -> X402Result<String> {
184        let json = self.to_json()?;
185        Ok(general_purpose::STANDARD.encode(json.as_bytes()))
186    }
187
188    /// Decode payment authorization from X-Payment-Authorization header value
189    pub fn from_header_value(encoded: &str) -> X402Result<Self> {
190        let decoded = general_purpose::STANDARD.decode(encoded)?;
191        let json = String::from_utf8(decoded).map_err(|e| {
192            X402Error::InvalidPaymentAuthorization(format!("Invalid UTF-8 in header: {}", e))
193        })?;
194        Self::from_json(&json)
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use chrono::Duration;
202
203    #[test]
204    fn test_payment_request_serialization() {
205        let expires_at = Utc::now() + Duration::seconds(300);
206        let request = PaymentRequest::new(
207            "0.10".to_string(),
208            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
209            "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU".to_string(),
210            "solana-devnet".to_string(),
211            expires_at,
212            "nonce123".to_string(),
213            "payment123".to_string(),
214            "/api/premium-data".to_string(),
215        )
216        .with_description("Access premium data".to_string());
217
218        let json = request.to_json().unwrap();
219        let deserialized = PaymentRequest::from_json(&json).unwrap();
220        assert_eq!(request, deserialized);
221    }
222
223    #[test]
224    fn test_payment_request_base64() {
225        let expires_at = Utc::now() + Duration::seconds(300);
226        let request = PaymentRequest::new(
227            "0.10".to_string(),
228            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
229            "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU".to_string(),
230            "solana-devnet".to_string(),
231            expires_at,
232            "nonce123".to_string(),
233            "payment123".to_string(),
234            "/api/premium-data".to_string(),
235        );
236
237        let encoded = request.to_base64().unwrap();
238        let decoded = PaymentRequest::from_base64(&encoded).unwrap();
239        assert_eq!(request, decoded);
240    }
241
242    #[test]
243    fn test_payment_request_expiration() {
244        let past = Utc::now() - Duration::seconds(10);
245        let request = PaymentRequest::new(
246            "0.10".to_string(),
247            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
248            "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU".to_string(),
249            "solana-devnet".to_string(),
250            past,
251            "nonce123".to_string(),
252            "payment123".to_string(),
253            "/api/premium-data".to_string(),
254        );
255
256        assert!(request.is_expired());
257
258        let future = Utc::now() + Duration::seconds(300);
259        let request2 = PaymentRequest::new(
260            "0.10".to_string(),
261            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
262            "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU".to_string(),
263            "solana-devnet".to_string(),
264            future,
265            "nonce123".to_string(),
266            "payment123".to_string(),
267            "/api/premium-data".to_string(),
268        );
269
270        assert!(!request2.is_expired());
271    }
272
273    #[test]
274    fn test_payment_authorization_header() {
275        let auth = PaymentAuthorization::new(
276            "payment123".to_string(),
277            "0.10".to_string(),
278            "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU".to_string(),
279            "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v".to_string(),
280            "solana-devnet".to_string(),
281            "5VERv8NMvzbJMEkV8xnrLkEaWRtSz9CosKDYjCJjBRnbJLgp8uirBgmQpjKhoR4tjF3ZpRzrFmBV6UjKdiSZkQUW".to_string(),
282            "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU".to_string(),
283        );
284
285        let header = auth.to_header_value().unwrap();
286        let decoded = PaymentAuthorization::from_header_value(&header).unwrap();
287
288        assert_eq!(auth.payment_id, decoded.payment_id);
289        assert_eq!(auth.signature, decoded.signature);
290        assert_eq!(auth.public_key, decoded.public_key);
291    }
292}