kaccy_bitcoin/
bip21.rs

1//! BIP 21: URI Scheme for Bitcoin Payments
2//!
3//! This module implements BIP 21, which defines a URI scheme for making Bitcoin
4//! payment requests. These URIs are commonly used in QR codes and payment links.
5//!
6//! # Features
7//!
8//! - Parse Bitcoin payment URIs
9//! - Generate payment URIs with amount, label, and message
10//! - Support for additional parameters
11//! - Lightning invoice fallback support
12//! - Network-aware address validation
13//!
14//! # URI Format
15//!
16//! ```text
17//! bitcoin:<address>[?amount=<amount>][&label=<label>][&message=<message>]
18//! ```
19//!
20//! # Example
21//!
22//! ```rust
23//! use kaccy_bitcoin::bip21::{BitcoinUri, BitcoinUriBuilder};
24//! use bitcoin::Network;
25//!
26//! # fn example() -> Result<(), Box<dyn std::error::Error>> {
27//! // Create a payment URI
28//! let uri = BitcoinUriBuilder::new("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh")
29//!     .amount(100_000) // 0.001 BTC in satoshis
30//!     .label("Donation to Alice")
31//!     .message("Thank you for your support")
32//!     .build()?;
33//!
34//! let uri_string = uri.to_string();
35//! // bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh?amount=0.00100000&label=Donation%20to%20Alice&message=Thank%20you%20for%20your%20support
36//!
37//! // Parse a URI
38//! let parsed = BitcoinUri::parse(&uri_string)?;
39//! assert_eq!(parsed.address, "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh");
40//! assert_eq!(parsed.amount, Some(100_000));
41//! # Ok(())
42//! # }
43//! ```
44
45use crate::error::BitcoinError;
46use bitcoin::{Address, Network};
47use serde::{Deserialize, Serialize};
48use std::collections::HashMap;
49use std::fmt;
50use std::str::FromStr;
51
52/// Bitcoin payment URI according to BIP 21
53#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct BitcoinUri {
55    /// Bitcoin address
56    pub address: String,
57
58    /// Amount in satoshis
59    pub amount: Option<u64>,
60
61    /// Human-readable label
62    pub label: Option<String>,
63
64    /// Human-readable message
65    pub message: Option<String>,
66
67    /// Additional parameters (e.g., for extensions)
68    pub extras: HashMap<String, String>,
69}
70
71impl BitcoinUri {
72    /// Create a new Bitcoin URI with just an address
73    pub fn new(address: String) -> Self {
74        Self {
75            address,
76            amount: None,
77            label: None,
78            message: None,
79            extras: HashMap::new(),
80        }
81    }
82
83    /// Parse a Bitcoin URI string
84    ///
85    /// # Example
86    ///
87    /// ```rust
88    /// use kaccy_bitcoin::bip21::BitcoinUri;
89    ///
90    /// let uri = BitcoinUri::parse("bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh?amount=0.001")?;
91    /// # Ok::<(), kaccy_bitcoin::BitcoinError>(())
92    /// ```
93    pub fn parse(uri: &str) -> Result<Self, BitcoinError> {
94        // Remove "bitcoin:" prefix if present
95        let uri = if let Some(stripped) = uri.strip_prefix("bitcoin:") {
96            stripped
97        } else {
98            uri
99        };
100
101        // Split address and query parameters
102        let parts: Vec<&str> = uri.splitn(2, '?').collect();
103        let address = parts[0].to_string();
104
105        if address.is_empty() {
106            return Err(BitcoinError::InvalidAddress("Empty address in URI".into()));
107        }
108
109        let mut parsed = Self::new(address);
110
111        // Parse query parameters if present
112        if parts.len() > 1 {
113            let query = parts[1];
114            for param in query.split('&') {
115                let kv: Vec<&str> = param.splitn(2, '=').collect();
116                if kv.len() != 2 {
117                    continue;
118                }
119
120                let key = urlencoding::decode(kv[0])
121                    .map_err(|e| BitcoinError::InvalidInput(format!("URL decode error: {}", e)))?
122                    .to_string();
123                let value = urlencoding::decode(kv[1])
124                    .map_err(|e| BitcoinError::InvalidInput(format!("URL decode error: {}", e)))?
125                    .to_string();
126
127                match key.as_str() {
128                    "amount" => {
129                        // Amount is in BTC, convert to satoshis
130                        let btc: f64 = value.parse().map_err(|_| {
131                            BitcoinError::InvalidInput(format!("Invalid amount: {}", value))
132                        })?;
133                        parsed.amount = Some((btc * 100_000_000.0) as u64);
134                    }
135                    "label" => {
136                        parsed.label = Some(value);
137                    }
138                    "message" => {
139                        parsed.message = Some(value);
140                    }
141                    _ => {
142                        // Store as extra parameter
143                        parsed.extras.insert(key, value);
144                    }
145                }
146            }
147        }
148
149        Ok(parsed)
150    }
151
152    /// Validate the address for a specific network
153    pub fn validate_address(&self, network: Network) -> Result<Address, BitcoinError> {
154        let address = Address::from_str(&self.address)
155            .map_err(|e| BitcoinError::InvalidAddress(format!("Invalid address: {}", e)))?;
156
157        let validated = address.require_network(network).map_err(|_| {
158            BitcoinError::InvalidAddress(format!(
159                "Address network does not match expected network: {:?}",
160                network
161            ))
162        })?;
163
164        Ok(validated)
165    }
166
167    /// Get the amount in BTC
168    pub fn amount_btc(&self) -> Option<f64> {
169        self.amount.map(|sats| sats as f64 / 100_000_000.0)
170    }
171
172    /// Get an extra parameter value
173    pub fn get_extra(&self, key: &str) -> Option<&str> {
174        self.extras.get(key).map(|s| s.as_str())
175    }
176
177    /// Check if this URI has a Lightning invoice fallback
178    pub fn has_lightning_fallback(&self) -> bool {
179        self.extras.contains_key("lightning")
180    }
181
182    /// Get the Lightning invoice if present
183    pub fn lightning_invoice(&self) -> Option<&str> {
184        self.get_extra("lightning")
185    }
186}
187
188impl fmt::Display for BitcoinUri {
189    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
190        write!(f, "bitcoin:{}", self.address)?;
191
192        let mut params = Vec::new();
193
194        if let Some(amount) = self.amount {
195            let btc = amount as f64 / 100_000_000.0;
196            params.push(format!("amount={:.8}", btc));
197        }
198
199        if let Some(ref label) = self.label {
200            params.push(format!("label={}", urlencoding::encode(label)));
201        }
202
203        if let Some(ref message) = self.message {
204            params.push(format!("message={}", urlencoding::encode(message)));
205        }
206
207        // Add extra parameters in sorted order for consistency
208        let mut extra_keys: Vec<_> = self.extras.keys().collect();
209        extra_keys.sort();
210        for key in extra_keys {
211            if let Some(value) = self.extras.get(key) {
212                params.push(format!(
213                    "{}={}",
214                    urlencoding::encode(key),
215                    urlencoding::encode(value)
216                ));
217            }
218        }
219
220        if !params.is_empty() {
221            write!(f, "?{}", params.join("&"))?;
222        }
223
224        Ok(())
225    }
226}
227
228/// Builder for creating Bitcoin URIs
229pub struct BitcoinUriBuilder {
230    uri: BitcoinUri,
231}
232
233impl BitcoinUriBuilder {
234    /// Create a new URI builder with an address
235    pub fn new(address: impl Into<String>) -> Self {
236        Self {
237            uri: BitcoinUri::new(address.into()),
238        }
239    }
240
241    /// Set the payment amount in satoshis
242    pub fn amount(mut self, satoshis: u64) -> Self {
243        self.uri.amount = Some(satoshis);
244        self
245    }
246
247    /// Set the payment amount in BTC
248    pub fn amount_btc(mut self, btc: f64) -> Self {
249        self.uri.amount = Some((btc * 100_000_000.0) as u64);
250        self
251    }
252
253    /// Set the label
254    pub fn label(mut self, label: impl Into<String>) -> Self {
255        self.uri.label = Some(label.into());
256        self
257    }
258
259    /// Set the message
260    pub fn message(mut self, message: impl Into<String>) -> Self {
261        self.uri.message = Some(message.into());
262        self
263    }
264
265    /// Add a custom parameter
266    pub fn extra(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
267        self.uri.extras.insert(key.into(), value.into());
268        self
269    }
270
271    /// Add a Lightning invoice fallback
272    pub fn lightning(mut self, invoice: impl Into<String>) -> Self {
273        self.uri
274            .extras
275            .insert("lightning".to_string(), invoice.into());
276        self
277    }
278
279    /// Build the URI
280    pub fn build(self) -> Result<BitcoinUri, BitcoinError> {
281        // Validate that the address is not empty
282        if self.uri.address.is_empty() {
283            return Err(BitcoinError::InvalidAddress(
284                "Address cannot be empty".into(),
285            ));
286        }
287
288        Ok(self.uri)
289    }
290}
291
292/// QR code generation helper for Bitcoin URIs
293pub struct QrCodeHelper;
294
295impl QrCodeHelper {
296    /// Get the recommended QR code error correction level
297    ///
298    /// BIP 21 recommends using Medium (M) level
299    pub fn recommended_error_correction() -> &'static str {
300        "M" // Medium: ~15% error correction
301    }
302
303    /// Estimate QR code size needed for a URI
304    ///
305    /// Returns the recommended QR code version (1-40)
306    pub fn estimate_qr_version(uri: &BitcoinUri) -> u8 {
307        let uri_string = uri.to_string();
308        let len = uri_string.len();
309
310        // Rough estimation based on URI length
311        // QR version 1 can hold ~25 alphanumeric chars
312        // Each version adds ~4 chars capacity
313        ((len as f64 / 25.0).ceil() as u8).clamp(1, 40)
314    }
315
316    /// Check if a URI is suitable for QR code
317    ///
318    /// Returns true if the URI can fit in a reasonable QR code size
319    pub fn is_qr_friendly(uri: &BitcoinUri) -> bool {
320        let uri_string = uri.to_string();
321        // QR codes become hard to scan above version 20 (~470 chars)
322        uri_string.len() <= 470
323    }
324}
325
326#[cfg(test)]
327mod tests {
328    use super::*;
329
330    #[test]
331    fn test_simple_uri() {
332        let uri = BitcoinUri::new("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string());
333        let uri_string = uri.to_string();
334        assert_eq!(
335            uri_string,
336            "bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh"
337        );
338    }
339
340    #[test]
341    fn test_uri_with_amount() {
342        let uri = BitcoinUriBuilder::new("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh")
343            .amount(100_000)
344            .build()
345            .unwrap();
346
347        let uri_string = uri.to_string();
348        assert!(uri_string.contains("amount=0.00100000"));
349    }
350
351    #[test]
352    fn test_uri_with_all_fields() {
353        let uri = BitcoinUriBuilder::new("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh")
354            .amount(100_000)
355            .label("Donation")
356            .message("Thank you")
357            .build()
358            .unwrap();
359
360        let uri_string = uri.to_string();
361        assert!(uri_string.contains("amount=0.00100000"));
362        assert!(uri_string.contains("label=Donation"));
363        assert!(uri_string.contains("message=Thank%20you"));
364    }
365
366    #[test]
367    fn test_parse_simple_uri() {
368        let uri = BitcoinUri::parse("bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh").unwrap();
369        assert_eq!(uri.address, "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh");
370        assert_eq!(uri.amount, None);
371        assert_eq!(uri.label, None);
372    }
373
374    #[test]
375    fn test_parse_uri_with_amount() {
376        let uri =
377            BitcoinUri::parse("bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh?amount=0.001")
378                .unwrap();
379        assert_eq!(uri.amount, Some(100_000));
380        assert_eq!(uri.amount_btc(), Some(0.001));
381    }
382
383    #[test]
384    fn test_parse_uri_with_all_fields() {
385        let uri = BitcoinUri::parse(
386            "bitcoin:bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh?amount=0.001&label=Donation&message=Thank%20you",
387        )
388        .unwrap();
389        assert_eq!(uri.address, "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh");
390        assert_eq!(uri.amount, Some(100_000));
391        assert_eq!(uri.label, Some("Donation".to_string()));
392        assert_eq!(uri.message, Some("Thank you".to_string()));
393    }
394
395    #[test]
396    fn test_parse_without_prefix() {
397        let uri =
398            BitcoinUri::parse("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh?amount=0.001").unwrap();
399        assert_eq!(uri.address, "bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh");
400        assert_eq!(uri.amount, Some(100_000));
401    }
402
403    #[test]
404    fn test_extra_parameters() {
405        let uri = BitcoinUriBuilder::new("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh")
406            .extra("req-payment", "xyz123")
407            .build()
408            .unwrap();
409
410        assert_eq!(uri.get_extra("req-payment"), Some("xyz123"));
411    }
412
413    #[test]
414    fn test_lightning_fallback() {
415        let uri = BitcoinUriBuilder::new("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh")
416            .lightning("lnbc1...")
417            .build()
418            .unwrap();
419
420        assert!(uri.has_lightning_fallback());
421        assert_eq!(uri.lightning_invoice(), Some("lnbc1..."));
422    }
423
424    #[test]
425    fn test_roundtrip() {
426        let original = BitcoinUriBuilder::new("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh")
427            .amount(100_000)
428            .label("Test Label")
429            .message("Test Message")
430            .build()
431            .unwrap();
432
433        let uri_string = original.to_string();
434        let parsed = BitcoinUri::parse(&uri_string).unwrap();
435
436        assert_eq!(parsed.address, original.address);
437        assert_eq!(parsed.amount, original.amount);
438        assert_eq!(parsed.label, original.label);
439        assert_eq!(parsed.message, original.message);
440    }
441
442    #[test]
443    fn test_qr_helper() {
444        let uri = BitcoinUri::new("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh".to_string());
445        assert!(QrCodeHelper::is_qr_friendly(&uri));
446
447        let version = QrCodeHelper::estimate_qr_version(&uri);
448        assert!(version > 0 && version <= 40);
449    }
450
451    #[test]
452    fn test_url_encoding() {
453        let uri = BitcoinUriBuilder::new("bc1qxy2kgdygjrsqtzq2n0yrf2493p83kkfjhx0wlh")
454            .label("Donation & Support")
455            .message("Thank you! 🎉")
456            .build()
457            .unwrap();
458
459        let uri_string = uri.to_string();
460        assert!(uri_string.contains("Donation%20%26%20Support"));
461
462        let parsed = BitcoinUri::parse(&uri_string).unwrap();
463        assert_eq!(parsed.label, Some("Donation & Support".to_string()));
464        assert_eq!(parsed.message, Some("Thank you! 🎉".to_string()));
465    }
466
467    #[test]
468    fn test_empty_address_error() {
469        let result = BitcoinUriBuilder::new("").build();
470        assert!(result.is_err());
471    }
472}