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 Some(format!("https://{domain}/.well-known/lnurlp/{user}"))
58}
59
60pub fn pay_request_tag() -> String {
61 "payRequest".to_string()
62}
63
64#[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#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct InvoiceResponse {
78 pub pr: Bolt11Invoice,
80 pub verify: Option<String>,
82}
83
84#[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
93pub 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
106pub 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
148pub 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}