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 pub fn description(&self) -> Option<String> {
17 super::utils::extract_description_from_metadata(&self.metadata)
18 }
19
20 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 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 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
85pub 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
115pub const LNURL_SERVICE_ERROR_PREFIX: &str = "LNURL service error: ";
120
121fn 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
138fn 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
150pub 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
201pub 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
226pub 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 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 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 assert!(resp.validate("LNURL1TEST", 5000).is_ok());
438 assert!(resp.validate("LNURL1TEST", 500).is_err());
440 assert!(resp.validate("LNURL1TEST", 20000).is_err());
442 }
443}