Skip to main content

fedimint_lnurl/
lib.rs

1use bech32::{Bech32, Hrp};
2use lightning_invoice::Bolt11Invoice;
3use serde::{Deserialize, Serialize};
4use serde_with::hex::Hex;
5use serde_with::serde_as;
6/// Generic LNURL response wrapper that handles the error case.
7/// Successful responses deserialize directly into `Ok(T)`, while error
8/// responses with `{"status": "ERROR", "reason": "..."}` fall back to `Error`.
9#[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
32/// Decode a bech32-encoded LNURL string to a URL string
33pub 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
43/// Encode a URL as a bech32 LNURL string
44pub 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
49/// Parse a lightning address (user@domain) to its LNURL-pay endpoint URL
50pub 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    // Hold both halves to the characters LUD-16 allows. Rejecting the
58    // structural ones is not enough: the URL parser percent-decodes and
59    // IDNA-maps whatever it is handed, so `a@evil%2Ecom` and
60    // `a@example.com\u{3002}evil.com` reach hosts the typed string never named.
61    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    // `.` and `..` are legal local parts but resolve away from
76    // `/.well-known/lnurlp/`. Longer runs of dots stay ordinary segments.
77    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/// LNURL-pay response (LUD-06)
89#[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/// Response when requesting an invoice from LNURL-pay callback
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct InvoiceResponse {
102    /// The BOLT11 invoice
103    pub pr: Bolt11Invoice,
104    /// Vestigial routing hints, always empty in practice, but required by
105    /// LUD-06. Defaulted when parsing since not all services send it.
106    #[serde(default)]
107    pub routes: Vec<serde_json::Value>,
108    /// LUD-21 verify URL
109    pub verify: Option<String>,
110}
111
112/// LUD-21 verify response
113#[serde_as]
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct VerifyResponse {
116    /// Always "OK" for successful responses per LUD-21. Defaulted when
117    /// parsing since not all services send it.
118    #[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    /// A LUD-21 response for a payment that has been settled
131    pub fn settled(preimage: [u8; 32]) -> Self {
132        Self {
133            status: ok_status(),
134            settled: true,
135            preimage: Some(preimage),
136        }
137    }
138
139    /// A LUD-21 response for a payment that is still pending
140    pub fn pending() -> Self {
141        Self {
142            status: ok_status(),
143            settled: false,
144            preimage: None,
145        }
146    }
147}
148
149/// Fetch and parse an LNURL-pay response
150pub 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
162/// Fetch an invoice from an LNURL-pay callback
163pub 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
204/// Verify a payment using LUD-21
205pub 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    // The LUD-16 local part is `a-z0-9-_.`, plus `+` for tags.
218    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        // Only `.` and `..` are dot segments; longer runs are ordinary.
236        (
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        // Path, query and fragment on an arbitrary host. A fragment is the
249        // worst of the three: it swallows the well-known path entirely.
250        "a@example.com/other?x=",
251        "a@example.com?x=",
252        "a@example.com#frag",
253        "a@example.com\\evil.com",
254        // `split_once` takes the first `@`, so this would put `b` in the
255        // authority's userinfo position.
256        "a@b@host",
257        // Percent escapes and IDNA mappings both reach a host the typed
258        // string does not name: `evil.com` and `example.com.evil.com`.
259        "a@evil%2Ecom",
260        "a@example.com\u{3002}evil.com",
261        // LUD-16 allows only lowercase in the local part.
262        "Satoshi@example.com",
263        "a@exam\u{200b}ple.com",
264        // Dot segments resolve off the well-known path.
265        "..@example.com",
266        ".@example.com",
267        "a/../b@example.com",
268        // Authority syntax the address has no business carrying.
269        "a@example.com:8080",
270        "a@[::1]",
271        // Whitespace and control characters.
272        "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    // LUD-06 requires the routes field to be present as an empty array
321    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    // LUD-21 responses carry an explicit "OK" status alongside the payment
354    // state
355    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}