Skip to main content

cashu/nuts/nut18/
payment_request.rs

1//! NUT-18: Payment Requests
2//!
3//! <https://github.com/cashubtc/nuts/blob/main/18.md>
4
5use std::fmt;
6use std::str::FromStr;
7
8use bitcoin::base64::engine::{general_purpose, GeneralPurpose};
9use bitcoin::base64::{alphabet, Engine};
10use serde::{Deserialize, Serialize};
11
12use super::{Error, Nut10SecretRequest, Transport};
13use crate::mint_url::MintUrl;
14use crate::nut26::CREQ_B_HRP;
15use crate::nuts::{CurrencyUnit, Proofs};
16use crate::Amount;
17
18const PAYMENT_REQUEST_PREFIX: &str = "creqA";
19
20/// Payment Request
21#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
22pub struct PaymentRequest {
23    /// `Payment id`
24    #[serde(rename = "i")]
25    pub payment_id: Option<String>,
26    /// Amount
27    #[serde(rename = "a")]
28    pub amount: Option<Amount>,
29    /// Unit
30    #[serde(rename = "u")]
31    pub unit: Option<CurrencyUnit>,
32    /// Single use
33    #[serde(rename = "s")]
34    pub single_use: Option<bool>,
35    /// Mints
36    #[serde(rename = "m")]
37    #[serde(skip_serializing_if = "Vec::is_empty", default)]
38    pub mints: Vec<MintUrl>,
39    /// Description
40    #[serde(rename = "d")]
41    pub description: Option<String>,
42    /// Transport
43    #[serde(rename = "t")]
44    #[serde(skip_serializing_if = "Vec::is_empty", default = "Vec::default")]
45    pub transports: Vec<Transport>,
46    /// Nut10
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub nut10: Option<Nut10SecretRequest>,
49}
50
51impl PaymentRequest {
52    /// Create a new PaymentRequestBuilder
53    pub fn builder() -> PaymentRequestBuilder {
54        PaymentRequestBuilder::default()
55    }
56}
57
58impl AsRef<Option<String>> for PaymentRequest {
59    fn as_ref(&self) -> &Option<String> {
60        &self.payment_id
61    }
62}
63
64impl fmt::Display for PaymentRequest {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        use serde::ser::Error;
67        let mut data = Vec::new();
68        ciborium::into_writer(self, &mut data).map_err(|e| fmt::Error::custom(e.to_string()))?;
69        let encoded = general_purpose::URL_SAFE.encode(data);
70        write!(f, "{PAYMENT_REQUEST_PREFIX}{encoded}")
71    }
72}
73
74impl FromStr for PaymentRequest {
75    type Err = Error;
76
77    fn from_str(s: &str) -> Result<Self, Self::Err> {
78        // Check if it's a bech32m format (CREQ-B) - case insensitive
79        if s.to_lowercase().starts_with(CREQ_B_HRP) {
80            // Use the bech32 decoding from NUT-26
81            return Self::from_bech32_string(s).map_err(Error::Nut26Error);
82        }
83
84        // Otherwise, try the legacy CBOR format (CREQ-A)
85        let s = s
86            .strip_prefix(PAYMENT_REQUEST_PREFIX)
87            .ok_or(Error::InvalidPrefix)?;
88
89        let decode_config = general_purpose::GeneralPurposeConfig::new()
90            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
91        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
92
93        Ok(ciborium::from_reader(&decoded[..])?)
94    }
95}
96
97/// Builder for PaymentRequest
98#[derive(Debug, Default, Clone)]
99pub struct PaymentRequestBuilder {
100    payment_id: Option<String>,
101    amount: Option<Amount>,
102    unit: Option<CurrencyUnit>,
103    single_use: Option<bool>,
104    mints: Vec<MintUrl>,
105    description: Option<String>,
106    transports: Vec<Transport>,
107    nut10: Option<Nut10SecretRequest>,
108}
109
110impl PaymentRequestBuilder {
111    /// Set payment ID
112    pub fn payment_id<S>(mut self, payment_id: S) -> Self
113    where
114        S: Into<String>,
115    {
116        self.payment_id = Some(payment_id.into());
117        self
118    }
119
120    /// Set amount
121    pub fn amount<A>(mut self, amount: A) -> Self
122    where
123        A: Into<Amount>,
124    {
125        self.amount = Some(amount.into());
126        self
127    }
128
129    /// Set unit
130    pub fn unit(mut self, unit: CurrencyUnit) -> Self {
131        self.unit = Some(unit);
132        self
133    }
134
135    /// Set single use flag
136    pub fn single_use(mut self, single_use: bool) -> Self {
137        self.single_use = Some(single_use);
138        self
139    }
140
141    /// Add a mint URL
142    pub fn add_mint(mut self, mint_url: MintUrl) -> Self {
143        self.mints.push(mint_url);
144        self
145    }
146
147    /// Set mints
148    pub fn mints(mut self, mints: Vec<MintUrl>) -> Self {
149        self.mints = mints;
150        self
151    }
152
153    /// Set description
154    pub fn description<S: Into<String>>(mut self, description: S) -> Self {
155        self.description = Some(description.into());
156        self
157    }
158
159    /// Add a transport
160    pub fn add_transport(mut self, transport: Transport) -> Self {
161        self.transports.push(transport);
162        self
163    }
164
165    /// Set transports
166    pub fn transports(mut self, transports: Vec<Transport>) -> Self {
167        self.transports = transports;
168        self
169    }
170
171    /// Set Nut10 secret
172    pub fn nut10(mut self, nut10: Nut10SecretRequest) -> Self {
173        self.nut10 = Some(nut10);
174        self
175    }
176
177    /// Build the PaymentRequest
178    pub fn build(self) -> PaymentRequest {
179        PaymentRequest {
180            payment_id: self.payment_id,
181            amount: self.amount,
182            unit: self.unit,
183            single_use: self.single_use,
184            mints: self.mints,
185            description: self.description,
186            transports: self.transports,
187            nut10: self.nut10,
188        }
189    }
190}
191
192/// Payment Request Payload
193#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
194pub struct PaymentRequestPayload {
195    /// Id
196    pub id: Option<String>,
197    /// Memo
198    pub memo: Option<String>,
199    /// Mint
200    pub mint: MintUrl,
201    /// Unit
202    pub unit: CurrencyUnit,
203    /// Proofs
204    pub proofs: Proofs,
205}
206
207#[cfg(test)]
208mod tests {
209    use std::str::FromStr;
210
211    use lightning_invoice::Bolt11Invoice;
212
213    use super::*;
214    use crate::nuts::nut10::Kind;
215    use crate::nuts::SpendingConditions;
216    use crate::TransportType;
217
218    const PAYMENT_REQUEST: &str = "creqAp2FpaGI3YTkwMTc2YWEKYXVjc2F0YXP2YW2BeCJodHRwczovL25vZmVlcy50ZXN0bnV0LmNhc2h1LnNwYWNlYWT2YXSBo2F0ZW5vc3RyYWF4qW5wcm9maWxlMXFxc2dtNnFmYTNjOGR0ejJmdnpodmZxZWFjbXdtMGU1MHBlM2s1dGZtdnBqam1uMHZqN20ydGdwejNtaHh1ZTY5dWhoeWV0dnY5dWp1ZXJwZDQ2aHh0bmZkdXEzd2Ftbnd2YXo3dG1qdjRreHo3Znc4cWVueHZld3dkY3h6Y205OXVxczZhbW53dmF6N3Rtd2RhZWp1bXIwZHM0bGpoN25hZ4GCYW5iMTc=";
219
220    #[test]
221    fn test_decode_payment_req() {
222        let req = PaymentRequest::from_str(PAYMENT_REQUEST).expect("valid payment request");
223
224        assert_eq!(&req.payment_id.unwrap(), "b7a90176");
225        assert_eq!(req.amount.unwrap(), 10.into());
226        assert_eq!(req.unit.clone().unwrap(), CurrencyUnit::Sat);
227        assert_eq!(
228            req.mints,
229            vec![MintUrl::from_str("https://nofees.testnut.cashu.space").expect("valid mint url")]
230        );
231        assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
232
233        let transport = req.transports.first().unwrap();
234
235        let expected_transport = Transport {_type: TransportType::Nostr, target: "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n".to_string(), tags: vec![vec!["n".to_string(), "17".to_string()]]};
236
237        assert_eq!(transport, &expected_transport);
238    }
239
240    #[test]
241    fn test_roundtrip_payment_req() {
242        let transport = Transport {_type: TransportType::Nostr, target: "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n".to_string(), tags: vec![vec!["n".to_string(), "17".to_string()]]};
243
244        let request = PaymentRequest {
245            payment_id: Some("b7a90176".to_string()),
246            amount: Some(10.into()),
247            unit: Some(CurrencyUnit::Sat),
248            single_use: None,
249            mints: vec!["https://nofees.testnut.cashu.space"
250                .parse()
251                .expect("valid mint url")],
252            description: None,
253            transports: vec![transport.clone()],
254            nut10: None,
255        };
256
257        let request_str = request.to_string();
258
259        assert_eq!(request_str, PAYMENT_REQUEST);
260
261        let req = PaymentRequest::from_str(&request_str).expect("valid payment request");
262
263        assert_eq!(&req.payment_id.unwrap(), "b7a90176");
264        assert_eq!(req.amount.unwrap(), 10.into());
265        assert_eq!(req.unit.clone().unwrap(), CurrencyUnit::Sat);
266        assert_eq!(
267            req.mints,
268            vec![MintUrl::from_str("https://nofees.testnut.cashu.space").expect("valid mint url")]
269        );
270        assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
271
272        let t = req.transports.first().unwrap();
273        assert_eq!(&transport, t);
274    }
275
276    #[test]
277    fn test_payment_request_builder() {
278        let transport = Transport {
279            _type: TransportType::Nostr,
280            target: "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n".to_string(),
281            tags: vec![vec!["n".to_string(), "17".to_string()]]
282        };
283
284        let mint_url =
285            MintUrl::from_str("https://nofees.testnut.cashu.space").expect("valid mint url");
286
287        // Build a payment request using the builder pattern
288        let request = PaymentRequest::builder()
289            .payment_id("b7a90176")
290            .amount(Amount::from(10))
291            .unit(CurrencyUnit::Sat)
292            .add_mint(mint_url.clone())
293            .add_transport(transport.clone())
294            .build();
295
296        // Verify the built request
297        assert_eq!(&request.payment_id.clone().unwrap(), "b7a90176");
298        assert_eq!(request.amount.unwrap(), 10.into());
299        assert_eq!(request.unit.clone().unwrap(), CurrencyUnit::Sat);
300        assert_eq!(request.mints.clone(), vec![mint_url]);
301
302        let t = request.transports.first().unwrap();
303        assert_eq!(&transport, t);
304
305        // Test serialization and deserialization
306        let request_str = request.to_string();
307        let req = PaymentRequest::from_str(&request_str).expect("valid payment request");
308
309        assert_eq!(req.payment_id, request.payment_id);
310        assert_eq!(req.amount, request.amount);
311        assert_eq!(req.unit, request.unit);
312    }
313
314    #[test]
315    fn test_transport_builder() {
316        // Build a transport using the builder pattern
317        let transport = Transport::builder()
318            .transport_type(TransportType::Nostr)
319            .target("nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n")
320            .add_tag(vec!["n".to_string(), "17".to_string()])
321            .build()
322            .expect("Valid transport");
323
324        // Verify the built transport
325        assert_eq!(transport._type, TransportType::Nostr);
326        assert_eq!(transport.target, "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n");
327        assert_eq!(
328            transport.tags,
329            vec![vec!["n".to_string(), "17".to_string()]]
330        );
331
332        // Test error case - missing required fields
333        let result = crate::nuts::nut18::transport::TransportBuilder::default().build();
334        assert!(result.is_err());
335    }
336
337    #[test]
338    fn test_nut10_secret_request() {
339        use crate::nuts::nut10::Kind;
340
341        // Create a Nut10SecretRequest
342        let secret_request = Nut10SecretRequest::new(
343            Kind::P2PK,
344            "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198",
345            Some(vec![vec!["key".to_string(), "value".to_string()]]),
346        );
347
348        // Convert to a full Nut10Secret
349        let full_secret: crate::nuts::Nut10Secret = secret_request.clone().into();
350
351        // Check conversion
352        assert_eq!(full_secret.kind(), Kind::P2PK);
353        assert_eq!(
354            full_secret.secret_data().data(),
355            "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198"
356        );
357        assert_eq!(
358            full_secret.secret_data().tags().clone(),
359            Some(vec![vec!["key".to_string(), "value".to_string()]]).as_ref()
360        );
361
362        // Convert back to Nut10SecretRequest
363        let converted_back = Nut10SecretRequest::from(full_secret);
364
365        // Check round-trip conversion
366        assert_eq!(converted_back.kind, secret_request.kind);
367        assert_eq!(converted_back.data, secret_request.data);
368        assert_eq!(converted_back.tags, secret_request.tags);
369
370        // Test in PaymentRequest builder
371        let payment_request = PaymentRequest::builder()
372            .payment_id("test123")
373            .amount(Amount::from(100))
374            .nut10(secret_request.clone())
375            .build();
376
377        assert_eq!(payment_request.nut10, Some(secret_request));
378    }
379
380    #[test]
381    fn test_nut10_secret_request_multiple_mints() {
382        let mint_urls = [
383            "https://8333.space:3338",
384            "https://mint.minibits.cash/Bitcoin",
385            "https://antifiat.cash",
386            "https://mint.macadamia.cash",
387        ]
388        .iter()
389        .map(|m| MintUrl::from_str(m).unwrap())
390        .collect();
391
392        let payment_request = PaymentRequestBuilder::default()
393            .unit(CurrencyUnit::Sat)
394            .amount(10)
395            .mints(mint_urls)
396            .build();
397
398        let payment_request_str = payment_request.to_string();
399
400        let r = PaymentRequest::from_str(&payment_request_str).unwrap();
401
402        assert_eq!(payment_request, r);
403    }
404
405    #[test]
406    fn test_nut10_secret_request_htlc() {
407        let bolt11 = "lnbc100n1p5z3a63pp56854ytysg7e5z9fl3w5mgvrlqjfcytnjv8ff5hm5qt6gl6alxesqdqqcqzzsxqyz5vqsp5p0x0dlhn27s63j4emxnk26p7f94u0lyarnfp5yqmac9gzy4ngdss9qxpqysgqne3v0hnzt2lp0hc69xpzckk0cdcar7glvjhq60lsrfe8gejdm8c564prrnsft6ctxxyrewp4jtezrq3gxxqnfjj0f9tw2qs9y0lslmqpfu7et9";
408
409        let bolt11 = Bolt11Invoice::from_str(bolt11).unwrap();
410
411        let nut10 = SpendingConditions::HTLCConditions {
412            data: *bolt11.payment_hash(),
413            conditions: None,
414        };
415
416        let payment_request = PaymentRequestBuilder::default()
417            .unit(CurrencyUnit::Sat)
418            .amount(10)
419            .nut10(nut10.into())
420            .build();
421
422        let payment_request_str = payment_request.to_string();
423
424        let r = PaymentRequest::from_str(&payment_request_str).unwrap();
425
426        assert_eq!(payment_request, r);
427    }
428
429    #[test]
430    fn test_nut10_secret_request_p2pk() {
431        // Use a public key for P2PK condition
432        let pubkey_hex = "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198";
433
434        // Create P2PK spending conditions
435        let nut10 = SpendingConditions::P2PKConditions {
436            data: crate::nuts::PublicKey::from_str(pubkey_hex).unwrap(),
437            conditions: None,
438        };
439
440        // Build payment request with P2PK condition
441        let payment_request = PaymentRequestBuilder::default()
442            .unit(CurrencyUnit::Sat)
443            .amount(10)
444            .payment_id("test-p2pk-id")
445            .description("P2PK locked payment")
446            .nut10(nut10.into())
447            .build();
448
449        // Convert to string representation
450        let payment_request_str = payment_request.to_string();
451
452        // Parse back from string
453        let decoded_request = PaymentRequest::from_str(&payment_request_str).unwrap();
454
455        // Verify round-trip serialization
456        assert_eq!(payment_request, decoded_request);
457
458        // Verify the P2PK data was preserved correctly
459        if let Some(nut10_secret) = decoded_request.nut10 {
460            assert_eq!(nut10_secret.kind, Kind::P2PK);
461            assert_eq!(nut10_secret.data, pubkey_hex);
462        } else {
463            panic!("NUT10 secret data missing in decoded payment request");
464        }
465    }
466
467    /// Test vectors from NUT-18 specification
468    /// https://github.com/cashubtc/nuts/blob/main/tests/18-tests.md
469
470    #[test]
471    fn test_basic_payment_request() {
472        // Basic payment request with required fields
473        let json = r#"{
474            "i": "b7a90176",
475            "a": 10,
476            "u": "sat",
477            "m": ["https://8333.space:3338"],
478            "t": [
479                {
480                    "t": "nostr",
481                    "a": "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n",
482                    "g": [["n", "17"]]
483                }
484            ]
485        }"#;
486
487        let expected_encoded = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF3aHR0cHM6Ly84MzMzLnNwYWNlOjMzMzg=";
488
489        // Parse the JSON into a PaymentRequest
490        let payment_request: PaymentRequest = serde_json::from_str(json).unwrap();
491        let payment_request_cloned = payment_request.clone();
492
493        // Verify the payment request fields
494        assert_eq!(
495            payment_request_cloned.payment_id.as_ref().unwrap(),
496            "b7a90176"
497        );
498        assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(10));
499        assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat);
500        assert_eq!(
501            payment_request_cloned.mints,
502            vec![MintUrl::from_str("https://8333.space:3338").unwrap()]
503        );
504
505        let transport = payment_request.transports.first().unwrap();
506        assert_eq!(transport._type, TransportType::Nostr);
507        assert_eq!(transport.target, "nprofile1qqsgm6qfa3c8dtz2fvzhvfqeacmwm0e50pe3k5tfmvpjjmn0vj7m2tgpz3mhxue69uhhyetvv9ujuerpd46hxtnfduq3wamnwvaz7tmjv4kxz7fw8qenxvewwdcxzcm99uqs6amnwvaz7tmwdaejumr0ds4ljh7n");
508        assert_eq!(
509            transport.tags,
510            vec![vec!["n".to_string(), "17".to_string()]]
511        );
512
513        // Test encoding - the encoded form should match the expected output
514        let encoded = payment_request.to_string();
515
516        // For now, let's verify it can be decoded back correctly
517        let decoded = PaymentRequest::from_str(&encoded).unwrap();
518        assert_eq!(payment_request, decoded);
519
520        // Test decoding the expected encoded string
521        let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
522        assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "b7a90176");
523        assert_eq!(decoded_from_spec.amount.unwrap(), Amount::from(10));
524        assert_eq!(decoded_from_spec.unit.unwrap(), CurrencyUnit::Sat);
525        assert_eq!(
526            decoded_from_spec.mints,
527            vec![MintUrl::from_str("https://8333.space:3338").unwrap()]
528        );
529    }
530
531    #[test]
532    fn test_nostr_transport_payment_request() {
533        // Nostr transport payment request with multiple mints
534        let json = r#"{
535            "i": "f92a51b8",
536            "a": 100,
537            "u": "sat",
538            "m": ["https://mint1.example.com", "https://mint2.example.com"],
539            "t": [
540                {
541                    "t": "nostr",
542                    "a": "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq28spj3",
543                    "g": [["n", "17"], ["n", "9735"]]
544                }
545            ]
546        }"#;
547
548        let expected_encoded = "creqApWF0gaNhdGVub3N0cmFheD9ucHViMXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXEyOHNwajNhZ4KCYW5iMTeCYW5kOTczNWFpaGY5MmE1MWI4YWEYZGF1Y3NhdGFtgngZaHR0cHM6Ly9taW50MS5leGFtcGxlLmNvbXgZaHR0cHM6Ly9taW50Mi5leGFtcGxlLmNvbQ==";
549
550        // Parse the JSON into a PaymentRequest
551        let payment_request: PaymentRequest = serde_json::from_str(json).unwrap();
552        let payment_request_cloned = payment_request.clone();
553
554        // Verify the payment request fields
555        assert_eq!(
556            payment_request_cloned.payment_id.as_ref().unwrap(),
557            "f92a51b8"
558        );
559        assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(100));
560        assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat);
561        assert_eq!(
562            payment_request_cloned.mints,
563            vec![
564                MintUrl::from_str("https://mint1.example.com").unwrap(),
565                MintUrl::from_str("https://mint2.example.com").unwrap()
566            ]
567        );
568
569        let transport = payment_request_cloned.transports.first().unwrap();
570        assert_eq!(transport._type, TransportType::Nostr);
571        assert_eq!(
572            transport.target,
573            "npub1qqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqqq28spj3"
574        );
575        assert_eq!(
576            transport.tags,
577            vec![
578                vec!["n".to_string(), "17".to_string()],
579                vec!["n".to_string(), "9735".to_string()]
580            ]
581        );
582
583        // Test round-trip serialization
584        let encoded = payment_request.to_string();
585        let decoded = PaymentRequest::from_str(&encoded).unwrap();
586        assert_eq!(payment_request, decoded);
587
588        // Test decoding the expected encoded string
589        let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
590        assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "f92a51b8");
591    }
592
593    #[test]
594    fn test_minimal_payment_request() {
595        // Minimal payment request with only required fields
596        let json = r#"{
597            "i": "7f4a2b39",
598            "u": "sat",
599            "m": ["https://mint.example.com"]
600        }"#;
601
602        let expected_encoded =
603            "creqAo2FpaDdmNGEyYjM5YXVjc2F0YW2BeBhodHRwczovL21pbnQuZXhhbXBsZS5jb20=";
604
605        // Parse the JSON into a PaymentRequest
606        let payment_request: PaymentRequest = serde_json::from_str(json).unwrap();
607        let payment_request_cloned = payment_request.clone();
608
609        // Verify the payment request fields
610        assert_eq!(
611            payment_request_cloned.payment_id.as_ref().unwrap(),
612            "7f4a2b39"
613        );
614        assert_eq!(payment_request_cloned.amount, None);
615        assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat);
616        assert_eq!(
617            payment_request_cloned.mints,
618            vec![MintUrl::from_str("https://mint.example.com").unwrap()]
619        );
620        assert_eq!(payment_request_cloned.transports, vec![]);
621
622        // Test round-trip serialization
623        let encoded = payment_request.to_string();
624        let decoded = PaymentRequest::from_str(&encoded).unwrap();
625        assert_eq!(payment_request, decoded);
626
627        // Test decoding the expected encoded string
628        let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
629        assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "7f4a2b39");
630    }
631
632    #[test]
633    fn test_nut10_locking_payment_request() {
634        // Payment request with NUT-10 P2PK locking
635        let json = r#"{
636            "i": "c9e45d2a",
637            "a": 500,
638            "u": "sat",
639            "m": ["https://mint.example.com"],
640            "nut10": {
641                "k": "P2PK",
642                "d": "02c3b5bb27e361457c92d93d78dd73d3d53732110b2cfe8b50fbc0abc615e9c331",
643                "t": [["timeout", "3600"]]
644            }
645        }"#;
646
647        let expected_encoded = "creqApWFpaGM5ZTQ1ZDJhYWEZAfRhdWNzYXRhbYF4GGh0dHBzOi8vbWludC5leGFtcGxlLmNvbWVudXQxMKNha2RQMlBLYWR4QjAyYzNiNWJiMjdlMzYxNDU3YzkyZDkzZDc4ZGQ3M2QzZDUzNzMyMTEwYjJjZmU4YjUwZmJjMGFiYzYxNWU5YzMzMWF0gYJndGltZW91dGQzNjAw";
648
649        // Parse the JSON into a PaymentRequest
650        let payment_request: PaymentRequest = serde_json::from_str(json).unwrap();
651        let payment_request_cloned = payment_request.clone();
652
653        // Verify the payment request fields
654        assert_eq!(
655            payment_request_cloned.payment_id.as_ref().unwrap(),
656            "c9e45d2a"
657        );
658        assert_eq!(payment_request_cloned.amount.unwrap(), Amount::from(500));
659        assert_eq!(payment_request_cloned.unit.unwrap(), CurrencyUnit::Sat);
660        assert_eq!(
661            payment_request_cloned.mints,
662            vec![MintUrl::from_str("https://mint.example.com").unwrap()]
663        );
664
665        // Test NUT-10 locking
666        let nut10 = payment_request_cloned.nut10.unwrap();
667        assert_eq!(nut10.kind, Kind::P2PK);
668        assert_eq!(
669            nut10.data,
670            "02c3b5bb27e361457c92d93d78dd73d3d53732110b2cfe8b50fbc0abc615e9c331"
671        );
672        assert_eq!(
673            nut10.tags,
674            Some(vec![vec!["timeout".to_string(), "3600".to_string()]])
675        );
676
677        // Test round-trip serialization
678        let encoded = payment_request.to_string();
679        let decoded = PaymentRequest::from_str(&encoded).unwrap();
680        assert_eq!(payment_request, decoded);
681
682        // Test decoding the expected encoded string
683        let decoded_from_spec = PaymentRequest::from_str(expected_encoded).unwrap();
684        assert_eq!(decoded_from_spec.payment_id.as_ref().unwrap(), "c9e45d2a");
685    }
686
687    #[test]
688    fn test_from_str_handles_both_formats() {
689        // Create a payment request
690        let payment_request = PaymentRequest {
691            payment_id: Some("test456".to_string()),
692            amount: Some(Amount::from(100)),
693            unit: Some(CurrencyUnit::Sat),
694            single_use: None,
695            mints: vec![MintUrl::from_str("https://mint.example.com").unwrap()],
696            description: Some("Test both formats".to_string()),
697            transports: vec![],
698            nut10: None,
699        };
700
701        // Test CBOR format (CREQ-A) - from Display trait
702        let cbor_encoded = payment_request.to_string();
703        assert!(cbor_encoded.starts_with("creqA"));
704        let decoded_cbor =
705            PaymentRequest::from_str(&cbor_encoded).expect("Should decode CBOR format");
706        assert_eq!(decoded_cbor.payment_id, payment_request.payment_id);
707        assert_eq!(decoded_cbor.amount, payment_request.amount);
708        assert_eq!(decoded_cbor.unit, payment_request.unit);
709        assert_eq!(decoded_cbor.description, payment_request.description);
710
711        // Test bech32 format (CREQ-B)
712        let bech32_encoded = payment_request
713            .to_bech32_string()
714            .expect("Should encode to bech32");
715        assert!(bech32_encoded.to_uppercase().starts_with("CREQB"));
716        let decoded_bech32 =
717            PaymentRequest::from_str(&bech32_encoded).expect("Should decode bech32 format");
718        assert_eq!(decoded_bech32.payment_id, payment_request.payment_id);
719        assert_eq!(decoded_bech32.amount, payment_request.amount);
720        assert_eq!(decoded_bech32.unit, payment_request.unit);
721        assert_eq!(decoded_bech32.description, payment_request.description);
722
723        // Test case insensitivity for bech32
724        let bech32_lowercase = bech32_encoded.to_lowercase();
725        let decoded_lowercase =
726            PaymentRequest::from_str(&bech32_lowercase).expect("Should decode lowercase bech32");
727        assert_eq!(decoded_lowercase.payment_id, payment_request.payment_id);
728
729        let bech32_uppercase = bech32_encoded.to_uppercase();
730        let decoded_uppercase =
731            PaymentRequest::from_str(&bech32_uppercase).expect("Should decode uppercase bech32");
732        assert_eq!(decoded_uppercase.payment_id, payment_request.payment_id);
733    }
734}