1use aes::cipher::block_padding::Pkcs7;
2use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit};
3use aes::Aes256;
4use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
5use base64::Engine;
6use bech32::primitives::decode::UncheckedHrpstring;
7use bitcoin::hashes::sha256::Hash as Sha256;
8use bitcoin::hashes::Hash;
9use bitcoin::key::XOnlyPublicKey;
10use cbc::{Decryptor, Encryptor};
11use serde::{Deserialize, Serialize};
12use std::convert::{TryFrom, TryInto};
13use url::Url;
14
15type Aes256CbcEnc = Encryptor<Aes256>;
16type Aes256CbcDec = Decryptor<Aes256>;
17
18use crate::{Error, Tag};
19
20#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
21pub struct PayResponse {
22 pub callback: String,
25 #[serde(rename = "maxSendable")]
27 pub max_sendable: u64,
28 #[serde(rename = "minSendable")]
31 pub min_sendable: u64,
32 pub tag: Tag,
34 pub metadata: String,
37
38 #[serde(rename = "commentAllowed")]
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub comment_allowed: Option<u32>,
43
44 #[serde(rename = "allowsNostr")]
46 pub allows_nostr: Option<bool>,
47
48 #[serde(rename = "nostrPubkey")]
50 pub nostr_pubkey: Option<XOnlyPublicKey>,
51}
52
53impl PayResponse {
54 pub fn metadata_json(&self) -> serde_json::Value {
55 serde_json::from_str(&self.metadata).unwrap()
56 }
57
58 pub fn metadata_hash(&self) -> [u8; 32] {
59 Sha256::hash(self.metadata.as_bytes()).to_byte_array()
60 }
61}
62
63#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
64pub struct VerifyResponse {
65 pub settled: bool,
67 pub preimage: Option<String>,
69 pub pr: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct LnURLPayInvoice {
75 pub pr: String,
77 pub hodl_invoice: Option<bool>,
79 #[serde(rename = "successAction")]
82 #[serde(skip_serializing_if = "Option::is_none")]
83 success_action: Option<SuccessActionParams>,
84 #[serde(skip_serializing_if = "Option::is_none")]
86 pub verify: Option<String>,
87}
88
89impl LnURLPayInvoice {
90 pub fn new(invoice: String) -> Self {
91 Self {
92 pr: invoice,
93 hodl_invoice: None,
94 success_action: None,
95 verify: None,
96 }
97 }
98
99 pub fn invoice(&self) -> &str {
100 self.pr.as_str()
101 }
102
103 pub fn success_action(&self) -> Option<SuccessAction> {
104 self.success_action.clone().map(SuccessAction::from_params)
105 }
106
107 pub fn verify_amount(&self, msats: u64) -> Result<(), Error> {
114 let invoice_msats = parse_bolt11_amount_msats(&self.pr)?;
115 if invoice_msats != Some(msats) {
116 return Err(Error::InvoiceAmountMismatch {
117 requested_msats: msats,
118 invoice_msats,
119 });
120 }
121
122 Ok(())
123 }
124}
125
126#[allow(clippy::manual_is_multiple_of)]
139fn parse_bolt11_amount_msats(invoice: &str) -> Result<Option<u64>, Error> {
140 let invalid = |msg: &str| Error::InvalidInvoice(msg.to_string());
141
142 let parsed =
148 UncheckedHrpstring::new(invoice).map_err(|e| Error::InvalidInvoice(e.to_string()))?;
149 let hrp = parsed.hrp().as_str().to_ascii_lowercase();
152
153 if !hrp.starts_with("ln") {
154 return Err(invalid("not a lightning invoice"));
155 }
156
157 let amount = match hrp.find(|c: char| c.is_ascii_digit()) {
160 None => return Ok(None), Some(idx) => &hrp[idx..],
162 };
163
164 let (digits, multiplier) = match amount.chars().last() {
166 Some(c) if c.is_ascii_digit() => (amount, None),
167 Some(c) => (&amount[..amount.len() - 1], Some(c)),
168 None => unreachable!("amount is non-empty"),
169 };
170
171 let value: u128 = digits
172 .parse()
173 .map_err(|_| invalid("invalid amount digits"))?;
174
175 let msats: u128 = match multiplier {
179 None => value.checked_mul(100_000_000_000),
180 Some('m') => value.checked_mul(100_000_000),
181 Some('u') => value.checked_mul(100_000),
182 Some('n') => value.checked_mul(100),
183 Some('p') => {
184 if value % 10 != 0 {
187 return Err(invalid("sub-millisatoshi amount"));
188 }
189 Some(value / 10)
190 }
191 Some(_) => return Err(invalid("invalid amount multiplier")),
192 }
193 .ok_or_else(|| invalid("amount overflow"))?;
194
195 let msats = u64::try_from(msats).map_err(|_| invalid("amount overflow"))?;
196
197 Ok(Some(msats))
198}
199
200#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
201pub enum SuccessAction {
202 Message(String),
203 Url { url: Url, description: String },
204 AES(AesParams),
205 Unknown(SuccessActionParams),
206}
207
208impl SuccessAction {
209 pub fn tag(&self) -> &str {
210 match self {
211 SuccessAction::Message(_) => "message",
212 SuccessAction::Url { .. } => "url",
213 SuccessAction::AES(_) => "aes",
214 SuccessAction::Unknown(params) => params.tag.as_str(),
215 }
216 }
217
218 pub fn into_params(self) -> SuccessActionParams {
219 match self {
220 SuccessAction::Message(message) => SuccessActionParams {
221 tag: "message".to_string(),
222 message: Some(message),
223 url: None,
224 description: None,
225 ciphertext: None,
226 iv: None,
227 },
228 SuccessAction::Url { url, description } => SuccessActionParams {
229 tag: "url".to_string(),
230 message: None,
231 url: Some(url),
232 description: Some(description),
233 ciphertext: None,
234 iv: None,
235 },
236 SuccessAction::AES(params) => SuccessActionParams {
237 tag: "aes".to_string(),
238 message: None,
239 url: None,
240 description: Some(params.description),
241 ciphertext: Some(params.ciphertext),
242 iv: Some(params.iv),
243 },
244 SuccessAction::Unknown(params) => params,
245 }
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
250pub struct AesParams {
251 pub description: String,
252 pub ciphertext: String,
253 pub iv: String,
254}
255
256impl AesParams {
257 pub fn new(description: String, text: &str, preimage: &[u8; 32]) -> anyhow::Result<AesParams> {
258 let iv = bitcoin::secp256k1::rand::random::<[u8; 16]>();
259 let cipher = Aes256CbcEnc::new(preimage.into(), &iv.into());
260 let encrypted: Vec<u8> = cipher.encrypt_padded_vec_mut::<Pkcs7>(text.as_bytes());
261 let ciphertext = BASE64_STANDARD.encode(encrypted);
262
263 let iv = BASE64_STANDARD.encode(iv);
264 Ok(AesParams {
265 description,
266 ciphertext,
267 iv,
268 })
269 }
270
271 pub fn decrypt(&self, preimage: &[u8; 32]) -> anyhow::Result<String> {
272 let iv = BASE64_STANDARD.decode(&self.iv)?;
274 let ciphertext = BASE64_STANDARD.decode(&self.ciphertext)?;
275
276 if iv.len() != 16 {
278 return Err(anyhow::anyhow!("iv length is not 16"));
279 }
280 let iv: [u8; 16] = iv.try_into().unwrap();
282
283 let cipher = Aes256CbcDec::new(preimage.into(), &iv.into());
285 let decrypted: Vec<u8> = cipher
286 .decrypt_padded_vec_mut::<Pkcs7>(&ciphertext)
287 .map_err(|_| anyhow::anyhow!("decryption failed"))?;
288
289 Ok(String::from_utf8(decrypted)?)
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
294pub struct SuccessActionParams {
295 pub tag: String,
296 pub message: Option<String>,
297 pub url: Option<Url>,
298 pub description: Option<String>,
299 pub ciphertext: Option<String>,
300 pub iv: Option<String>,
301}
302
303impl SuccessAction {
304 pub fn from_params(params: SuccessActionParams) -> Self {
305 match params.tag.as_str() {
306 "message" => {
307 if params.message.is_none() {
308 return SuccessAction::Unknown(params);
309 }
310 SuccessAction::Message(params.message.unwrap())
311 }
312 "url" => {
313 if params.url.is_none() || params.description.is_none() {
314 return SuccessAction::Unknown(params);
315 }
316 SuccessAction::Url {
317 url: params.url.unwrap(),
318 description: params.description.unwrap(),
319 }
320 }
321 "aes" => {
322 if params.description.is_none()
323 || params.ciphertext.is_none()
324 || params.iv.is_none()
325 {
326 return SuccessAction::Unknown(params);
327 }
328
329 SuccessAction::AES(AesParams {
330 description: params.description.unwrap(),
331 ciphertext: params.ciphertext.unwrap(),
332 iv: params.iv.unwrap(),
333 })
334 }
335 _ => SuccessAction::Unknown(params),
336 }
337 }
338}
339
340#[cfg(test)]
341mod test {
342 use super::*;
343 use crate::Response;
344
345 #[test]
346 fn test_encrypt_decrypt() {
347 let description = "test_description".to_string();
348 let text = "hello world".to_string();
349 let preimage = [1u8; 32];
350
351 let params = AesParams::new(description.clone(), &text, &preimage).unwrap();
352
353 let decrypted = params.decrypt(&preimage).unwrap();
354 assert_eq!(decrypted, text);
355 }
356
357 #[test]
358 fn test_parse_verify_settled() {
359 let settled = r#"{
360 "status": "OK",
361 "settled": true,
362 "preimage": "123456...",
363 "pr": "lnbc10..."
364}"#;
365
366 let parsed = serde_json::from_str::<Response<VerifyResponse>>(settled).unwrap();
367 let parsed = match parsed {
368 Response::Error { .. } => panic!("failed to parse"),
369 Response::Ok(p) => p,
370 };
371 assert!(parsed.settled);
372 assert!(parsed.preimage.is_some());
373 assert!(parsed.pr.starts_with("lnbc10"));
374 }
375
376 const AMOUNTLESS: &str = "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh";
380 const INV_250_000_000_MSAT: &str = "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt";
382 const INV_2_000_000_000_MSAT: &str = "lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp49qdkj";
384 const INV_1_000_000_000_MSAT: &str = "lnbc10m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdp9wpshjmt9de6zqmt9w3skgct5vysxjmnnd9jx2mq8q8a04uqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q2gqqqqqqsgq7hf8he7ecf7n4ffphs6awl9t6676rrclv9ckg3d3ncn7fct63p6s365duk5wrk202cfy3aj5xnnp5gs3vrdvruverwwq7yzhkf5a3xqpd05wjc";
386 const INV_967_878_534_MSAT: &str = "lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz";
388 const TESTNET_INV_2_000_000_000_MSAT: &str = "lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8";
390 const SUB_MSAT: &str = "lnbc2500000001p1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdq5xysxxatsyp3k7enxv4jsxqzpusp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9qrsgq0lzc236j96a95uv0m3umg28gclm5lqxtqqwk32uuk4k6673k6n5kfvx3d2h8s295fad45fdhmusm8sjudfhlf6dcsxmfvkeywmjdkxcp99202x";
392
393 #[test]
394 fn test_parse_bolt11_amount() {
395 let parse = |s: &str| parse_bolt11_amount_msats(s).unwrap();
396
397 assert_eq!(parse(AMOUNTLESS), None);
398 assert_eq!(parse(INV_250_000_000_MSAT), Some(250_000_000));
399 assert_eq!(parse(INV_2_000_000_000_MSAT), Some(2_000_000_000));
400 assert_eq!(parse(INV_1_000_000_000_MSAT), Some(1_000_000_000));
401 assert_eq!(parse(INV_967_878_534_MSAT), Some(967_878_534));
402 assert_eq!(parse(TESTNET_INV_2_000_000_000_MSAT), Some(2_000_000_000));
403 }
404
405 #[test]
406 fn test_parse_bolt11_amount_invalid() {
407 assert!(matches!(
409 parse_bolt11_amount_msats(SUB_MSAT),
410 Err(Error::InvalidInvoice(_))
411 ));
412 assert!(matches!(
414 parse_bolt11_amount_msats("not a real invoice"),
415 Err(Error::InvalidInvoice(_))
416 ));
417 assert!(matches!(
420 parse_bolt11_amount_msats("lnbc99999999999991q"),
421 Err(Error::InvalidInvoice(_))
422 ));
423 }
424
425 #[test]
426 fn test_verify_amount_matches() {
427 let inv = LnURLPayInvoice::new(INV_250_000_000_MSAT.to_string());
428 assert!(inv.verify_amount(250_000_000).is_ok());
429 }
430
431 #[test]
432 fn test_verify_amount_larger_invoice() {
433 let inv = LnURLPayInvoice::new(INV_2_000_000_000_MSAT.to_string());
435 match inv.verify_amount(250_000_000) {
436 Err(Error::InvoiceAmountMismatch {
437 requested_msats,
438 invoice_msats,
439 }) => {
440 assert_eq!(requested_msats, 250_000_000);
441 assert_eq!(invoice_msats, Some(2_000_000_000));
442 }
443 other => panic!("expected mismatch, got {:?}", other),
444 }
445 }
446
447 #[test]
448 fn test_verify_amount_smaller_invoice() {
449 let inv = LnURLPayInvoice::new(INV_250_000_000_MSAT.to_string());
451 match inv.verify_amount(2_000_000_000) {
452 Err(Error::InvoiceAmountMismatch {
453 requested_msats,
454 invoice_msats,
455 }) => {
456 assert_eq!(requested_msats, 2_000_000_000);
457 assert_eq!(invoice_msats, Some(250_000_000));
458 }
459 other => panic!("expected mismatch, got {:?}", other),
460 }
461 }
462
463 #[test]
464 fn test_verify_amount_amountless_invoice() {
465 let inv = LnURLPayInvoice::new(AMOUNTLESS.to_string());
466 match inv.verify_amount(250_000_000) {
467 Err(Error::InvoiceAmountMismatch {
468 requested_msats,
469 invoice_msats,
470 }) => {
471 assert_eq!(requested_msats, 250_000_000);
472 assert_eq!(invoice_msats, None);
473 }
474 other => panic!("expected mismatch, got {:?}", other),
475 }
476 }
477
478 #[test]
479 fn test_verify_amount_non_whole_sat() {
480 let inv = LnURLPayInvoice::new(INV_967_878_534_MSAT.to_string());
482 assert!(inv.verify_amount(967_878_534).is_ok());
483 assert!(matches!(
485 inv.verify_amount(967_878_000),
486 Err(Error::InvoiceAmountMismatch { .. })
487 ));
488 }
489
490 #[test]
491 fn test_verify_amount_invalid_invoice() {
492 let inv = LnURLPayInvoice::new("not a real invoice".to_string());
493 assert!(matches!(
494 inv.verify_amount(250_000_000),
495 Err(Error::InvalidInvoice(_))
496 ));
497 }
498
499 #[test]
500 fn test_parse_verify_not_settled() {
501 let settled = r#"{
502 "status": "OK",
503 "settled": false,
504 "preimage": null,
505 "pr": "lnbc10..."
506}"#;
507
508 let parsed = serde_json::from_str::<Response<VerifyResponse>>(settled).unwrap();
509 let parsed = match parsed {
510 Response::Error { .. } => panic!("failed to parse"),
511 Response::Ok(p) => p,
512 };
513 assert!(!parsed.settled);
514 assert!(parsed.preimage.is_none());
515 assert!(parsed.pr.starts_with("lnbc10"));
516 }
517}