Skip to main content

lnurl/
pay.rs

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    /// a second-level url which give you an invoice with a GET request
23    /// and an amount
24    pub callback: String,
25    /// max sendable amount for a given user on a given service
26    #[serde(rename = "maxSendable")]
27    pub max_sendable: u64,
28    /// min sendable amount for a given user on a given service,
29    /// can not be less than 1 or more than `max_sendable`
30    #[serde(rename = "minSendable")]
31    pub min_sendable: u64,
32    /// tag of the request
33    pub tag: Tag,
34    /// Metadata json which must be presented as raw string here,
35    /// this is required to pass signature verification at a later step
36    pub metadata: String,
37
38    /// Optional, if true, the service allows comments
39    /// the number is the max length of the comment
40    #[serde(rename = "commentAllowed")]
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub comment_allowed: Option<u32>,
43
44    /// Optional, if true, the service allows nostr zaps
45    #[serde(rename = "allowsNostr")]
46    pub allows_nostr: Option<bool>,
47
48    /// Optional, if true, the nostr pubkey that will be used to sign zap events
49    #[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    /// If invoice has been settled
66    pub settled: bool,
67    /// Pre-image of the payment request (when paid)
68    pub preimage: Option<String>,
69    /// Encoded bolt 11 invoice
70    pub pr: String,
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
74pub struct LnURLPayInvoice {
75    /// Encoded bolt 11 invoice
76    pub pr: String,
77    /// If this invoice is a hodl invoice
78    pub hodl_invoice: Option<bool>,
79    /// Optional, if present, can be used to display a message to the user
80    /// after the payment has been completed
81    #[serde(rename = "successAction")]
82    #[serde(skip_serializing_if = "Option::is_none")]
83    success_action: Option<SuccessActionParams>,
84    /// LUD-21 verify URL
85    #[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    /// Verify that the BOLT11 invoice's amount equals the requested amount in
108    /// millisatoshis, as required by LUD-06 before the invoice is paid.
109    ///
110    /// Returns [`Error::InvalidInvoice`] if `pr` cannot be parsed as a BOLT11
111    /// invoice, and [`Error::InvoiceAmountMismatch`] if the invoice amount is
112    /// absent or differs from `msats`.
113    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/// Parse the amount (in millisatoshis) encoded in a BOLT11 invoice without
127/// pulling in a full invoice-parsing dependency.
128///
129/// The amount lives entirely in the human-readable part (HRP) of the invoice,
130/// so we only parse and validate the bech32 structure (via the `bech32` crate)
131/// and read the amount out of the HRP. The HRP is `ln` + a currency prefix
132/// (letters only) + an optional amount, where the amount is one or more digits
133/// followed by an optional multiplier letter (`m`/`u`/`n`/`p`).
134///
135/// Returns `Ok(None)` for an amountless invoice, `Ok(Some(msats))` when an
136/// amount is present, and [`Error::InvalidInvoice`] if the HRP is malformed.
137// `u128::is_multiple_of` is too new for this crate's MSRV.
138#[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    // Parse the bech32 structure to split off and validate the human-readable
143    // part. We deliberately do not verify the checksum: BOLT11 invoices are not
144    // length-bounded, the amount lives entirely in the HRP, and the invoice
145    // signature is the (possibly malicious) service's own, so a checksum check
146    // would add no protection against a mismatched amount.
147    let parsed =
148        UncheckedHrpstring::new(invoice).map_err(|e| Error::InvalidInvoice(e.to_string()))?;
149    // The HRP is all lower- or all upper-case (mixed case is rejected by the
150    // parser above); normalize so the `ln` prefix and multiplier letters match.
151    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    // The currency prefix contains no digits, so the amount section (if any)
158    // starts at the first digit in the HRP.
159    let amount = match hrp.find(|c: char| c.is_ascii_digit()) {
160        None => return Ok(None), // amountless invoice
161        Some(idx) => &hrp[idx..],
162    };
163
164    // Split off an optional trailing multiplier letter.
165    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    // Convert to millisatoshis. 1 BTC = 100_000_000_000 msat, and the BOLT11
176    // multipliers scale the value by 10^-3 (m), 10^-6 (u), 10^-9 (n), 10^-12 (p)
177    // bitcoin respectively.
178    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            // A pico-bitcoin amount must be a multiple of 10, as 1 msat is the
185            // smallest representable unit (10 pico-bitcoin).
186            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        // decode base64
273        let iv = BASE64_STANDARD.decode(&self.iv)?;
274        let ciphertext = BASE64_STANDARD.decode(&self.ciphertext)?;
275
276        // check iv length
277        if iv.len() != 16 {
278            return Err(anyhow::anyhow!("iv length is not 16"));
279        }
280        // turn into generic array
281        let iv: [u8; 16] = iv.try_into().unwrap();
282
283        // decrypt
284        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    // Real BOLT11 invoices from the BOLT #11 test vectors, with known amounts.
377
378    // Amountless donation invoice.
379    const AMOUNTLESS: &str = "lnbc1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6na6hlh";
380    // 2500u = 250_000_000 msat (250_000 sat).
381    const INV_250_000_000_MSAT: &str = "lnbc2500u1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpquwpc4curk03c9wlrswe78q4eyqc7d8d0xqzpuyk0sg5g70me25alkluzd2x62aysf2pyy8edtjeevuv4p2d5p76r4zkmneet7uvyakky2zr4cusd45tftc9c5fh0nnqpnl2jfll544esqchsrnt";
382    // 20m = 2_000_000_000 msat (0.02 BTC).
383    const INV_2_000_000_000_MSAT: &str = "lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqs9qrsgq7ea976txfraylvgzuxs8kgcw23ezlrszfnh8r6qtfpr6cxga50aj6txm9rxrydzd06dfeawfk6swupvz4erwnyutnjq7x39ymw6j38gp49qdkj";
384    // 10m = 1_000_000_000 msat (0.01 BTC).
385    const INV_1_000_000_000_MSAT: &str = "lnbc10m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdp9wpshjmt9de6zqmt9w3skgct5vysxjmnnd9jx2mq8q8a04uqsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygs9q2gqqqqqqsgq7hf8he7ecf7n4ffphs6awl9t6676rrclv9ckg3d3ncn7fct63p6s365duk5wrk202cfy3aj5xnnp5gs3vrdvruverwwq7yzhkf5a3xqpd05wjc";
386    // 9678785340p = 967_878_534 msat: a whole-millisatoshi but non-whole-sat amount.
387    const INV_967_878_534_MSAT: &str = "lnbc9678785340p1pwmna7lpp5gc3xfm08u9qy06djf8dfflhugl6p7lgza6dsjxq454gxhj9t7a0sd8dgfkx7cmtwd68yetpd5s9xar0wfjn5gpc8qhrsdfq24f5ggrxdaezqsnvda3kkum5wfjkzmfqf3jkgem9wgsyuctwdus9xgrcyqcjcgpzgfskx6eqf9hzqnteypzxz7fzypfhg6trddjhygrcyqezcgpzfysywmm5ypxxjemgw3hxjmn8yptk7untd9hxwg3q2d6xjcmtv4ezq7pqxgsxzmnyyqcjqmt0wfjjq6t5v4khxsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygsxqyjw5qcqp2rzjq0gxwkzc8w6323m55m4jyxcjwmy7stt9hwkwe2qxmy8zpsgg7jcuwz87fcqqeuqqqyqqqqlgqqqqn3qq9q9qrsgqrvgkpnmps664wgkp43l22qsgdw4ve24aca4nymnxddlnp8vh9v2sdxlu5ywdxefsfvm0fq3sesf08uf6q9a2ke0hc9j6z6wlxg5z5kqpu2v9wz";
388    // testnet 20m = 2_000_000_000 msat.
389    const TESTNET_INV_2_000_000_000_MSAT: &str = "lntb20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqfpp3x9et2e20v6pu37c5d9vax37wxq72un989qrsgqdj545axuxtnfemtpwkc45hx9d2ft7x04mt8q7y6t0k2dge9e7h8kpy9p34ytyslj3yu569aalz2xdk8xkd7ltxqld94u8h2esmsmacgpghe9k8";
390    // 2500000001p has sub-millisatoshi precision and must be rejected.
391    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        // sub-millisatoshi precision (pico amount not a multiple of 10)
408        assert!(matches!(
409            parse_bolt11_amount_msats(SUB_MSAT),
410            Err(Error::InvalidInvoice(_))
411        ));
412        // not a lightning invoice
413        assert!(matches!(
414            parse_bolt11_amount_msats("not a real invoice"),
415            Err(Error::InvalidInvoice(_))
416        ));
417        // amount that overflows u64 millisatoshis. No real invoice can carry
418        // such a value, so this exercises the HRP parser directly.
419        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        // invoice is for 2_000_000_000 msat, but only 250_000_000 was requested
434        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        // invoice is for 250_000_000 msat, but 2_000_000_000 was requested
450        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        // 967_878_534 msat is not a whole number of sats
481        let inv = LnURLPayInvoice::new(INV_967_878_534_MSAT.to_string());
482        assert!(inv.verify_amount(967_878_534).is_ok());
483        // rounding the requested amount to whole sats must be rejected
484        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}