1use crate::error::BitcoinError;
46use bitcoin::{Address, Network};
47use serde::{Deserialize, Serialize};
48use std::collections::HashMap;
49use std::fmt;
50use std::str::FromStr;
51
52#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
54pub struct BitcoinUri {
55 pub address: String,
57
58 pub amount: Option<u64>,
60
61 pub label: Option<String>,
63
64 pub message: Option<String>,
66
67 pub extras: HashMap<String, String>,
69}
70
71impl BitcoinUri {
72 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 pub fn parse(uri: &str) -> Result<Self, BitcoinError> {
94 let uri = if let Some(stripped) = uri.strip_prefix("bitcoin:") {
96 stripped
97 } else {
98 uri
99 };
100
101 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 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 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 parsed.extras.insert(key, value);
144 }
145 }
146 }
147 }
148
149 Ok(parsed)
150 }
151
152 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 pub fn amount_btc(&self) -> Option<f64> {
169 self.amount.map(|sats| sats as f64 / 100_000_000.0)
170 }
171
172 pub fn get_extra(&self, key: &str) -> Option<&str> {
174 self.extras.get(key).map(|s| s.as_str())
175 }
176
177 pub fn has_lightning_fallback(&self) -> bool {
179 self.extras.contains_key("lightning")
180 }
181
182 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 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
228pub struct BitcoinUriBuilder {
230 uri: BitcoinUri,
231}
232
233impl BitcoinUriBuilder {
234 pub fn new(address: impl Into<String>) -> Self {
236 Self {
237 uri: BitcoinUri::new(address.into()),
238 }
239 }
240
241 pub fn amount(mut self, satoshis: u64) -> Self {
243 self.uri.amount = Some(satoshis);
244 self
245 }
246
247 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 pub fn label(mut self, label: impl Into<String>) -> Self {
255 self.uri.label = Some(label.into());
256 self
257 }
258
259 pub fn message(mut self, message: impl Into<String>) -> Self {
261 self.uri.message = Some(message.into());
262 self
263 }
264
265 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 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 pub fn build(self) -> Result<BitcoinUri, BitcoinError> {
281 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
292pub struct QrCodeHelper;
294
295impl QrCodeHelper {
296 pub fn recommended_error_correction() -> &'static str {
300 "M" }
302
303 pub fn estimate_qr_version(uri: &BitcoinUri) -> u8 {
307 let uri_string = uri.to_string();
308 let len = uri_string.len();
309
310 ((len as f64 / 25.0).ceil() as u8).clamp(1, 40)
314 }
315
316 pub fn is_qr_friendly(uri: &BitcoinUri) -> bool {
320 let uri_string = uri.to_string();
321 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}