Skip to main content

glsdk/
input.rs

1// Input parsing for BOLT11 invoices and Lightning node IDs.
2// Works offline — no node connection needed.
3
4use crate::Error;
5
6/// Parsed BOLT11 invoice with extracted fields.
7#[derive(Clone, uniffi::Record)]
8pub struct ParsedInvoice {
9    /// The original invoice string.
10    pub bolt11: String,
11    /// 33-byte recipient public key, recovered from the invoice signature.
12    pub payee_pubkey: Option<Vec<u8>>,
13    /// 32-byte payment hash identifying this payment.
14    pub payment_hash: Vec<u8>,
15    /// Invoice description. None if the invoice uses a description hash.
16    pub description: Option<String>,
17    /// Requested amount in millisatoshis. None for "any amount" invoices.
18    pub amount_msat: Option<u64>,
19    /// Seconds from creation until the invoice expires.
20    pub expiry: u64,
21    /// Unix timestamp (seconds) when the invoice was created.
22    pub timestamp: u64,
23}
24
25/// The result of parsing user input.
26#[derive(Clone, uniffi::Enum)]
27pub enum InputType {
28    /// A BOLT11 Lightning invoice.
29    Bolt11 { invoice: ParsedInvoice },
30    /// A Lightning node public key (66 hex characters, 33 bytes compressed).
31    NodeId { node_id: String },
32}
33
34/// Parse a string and identify whether it's a BOLT11 invoice or a node ID.
35///
36/// Strips `lightning:` / `LIGHTNING:` prefixes automatically.
37/// Returns an error if the input is not recognized or is malformed.
38pub fn parse_input(input: String) -> Result<InputType, Error> {
39    let trimmed = input.trim();
40    if trimmed.is_empty() {
41        return Err(Error::Other("Empty input".to_string()));
42    }
43
44    // Strip lightning: prefix (case-insensitive)
45    let stripped = if let Some(rest) = trimmed.strip_prefix("lightning:") {
46        rest
47    } else if let Some(rest) = trimmed.strip_prefix("LIGHTNING:") {
48        rest
49    } else {
50        trimmed
51    };
52
53    // Try BOLT11
54    if let Some(input_type) = try_parse_bolt11(stripped) {
55        return input_type;
56    }
57
58    // Try Node ID
59    if let Some(input_type) = try_parse_node_id(stripped) {
60        return Ok(input_type);
61    }
62
63    Err(Error::Other("Unrecognized input".to_string()))
64}
65
66/// Try parsing as a BOLT11 invoice. Returns None if the input doesn't
67/// look like an invoice, or Some(Result) if it does (even if malformed).
68fn try_parse_bolt11(input: &str) -> Option<Result<InputType, Error>> {
69    let lower = input.to_lowercase();
70    if !lower.starts_with("lnbc") && !lower.starts_with("lntb") && !lower.starts_with("lnbcrt") {
71        return None;
72    }
73
74    let parsed: lightning_invoice::Bolt11Invoice = match input.parse() {
75        Ok(inv) => inv,
76        Err(e) => return Some(Err(Error::Other(format!("Invalid BOLT11 invoice: {e}")))),
77    };
78
79    if parsed.check_signature().is_err() {
80        return Some(Err(Error::Other(
81            "BOLT11 invoice has invalid signature".to_string(),
82        )));
83    }
84
85    let payee_pubkey = parsed
86        .recover_payee_pub_key()
87        .serialize()
88        .to_vec();
89
90    let payment_hash = format!("{}", parsed.payment_hash());
91    let payment_hash = hex::decode(&payment_hash)
92        .unwrap_or_default();
93
94    let description = match parsed.description() {
95        lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(d) => Some(d.to_string()),
96        lightning_invoice::Bolt11InvoiceDescriptionRef::Hash(_) => None,
97    };
98
99    let amount_msat = parsed.amount_milli_satoshis();
100
101    let expiry = parsed.expiry_time().as_secs();
102
103    let timestamp = parsed
104        .timestamp()
105        .duration_since(std::time::SystemTime::UNIX_EPOCH)
106        .unwrap_or_default()
107        .as_secs();
108
109    Some(Ok(InputType::Bolt11 {
110        invoice: ParsedInvoice {
111            bolt11: input.to_string(),
112            payee_pubkey: Some(payee_pubkey),
113            payment_hash,
114            description,
115            amount_msat,
116            expiry,
117            timestamp,
118        },
119    }))
120}
121
122/// Try parsing as a node ID (66-char hex → 33-byte compressed pubkey).
123fn try_parse_node_id(input: &str) -> Option<InputType> {
124    if input.len() != 66 {
125        return None;
126    }
127    let bytes = hex::decode(input).ok()?;
128    if bytes.len() != 33 {
129        return None;
130    }
131    // Compressed pubkeys start with 0x02 or 0x03
132    if bytes[0] != 0x02 && bytes[0] != 0x03 {
133        return None;
134    }
135    Some(InputType::NodeId {
136        node_id: input.to_string(),
137    })
138}