1pub mod auth;
2pub mod channel;
3pub mod pay;
4pub mod withdraw;
5
6pub enum Resolved {
7 Url(url::Url),
8 Auth(url::Url, auth::Entrypoint),
9 Withdraw(url::Url, withdraw::client::Entrypoint),
10}
11
12pub fn resolve(s: &str) -> Result<Resolved, &'static str> {
16 let url = if s.starts_with("lnurl1") || s.starts_with("LNURL1") {
17 resolve_bech32(s)
18 } else if s.starts_with("lnurl") || s.starts_with("keyauth") {
19 resolve_scheme(s)
20 } else if s.contains('@') {
21 resolve_address(s)
22 } else {
23 Err("unknown")
24 }?;
25
26 let tag = url
27 .query_pairs()
28 .find_map(|(k, v)| (k == "tag").then_some(v));
29
30 Ok(match tag.as_deref() {
31 Some(withdraw::TAG) => match url.as_str().try_into() {
32 Ok(w) => Resolved::Withdraw(url, w),
33 Err(_) => Resolved::Url(url),
34 },
35 Some(auth::TAG) => match url.as_str().try_into() {
36 Ok(w) => Resolved::Auth(url, w),
37 Err(_) => Resolved::Url(url),
38 },
39 _ => Resolved::Url(url),
40 })
41}
42
43fn resolve_bech32(s: &str) -> Result<url::Url, &'static str> {
44 let Ok((hrp, bytes)) = bech32::decode(s) else {
45 return Err("bech32 decode failed");
46 };
47
48 if hrp.to_lowercase() != "lnurl" {
49 return Err("bech32 hrp invalid");
50 }
51
52 let Ok(text) = String::from_utf8(bytes) else {
53 return Err("bech32 bytes is not string");
54 };
55
56 let Ok(url) = url::Url::parse(&text) else {
57 return Err("bech32 text is not a url");
58 };
59
60 Ok(url)
61}
62
63fn resolve_scheme(s: &str) -> Result<url::Url, &'static str> {
64 let s = s
65 .trim_start_matches("keyauth://")
66 .trim_start_matches("lnurlc://")
67 .trim_start_matches("lnurlw://")
68 .trim_start_matches("lnurlp://");
69
70 let Ok(url) = url::Url::parse(&format!("https://{s}")) else {
71 return Err("bad url");
72 };
73
74 Ok(url)
75}
76
77fn resolve_address(s: &str) -> Result<url::Url, &'static str> {
78 let Some((identifier, domain)) = s.split_once('@') else {
79 return Err("split failed");
80 };
81
82 let Ok(url) = url::Url::parse(&format!("https://{domain}/.well-known/lnurlp/{identifier}"))
83 else {
84 return Err("bad url");
85 };
86
87 Ok(url)
88}
89
90#[derive(Debug)]
91pub enum Entrypoint {
92 Channel(channel::client::Entrypoint),
93 Pay(Box<pay::client::Entrypoint>),
94 Withdraw(withdraw::client::Entrypoint),
95}
96
97impl TryFrom<&[u8]> for Entrypoint {
98 type Error = &'static str;
99
100 fn try_from(s: &[u8]) -> Result<Self, Self::Error> {
101 #[derive(serde::Deserialize)]
102 struct Tag {
103 tag: String,
104 }
105
106 let tag = serde_json::from_slice::<Tag>(s).map_err(|_| "deserialize tag failed")?;
107
108 if tag.tag == channel::TAG {
109 let cr = s.try_into().map_err(|_| "deserialize data failed")?;
110 Ok(Entrypoint::Channel(cr))
111 } else if tag.tag == pay::TAG {
112 let pr = s.try_into().map_err(|_| "deserialize data failed")?;
113 Ok(Entrypoint::Pay(Box::new(pr)))
114 } else if tag.tag == withdraw::TAG {
115 let wr = s.try_into().map_err(|_| "deserialize data failed")?;
116 Ok(Entrypoint::Withdraw(wr))
117 } else {
118 Err("unknown tag")
119 }
120 }
121}
122
123#[derive(Clone, Debug)]
124pub enum CallbackResponse {
125 Error { reason: String },
126 Ok,
127}
128
129#[derive(serde::Serialize, serde::Deserialize)]
130#[serde(tag = "status", rename_all = "UPPERCASE")]
131enum CallbackResponseSerde {
132 Error { reason: String },
133 Ok,
134}
135
136impl TryFrom<&[u8]> for CallbackResponse {
137 type Error = &'static str;
138
139 fn try_from(s: &[u8]) -> Result<Self, &'static str> {
140 serde_json::from_slice::<CallbackResponseSerde>(s)
141 .map_err(|_| "deserialize failed")
142 .map(|a| match a {
143 CallbackResponseSerde::Error { reason } => CallbackResponse::Error { reason },
144 CallbackResponseSerde::Ok => CallbackResponse::Ok,
145 })
146 }
147}
148
149impl TryFrom<CallbackResponse> for Vec<u8> {
150 type Error = &'static str;
151
152 fn try_from(c: CallbackResponse) -> Result<Self, Self::Error> {
153 serde_json::to_vec(&match c {
154 CallbackResponse::Error { reason } => CallbackResponseSerde::Error { reason },
155 CallbackResponse::Ok => CallbackResponseSerde::Ok,
156 })
157 .map_err(|_| "deserialize failed")
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 #[test]
164 fn resolve_bech32() {
165 let input = "lnurl1dp68gurn8ghj7argv4ex2tnfwvhkumelwv7hqmm0dc6p3ztw";
166 let super::Resolved::Url(url) = super::resolve(input).unwrap() else {
167 panic!("expected resolved url");
168 };
169
170 assert_eq!(url.as_str(), "https://there.is/no?s=poon");
171
172 let input = "LNURL1DP68GURN8GHJ7ARGV4EX2TNFWVHKUMELWV7HQMM0DC6P3ZTW";
173 let super::Resolved::Url(url) = super::resolve(input).unwrap() else {
174 panic!("expected resolved url");
175 };
176
177 assert_eq!(url.as_str(), "https://there.is/no?s=poon");
178 }
179
180 #[test]
181 fn resolve_address() {
182 let super::Resolved::Url(url) = super::resolve("no-spoon@there.is").unwrap() else {
183 panic!("expected resolved url");
184 };
185
186 assert_eq!(url.as_str(), "https://there.is/.well-known/lnurlp/no-spoon");
187 }
188
189 #[test]
190 fn resolve_schemes() {
191 let input = "lnurlc://there.is/no?s=poon";
192 let super::Resolved::Url(url) = super::resolve(input).unwrap() else {
193 panic!("expected resolved url");
194 };
195
196 assert_eq!(url.as_str(), "https://there.is/no?s=poon");
197
198 let input = "lnurlw://there.is/no?s=poon";
199 let super::Resolved::Url(url) = super::resolve(input).unwrap() else {
200 panic!("expected resolved url");
201 };
202
203 assert_eq!(url.as_str(), "https://there.is/no?s=poon");
204
205 let input = "lnurlp://there.is/no?s=poon";
206 let super::Resolved::Url(url) = super::resolve(input).unwrap() else {
207 panic!("expected resolved url");
208 };
209
210 assert_eq!(url.as_str(), "https://there.is/no?s=poon");
211
212 let input = "keyauth://there.is/no?s=poon";
213 let super::Resolved::Url(url) = super::resolve(input).unwrap() else {
214 panic!("expected resolved url");
215 };
216
217 assert_eq!(url.as_str(), "https://there.is/no?s=poon");
218 }
219
220 #[test]
221 fn resolve_auth() {
222 let input = "keyauth://site.com\
223 ?tag=login\
224 &k1=6f697072617a65726575736f756f6261697465732176616d6f63616d69676f73\
225 &action=login";
226
227 let super::Resolved::Auth(_, _) = super::resolve(input).unwrap() else {
228 panic!("expected resolved url");
229 };
230 }
231
232 #[test]
233 fn resolve_fast_withdraw() {
234 let input = "lnurlw://there.is/no\
235 ?s=poon\
236 &tag=withdrawRequest\
237 &k1=caum\
238 &minWithdrawable=314\
239 &maxWithdrawable=315\
240 &defaultDescription=descrical\
241 &callback=https://call.back";
242
243 let super::Resolved::Withdraw(_, _) = super::resolve(input).unwrap() else {
244 panic!("expected resolved url");
245 };
246 }
247
248 #[test]
249 fn callback_response_parse() {
250 assert!(matches!(
251 (br#"{ "status": "OK" }"# as &[u8]).try_into().unwrap(),
252 super::CallbackResponse::Ok
253 ));
254
255 assert!(matches!(
256 (br#"{ "status": "ERROR", "reason": "razao" }"# as &[u8]).try_into().unwrap(),
257 super::CallbackResponse::Error { reason } if reason == "razao"
258 ));
259 }
260
261 #[test]
262 fn callback_response_render() {
263 assert_eq!(
264 <Vec::<u8>>::try_from(super::CallbackResponse::Ok).unwrap(),
265 br#"{"status":"OK"}"#
266 );
267
268 assert_eq!(
269 <Vec::<u8>>::try_from(super::CallbackResponse::Error {
270 reason: String::from("razao")
271 })
272 .unwrap(),
273 br#"{"status":"ERROR","reason":"razao"}"#
274 );
275 }
276}