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    Some(format!("https://{domain}/.well-known/lnurlp/{user}"))
58}
59
60pub fn pay_request_tag() -> String {
61    "payRequest".to_string()
62}
63
64/// LNURL-pay response (LUD-06)
65#[derive(Debug, Clone, Serialize, Deserialize)]
66#[serde(rename_all = "camelCase")]
67pub struct PayResponse {
68    pub tag: String,
69    pub callback: String,
70    pub metadata: String,
71    pub min_sendable: u64,
72    pub max_sendable: u64,
73}
74
75/// Response when requesting an invoice from LNURL-pay callback
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct InvoiceResponse {
78    /// The BOLT11 invoice
79    pub pr: Bolt11Invoice,
80    /// LUD-21 verify URL
81    pub verify: Option<String>,
82}
83
84/// LUD-21 verify response
85#[serde_as]
86#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
87pub struct VerifyResponse {
88    pub settled: bool,
89    #[serde_as(as = "Option<Hex>")]
90    pub preimage: Option<[u8; 32]>,
91}
92
93/// Fetch and parse an LNURL-pay response
94pub async fn request(url: &str) -> Result<PayResponse, String> {
95    let response = reqwest::get(url)
96        .await
97        .map_err(|_| "Failed to fetch lnurl pay response".to_string())?
98        .json::<LnurlResponse<PayResponse>>()
99        .await
100        .map_err(|_| "Failed to parse lnurl pay response".to_string())?
101        .into_result()?;
102
103    Ok(response)
104}
105
106/// Fetch an invoice from an LNURL-pay callback
107pub async fn get_invoice(
108    response: &PayResponse,
109    amount_msat: u64,
110) -> Result<InvoiceResponse, String> {
111    if amount_msat < response.min_sendable {
112        return Err(format!(
113            "Minimum amount is {} sats",
114            response.min_sendable / 1000
115        ));
116    }
117
118    if amount_msat > response.max_sendable {
119        return Err(format!(
120            "Maximum amount is {} sats",
121            response.max_sendable / 1000
122        ));
123    }
124
125    let separator = if response.callback.contains('?') {
126        '&'
127    } else {
128        '?'
129    };
130
131    let callback_url = format!("{}{}amount={}", response.callback, separator, amount_msat);
132
133    let invoice = reqwest::get(callback_url)
134        .await
135        .map_err(|_| "Failed to fetch lnurl callback response".to_string())?
136        .json::<LnurlResponse<InvoiceResponse>>()
137        .await
138        .map_err(|_| "Failed to parse lnurl callback response".to_string())?
139        .into_result()?;
140
141    if invoice.pr.amount_milli_satoshis() != Some(amount_msat) {
142        return Err("Invoice amount does not match requested amount".to_string());
143    }
144
145    Ok(invoice)
146}
147
148/// Verify a payment using LUD-21
149pub async fn verify_invoice(url: &str) -> Result<VerifyResponse, String> {
150    reqwest::get(url)
151        .await
152        .map_err(|_| "Failed to fetch lnurl verify response".to_string())?
153        .json::<LnurlResponse<VerifyResponse>>()
154        .await
155        .map_err(|_| "Failed to parse lnurl verify response".to_string())?
156        .into_result()
157}
158
159#[test]
160fn parse_lnurl_official_test_vector_lud_01() {
161    let lnurl = "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS";
162    let expected = "https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df";
163
164    assert_eq!(parse_lnurl(lnurl).unwrap(), expected);
165}
166
167#[test]
168fn parse_pay_response_lud_06() {
169    let json = r#"{
170        "callback": "https://example.com/lnurl/pay/callback",
171        "maxSendable": 100000000,
172        "minSendable": 1000,
173        "metadata": "[[\"text/plain\",\"Pay to example.com\"]]",
174        "tag": "payRequest"
175    }"#;
176
177    let response: LnurlResponse<PayResponse> = serde_json::from_str(json).unwrap();
178
179    let pay = response.into_result().unwrap();
180
181    assert_eq!(pay.tag, "payRequest");
182    assert_eq!(pay.callback, "https://example.com/lnurl/pay/callback");
183    assert_eq!(pay.min_sendable, 1000);
184    assert_eq!(pay.max_sendable, 100000000);
185}
186
187#[test]
188fn parse_error_response() {
189    let json = r#"{"status": "ERROR", "reason": "Invalid request"}"#;
190
191    let response: LnurlResponse<PayResponse> = serde_json::from_str(json).unwrap();
192
193    assert_eq!(response.into_result().unwrap_err(), "Invalid request");
194}
195
196#[test]
197fn parse_verify_response_lud_21() {
198    let json = r#"{
199        "status": "OK",
200        "settled": true,
201        "preimage": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
202    }"#;
203
204    let response: LnurlResponse<VerifyResponse> = serde_json::from_str(json).unwrap();
205
206    let verify = response.into_result().unwrap();
207
208    assert!(verify.settled);
209    assert!(verify.preimage.is_some());
210}