Skip to main content

glsdk/
input.rs

1// Input parsing for BOLT11 invoices, Lightning node IDs, LNURL
2// strings, and Lightning Addresses.
3//
4// Two entry points with explicit cost contracts:
5//
6//   * `parse_input(input)` — synchronous, offline, no I/O. Returns
7//     `ParsedInput` identifying *what* the input is. LNURL bech32
8//     strings are decoded to their underlying URL; Lightning
9//     Addresses are returned as the unparsed `user@host` form. The
10//     caller decides whether to resolve further.
11//
12//   * `resolve_input(input)` — asynchronous, network-touching. Calls
13//     `parse_input` internally, then for the LNURL / Lightning
14//     Address branches fetches the endpoint to produce typed pay or
15//     withdraw request data. BOLT11 and node IDs pass through
16//     without I/O.
17//
18// Wallets that want offline classification (clipboard checks,
19// invoice sanity-checks on the send screen) call `parse_input`.
20// Wallets handling a QR scan that should proceed straight to the
21// pay/withdraw screen call `resolve_input`.
22
23use crate::lnurl::{LnUrlPayRequestData, LnUrlWithdrawRequestData};
24use crate::Error;
25
26/// Parsed BOLT11 invoice with extracted fields.
27#[derive(Clone, uniffi::Record)]
28pub struct ParsedInvoice {
29    /// The original invoice string.
30    pub bolt11: String,
31    /// Recipient public key as lowercase hex (66 chars), recovered from the invoice signature.
32    pub payee_pubkey: Option<String>,
33    /// Payment hash as lowercase hex (64 chars) identifying this payment.
34    pub payment_hash: String,
35    /// Invoice description. None if the invoice uses a description hash.
36    pub description: Option<String>,
37    /// Requested amount in millisatoshis. None for "any amount" invoices.
38    pub amount_msat: Option<u64>,
39    /// Seconds from creation until the invoice expires.
40    pub expiry: u64,
41    /// Unix timestamp (seconds) when the invoice was created.
42    pub timestamp: u64,
43}
44
45/// The result of `parse_input`: an offline classification of the
46/// input. No HTTP, no I/O. LNURL bech32 strings are returned as their
47/// decoded URL; Lightning Addresses as the unparsed `user@host` form.
48#[derive(Clone, uniffi::Enum)]
49pub enum ParsedInput {
50    /// A BOLT11 Lightning invoice.
51    Bolt11 { invoice: ParsedInvoice },
52    /// A Lightning node public key.
53    NodeId { node_id: String },
54    /// An LNURL bech32 string (LUD-01) decoded to its underlying URL.
55    /// Pass to `resolve_input` (or fetch yourself) to determine
56    /// whether it's a pay, withdraw, or auth endpoint.
57    LnUrl { url: String },
58    /// A Lightning Address (LUD-16) in the form `user@host`. The
59    /// well-known URL is not constructed offline; call `resolve_input`
60    /// to fetch and classify.
61    LnUrlAddress { address: String },
62}
63
64/// The result of `resolve_input`: a fully-resolved input ready for
65/// the caller's next action. LNURL bech32 strings and Lightning
66/// Addresses are resolved over HTTP into typed pay or withdraw
67/// request data; BOLT11 and node IDs pass through unchanged.
68#[derive(Clone, uniffi::Enum)]
69pub enum ResolvedInput {
70    /// A BOLT11 Lightning invoice. No HTTP was performed.
71    Bolt11 { invoice: ParsedInvoice },
72    /// A Lightning node public key. No HTTP was performed.
73    NodeId { node_id: String },
74    /// An LNURL-pay endpoint with the service's parameters fetched.
75    LnUrlPay { data: LnUrlPayRequestData },
76    /// An LNURL-withdraw endpoint with the service's parameters fetched.
77    LnUrlWithdraw { data: LnUrlWithdrawRequestData },
78}
79
80/// Synchronously classify the input. **No HTTP, no I/O.**
81///
82/// Recognises BOLT11 invoices, node IDs, LNURL bech32 strings, and
83/// Lightning Addresses. Strips `lightning:` / `LIGHTNING:` prefixes
84/// automatically.
85///
86/// LNURL inputs are decoded to their underlying URL but **not
87/// fetched** — the caller chooses whether to resolve further (via
88/// `resolve_input`) or to surface the URL to the user as-is.
89pub 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    // Strip lightning: prefix (case-insensitive)
96    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    // Try LNURL bech32 (must come before BOLT11 since both start with "ln")
105    if let Some(result) = try_parse_lnurl(stripped) {
106        return result;
107    }
108
109    // Try BOLT11
110    if let Some(result) = try_parse_bolt11(stripped) {
111        return result;
112    }
113
114    // Try Lightning Address (user@domain)
115    if let Some(result) = try_parse_lightning_address(stripped) {
116        return Ok(result);
117    }
118
119    // Try Node ID
120    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
127/// Asynchronously classify and resolve the input.
128///
129/// Internally calls `parse_input`. For BOLT11 and node IDs returns
130/// immediately without I/O. For LNURL bech32 strings and Lightning
131/// Addresses, performs the HTTP GET and returns the typed pay or
132/// withdraw request data.
133pub 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    // Capture the user's original input (post-trim) so that
138    // `data.lnurl` on the resolved response carries the exact string
139    // the caller handed us.
140    let original = input.trim().to_string();
141
142    // The two LNURL-shaped branches converge to a single HTTP fetch
143    // — the only branch-specific bit is how the URL is derived.
144    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
174/// Try parsing as an LNURL bech32 string (LUD-01).
175/// Returns None if the input doesn't look like an LNURL.
176fn 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
186/// Try parsing as a Lightning Address (LUD-16): `user@domain.tld`.
187fn 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    // Domain must contain a dot (rules out bare hostnames and emails
197    // to local domains which aren't valid Lightning Addresses).
198    if !domain.contains('.') {
199        return None;
200    }
201    // Username: alphanumeric + limited symbols per LUD-16.
202    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
213/// Try parsing as a BOLT11 invoice. Returns None if the input doesn't
214/// look like an invoice, or Some(Result) if it does (even if malformed).
215fn 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
262/// Try parsing as a node ID (66-char hex → 33-byte compressed pubkey).
263fn 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    // Compressed pubkeys start with 0x02 or 0x03
272    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    // ── parse_input (sync) ──────────────────────────────────────
303
304    #[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        // Bech32-encoded "https://service.com/lnurl"
332        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        // 66 chars but starts with 0x04 (uncompressed pubkey prefix)
374        assert!(parse_input(
375            "04eec7245d6b7d2ccb30380bfbe2a3648cd7a942653f5aa340edcea1f283686619".to_string()
376        )
377        .is_err());
378        // 66 non-hex chars
379        assert!(parse_input(
380            "not_valid_hex_at_all_but_66_chars_long_xxxxxxxxxxxxxxxxxxxxxxxxxxx".to_string()
381        )
382        .is_err());
383    }
384
385    // ── resolve_input pass-through paths (no HTTP needed) ───────
386
387    #[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}