Skip to main content

gl_client/lnurl/pay/
mod.rs

1use super::models::SuccessAction;
2use super::utils::parse_lnurl;
3
4use crate::lightning_invoice::Bolt11Invoice;
5use crate::lnurl::{
6    models::{LnUrlHttpClient, PayRequestCallbackResponse, PayRequestResponse},
7    utils::parse_invoice,
8};
9
10use anyhow::{anyhow, ensure, Result};
11use log::debug;
12use reqwest::Url;
13
14impl PayRequestResponse {
15    /// Extract the "text/plain" description from the metadata JSON.
16    pub fn description(&self) -> Option<String> {
17        super::utils::extract_description_from_metadata(&self.metadata)
18    }
19
20    /// Validate this pay request response for a given amount.
21    ///
22    /// Checks the tag, amount range, and — for lightning addresses —
23    /// that the metadata contains a matching identifier.
24    pub fn validate(&self, identifier: &str, amount_msats: u64) -> Result<()> {
25        if self.tag != "payRequest" {
26            return Err(anyhow!("Expected tag to say 'payRequest'"));
27        }
28
29        if amount_msats < self.min_sendable {
30            return Err(anyhow!(
31                "Amount must be {} or greater",
32                self.min_sendable
33            ));
34        }
35        if amount_msats > self.max_sendable {
36            return Err(anyhow!(
37                "Amount must be {} or less",
38                self.max_sendable
39            ));
40        }
41
42        debug!(
43            "Accepted range (in millisatoshis): {} - {}",
44            self.min_sendable, self.max_sendable
45        );
46
47        // For lightning addresses, verify the identifier appears in metadata
48        if !is_lnurl(identifier) {
49            let entries: Vec<Vec<String>> =
50                serde_json::from_str(&self.metadata)
51                    .map_err(|e| anyhow!("Failed to deserialize metadata: {}", e))?;
52
53            let found = entries.iter().any(|entry| {
54                entry.len() >= 2
55                    && (entry[0] == "text/email" || entry[0] == "text/identifier")
56                    && entry[1] == identifier
57            });
58
59            if !found {
60                return Err(anyhow!(
61                    "The lightning address specified in the original request \
62                     does not match what was found in the metadata array"
63                ));
64            }
65        }
66
67        Ok(())
68    }
69
70    /// Fetch an invoice from this pay request's callback endpoint.
71    ///
72    /// Builds the callback URL with the given amount and optional comment,
73    /// fetches the invoice, validates it against the metadata, and returns
74    /// the invoice string along with any success action.
75    pub async fn get_invoice<T: LnUrlHttpClient>(
76        &self,
77        http_client: &T,
78        amount_msats: u64,
79        comment: Option<&str>,
80    ) -> Result<(String, Option<SuccessAction>)> {
81        fetch_invoice(http_client, &self.callback, amount_msats, comment).await
82    }
83}
84
85/// Fetch an invoice from a pay-request callback URL.
86///
87/// This is the "phase 2" of the two-phase LNURL-pay flow: the caller
88/// already has the callback URL from the initial `payRequest` response,
89/// and now requests an invoice for a specific amount. The returned
90/// invoice is validated to be parseable and match the requested amount.
91pub async fn fetch_invoice<T: LnUrlHttpClient>(
92    http_client: &T,
93    callback: &str,
94    amount_msats: u64,
95    comment: Option<&str>,
96) -> Result<(String, Option<SuccessAction>)> {
97    let callback_url = build_callback_url(callback, amount_msats, comment)?;
98    let raw: serde_json::Value = http_client.get_json(&callback_url).await?;
99
100    if raw.get("status").and_then(|v| v.as_str()) == Some("ERROR") {
101        let reason = raw
102            .get("reason")
103            .and_then(|v| v.as_str())
104            .unwrap_or("unknown error");
105        return Err(anyhow!("{}{}", LNURL_SERVICE_ERROR_PREFIX, reason));
106    }
107
108    let callback_response: PayRequestCallbackResponse = serde_json::from_value(raw)?;
109
110    let invoice = parse_invoice(&callback_response.pr)?;
111    validate_invoice(&invoice, amount_msats)?;
112    Ok((invoice.to_string(), callback_response.success_action))
113}
114
115/// Prefix used on `fetch_invoice` errors that originate from the LNURL
116/// service returning a `{"status":"ERROR"}` body. Callers can match on
117/// this prefix to distinguish service-side rejections from transport
118/// or parsing failures.
119pub const LNURL_SERVICE_ERROR_PREFIX: &str = "LNURL service error: ";
120
121/// Build a callback URL with amount and optional comment query parameters.
122fn build_callback_url(
123    callback: &str,
124    amount: u64,
125    comment: Option<&str>,
126) -> Result<String> {
127    let mut url = Url::parse(callback)?;
128    url.query_pairs_mut()
129        .append_pair("amount", &amount.to_string());
130    if let Some(c) = comment {
131        if !c.is_empty() {
132            url.query_pairs_mut().append_pair("comment", c);
133        }
134    }
135    Ok(url.to_string())
136}
137
138/// Validate a BOLT11 invoice against the user-requested amount.
139fn validate_invoice(invoice: &Bolt11Invoice, amount_msats: u64) -> Result<()> {
140    ensure!(
141        invoice.amount_milli_satoshis().unwrap_or_default() == amount_msats,
142        "Amount found in invoice was not equal to the amount found in the original request\n\
143         Request amount: {}\nInvoice amount: {:?}",
144        amount_msats,
145        invoice.amount_milli_satoshis()
146    );
147    Ok(())
148}
149
150/// Decrypt an AES-256-CBC encrypted success action payload (LUD-10).
151///
152/// - `preimage`: 32-byte payment preimage (used as the AES key)
153/// - `ciphertext_b64`: base64-encoded ciphertext
154/// - `iv_b64`: base64-encoded IV (decodes to 16 bytes)
155pub fn decrypt_aes_success_action(
156    preimage: &[u8],
157    ciphertext_b64: &str,
158    iv_b64: &str,
159) -> Result<String> {
160    use aes::Aes256;
161    use base64::{engine::general_purpose::STANDARD, Engine};
162    use cbc::cipher::{block_padding::Pkcs7, BlockDecryptMut, KeyIvInit};
163
164    let ciphertext = STANDARD
165        .decode(ciphertext_b64)
166        .map_err(|e| anyhow!("Invalid base64 ciphertext: {}", e))?;
167    let iv = STANDARD
168        .decode(iv_b64)
169        .map_err(|e| anyhow!("Invalid base64 IV: {}", e))?;
170
171    if preimage.len() != 32 {
172        return Err(anyhow!(
173            "Payment preimage must be 32 bytes, got {}",
174            preimage.len()
175        ));
176    }
177    if iv.len() != 16 {
178        return Err(anyhow!("IV must be 16 bytes, got {}", iv.len()));
179    }
180
181    type Aes256CbcDec = cbc::Decryptor<Aes256>;
182    let decryptor = Aes256CbcDec::new_from_slices(preimage, &iv)
183        .map_err(|e| anyhow!("AES init failed: {}", e))?;
184
185    let plaintext_bytes = decryptor
186        .decrypt_padded_vec_mut::<Pkcs7>(&ciphertext)
187        .map_err(|e| anyhow!("AES decryption failed: {}", e))?;
188
189    String::from_utf8(plaintext_bytes)
190        .map_err(|e| anyhow!("Decrypted data is not valid UTF-8: {}", e))
191}
192
193fn is_lnurl(lnurl_identifier: &str) -> bool {
194    const LNURL_PREFIX: &str = "LNURL";
195    lnurl_identifier
196        .trim()
197        .to_uppercase()
198        .starts_with(LNURL_PREFIX)
199}
200
201/// Resolve an LNURL or lightning address to an invoice in one shot.
202///
203/// Convenience function that combines resolution + validation + invoice
204/// fetching. For a two-phase flow, use `PayRequestResponse::get_invoice()`
205/// directly after resolving.
206pub async fn resolve_lnurl_to_invoice<T: LnUrlHttpClient>(
207    http_client: &T,
208    lnurl_identifier: &str,
209    amount_msats: u64,
210    comment: Option<&str>,
211) -> Result<(String, Option<SuccessAction>)> {
212    let url = match is_lnurl(lnurl_identifier) {
213        true => parse_lnurl(lnurl_identifier)?,
214        false => parse_lightning_address(lnurl_identifier)?,
215    };
216
217    debug!("Domain: {}", Url::parse(&url).unwrap().host().unwrap());
218
219    let pay_request: PayRequestResponse =
220        http_client.get_pay_request_response(&url).await?;
221
222    pay_request.validate(lnurl_identifier, amount_msats)?;
223    pay_request.get_invoice(http_client, amount_msats, comment).await
224}
225
226/// Parse a lightning address into its well-known LNURL-pay URL (LUD-16).
227pub fn parse_lightning_address(lightning_address: &str) -> Result<String> {
228    let parts: Vec<&str> = lightning_address.split('@').collect();
229
230    if parts.len() != 2 {
231        return Err(anyhow!(
232            "The provided lightning address is improperly formatted"
233        ));
234    }
235
236    let username = parts[0];
237    let domain = parts[1];
238
239    if username.is_empty() {
240        return Err(anyhow!("Username can not be empty"));
241    }
242    if domain.is_empty() {
243        return Err(anyhow!("Domain can not be empty"));
244    }
245
246    Ok(format!(
247        "https://{}/.well-known/lnurlp/{}",
248        domain, username
249    ))
250}
251
252#[cfg(test)]
253mod tests {
254    use crate::lnurl::models::MockLnUrlHttpClient;
255    use futures::future;
256    use futures::future::Ready;
257    use std::pin::Pin;
258
259    use super::*;
260
261    fn convert_to_async_return_value<T: Send + 'static>(
262        value: T,
263    ) -> Pin<Box<dyn std::future::Future<Output = T> + Send>> {
264        let ready_future: Ready<_> = future::ready(value);
265        Pin::new(Box::new(ready_future)) as Pin<Box<dyn std::future::Future<Output = T> + Send>>
266    }
267
268    #[test]
269    fn test_parse_invoice() {
270        let invoice_str = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg";
271
272        let result = parse_invoice(invoice_str);
273        assert!(result.is_ok());
274
275        let invoice = result.unwrap();
276        assert_eq!(invoice.amount_milli_satoshis().unwrap(), 10);
277    }
278
279    #[tokio::test]
280    async fn test_lnurl_pay() {
281        let mut mock_http_client = MockLnUrlHttpClient::new();
282
283        mock_http_client.expect_get_pay_request_response().returning(|_url| {
284            let x: PayRequestResponse = serde_json::from_str("{ \"callback\": \"https://cipherpunk.com/lnurlp/api/v1/lnurl/cb/1\", \"maxSendable\": 100000, \"minSendable\": 100, \"tag\": \"payRequest\", \"metadata\": \"[[\\\"text/plain\\\", \\\"Start the CoinTrain\\\"]]\" }").unwrap();
285            convert_to_async_return_value(Ok(x))
286        });
287
288        mock_http_client.expect_get_json().returning(|_url| {
289            let invoice = "lnbc1u1pjv9qrvsp5e5wwexctzp9yklcrzx448c68q2a7kma55cm67ruajjwfkrswnqvqpp55x6mmz8ch6nahrcuxjsjvs23xkgt8eu748nukq463zhjcjk4s65shp5dd6hc533r655wtyz63jpf6ja08srn6rz6cjhwsjuyckrqwanhjtsxqzjccqpjrzjqw6lfdpjecp4d5t0gxk5khkrzfejjxyxtxg5exqsd95py6rhwwh72rpgrgqq3hcqqgqqqqlgqqqqqqgq9q9qxpqysgq95njz4sz6h7r2qh7txnevcrvg0jdsfpe72cecmjfka8mw5nvm7tydd0j34ps2u9q9h6v5u8h3vxs8jqq5fwehdda6a8qmpn93fm290cquhuc6r";
290            let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice);
291            let x: serde_json::Value = serde_json::from_str(&callback_response_json).unwrap();
292            convert_to_async_return_value(Ok(x))
293        });
294
295        let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2";
296        let amount = 100000;
297
298        let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, amount, None).await;
299        assert!(result.is_ok());
300    }
301
302    #[tokio::test]
303    async fn test_lnurl_pay_with_lightning_address() {
304        let mut mock_http_client = MockLnUrlHttpClient::new();
305        let lightning_address_username = "satoshi";
306        let lightning_address_domain = "cipherpunk.com";
307        let lnurl = format!(
308            "{}@{}",
309            lightning_address_username, lightning_address_domain
310        );
311
312        let lnurl_clone = lnurl.clone();
313        mock_http_client.expect_get_pay_request_response().returning(move |url| {
314            let expected_url = format!("https://{}/.well-known/lnurlp/{}", lightning_address_domain, lightning_address_username);
315            assert_eq!(expected_url, url);
316
317            let pay_request_json = format!("{{\"callback\": \"https://cipherpunk.com/lnurlp/api/v1/lnurl/cb/1\", \"maxSendable\": 100000, \"minSendable\": 100, \"tag\": \"payRequest\", \"metadata\": \"[[\\\"text/plain\\\", \\\"Start the CoinTrain\\\"], [\\\"text/identifier\\\", \\\"{}\\\"]]\" }}", lnurl_clone);
318
319            let x: PayRequestResponse = serde_json::from_str(&pay_request_json).unwrap();
320            convert_to_async_return_value(Ok(x))
321        });
322
323        mock_http_client.expect_get_json().returning(|_url| {
324            let invoice = "lnbcrt1u1pj0ypx6sp5hzczugdw9eyw3fcsjkssux7awjlt68vpj7uhmen7sup0hdlrqxaqpp5gp5fm2sn5rua2jlzftkf5h22rxppwgszs7ncm73pmwhvjcttqp3qdy2tddjyar90p6z7urvv95kug3vyq39xarpwf6zqargv5syxmmfde28yctfdc396tpqtv38getcwshkjer9de6xjenfv4ezytpqyfekzar0wd5xjsrrd9cxsetjwp6ku6ewvdhk6gjat5xqyjw5qcqp29qxpqysgqujuf5zavazln2q9gks7nqwdgjypg2qlvv7aqwfmwg7xmjt8hy4hx2ctr5fcspjvmz9x5wvmur8vh6nkynsvateafm73zwg5hkf7xszsqajqwcf";
325            let callback_response_json = format!("{{\"pr\":\"{}\",\"routes\":[]}}", invoice);
326            let x: serde_json::Value = serde_json::from_str(&callback_response_json).unwrap();
327            convert_to_async_return_value(Ok(x))
328        });
329
330        let amount = 100000;
331
332        let result = resolve_lnurl_to_invoice(&mock_http_client, &lnurl, amount, None).await;
333        assert!(result.is_ok());
334    }
335
336    #[tokio::test]
337    async fn test_lnurl_pay_with_lightning_address_fails_with_empty_username() {
338        let mock_http_client = MockLnUrlHttpClient::new();
339        let lnurl = "@cipherpunk.com";
340        let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, 100000, None).await;
341        assert!(result.is_err());
342        assert!(result.unwrap_err().to_string().contains("Username can not be empty"));
343    }
344
345    #[tokio::test]
346    async fn test_lnurl_pay_with_lightning_address_fails_with_empty_domain() {
347        let mock_http_client = MockLnUrlHttpClient::new();
348        let lnurl = "satoshi@";
349        let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, 100000, None).await;
350        assert!(result.is_err());
351        assert!(result.unwrap_err().to_string().contains("Domain can not be empty"));
352    }
353
354    #[tokio::test]
355    async fn test_lnurl_pay_returns_error_on_invalid_lnurl() {
356        let mock_http_client = MockLnUrlHttpClient::new();
357        let lnurl = "LNURL1111111111111111111111111111111111111111111111111111111111111111111";
358
359        let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, 100000, None).await;
360        assert!(result.unwrap_err().to_string().contains("Failed to decode lnurl: invalid length"));
361    }
362
363    #[tokio::test]
364    async fn test_lnurl_pay_returns_error_on_amount_less_than_min_sendable() {
365        let mut mock_http_client = MockLnUrlHttpClient::new();
366
367        mock_http_client.expect_get_pay_request_response().returning(|_url| {
368            let x: PayRequestResponse = serde_json::from_str("{ \"callback\": \"https://cipherpunk.com/lnurlp/api/v1/lnurl/cb/1\", \"maxSendable\": 100000, \"minSendable\": 100000, \"tag\": \"payRequest\", \"metadata\": \"[[\\\"text/plain\\\", \\\"Start the CoinTrain\\\"]]\" }").unwrap();
369            convert_to_async_return_value(Ok(x))
370        });
371
372        let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2";
373        let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, 1, None).await;
374        assert!(result.unwrap_err().to_string().contains("Amount must be"));
375    }
376
377    #[tokio::test]
378    async fn test_lnurl_pay_returns_error_on_amount_greater_than_max_sendable() {
379        let mut mock_http_client = MockLnUrlHttpClient::new();
380
381        mock_http_client.expect_get_pay_request_response().returning(|_url| {
382            let x: PayRequestResponse = serde_json::from_str("{ \"callback\": \"https://cipherpunk.com/lnurlp/api/v1/lnurl/cb/1\", \"maxSendable\": 100000, \"minSendable\": 100000, \"tag\": \"payRequest\", \"metadata\": \"[[\\\"text/plain\\\", \\\"Start the CoinTrain\\\"]]\" }").unwrap();
383            convert_to_async_return_value(Ok(x))
384        });
385
386        let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2";
387        let result = resolve_lnurl_to_invoice(&mock_http_client, lnurl, 200000, None).await;
388        assert!(result.unwrap_err().to_string().contains("Amount must be"));
389    }
390
391    #[test]
392    fn test_aes_decrypt_known_vector() {
393        use aes::Aes256;
394        use base64::{engine::general_purpose::STANDARD, Engine};
395        use cbc::cipher::{block_padding::Pkcs7, BlockEncryptMut, KeyIvInit};
396
397        let key = [0x42u8; 32];
398        let iv = [0x24u8; 16];
399        let plaintext = b"hello world";
400
401        // Encrypt
402        type Aes256CbcEnc = cbc::Encryptor<Aes256>;
403        let ciphertext = Aes256CbcEnc::new_from_slices(&key, &iv)
404            .unwrap()
405            .encrypt_padded_vec_mut::<Pkcs7>(plaintext);
406
407        let ciphertext_b64 = STANDARD.encode(&ciphertext);
408        let iv_b64 = STANDARD.encode(&iv);
409
410        // Decrypt
411        let result = decrypt_aes_success_action(&key, &ciphertext_b64, &iv_b64).unwrap();
412        assert_eq!(result, "hello world");
413    }
414
415    #[test]
416    fn test_aes_decrypt_wrong_preimage_length() {
417        let result = decrypt_aes_success_action(&[0u8; 16], "YWJj", "MTIzNDU2Nzg5MDEyMzQ1Ng==");
418        assert!(result.is_err());
419        assert!(result.unwrap_err().to_string().contains("32 bytes"));
420    }
421
422    #[test]
423    fn test_pay_request_description() {
424        let resp: PayRequestResponse = serde_json::from_str(
425            r#"{"callback":"https://x.com/cb","maxSendable":1000,"minSendable":1,"tag":"payRequest","metadata":"[[\"text/plain\",\"Buy coffee\"]]"}"#
426        ).unwrap();
427        assert_eq!(resp.description(), Some("Buy coffee".to_string()));
428    }
429
430    #[test]
431    fn test_pay_request_validate_amount_range() {
432        let resp: PayRequestResponse = serde_json::from_str(
433            r#"{"callback":"https://x.com/cb","maxSendable":10000,"minSendable":1000,"tag":"payRequest","metadata":"[[\"text/plain\",\"test\"]]"}"#
434        ).unwrap();
435
436        // In range
437        assert!(resp.validate("LNURL1TEST", 5000).is_ok());
438        // Below min
439        assert!(resp.validate("LNURL1TEST", 500).is_err());
440        // Above max
441        assert!(resp.validate("LNURL1TEST", 20000).is_err());
442    }
443}