1use bech32::{Bech32, Hrp};
2use lightning_invoice::Bolt11Invoice;
3use serde::{Deserialize, Serialize};
4use serde_with::hex::Hex;
5use serde_with::serde_as;
6#[derive(Debug, Clone, Serialize, Deserialize)]
10#[serde(untagged)]
11pub enum LnurlResponse<T> {
12 Ok(T),
13 Error { status: String, reason: String },
14}
15
16impl<T> LnurlResponse<T> {
17 pub fn error(reason: impl Into<String>) -> Self {
18 Self::Error {
19 status: "ERROR".to_string(),
20 reason: reason.into(),
21 }
22 }
23
24 pub fn into_result(self) -> Result<T, String> {
25 match self {
26 Self::Ok(data) => Ok(data),
27 Self::Error { reason, .. } => Err(reason),
28 }
29 }
30}
31
32pub fn parse_lnurl(s: &str) -> Option<String> {
34 let (hrp, data) = bech32::decode(&s.to_lowercase()).ok()?;
35
36 if hrp.as_str() != "lnurl" {
37 return None;
38 }
39
40 String::from_utf8(data).ok()
41}
42
43pub fn encode_lnurl(url: &str) -> String {
45 bech32::encode::<Bech32>(Hrp::parse("lnurl").expect("valid hrp"), url.as_bytes())
46 .expect("encoding succeeds")
47}
48
49pub fn parse_address(s: &str) -> Option<String> {
51 let (user, domain) = s.split_once('@')?;
52
53 if user.is_empty() || domain.is_empty() {
54 return None;
55 }
56
57 if !user
62 .chars()
63 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '-' | '_' | '+'))
64 {
65 return None;
66 }
67
68 if !domain
69 .chars()
70 .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-'))
71 {
72 return None;
73 }
74
75 if matches!(user, "." | "..") {
78 return None;
79 }
80
81 Some(format!("https://{domain}/.well-known/lnurlp/{user}"))
82}
83
84pub fn pay_request_tag() -> String {
85 "payRequest".to_string()
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
90#[serde(rename_all = "camelCase")]
91pub struct PayResponse {
92 pub tag: String,
93 pub callback: String,
94 pub metadata: String,
95 pub min_sendable: u64,
96 pub max_sendable: u64,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct InvoiceResponse {
102 pub pr: Bolt11Invoice,
104 #[serde(default)]
107 pub routes: Vec<serde_json::Value>,
108 pub verify: Option<String>,
110}
111
112#[serde_as]
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct VerifyResponse {
116 #[serde(default = "ok_status")]
119 pub status: String,
120 pub settled: bool,
121 #[serde_as(as = "Option<Hex>")]
122 pub preimage: Option<[u8; 32]>,
123}
124
125fn ok_status() -> String {
126 "OK".to_string()
127}
128
129impl VerifyResponse {
130 pub fn settled(preimage: [u8; 32]) -> Self {
132 Self {
133 status: ok_status(),
134 settled: true,
135 preimage: Some(preimage),
136 }
137 }
138
139 pub fn pending() -> Self {
141 Self {
142 status: ok_status(),
143 settled: false,
144 preimage: None,
145 }
146 }
147}
148
149pub async fn request(url: &str) -> Result<PayResponse, String> {
151 let response = reqwest::get(url)
152 .await
153 .map_err(|_| "Failed to fetch lnurl pay response".to_string())?
154 .json::<LnurlResponse<PayResponse>>()
155 .await
156 .map_err(|_| "Failed to parse lnurl pay response".to_string())?
157 .into_result()?;
158
159 Ok(response)
160}
161
162pub async fn get_invoice(
164 response: &PayResponse,
165 amount_msat: u64,
166) -> Result<InvoiceResponse, String> {
167 if amount_msat < response.min_sendable {
168 return Err(format!(
169 "Minimum amount is {} sats",
170 response.min_sendable / 1000
171 ));
172 }
173
174 if amount_msat > response.max_sendable {
175 return Err(format!(
176 "Maximum amount is {} sats",
177 response.max_sendable / 1000
178 ));
179 }
180
181 let separator = if response.callback.contains('?') {
182 '&'
183 } else {
184 '?'
185 };
186
187 let callback_url = format!("{}{}amount={}", response.callback, separator, amount_msat);
188
189 let invoice = reqwest::get(callback_url)
190 .await
191 .map_err(|_| "Failed to fetch lnurl callback response".to_string())?
192 .json::<LnurlResponse<InvoiceResponse>>()
193 .await
194 .map_err(|_| "Failed to parse lnurl callback response".to_string())?
195 .into_result()?;
196
197 if invoice.pr.amount_milli_satoshis() != Some(amount_msat) {
198 return Err("Invoice amount does not match requested amount".to_string());
199 }
200
201 Ok(invoice)
202}
203
204pub async fn verify_invoice(url: &str) -> Result<VerifyResponse, String> {
206 reqwest::get(url)
207 .await
208 .map_err(|_| "Failed to fetch lnurl verify response".to_string())?
209 .json::<LnurlResponse<VerifyResponse>>()
210 .await
211 .map_err(|_| "Failed to parse lnurl verify response".to_string())?
212 .into_result()
213}
214
215#[test]
216fn parse_address_lud_16() {
217 for (address, expected) in [
219 (
220 "satoshi@example.com",
221 "https://example.com/.well-known/lnurlp/satoshi",
222 ),
223 (
224 "satoshi+tag@example.com",
225 "https://example.com/.well-known/lnurlp/satoshi+tag",
226 ),
227 (
228 "user-name_1@my-domain.co.uk",
229 "https://my-domain.co.uk/.well-known/lnurlp/user-name_1",
230 ),
231 (
232 "a@xn--bcher-kva.de",
233 "https://xn--bcher-kva.de/.well-known/lnurlp/a",
234 ),
235 (
237 "...@example.com",
238 "https://example.com/.well-known/lnurlp/...",
239 ),
240 ] {
241 assert_eq!(parse_address(address).unwrap(), expected);
242 }
243}
244
245#[test]
246fn parse_address_rejects_addresses_that_move_the_request() {
247 for address in [
248 "a@example.com/other?x=",
251 "a@example.com?x=",
252 "a@example.com#frag",
253 "a@example.com\\evil.com",
254 "a@b@host",
257 "a@evil%2Ecom",
260 "a@example.com\u{3002}evil.com",
261 "Satoshi@example.com",
263 "a@exam\u{200b}ple.com",
264 "..@example.com",
266 ".@example.com",
267 "a/../b@example.com",
268 "a@example.com:8080",
270 "a@[::1]",
271 "a @example.com",
273 "a\u{0}@example.com",
274 ] {
275 assert_eq!(parse_address(address), None, "accepted {address:?}");
276 }
277}
278
279#[test]
280fn parse_lnurl_official_test_vector_lud_01() {
281 let lnurl = "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS";
282 let expected = "https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df";
283
284 assert_eq!(parse_lnurl(lnurl).unwrap(), expected);
285}
286
287#[test]
288fn parse_pay_response_lud_06() {
289 let json = r#"{
290 "callback": "https://example.com/lnurl/pay/callback",
291 "maxSendable": 100000000,
292 "minSendable": 1000,
293 "metadata": "[[\"text/plain\",\"Pay to example.com\"]]",
294 "tag": "payRequest"
295 }"#;
296
297 let response: LnurlResponse<PayResponse> = serde_json::from_str(json).unwrap();
298
299 let pay = response.into_result().unwrap();
300
301 assert_eq!(pay.tag, "payRequest");
302 assert_eq!(pay.callback, "https://example.com/lnurl/pay/callback");
303 assert_eq!(pay.min_sendable, 1000);
304 assert_eq!(pay.max_sendable, 100000000);
305}
306
307#[test]
308fn serialize_invoice_response_lud_06() {
309 let invoice = "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0";
310
311 let response = InvoiceResponse {
312 pr: invoice.parse().unwrap(),
313 routes: vec![],
314 verify: Some("https://example.com/verify/abc".to_string()),
315 };
316
317 let json = serde_json::to_value(LnurlResponse::Ok(response)).unwrap();
318
319 assert_eq!(json["pr"], invoice);
320 assert_eq!(json["routes"], serde_json::json!([]));
322 assert_eq!(json["verify"], "https://example.com/verify/abc");
323}
324
325#[test]
326fn parse_invoice_response_without_routes() {
327 let json = r#"{
328 "pr": "lnbc20m1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzq9qrsgqdfjcdk6w3ak5pca9hwfwfh63zrrz06wwfya0ydlzpgzxkn5xagsqz7x9j4jwe7yj7vaf2k9lqsdk45kts2fd0fkr28am0u4w95tt2nsq76cqw0",
329 "verify": null
330 }"#;
331
332 let response: LnurlResponse<InvoiceResponse> = serde_json::from_str(json).unwrap();
333
334 let invoice = response.into_result().unwrap();
335
336 assert!(invoice.routes.is_empty());
337 assert!(invoice.verify.is_none());
338}
339
340#[test]
341fn parse_error_response() {
342 let json = r#"{"status": "ERROR", "reason": "Invalid request"}"#;
343
344 let response: LnurlResponse<PayResponse> = serde_json::from_str(json).unwrap();
345
346 assert_eq!(response.into_result().unwrap_err(), "Invalid request");
347}
348
349#[test]
350fn serialize_verify_response_lud_21() {
351 let json = serde_json::to_value(LnurlResponse::Ok(VerifyResponse::pending())).unwrap();
352
353 assert_eq!(json["status"], "OK");
356 assert_eq!(json["settled"], false);
357 assert_eq!(json["preimage"], serde_json::Value::Null);
358
359 let json =
360 serde_json::to_value(LnurlResponse::Ok(VerifyResponse::settled([0x42; 32]))).unwrap();
361
362 assert_eq!(json["status"], "OK");
363 assert_eq!(json["settled"], true);
364 assert_eq!(json["preimage"], "42".repeat(32));
365}
366
367#[test]
368fn parse_verify_response_lud_21() {
369 let json = r#"{
370 "status": "OK",
371 "settled": true,
372 "preimage": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
373 }"#;
374
375 let response: LnurlResponse<VerifyResponse> = serde_json::from_str(json).unwrap();
376
377 let verify = response.into_result().unwrap();
378
379 assert!(verify.settled);
380 assert!(verify.preimage.is_some());
381}