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 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
142pub 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}