1use 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#[derive(Debug, Error)]
21pub enum Error {
22 #[error("Invalid Prefix")]
24 InvalidPrefix,
25 #[error(transparent)]
27 CiboriumError(#[from] ciborium::de::Error<std::io::Error>),
28 #[error(transparent)]
30 Base64Error(#[from] bitcoin::base64::DecodeError),
31}
32
33#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Serialize, Deserialize)]
35pub enum TransportType {
36 #[serde(rename = "nostr")]
38 Nostr,
39 #[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#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
78pub struct Transport {
79 #[serde(rename = "t")]
81 pub _type: TransportType,
82 #[serde(rename = "a")]
84 pub target: String,
85 #[serde(rename = "g")]
87 pub tags: Option<Vec<Vec<String>>>,
88}
89
90impl Transport {
91 pub fn builder() -> TransportBuilder {
93 TransportBuilder::default()
94 }
95}
96
97#[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 pub fn transport_type(mut self, transport_type: TransportType) -> Self {
108 self._type = Some(transport_type);
109 self
110 }
111
112 pub fn target<S: Into<String>>(mut self, target: S) -> Self {
114 self.target = Some(target.into());
115 self
116 }
117
118 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 pub fn tags(mut self, tags: Vec<Vec<String>>) -> Self {
126 self.tags = Some(tags);
127 self
128 }
129
130 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#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
151pub struct PaymentRequest {
152 #[serde(rename = "i")]
154 pub payment_id: Option<String>,
155 #[serde(rename = "a")]
157 pub amount: Option<Amount>,
158 #[serde(rename = "u")]
160 pub unit: Option<CurrencyUnit>,
161 #[serde(rename = "s")]
163 pub single_use: Option<bool>,
164 #[serde(rename = "m")]
166 pub mints: Option<Vec<MintUrl>>,
167 #[serde(rename = "d")]
169 pub description: Option<String>,
170 #[serde(rename = "t")]
172 pub transports: Vec<Transport>,
173}
174
175impl PaymentRequest {
176 pub fn builder() -> PaymentRequestBuilder {
178 PaymentRequestBuilder::default()
179 }
180}
181
182#[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 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 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 pub fn unit(mut self, unit: CurrencyUnit) -> Self {
215 self.unit = Some(unit);
216 self
217 }
218
219 pub fn single_use(mut self, single_use: bool) -> Self {
221 self.single_use = Some(single_use);
222 self
223 }
224
225 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 pub fn mints(mut self, mints: Vec<MintUrl>) -> Self {
233 self.mints = Some(mints);
234 self
235 }
236
237 pub fn description<S: Into<String>>(mut self, description: S) -> Self {
239 self.description = Some(description.into());
240 self
241 }
242
243 pub fn add_transport(mut self, transport: Transport) -> Self {
245 self.transports.push(transport);
246 self
247 }
248
249 pub fn transports(mut self, transports: Vec<Transport>) -> Self {
251 self.transports = transports;
252 self
253 }
254
255 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#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
303pub struct PaymentRequestPayload {
304 pub id: Option<String>,
306 pub memo: Option<String>,
308 pub mint: MintUrl,
310 pub unit: CurrencyUnit,
312 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 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 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 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 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 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 let result = TransportBuilder::default().build();
435 assert!(result.is_err());
436 }
437}