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::ops::Not;
7use std::str::FromStr;
8
9use bitcoin::base64::engine::{general_purpose, GeneralPurpose};
10use bitcoin::base64::{alphabet, Engine};
11use serde::{Deserialize, Serialize};
12
13use super::{Error, Nut10SecretRequest, Transport};
14use crate::mint_url::MintUrl;
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    pub mints: Option<Vec<MintUrl>>,
38    /// Description
39    #[serde(rename = "d")]
40    pub description: Option<String>,
41    /// Transport
42    #[serde(rename = "t")]
43    #[serde(skip_serializing_if = "Option::is_none")]
44    pub transports: Option<Vec<Transport>>,
45    /// Nut10
46    pub nut10: Option<Nut10SecretRequest>,
47}
48
49impl PaymentRequest {
50    /// Create a new PaymentRequestBuilder
51    pub fn builder() -> PaymentRequestBuilder {
52        PaymentRequestBuilder::default()
53    }
54}
55
56impl AsRef<Option<String>> for PaymentRequest {
57    fn as_ref(&self) -> &Option<String> {
58        &self.payment_id
59    }
60}
61
62impl fmt::Display for PaymentRequest {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        use serde::ser::Error;
65        let mut data = Vec::new();
66        ciborium::into_writer(self, &mut data).map_err(|e| fmt::Error::custom(e.to_string()))?;
67        let encoded = general_purpose::URL_SAFE.encode(data);
68        write!(f, "{PAYMENT_REQUEST_PREFIX}{encoded}")
69    }
70}
71
72impl FromStr for PaymentRequest {
73    type Err = Error;
74
75    fn from_str(s: &str) -> Result<Self, Self::Err> {
76        let s = s
77            .strip_prefix(PAYMENT_REQUEST_PREFIX)
78            .ok_or(Error::InvalidPrefix)?;
79
80        let decode_config = general_purpose::GeneralPurposeConfig::new()
81            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
82        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
83
84        Ok(ciborium::from_reader(&decoded[..])?)
85    }
86}
87
88/// Builder for PaymentRequest
89#[derive(Debug, Default, Clone)]
90pub struct PaymentRequestBuilder {
91    payment_id: Option<String>,
92    amount: Option<Amount>,
93    unit: Option<CurrencyUnit>,
94    single_use: Option<bool>,
95    mints: Option<Vec<MintUrl>>,
96    description: Option<String>,
97    transports: Vec<Transport>,
98    nut10: Option<Nut10SecretRequest>,
99}
100
101impl PaymentRequestBuilder {
102    /// Set payment ID
103    pub fn payment_id<S>(mut self, payment_id: S) -> Self
104    where
105        S: Into<String>,
106    {
107        self.payment_id = Some(payment_id.into());
108        self
109    }
110
111    /// Set amount
112    pub fn amount<A>(mut self, amount: A) -> Self
113    where
114        A: Into<Amount>,
115    {
116        self.amount = Some(amount.into());
117        self
118    }
119
120    /// Set unit
121    pub fn unit(mut self, unit: CurrencyUnit) -> Self {
122        self.unit = Some(unit);
123        self
124    }
125
126    /// Set single use flag
127    pub fn single_use(mut self, single_use: bool) -> Self {
128        self.single_use = Some(single_use);
129        self
130    }
131
132    /// Add a mint URL
133    pub fn add_mint(mut self, mint_url: MintUrl) -> Self {
134        self.mints.get_or_insert_with(Vec::new).push(mint_url);
135        self
136    }
137
138    /// Set mints
139    pub fn mints(mut self, mints: Vec<MintUrl>) -> Self {
140        self.mints = Some(mints);
141        self
142    }
143
144    /// Set description
145    pub fn description<S: Into<String>>(mut self, description: S) -> Self {
146        self.description = Some(description.into());
147        self
148    }
149
150    /// Add a transport
151    pub fn add_transport(mut self, transport: Transport) -> Self {
152        self.transports.push(transport);
153        self
154    }
155
156    /// Set transports
157    pub fn transports(mut self, transports: Vec<Transport>) -> Self {
158        self.transports = transports;
159        self
160    }
161
162    /// Set Nut10 secret
163    pub fn nut10(mut self, nut10: Nut10SecretRequest) -> Self {
164        self.nut10 = Some(nut10);
165        self
166    }
167
168    /// Build the PaymentRequest
169    pub fn build(self) -> PaymentRequest {
170        let transports = self.transports.is_empty().not().then_some(self.transports);
171
172        PaymentRequest {
173            payment_id: self.payment_id,
174            amount: self.amount,
175            unit: self.unit,
176            single_use: self.single_use,
177            mints: self.mints,
178            description: self.description,
179            transports,
180            nut10: self.nut10,
181        }
182    }
183}
184
185/// Payment Request
186#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
187pub struct PaymentRequestPayload {
188    /// Id
189    pub id: Option<String>,
190    /// Memo
191    pub memo: Option<String>,
192    /// Mint
193    pub mint: MintUrl,
194    /// Unit
195    pub unit: CurrencyUnit,
196    /// Proofs
197    pub proofs: Proofs,
198}
199
200#[cfg(test)]
201mod tests {
202    use std::str::FromStr;
203
204    use lightning_invoice::Bolt11Invoice;
205
206    use super::*;
207    use crate::nuts::nut10::Kind;
208    use crate::nuts::SpendingConditions;
209    use crate::TransportType;
210
211    const PAYMENT_REQUEST: &str = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF4Imh0dHBzOi8vbm9mZWVzLnRlc3RudXQuY2FzaHUuc3BhY2U=";
212
213    #[test]
214    fn test_decode_payment_req() {
215        let req = PaymentRequest::from_str(PAYMENT_REQUEST).expect("valid payment request");
216
217        assert_eq!(&req.payment_id.unwrap(), "b7a90176");
218        assert_eq!(req.amount.unwrap(), 10.into());
219        assert_eq!(req.unit.clone().unwrap(), CurrencyUnit::Sat);
220        assert_eq!(
221            req.mints.unwrap(),
222            vec![MintUrl::from_str("https://nofees.testnut.cashu.space").expect("valid mint url")]
223        );
224        assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
225
226        let transport = req.transports.unwrap();
227        let transport = transport.first().unwrap();
228
229        let expected_transport = Transport {_type: TransportType::Nostr, target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])};
230
231        assert_eq!(transport, &expected_transport);
232    }
233
234    #[test]
235    fn test_roundtrip_payment_req() {
236        let transport = Transport {_type: TransportType::Nostr, target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])};
237
238        let request = PaymentRequest {
239            payment_id: Some("b7a90176".to_string()),
240            amount: Some(10.into()),
241            unit: Some(CurrencyUnit::Sat),
242            single_use: None,
243            mints: Some(vec!["https://nofees.testnut.cashu.space"
244                .parse()
245                .expect("valid mint url")]),
246            description: None,
247            transports: Some(vec![transport.clone()]),
248            nut10: None,
249        };
250
251        let request_str = request.to_string();
252
253        let req = PaymentRequest::from_str(&request_str).expect("valid payment request");
254
255        assert_eq!(&req.payment_id.unwrap(), "b7a90176");
256        assert_eq!(req.amount.unwrap(), 10.into());
257        assert_eq!(req.unit.clone().unwrap(), CurrencyUnit::Sat);
258        assert_eq!(
259            req.mints.unwrap(),
260            vec![MintUrl::from_str("https://nofees.testnut.cashu.space").expect("valid mint url")]
261        );
262        assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
263
264        let t = req.transports.unwrap();
265        let t = t.first().unwrap();
266        assert_eq!(&transport, t);
267    }
268
269    #[test]
270    fn test_payment_request_builder() {
271        let transport = Transport {
272            _type: TransportType::Nostr,
273            target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), 
274            tags: Some(vec![vec!["n".to_string(), "17".to_string()]])
275        };
276
277        let mint_url =
278            MintUrl::from_str("https://nofees.testnut.cashu.space").expect("valid mint url");
279
280        // Build a payment request using the builder pattern
281        let request = PaymentRequest::builder()
282            .payment_id("b7a90176")
283            .amount(Amount::from(10))
284            .unit(CurrencyUnit::Sat)
285            .add_mint(mint_url.clone())
286            .add_transport(transport.clone())
287            .build();
288
289        // Verify the built request
290        assert_eq!(&request.payment_id.clone().unwrap(), "b7a90176");
291        assert_eq!(request.amount.unwrap(), 10.into());
292        assert_eq!(request.unit.clone().unwrap(), CurrencyUnit::Sat);
293        assert_eq!(request.mints.clone().unwrap(), vec![mint_url]);
294
295        let t = request.transports.clone().unwrap();
296        let t = t.first().unwrap();
297        assert_eq!(&transport, t);
298
299        // Test serialization and deserialization
300        let request_str = request.to_string();
301        let req = PaymentRequest::from_str(&request_str).expect("valid payment request");
302
303        assert_eq!(req.payment_id, request.payment_id);
304        assert_eq!(req.amount, request.amount);
305        assert_eq!(req.unit, request.unit);
306    }
307
308    #[test]
309    fn test_transport_builder() {
310        // Build a transport using the builder pattern
311        let transport = Transport::builder()
312            .transport_type(TransportType::Nostr)
313            .target("nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5")
314            .add_tag(vec!["n".to_string(), "17".to_string()])
315            .build()
316            .expect("Valid transport");
317
318        // Verify the built transport
319        assert_eq!(transport._type, TransportType::Nostr);
320        assert_eq!(transport.target, "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5");
321        assert_eq!(
322            transport.tags,
323            Some(vec![vec!["n".to_string(), "17".to_string()]])
324        );
325
326        // Test error case - missing required fields
327        let result = crate::nuts::nut18::transport::TransportBuilder::default().build();
328        assert!(result.is_err());
329    }
330
331    #[test]
332    fn test_nut10_secret_request() {
333        use crate::nuts::nut10::Kind;
334
335        // Create a Nut10SecretRequest
336        let secret_request = Nut10SecretRequest::new(
337            Kind::P2PK,
338            "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198",
339            Some(vec![vec!["key".to_string(), "value".to_string()]]),
340        );
341
342        // Convert to a full Nut10Secret
343        let full_secret: crate::nuts::Nut10Secret = secret_request.clone().into();
344
345        // Check conversion
346        assert_eq!(full_secret.kind, Kind::P2PK);
347        assert_eq!(
348            full_secret.secret_data.data,
349            "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198"
350        );
351        assert_eq!(
352            full_secret.secret_data.tags,
353            Some(vec![vec!["key".to_string(), "value".to_string()]])
354        );
355
356        // Convert back to Nut10SecretRequest
357        let converted_back = Nut10SecretRequest::from(full_secret);
358
359        // Check round-trip conversion
360        assert_eq!(converted_back.kind, secret_request.kind);
361        assert_eq!(
362            converted_back.secret_data.data,
363            secret_request.secret_data.data
364        );
365        assert_eq!(
366            converted_back.secret_data.tags,
367            secret_request.secret_data.tags
368        );
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().clone(),
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.secret_data.data, pubkey_hex);
462        } else {
463            panic!("NUT10 secret data missing in decoded payment request");
464        }
465    }
466}