1use crate::lnurl::{LnUrlPayRequestData, LnUrlWithdrawRequestData};
24use crate::Error;
25
26#[derive(Clone, uniffi::Record)]
28pub struct ParsedInvoice {
29 pub bolt11: String,
31 pub payee_pubkey: Option<String>,
33 pub payment_hash: String,
35 pub description: Option<String>,
37 pub amount_msat: Option<u64>,
39 pub expiry: u64,
41 pub timestamp: u64,
43}
44
45#[derive(Clone, uniffi::Enum)]
49pub enum ParsedInput {
50 Bolt11 { invoice: ParsedInvoice },
52 NodeId { node_id: String },
54 LnUrl { url: String },
58 LnUrlAddress { address: String },
62}
63
64#[derive(Clone, uniffi::Enum)]
69pub enum ResolvedInput {
70 Bolt11 { invoice: ParsedInvoice },
72 NodeId { node_id: String },
74 LnUrlPay { data: LnUrlPayRequestData },
76 LnUrlWithdraw { data: LnUrlWithdrawRequestData },
78}
79
80pub fn parse_input(input: String) -> Result<ParsedInput, Error> {
90 let trimmed = input.trim();
91 if trimmed.is_empty() {
92 return Err(Error::other("Empty input".to_string()));
93 }
94
95 let stripped = if let Some(rest) = trimmed.strip_prefix("lightning:") {
97 rest
98 } else if let Some(rest) = trimmed.strip_prefix("LIGHTNING:") {
99 rest
100 } else {
101 trimmed
102 };
103
104 if let Some(result) = try_parse_lnurl(stripped) {
106 return result;
107 }
108
109 if let Some(result) = try_parse_bolt11(stripped) {
111 return result;
112 }
113
114 if let Some(result) = try_parse_lightning_address(stripped) {
116 return Ok(result);
117 }
118
119 if let Some(result) = try_parse_node_id(stripped) {
121 return Ok(result);
122 }
123
124 Err(Error::other("Unrecognized input".to_string()))
125}
126
127pub async fn resolve_input(input: String) -> Result<ResolvedInput, Error> {
134 use gl_client::lnurl::models::LnUrlHttpClearnetClient;
135 use gl_client::lnurl::{LnUrlResponse, LNURL};
136
137 let original = input.trim().to_string();
141
142 let url = match parse_input(input)? {
145 ParsedInput::Bolt11 { invoice } => return Ok(ResolvedInput::Bolt11 { invoice }),
146 ParsedInput::NodeId { node_id } => return Ok(ResolvedInput::NodeId { node_id }),
147 ParsedInput::LnUrl { url } => url,
148 ParsedInput::LnUrlAddress { address } => {
149 gl_client::lnurl::pay::parse_lightning_address(&address)
150 .map_err(|e| Error::other(e.to_string()))?
151 }
152 };
153
154 let client = LNURL::new(LnUrlHttpClearnetClient::new());
155 let response = client
156 .resolve(&url)
157 .await
158 .map_err(|e| Error::other(e.to_string()))?;
159
160 Ok(match response {
161 LnUrlResponse::Pay(d) => {
162 let mut data: LnUrlPayRequestData = d.into();
163 data.lnurl = original;
164 ResolvedInput::LnUrlPay { data }
165 }
166 LnUrlResponse::Withdraw(d) => {
167 let mut data: LnUrlWithdrawRequestData = d.into();
168 data.lnurl = original;
169 ResolvedInput::LnUrlWithdraw { data }
170 }
171 })
172}
173
174fn try_parse_lnurl(input: &str) -> Option<Result<ParsedInput, Error>> {
177 if !input.to_uppercase().starts_with("LNURL1") {
178 return None;
179 }
180 match gl_client::lnurl::utils::parse_lnurl(input) {
181 Ok(url) => Some(Ok(ParsedInput::LnUrl { url })),
182 Err(e) => Some(Err(Error::other(format!("Invalid LNURL: {}", e)))),
183 }
184}
185
186fn try_parse_lightning_address(input: &str) -> Option<ParsedInput> {
188 let parts: Vec<&str> = input.split('@').collect();
189 if parts.len() != 2 {
190 return None;
191 }
192 let (username, domain) = (parts[0], parts[1]);
193 if username.is_empty() || domain.is_empty() {
194 return None;
195 }
196 if !domain.contains('.') {
199 return None;
200 }
201 if !username
203 .chars()
204 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-' || c == '.')
205 {
206 return None;
207 }
208 Some(ParsedInput::LnUrlAddress {
209 address: input.to_string(),
210 })
211}
212
213fn try_parse_bolt11(input: &str) -> Option<Result<ParsedInput, Error>> {
216 let lower = input.to_lowercase();
217 if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") {
218 return None;
219 }
220
221 let parsed: lightning_invoice::Bolt11Invoice = match input.parse() {
222 Ok(inv) => inv,
223 Err(e) => return Some(Err(Error::other(format!("Invalid BOLT11 invoice: {e}")))),
224 };
225
226 if parsed.check_signature().is_err() {
227 return Some(Err(Error::other(
228 "BOLT11 invoice has invalid signature".to_string(),
229 )));
230 }
231
232 let payee_pubkey = hex::encode(parsed.recover_payee_pub_key().serialize());
233
234 let payment_hash = format!("{}", parsed.payment_hash());
235
236 let description = match parsed.description() {
237 lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(d) => Some(d.to_string()),
238 lightning_invoice::Bolt11InvoiceDescriptionRef::Hash(_) => None,
239 };
240
241 let amount_msat = parsed.amount_milli_satoshis();
242 let expiry = parsed.expiry_time().as_secs();
243 let timestamp = parsed
244 .timestamp()
245 .duration_since(std::time::SystemTime::UNIX_EPOCH)
246 .unwrap_or_default()
247 .as_secs();
248
249 Some(Ok(ParsedInput::Bolt11 {
250 invoice: ParsedInvoice {
251 bolt11: input.to_string(),
252 payee_pubkey: Some(payee_pubkey),
253 payment_hash,
254 description,
255 amount_msat,
256 expiry,
257 timestamp,
258 },
259 }))
260}
261
262fn try_parse_node_id(input: &str) -> Option<ParsedInput> {
264 if input.len() != 66 {
265 return None;
266 }
267 let bytes = hex::decode(input).ok()?;
268 if bytes.len() != 33 {
269 return None;
270 }
271 if bytes[0] != 0x02 && bytes[0] != 0x03 {
273 return None;
274 }
275 Some(ParsedInput::NodeId {
276 node_id: input.to_string(),
277 })
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 fn parsed_variant_name(t: &ParsedInput) -> &'static str {
285 match t {
286 ParsedInput::Bolt11 { .. } => "Bolt11",
287 ParsedInput::NodeId { .. } => "NodeId",
288 ParsedInput::LnUrl { .. } => "LnUrl",
289 ParsedInput::LnUrlAddress { .. } => "LnUrlAddress",
290 }
291 }
292
293 fn resolved_variant_name(t: &ResolvedInput) -> &'static str {
294 match t {
295 ResolvedInput::Bolt11 { .. } => "Bolt11",
296 ResolvedInput::NodeId { .. } => "NodeId",
297 ResolvedInput::LnUrlPay { .. } => "LnUrlPay",
298 ResolvedInput::LnUrlWithdraw { .. } => "LnUrlWithdraw",
299 }
300 }
301
302 #[test]
305 fn test_parse_input_bolt11() {
306 let invoice = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg";
307 match parse_input(invoice.to_string()).unwrap() {
308 ParsedInput::Bolt11 { invoice: parsed } => assert_eq!(parsed.amount_msat, Some(10)),
309 other => panic!("Expected Bolt11, got {}", parsed_variant_name(&other)),
310 }
311 }
312
313 #[test]
314 fn test_parse_input_bolt11_with_lightning_prefix() {
315 let invoice = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg";
316 let result = parse_input(format!("lightning:{}", invoice)).unwrap();
317 assert!(matches!(result, ParsedInput::Bolt11 { .. }));
318 }
319
320 #[test]
321 fn test_parse_input_node_id() {
322 let node_id = "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619";
323 match parse_input(node_id.to_string()).unwrap() {
324 ParsedInput::NodeId { node_id: id } => assert_eq!(id, node_id),
325 other => panic!("Expected NodeId, got {}", parsed_variant_name(&other)),
326 }
327 }
328
329 #[test]
330 fn test_parse_input_lnurl_decodes_url() {
331 let lnurl = "LNURL1DP68GURN8GHJ7CMFWP5X2UNSW4HXKTNRDAKJ7CTSDYHHVVF0D3H82UNV9UCSAXQZE2";
333 match parse_input(lnurl.to_string()).unwrap() {
334 ParsedInput::LnUrl { url } => assert!(url.starts_with("https://")),
335 other => panic!("Expected LnUrl, got {}", parsed_variant_name(&other)),
336 }
337 }
338
339 #[test]
340 fn test_parse_input_lightning_address_returns_address() {
341 match parse_input("user@example.com".to_string()).unwrap() {
342 ParsedInput::LnUrlAddress { address } => assert_eq!(address, "user@example.com"),
343 other => panic!("Expected LnUrlAddress, got {}", parsed_variant_name(&other)),
344 }
345 }
346
347 #[test]
348 fn test_parse_input_invalid_lnurl_errors() {
349 assert!(parse_input("LNURL1INVALIDDATA".to_string()).is_err());
350 }
351
352 #[test]
353 fn test_parse_input_address_no_dot_in_domain_errors() {
354 assert!(parse_input("user@localhost".to_string()).is_err());
355 }
356
357 #[test]
358 fn test_parse_input_empty_address_parts_errors() {
359 assert!(parse_input("@example.com".to_string()).is_err());
360 assert!(parse_input("user@".to_string()).is_err());
361 }
362
363 #[test]
364 fn test_parse_input_unrecognized_errors() {
365 assert!(parse_input("hello world".to_string()).is_err());
366 assert!(parse_input("".to_string()).is_err());
367 assert!(parse_input(" ".to_string()).is_err());
368 assert!(parse_input("bc1qw508d6qejxtdg4y5r3zarvary0c5xw7kv8f3t4".to_string()).is_err());
369 }
370
371 #[test]
372 fn test_parse_input_invalid_node_id_errors() {
373 assert!(parse_input(
375 "04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619".to_string()
376 )
377 .is_err());
378 assert!(parse_input(
380 "not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string()
381 )
382 .is_err());
383 }
384
385 #[test]
388 fn test_resolve_input_bolt11_passes_through() {
389 let invoice = "lnbc100p1psj9jhxdqud3jxktt5w46x7unfv9kz6mn0v3jsnp4q0d3p2sfluzdx45tqcsh2pu5qc7lgq0xs578ngs6s0s68ua4h7cvspp5q6rmq35js88zp5dvwrv9m459tnk2zunwj5jalqtyxqulh0l5gflssp5nf55ny5gcrfl30xuhzj3nphgj27rstekmr9fw3ny5989s300gyus9qyysgqcqpcrzjqw2sxwe993h5pcm4dxzpvttgza8zhkqxpgffcrf5v25nwpr3cmfg7z54kuqq8rgqqqqqqqq2qqqqq9qq9qrzjqd0ylaqclj9424x9m8h2vcukcgnm6s56xfgu3j78zyqzhgs4hlpzvznlugqq9vsqqqqqqqlgqqqqqeqq9qrzjqwldmj9dha74df76zhx6l9we0vjdquygcdt3kssupehe64g6yyp5yz5rhuqqwccqqyqqqqlgqqqqjcqq9qrzjqf9e58aguqr0rcun0ajlvmzq3ek63cw2w282gv3z5uupmuwvgjtq2z55qsqqg6qqqyqqqrtnqqqzq3cqygrzjqvphmsywntrrhqjcraumvc4y6r8v4z5v593trte429v4hredj7ms5z52usqq9ngqqqqqqqlgqqqqqqgq9qrzjq2v0vp62g49p7569ev48cmulecsxe59lvaw3wlxm7r982zxa9zzj7z5l0cqqxusqqyqqqqlgqqqqqzsqygarl9fh38s0gyuxjjgux34w75dnc6xp2l35j7es3jd4ugt3lu0xzre26yg5m7ke54n2d5sym4xcmxtl8238xxvw5h5h5j5r6drg6k6zcqj0fcwg";
390 match crate::util::exec(resolve_input(invoice.to_string())).unwrap() {
391 ResolvedInput::Bolt11 { invoice: parsed } => {
392 assert_eq!(parsed.amount_msat, Some(10))
393 }
394 other => panic!("Expected Bolt11, got {}", resolved_variant_name(&other)),
395 }
396 }
397
398 #[test]
399 fn test_resolve_input_node_id_passes_through() {
400 let node_id = "02eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619";
401 match crate::util::exec(resolve_input(node_id.to_string())).unwrap() {
402 ResolvedInput::NodeId { node_id: id } => assert_eq!(id, node_id),
403 other => panic!("Expected NodeId, got {}", resolved_variant_name(&other)),
404 }
405 }
406
407 #[test]
408 fn test_resolve_input_invalid_lnurl_errors_before_http() {
409 assert!(crate::util::exec(resolve_input("LNURL1INVALIDDATA".to_string())).is_err());
410 }
411}