1use 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#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
22pub struct PaymentRequest {
23 #[serde(rename = "i")]
25 pub payment_id: Option<String>,
26 #[serde(rename = "a")]
28 pub amount: Option<Amount>,
29 #[serde(rename = "u")]
31 pub unit: Option<CurrencyUnit>,
32 #[serde(rename = "s")]
34 pub single_use: Option<bool>,
35 #[serde(rename = "m")]
37 pub mints: Option<Vec<MintUrl>>,
38 #[serde(rename = "d")]
40 pub description: Option<String>,
41 #[serde(rename = "t")]
43 #[serde(skip_serializing_if = "Option::is_none")]
44 pub transports: Option<Vec<Transport>>,
45 pub nut10: Option<Nut10SecretRequest>,
47}
48
49impl PaymentRequest {
50 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#[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 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 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 pub fn unit(mut self, unit: CurrencyUnit) -> Self {
122 self.unit = Some(unit);
123 self
124 }
125
126 pub fn single_use(mut self, single_use: bool) -> Self {
128 self.single_use = Some(single_use);
129 self
130 }
131
132 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 pub fn mints(mut self, mints: Vec<MintUrl>) -> Self {
140 self.mints = Some(mints);
141 self
142 }
143
144 pub fn description<S: Into<String>>(mut self, description: S) -> Self {
146 self.description = Some(description.into());
147 self
148 }
149
150 pub fn add_transport(mut self, transport: Transport) -> Self {
152 self.transports.push(transport);
153 self
154 }
155
156 pub fn transports(mut self, transports: Vec<Transport>) -> Self {
158 self.transports = transports;
159 self
160 }
161
162 pub fn nut10(mut self, nut10: Nut10SecretRequest) -> Self {
164 self.nut10 = Some(nut10);
165 self
166 }
167
168 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#[derive(Debug, Clone, Hash, PartialEq, Eq, Serialize, Deserialize)]
187pub struct PaymentRequestPayload {
188 pub id: Option<String>,
190 pub memo: Option<String>,
192 pub mint: MintUrl,
194 pub unit: CurrencyUnit,
196 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 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 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 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 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 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 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 let secret_request = Nut10SecretRequest::new(
337 Kind::P2PK,
338 "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198",
339 Some(vec![vec!["key".to_string(), "value".to_string()]]),
340 );
341
342 let full_secret: crate::nuts::Nut10Secret = secret_request.clone().into();
344
345 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 let converted_back = Nut10SecretRequest::from(full_secret);
358
359 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 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 let pubkey_hex = "026562efcfadc8e86d44da6a8adf80633d974302e62c850774db1fb36ff4cc7198";
433
434 let nut10 = SpendingConditions::P2PKConditions {
436 data: crate::nuts::PublicKey::from_str(pubkey_hex).unwrap(),
437 conditions: None,
438 };
439
440 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 let payment_request_str = payment_request.to_string();
451
452 let decoded_request = PaymentRequest::from_str(&payment_request_str).unwrap();
454
455 assert_eq!(payment_request, decoded_request);
457
458 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}