lnurlkit/
core.rs

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
12/// # Errors
13///
14/// Returns error in case `s` cannot be understood.
15pub 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}