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    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
142/// Verify a payment using LUD-21
143pub async fn verify_invoice(url: &str) -> Result<VerifyResponse, String> {
144    reqwest::get(url)
145        .await
146        .map_err(|_| "Failed to fetch lnurl verify response".to_string())?
147        .json::<LnurlResponse<VerifyResponse>>()
148        .await
149        .map_err(|_| "Failed to parse lnurl verify response".to_string())?
150        .into_result()
151}
152
153#[test]
154fn parse_lnurl_official_test_vector_lud_01() {
155    let lnurl = "LNURL1DP68GURN8GHJ7UM9WFMXJCM99E3K7MF0V9CXJ0M385EKVCENXC6R2C35XVUKXEFCV5MKVV34X5EKZD3EV56NYD3HXQURZEPEXEJXXEPNXSCRVWFNV9NXZCN9XQ6XYEFHVGCXXCMYXYMNSERXFQ5FNS";
156    let expected = "https://service.com/api?q=3fc3645b439ce8e7f2553a69e5267081d96dcd340693afabe04be7b0ccd178df";
157
158    assert_eq!(parse_lnurl(lnurl).unwrap(), expected);
159}
160
161#[test]
162fn parse_pay_response_lud_06() {
163    let json = r#"{
164        "callback": "https://example.com/lnurl/pay/callback",
165        "maxSendable": 100000000,
166        "minSendable": 1000,
167        "metadata": "[[\"text/plain\",\"Pay to example.com\"]]",
168        "tag": "payRequest"
169    }"#;
170
171    let response: LnurlResponse<PayResponse> = serde_json::from_str(json).unwrap();
172
173    let pay = response.into_result().unwrap();
174
175    assert_eq!(pay.tag, "payRequest");
176    assert_eq!(pay.callback, "https://example.com/lnurl/pay/callback");
177    assert_eq!(pay.min_sendable, 1000);
178    assert_eq!(pay.max_sendable, 100000000);
179}
180
181#[test]
182fn parse_error_response() {
183    let json = r#"{"status": "ERROR", "reason": "Invalid request"}"#;
184
185    let response: LnurlResponse<PayResponse> = serde_json::from_str(json).unwrap();
186
187    assert_eq!(response.into_result().unwrap_err(), "Invalid request");
188}
189
190#[test]
191fn parse_verify_response_lud_21() {
192    let json = r#"{
193        "status": "OK",
194        "settled": true,
195        "preimage": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
196    }"#;
197
198    let response: LnurlResponse<VerifyResponse> = serde_json::from_str(json).unwrap();
199
200    let verify = response.into_result().unwrap();
201
202    assert!(verify.settled);
203    assert!(verify.preimage.is_some());
204}