rustywallet_lightning/
bolt11.rs

1//! BOLT11 invoice parsing and creation.
2//!
3//! This module provides types for working with Lightning Network invoices
4//! as specified in BOLT11.
5
6use crate::error::LightningError;
7use crate::payment::PaymentHash;
8use crate::route::RouteHint;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11/// A parsed BOLT11 invoice.
12#[derive(Debug, Clone)]
13pub struct Bolt11Invoice {
14    /// Human-readable part (network prefix)
15    #[allow(dead_code)]
16    hrp: String,
17    /// Invoice data
18    data: InvoiceData,
19    /// Raw invoice string
20    raw: String,
21}
22
23impl Bolt11Invoice {
24    /// Parse a BOLT11 invoice string.
25    ///
26    /// Note: This is a simplified parser that extracts basic information.
27    /// For full BOLT11 compliance, consider using a dedicated library.
28    pub fn parse(invoice: &str) -> Result<Self, LightningError> {
29        let invoice = invoice.to_lowercase();
30        
31        // Check prefix
32        if !invoice.starts_with("ln") {
33            return Err(LightningError::InvalidInvoice(
34                "Invoice must start with 'ln'".into(),
35            ));
36        }
37
38        // Extract HRP (everything before '1')
39        let separator_pos = invoice.rfind('1').ok_or_else(|| {
40            LightningError::InvalidInvoice("Missing separator '1'".into())
41        })?;
42
43        let hrp = invoice[..separator_pos].to_string();
44        
45        // Determine network from HRP
46        let network = if hrp.starts_with("lnbc") {
47            Network::Mainnet
48        } else if hrp.starts_with("lntb") {
49            Network::Testnet
50        } else if hrp.starts_with("lnbcrt") {
51            Network::Regtest
52        } else {
53            return Err(LightningError::InvalidInvoice(format!(
54                "Unknown network prefix: {}",
55                hrp
56            )));
57        };
58
59        // Extract amount from HRP if present
60        let amount_msat = Self::parse_amount(&hrp)?;
61
62        // For now, create a basic invoice data structure
63        // Full parsing would require bech32 decoding of the data part
64        let data = InvoiceData {
65            network,
66            amount_msat,
67            timestamp: SystemTime::now()
68                .duration_since(UNIX_EPOCH)
69                .unwrap_or_default()
70                .as_secs(),
71            expiry: 3600, // Default 1 hour
72            payment_hash: None,
73            description: None,
74            description_hash: None,
75            payee_pubkey: None,
76            route_hints: Vec::new(),
77            min_final_cltv_expiry: 18,
78        };
79
80        Ok(Self {
81            hrp,
82            data,
83            raw: invoice,
84        })
85    }
86
87    /// Parse amount from HRP.
88    fn parse_amount(hrp: &str) -> Result<Option<u64>, LightningError> {
89        // Remove network prefix using strip_prefix
90        let amount_str = if let Some(stripped) = hrp.strip_prefix("lnbcrt") {
91            stripped
92        } else if let Some(stripped) = hrp.strip_prefix("lnbc") {
93            stripped
94        } else if let Some(stripped) = hrp.strip_prefix("lntb") {
95            stripped
96        } else {
97            return Ok(None);
98        };
99
100        if amount_str.is_empty() {
101            return Ok(None);
102        }
103
104        // Parse amount with multiplier
105        let (num_str, multiplier) = if let Some(last) = amount_str.chars().last() {
106            match last {
107                'm' => (&amount_str[..amount_str.len()-1], 100_000_000u64), // milli-BTC
108                'u' => (&amount_str[..amount_str.len()-1], 100_000u64),     // micro-BTC
109                'n' => (&amount_str[..amount_str.len()-1], 100u64),         // nano-BTC
110                'p' => (&amount_str[..amount_str.len()-1], 1u64),           // pico-BTC (0.1 msat)
111                _ => (amount_str, 100_000_000_000u64),                       // BTC
112            }
113        } else {
114            return Ok(None);
115        };
116
117        let amount: u64 = num_str.parse().map_err(|_| {
118            LightningError::InvalidInvoice(format!("Invalid amount: {}", amount_str))
119        })?;
120
121        Ok(Some(amount * multiplier))
122    }
123
124    /// Get the network.
125    pub fn network(&self) -> Network {
126        self.data.network
127    }
128
129    /// Get the amount in millisatoshis.
130    pub fn amount_msat(&self) -> Option<u64> {
131        self.data.amount_msat
132    }
133
134    /// Get the payment hash.
135    pub fn payment_hash(&self) -> Option<&PaymentHash> {
136        self.data.payment_hash.as_ref()
137    }
138
139    /// Get the description.
140    pub fn description(&self) -> Option<&str> {
141        self.data.description.as_deref()
142    }
143
144    /// Get the expiry time in seconds.
145    pub fn expiry(&self) -> u64 {
146        self.data.expiry
147    }
148
149    /// Get the timestamp.
150    pub fn timestamp(&self) -> u64 {
151        self.data.timestamp
152    }
153
154    /// Check if the invoice has expired.
155    pub fn is_expired(&self) -> bool {
156        let now = SystemTime::now()
157            .duration_since(UNIX_EPOCH)
158            .unwrap_or_default()
159            .as_secs();
160        
161        now > self.data.timestamp + self.data.expiry
162    }
163
164    /// Get the raw invoice string.
165    pub fn raw(&self) -> &str {
166        &self.raw
167    }
168
169    /// Get the invoice data.
170    pub fn data(&self) -> &InvoiceData {
171        &self.data
172    }
173}
174
175/// Invoice data extracted from a BOLT11 invoice.
176#[derive(Debug, Clone)]
177pub struct InvoiceData {
178    /// Network (mainnet, testnet, regtest)
179    pub network: Network,
180    /// Amount in millisatoshis
181    pub amount_msat: Option<u64>,
182    /// Creation timestamp (Unix seconds)
183    pub timestamp: u64,
184    /// Expiry time in seconds
185    pub expiry: u64,
186    /// Payment hash
187    pub payment_hash: Option<PaymentHash>,
188    /// Description string
189    pub description: Option<String>,
190    /// Description hash (for long descriptions)
191    pub description_hash: Option<[u8; 32]>,
192    /// Payee public key
193    pub payee_pubkey: Option<[u8; 33]>,
194    /// Route hints
195    pub route_hints: Vec<RouteHint>,
196    /// Minimum final CLTV expiry
197    pub min_final_cltv_expiry: u32,
198}
199
200/// Lightning Network type.
201#[derive(Debug, Clone, Copy, PartialEq, Eq)]
202pub enum Network {
203    /// Bitcoin mainnet
204    Mainnet,
205    /// Bitcoin testnet
206    Testnet,
207    /// Bitcoin regtest
208    Regtest,
209}
210
211impl Network {
212    /// Get the HRP prefix for this network.
213    pub fn hrp_prefix(&self) -> &'static str {
214        match self {
215            Network::Mainnet => "lnbc",
216            Network::Testnet => "lntb",
217            Network::Regtest => "lnbcrt",
218        }
219    }
220}
221
222/// Builder for creating BOLT11 invoices.
223pub struct InvoiceBuilder {
224    network: Network,
225    amount_msat: Option<u64>,
226    description: Option<String>,
227    payment_hash: Option<PaymentHash>,
228    expiry: u64,
229    route_hints: Vec<RouteHint>,
230    min_final_cltv_expiry: u32,
231}
232
233impl InvoiceBuilder {
234    /// Create a new invoice builder.
235    pub fn new(network: Network) -> Self {
236        Self {
237            network,
238            amount_msat: None,
239            description: None,
240            payment_hash: None,
241            expiry: 3600, // 1 hour default
242            route_hints: Vec::new(),
243            min_final_cltv_expiry: 18,
244        }
245    }
246
247    /// Set the amount in millisatoshis.
248    pub fn amount_msat(mut self, amount: u64) -> Self {
249        self.amount_msat = Some(amount);
250        self
251    }
252
253    /// Set the amount in satoshis.
254    pub fn amount_sats(mut self, sats: u64) -> Self {
255        self.amount_msat = Some(sats * 1000);
256        self
257    }
258
259    /// Set the description.
260    pub fn description(mut self, desc: &str) -> Self {
261        self.description = Some(desc.to_string());
262        self
263    }
264
265    /// Set the payment hash.
266    pub fn payment_hash(mut self, hash: PaymentHash) -> Self {
267        self.payment_hash = Some(hash);
268        self
269    }
270
271    /// Set the expiry time in seconds.
272    pub fn expiry(mut self, seconds: u64) -> Self {
273        self.expiry = seconds;
274        self
275    }
276
277    /// Add a route hint.
278    pub fn route_hint(mut self, hint: RouteHint) -> Self {
279        self.route_hints.push(hint);
280        self
281    }
282
283    /// Set the minimum final CLTV expiry.
284    pub fn min_final_cltv_expiry(mut self, blocks: u32) -> Self {
285        self.min_final_cltv_expiry = blocks;
286        self
287    }
288
289    /// Build the invoice data.
290    ///
291    /// Note: This creates the invoice data structure but does not
292    /// encode it to a full BOLT11 string (which requires signing).
293    pub fn build(self) -> Result<InvoiceData, LightningError> {
294        let payment_hash = self.payment_hash.ok_or_else(|| {
295            LightningError::InvalidInvoice("Payment hash is required".into())
296        })?;
297
298        let timestamp = SystemTime::now()
299            .duration_since(UNIX_EPOCH)
300            .unwrap_or_default()
301            .as_secs();
302
303        Ok(InvoiceData {
304            network: self.network,
305            amount_msat: self.amount_msat,
306            timestamp,
307            expiry: self.expiry,
308            payment_hash: Some(payment_hash),
309            description: self.description,
310            description_hash: None,
311            payee_pubkey: None,
312            route_hints: self.route_hints,
313            min_final_cltv_expiry: self.min_final_cltv_expiry,
314        })
315    }
316}
317
318#[cfg(test)]
319mod tests {
320    use super::*;
321    use crate::payment::PaymentPreimage;
322
323    #[test]
324    fn test_parse_mainnet_invoice() {
325        // Simple mainnet invoice prefix
326        let invoice = "lnbc1pvjluezsp5zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zyg3zygshp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqspp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqdpl2pkx2ctnv5sxxmmwwd5kgetjypeh2ursdae8g6twvus8g6rfwvs8qun0dfjkxaq9qrsgq357wnc5r2ueh7ck6q93dj32dlqnls087fxdwk8qakdyafkq3yap9us6v52vjjsrvywa6rt52cm9r9zqt8r2t7mlcwspyetp5h2tztugp9lfyql";
327        
328        let parsed = Bolt11Invoice::parse(invoice).unwrap();
329        assert_eq!(parsed.network(), Network::Mainnet);
330    }
331
332    #[test]
333    fn test_parse_testnet_invoice() {
334        let invoice = "lntb1u1p0xxxx";
335        let parsed = Bolt11Invoice::parse(invoice).unwrap();
336        assert_eq!(parsed.network(), Network::Testnet);
337    }
338
339    #[test]
340    fn test_parse_amount() {
341        // 1 mBTC = 100,000,000 msat
342        assert_eq!(Bolt11Invoice::parse_amount("lnbc1m").unwrap(), Some(100_000_000));
343        
344        // 1 uBTC = 100,000 msat
345        assert_eq!(Bolt11Invoice::parse_amount("lnbc1u").unwrap(), Some(100_000));
346        
347        // 1 nBTC = 100 msat
348        assert_eq!(Bolt11Invoice::parse_amount("lnbc1n").unwrap(), Some(100));
349    }
350
351    #[test]
352    fn test_invoice_builder() {
353        let preimage = PaymentPreimage::random();
354        let payment_hash = preimage.payment_hash();
355
356        let data = InvoiceBuilder::new(Network::Mainnet)
357            .amount_sats(10000)
358            .description("Test payment")
359            .payment_hash(payment_hash)
360            .expiry(3600)
361            .build()
362            .unwrap();
363
364        assert_eq!(data.network, Network::Mainnet);
365        assert_eq!(data.amount_msat, Some(10_000_000));
366        assert_eq!(data.description, Some("Test payment".to_string()));
367    }
368
369    #[test]
370    fn test_network_hrp() {
371        assert_eq!(Network::Mainnet.hrp_prefix(), "lnbc");
372        assert_eq!(Network::Testnet.hrp_prefix(), "lntb");
373        assert_eq!(Network::Regtest.hrp_prefix(), "lnbcrt");
374    }
375}