cashu/nuts/
nut18.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};
11use thiserror::Error;
12
13use super::{CurrencyUnit, Proofs};
14use crate::mint_url::MintUrl;
15use crate::Amount;
16
17const PAYMENT_REQUEST_PREFIX: &str = "creqA";
18
19/// NUT18 Error
20#[derive(Debug, Error)]
21pub enum Error {
22    /// Invalid Prefix
23    #[error("Invalid Prefix")]
24    InvalidPrefix,
25    /// Ciborium error
26    #[error(transparent)]
27    CiboriumError(#[from] ciborium::de::Error<std::io::Error>),
28    /// Base64 error
29    #[error(transparent)]
30    Base64Error(#[from] bitcoin::base64::DecodeError),
31}
32
33/// Transport Type
34#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
35pub enum TransportType {
36    /// Nostr
37    #[serde(rename = "nostr")]
38    Nostr,
39    /// Http post
40    #[serde(rename = "post")]
41    HttpPost,
42}
43
44impl fmt::Display for TransportType {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        use serde::ser::Error;
47        let t = serde_json::to_string(self).map_err(|e| fmt::Error::custom(e.to_string()))?;
48        write!(f, "{}", t)
49    }
50}
51
52impl FromStr for TransportType {
53    type Err = Error;
54
55    fn from_str(s: &str) -> Result<Self, Self::Err> {
56        match s.to_lowercase().as_str() {
57            "nostr" => Ok(Self::Nostr),
58            "post" => Ok(Self::HttpPost),
59            _ => Err(Error::InvalidPrefix),
60        }
61    }
62}
63
64impl FromStr for Transport {
65    type Err = Error;
66
67    fn from_str(s: &str) -> Result<Self, Self::Err> {
68        let decode_config = general_purpose::GeneralPurposeConfig::new()
69            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
70        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
71
72        Ok(ciborium::from_reader(&decoded[..])?)
73    }
74}
75
76/// Transport
77#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Transport {
79    /// Type
80    #[serde(rename = "t")]
81    pub _type: TransportType,
82    /// Target
83    #[serde(rename = "a")]
84    pub target: String,
85    /// Tags
86    #[serde(rename = "g")]
87    pub tags: Option<Vec<Vec<String>>>,
88}
89
90impl Transport {
91    /// Create a new TransportBuilder
92    pub fn builder() -> TransportBuilder {
93        TransportBuilder::default()
94    }
95}
96
97/// Builder for Transport
98#[derive(Debug, Default, Clone)]
99pub struct TransportBuilder {
100    _type: Option<TransportType>,
101    target: Option<String>,
102    tags: Option<Vec<Vec<String>>>,
103}
104
105impl TransportBuilder {
106    /// Set transport type
107    pub fn transport_type(mut self, transport_type: TransportType) -> Self {
108        self._type = Some(transport_type);
109        self
110    }
111
112    /// Set target
113    pub fn target<S: Into<String>>(mut self, target: S) -> Self {
114        self.target = Some(target.into());
115        self
116    }
117
118    /// Add a tag
119    pub fn add_tag(mut self, tag: Vec<String>) -> Self {
120        self.tags.get_or_insert_with(Vec::new).push(tag);
121        self
122    }
123
124    /// Set tags
125    pub fn tags(mut self, tags: Vec<Vec<String>>) -> Self {
126        self.tags = Some(tags);
127        self
128    }
129
130    /// Build the Transport
131    pub fn build(self) -> Result<Transport, &'static str> {
132        let _type = self._type.ok_or("Transport type is required")?;
133        let target = self.target.ok_or("Target is required")?;
134
135        Ok(Transport {
136            _type,
137            target,
138            tags: self.tags,
139        })
140    }
141}
142
143impl AsRef<String> for Transport {
144    fn as_ref(&self) -> &String {
145        &self.target
146    }
147}
148
149/// Payment Request
150#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
151pub struct PaymentRequest {
152    /// `Payment id`
153    #[serde(rename = "i")]
154    pub payment_id: Option<String>,
155    /// Amount
156    #[serde(rename = "a")]
157    pub amount: Option<Amount>,
158    /// Unit
159    #[serde(rename = "u")]
160    pub unit: Option<CurrencyUnit>,
161    /// Single use
162    #[serde(rename = "s")]
163    pub single_use: Option<bool>,
164    /// Mints
165    #[serde(rename = "m")]
166    pub mints: Option<Vec<MintUrl>>,
167    /// Description
168    #[serde(rename = "d")]
169    pub description: Option<String>,
170    /// Transport
171    #[serde(rename = "t")]
172    pub transports: Vec<Transport>,
173}
174
175impl PaymentRequest {
176    /// Create a new PaymentRequestBuilder
177    pub fn builder() -> PaymentRequestBuilder {
178        PaymentRequestBuilder::default()
179    }
180}
181
182/// Builder for PaymentRequest
183#[derive(Debug, Default, Clone)]
184pub struct PaymentRequestBuilder {
185    payment_id: Option<String>,
186    amount: Option<Amount>,
187    unit: Option<CurrencyUnit>,
188    single_use: Option<bool>,
189    mints: Option<Vec<MintUrl>>,
190    description: Option<String>,
191    transports: Vec<Transport>,
192}
193
194impl PaymentRequestBuilder {
195    /// Set payment ID
196    pub fn payment_id<S>(mut self, payment_id: S) -> Self
197    where
198        S: Into<String>,
199    {
200        self.payment_id = Some(payment_id.into());
201        self
202    }
203
204    /// Set amount
205    pub fn amount<A>(mut self, amount: A) -> Self
206    where
207        A: Into<Amount>,
208    {
209        self.amount = Some(amount.into());
210        self
211    }
212
213    /// Set unit
214    pub fn unit(mut self, unit: CurrencyUnit) -> Self {
215        self.unit = Some(unit);
216        self
217    }
218
219    /// Set single use flag
220    pub fn single_use(mut self, single_use: bool) -> Self {
221        self.single_use = Some(single_use);
222        self
223    }
224
225    /// Add a mint URL
226    pub fn add_mint(mut self, mint_url: MintUrl) -> Self {
227        self.mints.get_or_insert_with(Vec::new).push(mint_url);
228        self
229    }
230
231    /// Set mints
232    pub fn mints(mut self, mints: Vec<MintUrl>) -> Self {
233        self.mints = Some(mints);
234        self
235    }
236
237    /// Set description
238    pub fn description<S: Into<String>>(mut self, description: S) -> Self {
239        self.description = Some(description.into());
240        self
241    }
242
243    /// Add a transport
244    pub fn add_transport(mut self, transport: Transport) -> Self {
245        self.transports.push(transport);
246        self
247    }
248
249    /// Set transports
250    pub fn transports(mut self, transports: Vec<Transport>) -> Self {
251        self.transports = transports;
252        self
253    }
254
255    /// Build the PaymentRequest
256    pub fn build(self) -> PaymentRequest {
257        PaymentRequest {
258            payment_id: self.payment_id,
259            amount: self.amount,
260            unit: self.unit,
261            single_use: self.single_use,
262            mints: self.mints,
263            description: self.description,
264            transports: self.transports,
265        }
266    }
267}
268
269impl AsRef<Option<String>> for PaymentRequest {
270    fn as_ref(&self) -> &Option<String> {
271        &self.payment_id
272    }
273}
274
275impl fmt::Display for PaymentRequest {
276    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
277        use serde::ser::Error;
278        let mut data = Vec::new();
279        ciborium::into_writer(self, &mut data).map_err(|e| fmt::Error::custom(e.to_string()))?;
280        let encoded = general_purpose::URL_SAFE.encode(data);
281        write!(f, "{}{}", PAYMENT_REQUEST_PREFIX, encoded)
282    }
283}
284
285impl FromStr for PaymentRequest {
286    type Err = Error;
287
288    fn from_str(s: &str) -> Result<Self, Self::Err> {
289        let s = s
290            .strip_prefix(PAYMENT_REQUEST_PREFIX)
291            .ok_or(Error::InvalidPrefix)?;
292
293        let decode_config = general_purpose::GeneralPurposeConfig::new()
294            .with_decode_padding_mode(bitcoin::base64::engine::DecodePaddingMode::Indifferent);
295        let decoded = GeneralPurpose::new(&alphabet::URL_SAFE, decode_config).decode(s)?;
296
297        Ok(ciborium::from_reader(&decoded[..])?)
298    }
299}
300
301/// Payment Request
302#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
303pub struct PaymentRequestPayload {
304    /// Id
305    pub id: Option<String>,
306    /// Memo
307    pub memo: Option<String>,
308    /// Mint
309    pub mint: MintUrl,
310    /// Unit
311    pub unit: CurrencyUnit,
312    /// Proofs
313    pub proofs: Proofs,
314}
315
316#[cfg(test)]
317mod tests {
318    use std::str::FromStr;
319
320    use super::*;
321
322    const PAYMENT_REQUEST: &str = "creqApWF0gaNhdGVub3N0cmFheKlucHJvZmlsZTFxeTI4d3VtbjhnaGo3dW45ZDNzaGp0bnl2OWtoMnVld2Q5aHN6OW1od2RlbjV0ZTB3ZmprY2N0ZTljdXJ4dmVuOWVlaHFjdHJ2NWhzenJ0aHdkZW41dGUwZGVoaHh0bnZkYWtxcWd5ZGFxeTdjdXJrNDM5eWtwdGt5c3Y3dWRoZGh1NjhzdWNtMjk1YWtxZWZkZWhrZjBkNDk1Y3d1bmw1YWeBgmFuYjE3YWloYjdhOTAxNzZhYQphdWNzYXRhbYF4Imh0dHBzOi8vbm9mZWVzLnRlc3RudXQuY2FzaHUuc3BhY2U=";
323
324    #[test]
325    fn test_decode_payment_req() {
326        let req = PaymentRequest::from_str(PAYMENT_REQUEST).expect("valid payment request");
327
328        assert_eq!(&req.payment_id.unwrap(), "b7a90176");
329        assert_eq!(req.amount.unwrap(), 10.into());
330        assert_eq!(req.unit.clone().unwrap(), CurrencyUnit::Sat);
331        assert_eq!(
332            req.mints.unwrap(),
333            vec![MintUrl::from_str("https://nofees.testnut.cashu.space").expect("valid mint url")]
334        );
335        assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
336
337        let transport = req.transports.first().unwrap();
338
339        let expected_transport = Transport {_type: TransportType::Nostr, target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])};
340
341        assert_eq!(transport, &expected_transport);
342    }
343
344    #[test]
345    fn test_roundtrip_payment_req() {
346        let transport = Transport {_type: TransportType::Nostr, target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), tags: Some(vec![vec!["n".to_string(), "17".to_string()]])};
347
348        let request = PaymentRequest {
349            payment_id: Some("b7a90176".to_string()),
350            amount: Some(10.into()),
351            unit: Some(CurrencyUnit::Sat),
352            single_use: None,
353            mints: Some(vec!["https://nofees.testnut.cashu.space"
354                .parse()
355                .expect("valid mint url")]),
356            description: None,
357            transports: vec![transport.clone()],
358        };
359
360        let request_str = request.to_string();
361
362        let req = PaymentRequest::from_str(&request_str).expect("valid payment request");
363
364        assert_eq!(&req.payment_id.unwrap(), "b7a90176");
365        assert_eq!(req.amount.unwrap(), 10.into());
366        assert_eq!(req.unit.clone().unwrap(), CurrencyUnit::Sat);
367        assert_eq!(
368            req.mints.unwrap(),
369            vec![MintUrl::from_str("https://nofees.testnut.cashu.space").expect("valid mint url")]
370        );
371        assert_eq!(req.unit.unwrap(), CurrencyUnit::Sat);
372
373        let t = req.transports.first().unwrap();
374        assert_eq!(&transport, t);
375    }
376
377    #[test]
378    fn test_payment_request_builder() {
379        let transport = Transport {
380            _type: TransportType::Nostr,
381            target: "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5".to_string(), 
382            tags: Some(vec![vec!["n".to_string(), "17".to_string()]])
383        };
384
385        let mint_url =
386            MintUrl::from_str("https://nofees.testnut.cashu.space").expect("valid mint url");
387
388        // Build a payment request using the builder pattern
389        let request = PaymentRequest::builder()
390            .payment_id("b7a90176")
391            .amount(Amount::from(10))
392            .unit(CurrencyUnit::Sat)
393            .add_mint(mint_url.clone())
394            .add_transport(transport.clone())
395            .build();
396
397        // Verify the built request
398        assert_eq!(&request.payment_id.clone().unwrap(), "b7a90176");
399        assert_eq!(request.amount.unwrap(), 10.into());
400        assert_eq!(request.unit.clone().unwrap(), CurrencyUnit::Sat);
401        assert_eq!(request.mints.clone().unwrap(), vec![mint_url]);
402
403        let t = request.transports.first().unwrap();
404        assert_eq!(&transport, t);
405
406        // Test serialization and deserialization
407        let request_str = request.to_string();
408        let req = PaymentRequest::from_str(&request_str).expect("valid payment request");
409
410        assert_eq!(req.payment_id, request.payment_id);
411        assert_eq!(req.amount, request.amount);
412        assert_eq!(req.unit, request.unit);
413    }
414
415    #[test]
416    fn test_transport_builder() {
417        // Build a transport using the builder pattern
418        let transport = Transport::builder()
419            .transport_type(TransportType::Nostr)
420            .target("nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5")
421            .add_tag(vec!["n".to_string(), "17".to_string()])
422            .build()
423            .expect("Valid transport");
424
425        // Verify the built transport
426        assert_eq!(transport._type, TransportType::Nostr);
427        assert_eq!(transport.target, "nprofile1qy28wumn8ghj7un9d3shjtnyv9kh2uewd9hsz9mhwden5te0wfjkccte9curxven9eehqctrv5hszrthwden5te0dehhxtnvdakqqgydaqy7curk439ykptkysv7udhdhu68sucm295akqefdehkf0d495cwunl5");
428        assert_eq!(
429            transport.tags,
430            Some(vec![vec!["n".to_string(), "17".to_string()]])
431        );
432
433        // Test error case - missing required fields
434        let result = TransportBuilder::default().build();
435        assert!(result.is_err());
436    }
437}