1mod common;
2#[cfg(feature = "btc-core")]
3mod core_rpc;
4#[cfg(feature = "btc-electrum")]
5mod electrum;
6#[cfg(feature = "btc-esplora")]
7mod esplora;
8
9use crate::provider::{HistorySyncStats, PayError, PayProvider};
10use crate::store::wallet::{self, WalletMetadata};
11use crate::store::{PayStore, StorageBackend};
12use crate::types::*;
13use async_trait::async_trait;
14use bdk_wallet::bitcoin::{Address, Amount as BtcAmount, Transaction, Txid};
15use bdk_wallet::chain::{ChainPosition, ConfirmationBlockTime};
16use bdk_wallet::keys::bip39::Mnemonic;
17use bdk_wallet::{KeychainKind, Wallet};
18use common::*;
19use std::collections::HashMap;
20use std::str::FromStr;
21use std::sync::Arc;
22
23#[async_trait]
28pub(crate) trait BtcChainSource: Send + Sync {
29 async fn sync(&self, wallet: &mut Wallet) -> Result<(), PayError>;
31 async fn full_scan(&self, wallet: &mut Wallet) -> Result<(), PayError>;
33 async fn broadcast(&self, tx: &Transaction) -> Result<(), PayError>;
35}
36
37fn resolve_chain_source(meta: &WalletMetadata) -> Result<Box<dyn BtcChainSource>, PayError> {
42 let backend = meta.backend.as_deref();
43 match backend {
44 #[cfg(feature = "btc-esplora")]
45 None | Some("esplora") => Ok(Box::new(esplora::EsploraSource::new(meta))),
46
47 #[cfg(feature = "btc-core")]
48 Some("core-rpc") => Ok(Box::new(core_rpc::CoreRpcSource::new(meta)?)),
49
50 #[cfg(feature = "btc-electrum")]
51 Some("electrum") => Ok(Box::new(electrum::ElectrumSource::new(meta)?)),
52
53 #[cfg(not(feature = "btc-esplora"))]
54 None => Err(PayError::InternalError(
55 "no default btc backend available; enable btc-esplora feature".to_string(),
56 )),
57
58 Some(other) => Err(PayError::InternalError(format!(
59 "unknown btc backend '{other}'; expected: esplora, core-rpc, electrum"
60 ))),
61 }
62}
63
64fn default_btc_backend() -> BtcBackend {
65 if cfg!(feature = "btc-esplora") {
66 BtcBackend::Esplora
67 } else if cfg!(feature = "btc-core") {
68 BtcBackend::CoreRpc
69 } else {
70 BtcBackend::Electrum
71 }
72}
73
74fn backend_feature_name(backend: BtcBackend) -> &'static str {
75 match backend {
76 BtcBackend::Esplora => "btc-esplora",
77 BtcBackend::CoreRpc => "btc-core",
78 BtcBackend::Electrum => "btc-electrum",
79 }
80}
81
82fn backend_enabled(backend: BtcBackend) -> bool {
83 match backend {
84 BtcBackend::Esplora => cfg!(feature = "btc-esplora"),
85 BtcBackend::CoreRpc => cfg!(feature = "btc-core"),
86 BtcBackend::Electrum => cfg!(feature = "btc-electrum"),
87 }
88}
89
90fn ensure_backend_enabled(backend: BtcBackend) -> Result<(), PayError> {
91 if backend_enabled(backend) {
92 return Ok(());
93 }
94 let feature = backend_feature_name(backend);
95 Err(PayError::NotImplemented(format!(
96 "btc backend '{}' is not enabled in this build; rebuild with --features {feature}",
97 backend.as_str()
98 )))
99}
100
101fn validate_backend_request(
102 request: &WalletCreateRequest,
103 backend: BtcBackend,
104) -> Result<(), PayError> {
105 match backend {
106 BtcBackend::Esplora => {
107 if matches!(request.btc_esplora_url.as_deref(), Some(url) if url.trim().is_empty()) {
108 return Err(PayError::InvalidAmount(
109 "btc_esplora_url must not be empty when provided".to_string(),
110 ));
111 }
112 }
113 BtcBackend::CoreRpc => {
114 if request
115 .btc_core_url
116 .as_deref()
117 .map(str::trim)
118 .filter(|s| !s.is_empty())
119 .is_none()
120 {
121 return Err(PayError::InvalidAmount(
122 "btc_core_url is required when btc_backend=core-rpc".to_string(),
123 ));
124 }
125 }
126 BtcBackend::Electrum => {
127 if request
128 .btc_electrum_url
129 .as_deref()
130 .map(str::trim)
131 .filter(|s| !s.is_empty())
132 .is_none()
133 {
134 return Err(PayError::InvalidAmount(
135 "btc_electrum_url is required when btc_backend=electrum".to_string(),
136 ));
137 }
138 }
139 }
140 Ok(())
141}
142
143fn chain_txid_from_record(record: &HistoryRecord) -> Option<Txid> {
144 if let Some(onchain_id) = record.onchain_memo.as_deref() {
145 if let Ok(txid) = Txid::from_str(onchain_id) {
146 return Some(txid);
147 }
148 }
149 Txid::from_str(&record.transaction_id).ok()
150}
151
152fn status_and_confirmations(
153 chain_position: ChainPosition<ConfirmationBlockTime>,
154 tip_height: u32,
155) -> (TxStatus, u32) {
156 match chain_position {
157 ChainPosition::Confirmed { anchor, .. } => (
158 TxStatus::Confirmed,
159 tip_height
160 .saturating_sub(anchor.block_id.height)
161 .saturating_add(1),
162 ),
163 ChainPosition::Unconfirmed { .. } => (TxStatus::Pending, 0),
164 }
165}
166
167fn chain_position_epoch_s(chain_position: ChainPosition<ConfirmationBlockTime>) -> u64 {
168 match chain_position {
169 ChainPosition::Confirmed { anchor, .. } => anchor.confirmation_time,
170 ChainPosition::Unconfirmed {
171 last_seen,
172 first_seen,
173 } => last_seen
174 .or(first_seen)
175 .unwrap_or_else(wallet::now_epoch_seconds),
176 }
177}
178
179pub struct BtcProvider {
184 data_dir: String,
185 store: Arc<StorageBackend>,
186}
187
188impl BtcProvider {
189 pub fn new(data_dir: &str, store: Arc<StorageBackend>) -> Self {
190 Self {
191 data_dir: data_dir.to_string(),
192 store,
193 }
194 }
195
196 fn resolve_wallet_id(&self, wallet_id: &str) -> Result<String, PayError> {
197 self.store.resolve_wallet_id(wallet_id)
198 }
199
200 fn load_btc_wallet(&self, wallet_id: &str) -> Result<WalletMetadata, PayError> {
201 let id = self.resolve_wallet_id(wallet_id)?;
202 let meta = self.store.load_wallet_metadata(&id)?;
203 if meta.network != Network::Btc {
204 return Err(PayError::WalletNotFound(format!(
205 "wallet {id} is not a btc wallet"
206 )));
207 }
208 Ok(meta)
209 }
210
211 async fn sync_wallet(
212 data_dir: &str,
213 meta: &WalletMetadata,
214 wallet: &mut Wallet,
215 ) -> Result<(), PayError> {
216 let source = resolve_chain_source(meta)?;
217 source.sync(wallet).await?;
218 persist_changeset(data_dir, meta, wallet)?;
219 Ok(())
220 }
221
222 #[allow(dead_code)]
223 async fn full_scan_wallet(
224 data_dir: &str,
225 meta: &WalletMetadata,
226 wallet: &mut Wallet,
227 ) -> Result<(), PayError> {
228 let source = resolve_chain_source(meta)?;
229 source.full_scan(wallet).await?;
230 persist_changeset(data_dir, meta, wallet)?;
231 Ok(())
232 }
233}
234
235#[async_trait]
236impl PayProvider for BtcProvider {
237 fn network(&self) -> Network {
238 Network::Btc
239 }
240
241 fn writes_locally(&self) -> bool {
242 true
243 }
244
245 async fn create_wallet(&self, request: &WalletCreateRequest) -> Result<WalletInfo, PayError> {
246 let is_restore = request.mnemonic_secret.is_some();
247 let mnemonic_str = if let Some(ref mnemonic) = request.mnemonic_secret {
248 Mnemonic::parse(mnemonic)
249 .map_err(|e| PayError::InvalidAmount(format!("invalid mnemonic: {e}")))?;
250 mnemonic.clone()
251 } else {
252 let mut entropy = [0u8; 16];
253 getrandom::fill(&mut entropy)
254 .map_err(|e| PayError::InternalError(format!("rng failed: {e}")))?;
255 let mnemonic = Mnemonic::from_entropy(&entropy)
256 .map_err(|e| PayError::InternalError(format!("mnemonic gen: {e}")))?;
257 mnemonic.to_string()
258 };
259
260 let btc_network_str = request
261 .btc_network
262 .as_deref()
263 .unwrap_or("mainnet")
264 .to_string();
265 let btc_address_type = request
266 .btc_address_type
267 .as_deref()
268 .unwrap_or("taproot")
269 .to_string();
270
271 if !["mainnet", "signet"].contains(&btc_network_str.as_str()) {
272 return Err(PayError::InvalidAmount(format!(
273 "unsupported btc_network '{btc_network_str}'; expected: mainnet, signet"
274 )));
275 }
276 if !["taproot", "segwit"].contains(&btc_address_type.as_str()) {
277 return Err(PayError::InvalidAmount(format!(
278 "unsupported btc_address_type '{btc_address_type}'; expected: taproot, segwit"
279 )));
280 }
281
282 let btc_backend = request.btc_backend.unwrap_or_else(default_btc_backend);
283 ensure_backend_enabled(btc_backend)?;
284 validate_backend_request(request, btc_backend)?;
285
286 let wallet_id = wallet::generate_wallet_identifier()?;
287 let normalized_label = {
288 let trimmed = request.label.trim();
289 if trimmed.is_empty() || trimmed == "default" {
290 None
291 } else {
292 Some(trimmed.to_string())
293 }
294 };
295
296 let meta = WalletMetadata {
297 id: wallet_id.clone(),
298 network: Network::Btc,
299 label: normalized_label.clone(),
300 mint_url: None,
301 sol_rpc_endpoints: None,
302 evm_rpc_endpoints: None,
303 evm_chain_id: None,
304 seed_secret: Some(mnemonic_str.clone()),
305 backend: Some(btc_backend.as_str().to_string()),
306 btc_esplora_url: request.btc_esplora_url.clone(),
307 btc_network: Some(btc_network_str),
308 btc_address_type: Some(btc_address_type),
309 btc_core_url: request.btc_core_url.clone(),
310 btc_core_auth_secret: request.btc_core_auth_secret.clone(),
311 btc_electrum_url: request.btc_electrum_url.clone(),
312 custom_tokens: None,
313 created_at_epoch_s: wallet::now_epoch_seconds(),
314 error: None,
315 };
316
317 let address = wallet_address(&meta)?;
318
319 self.store.save_wallet_metadata(&meta)?;
320
321 let mut bdk_wallet = open_bdk_wallet_with_dir(&self.data_dir, &meta)?;
322 let _ = bdk_wallet.reveal_addresses_to(KeychainKind::External, 0);
323 persist_changeset(&self.data_dir, &meta, &mut bdk_wallet)?;
324
325 if is_restore {
326 if let Err(e) = Self::full_scan_wallet(&self.data_dir, &meta, &mut bdk_wallet).await {
327 let _ = self.store.delete_wallet_metadata(&wallet_id);
328 return Err(e);
329 }
330 }
331
332 Ok(WalletInfo {
333 id: wallet_id,
334 network: Network::Btc,
335 address,
336 label: normalized_label,
337 mnemonic: Some(mnemonic_str),
338 })
339 }
340
341 async fn close_wallet(&self, wallet_id: &str) -> Result<(), PayError> {
342 let id = self.resolve_wallet_id(wallet_id)?;
343 let meta = self.load_btc_wallet(&id)?;
344
345 let mut bdk_wallet = open_bdk_wallet_with_dir(&self.data_dir, &meta)?;
346 Self::sync_wallet(&self.data_dir, &meta, &mut bdk_wallet).await?;
347 let balance = bdk_wallet.balance();
348 let total = balance.total().to_sat();
349 if total > 0 {
350 return Err(PayError::InvalidAmount(format!(
351 "wallet {id} has {total} sats remaining; transfer funds before closing, \
352 or use --dangerously-skip-balance-check-and-may-lose-money"
353 )));
354 }
355
356 self.store.delete_wallet_metadata(&id)?;
357 Ok(())
358 }
359
360 async fn list_wallets(&self) -> Result<Vec<WalletSummary>, PayError> {
361 let metas = self.store.list_wallet_metadata(Some(Network::Btc))?;
362 let mut summaries = Vec::with_capacity(metas.len());
363 for meta in metas {
364 let address = wallet_address(&meta).unwrap_or_else(|_| "error".to_string());
365 summaries.push(btc_wallet_summary(meta, address));
366 }
367 Ok(summaries)
368 }
369
370 async fn balance(&self, wallet_id: &str) -> Result<BalanceInfo, PayError> {
371 let id = self.resolve_wallet_id(wallet_id)?;
372 let meta = self.load_btc_wallet(&id)?;
373 let mut bdk_wallet = open_bdk_wallet_with_dir(&self.data_dir, &meta)?;
374 Self::sync_wallet(&self.data_dir, &meta, &mut bdk_wallet).await?;
375 let balance = bdk_wallet.balance();
376 Ok(BalanceInfo::new(
377 balance.confirmed.to_sat(),
378 balance.trusted_pending.to_sat() + balance.untrusted_pending.to_sat(),
379 "sats",
380 ))
381 }
382
383 async fn check_balance(&self, wallet_id: &str) -> Result<BalanceInfo, PayError> {
384 self.balance(wallet_id).await
385 }
386
387 async fn balance_all(&self) -> Result<Vec<WalletBalanceItem>, PayError> {
388 let wallets = self.list_wallets().await?;
389 let mut items = Vec::with_capacity(wallets.len());
390 for ws in wallets {
391 match self.balance(&ws.id).await {
392 Ok(bal) => items.push(WalletBalanceItem {
393 wallet: ws,
394 balance: Some(bal),
395 error: None,
396 }),
397 Err(e) => items.push(WalletBalanceItem {
398 wallet: ws,
399 balance: None,
400 error: Some(e.to_string()),
401 }),
402 }
403 }
404 Ok(items)
405 }
406
407 async fn receive_info(
408 &self,
409 wallet_id: &str,
410 _amount: Option<Amount>,
411 ) -> Result<ReceiveInfo, PayError> {
412 let id = self.resolve_wallet_id(wallet_id)?;
413 let meta = self.load_btc_wallet(&id)?;
414 let mut bdk_wallet = open_bdk_wallet_with_dir(&self.data_dir, &meta)?;
415 let addr_info = bdk_wallet.next_unused_address(KeychainKind::External);
416 persist_changeset(&self.data_dir, &meta, &mut bdk_wallet)?;
417 Ok(ReceiveInfo {
418 address: Some(addr_info.address.to_string()),
419 invoice: None,
420 quote_id: None,
421 })
422 }
423
424 async fn receive_claim(&self, _wallet: &str, _quote_id: &str) -> Result<u64, PayError> {
425 Err(PayError::NotImplemented(
426 "btc does not use receive_claim; on-chain transactions are automatic".to_string(),
427 ))
428 }
429
430 async fn cashu_send(
431 &self,
432 _wallet: &str,
433 _amount: Amount,
434 _onchain_memo: Option<&str>,
435 _mints: Option<&[String]>,
436 ) -> Result<CashuSendResult, PayError> {
437 Err(PayError::NotImplemented(
438 "cashu_send not supported for btc".to_string(),
439 ))
440 }
441
442 async fn cashu_receive(
443 &self,
444 _wallet: &str,
445 _token: &str,
446 ) -> Result<CashuReceiveResult, PayError> {
447 Err(PayError::NotImplemented(
448 "cashu_receive not supported for btc".to_string(),
449 ))
450 }
451
452 async fn send(
453 &self,
454 wallet_id: &str,
455 to: &str,
456 _onchain_memo: Option<&str>,
457 _mints: Option<&[String]>,
458 ) -> Result<SendResult, PayError> {
459 let id = self.resolve_wallet_id(wallet_id)?;
460 let meta = self.load_btc_wallet(&id)?;
461 let target = parse_transfer_target(to)?;
462 if target.amount_sats == 0 {
463 return Err(PayError::InvalidAmount(
464 "amount must be greater than 0 sats".to_string(),
465 ));
466 }
467
468 let btc_net = btc_network_for_meta(&meta);
469 let recipient = Address::from_str(&target.address)
470 .map_err(|e| PayError::InvalidAmount(format!("invalid btc address: {e}")))?
471 .require_network(btc_net)
472 .map_err(|e| PayError::InvalidAmount(format!("address network mismatch: {e}")))?;
473
474 let mut bdk_wallet = open_bdk_wallet_with_dir(&self.data_dir, &meta)?;
475
476 Self::sync_wallet(&self.data_dir, &meta, &mut bdk_wallet).await?;
477
478 let mut tx_builder = bdk_wallet.build_tx();
479 tx_builder.add_recipient(
480 recipient.script_pubkey(),
481 BtcAmount::from_sat(target.amount_sats),
482 );
483
484 let mut psbt = tx_builder
485 .finish()
486 .map_err(|e| PayError::InternalError(format!("build tx: {e}")))?;
487
488 #[allow(deprecated)]
489 let finalized = bdk_wallet
490 .sign(&mut psbt, bdk_wallet::SignOptions::default())
491 .map_err(|e| PayError::InternalError(format!("sign tx: {e}")))?;
492
493 if !finalized {
494 return Err(PayError::InternalError(
495 "transaction signing did not finalize".to_string(),
496 ));
497 }
498
499 let tx = psbt
500 .extract_tx()
501 .map_err(|e| PayError::InternalError(format!("extract tx: {e}")))?;
502 let txid = tx.compute_txid().to_string();
503
504 let source = resolve_chain_source(&meta)?;
506 source.broadcast(&tx).await?;
507
508 persist_changeset(&self.data_dir, &meta, &mut bdk_wallet)?;
509
510 let tx_id = wallet::generate_transaction_identifier()?;
511 let fee_amount = bdk_wallet.calculate_fee(&tx).map(|f| f.to_sat()).ok();
512
513 let record = HistoryRecord {
514 transaction_id: tx_id.clone(),
515 wallet: id.clone(),
516 network: Network::Btc,
517 direction: Direction::Send,
518 amount: Amount {
519 value: target.amount_sats,
520 token: "sats".to_string(),
521 },
522 status: TxStatus::Pending,
523 onchain_memo: Some(txid.clone()),
524 local_memo: None,
525 remote_addr: Some(target.address),
526 preimage: None,
527 created_at_epoch_s: wallet::now_epoch_seconds(),
528 confirmed_at_epoch_s: None,
529 fee: fee_amount.map(|f| Amount {
530 value: f,
531 token: "sats".to_string(),
532 }),
533 };
534
535 let _ = self.store.append_transaction_record(&record);
536
537 Ok(SendResult {
538 wallet: id,
539 transaction_id: tx_id,
540 amount: Amount {
541 value: target.amount_sats,
542 token: "sats".to_string(),
543 },
544 fee: fee_amount.map(|f| Amount {
545 value: f,
546 token: "sats".to_string(),
547 }),
548 preimage: None,
549 })
550 }
551
552 async fn send_quote(
553 &self,
554 wallet_id: &str,
555 to: &str,
556 _mints: Option<&[String]>,
557 ) -> Result<SendQuoteInfo, PayError> {
558 let id = self.resolve_wallet_id(wallet_id)?;
559 let meta = self.load_btc_wallet(&id)?;
560 let target = parse_transfer_target(to)?;
561 if target.amount_sats == 0 {
562 return Err(PayError::InvalidAmount(
563 "amount must be greater than 0 sats".to_string(),
564 ));
565 }
566
567 let btc_net = btc_network_for_meta(&meta);
568 let recipient = Address::from_str(&target.address)
569 .map_err(|e| PayError::InvalidAmount(format!("invalid btc address: {e}")))?
570 .require_network(btc_net)
571 .map_err(|e| PayError::InvalidAmount(format!("address network mismatch: {e}")))?;
572
573 let mut bdk_wallet = open_bdk_wallet_with_dir(&self.data_dir, &meta)?;
574 Self::sync_wallet(&self.data_dir, &meta, &mut bdk_wallet).await?;
575
576 let mut tx_builder = bdk_wallet.build_tx();
577 tx_builder.add_recipient(
578 recipient.script_pubkey(),
579 BtcAmount::from_sat(target.amount_sats),
580 );
581
582 let mut psbt = tx_builder
583 .finish()
584 .map_err(|e| PayError::InternalError(format!("build tx quote: {e}")))?;
585
586 #[allow(deprecated)]
587 let finalized = bdk_wallet
588 .sign(&mut psbt, bdk_wallet::SignOptions::default())
589 .map_err(|e| PayError::InternalError(format!("sign tx quote: {e}")))?;
590 if !finalized {
591 return Err(PayError::InternalError(
592 "transaction quote signing did not finalize".to_string(),
593 ));
594 }
595
596 let tx = psbt
597 .extract_tx()
598 .map_err(|e| PayError::InternalError(format!("extract tx quote: {e}")))?;
599 let fee_estimate_native = bdk_wallet
600 .calculate_fee(&tx)
601 .map(|fee| fee.to_sat())
602 .unwrap_or(0);
603 persist_changeset(&self.data_dir, &meta, &mut bdk_wallet)?;
604
605 Ok(SendQuoteInfo {
606 wallet: id,
607 amount_native: target.amount_sats,
608 fee_estimate_native,
609 fee_unit: "sats".to_string(),
610 })
611 }
612
613 async fn history_list(
614 &self,
615 wallet_id: &str,
616 limit: usize,
617 offset: usize,
618 ) -> Result<Vec<HistoryRecord>, PayError> {
619 let id = self.resolve_wallet_id(wallet_id)?;
620 let _meta = self.load_btc_wallet(&id)?;
621 let all = self.store.load_wallet_transaction_records(&id)?;
622 let end = all.len().min(offset + limit);
623 let start = all.len().min(offset);
624 Ok(all[start..end].to_vec())
625 }
626
627 async fn history_status(&self, transaction_id: &str) -> Result<HistoryStatusInfo, PayError> {
628 match self.store.find_transaction_record_by_id(transaction_id)? {
629 Some(mut rec) => {
630 if let Some(chain_txid) = chain_txid_from_record(&rec) {
631 if let Ok(meta) = self.load_btc_wallet(&rec.wallet) {
632 let mut bdk_wallet = open_bdk_wallet_with_dir(&self.data_dir, &meta)?;
633 Self::sync_wallet(&self.data_dir, &meta, &mut bdk_wallet).await?;
634 if let Some(wallet_tx) = bdk_wallet.get_tx(chain_txid) {
635 let tip_height = bdk_wallet.latest_checkpoint().height();
636 let (status, confirmations) =
637 status_and_confirmations(wallet_tx.chain_position, tip_height);
638 let confirmed_at_epoch_s = if status == TxStatus::Confirmed {
639 Some(
640 rec.confirmed_at_epoch_s
641 .unwrap_or_else(wallet::now_epoch_seconds),
642 )
643 } else {
644 None
645 };
646
647 if rec.status != status
648 || rec.confirmed_at_epoch_s != confirmed_at_epoch_s
649 {
650 let _ = self.store.update_transaction_record_status(
651 &rec.transaction_id,
652 status,
653 confirmed_at_epoch_s,
654 );
655 rec.status = status;
656 rec.confirmed_at_epoch_s = confirmed_at_epoch_s;
657 }
658
659 return Ok(HistoryStatusInfo {
660 transaction_id: rec.transaction_id.clone(),
661 status: rec.status,
662 confirmations: Some(confirmations),
663 preimage: rec.preimage.clone(),
664 item: Some(rec),
665 });
666 }
667 }
668 }
669
670 Ok(HistoryStatusInfo {
671 transaction_id: rec.transaction_id.clone(),
672 status: rec.status,
673 confirmations: None,
674 preimage: rec.preimage.clone(),
675 item: Some(rec),
676 })
677 }
678 None => Err(PayError::WalletNotFound(format!(
679 "transaction {transaction_id} not found"
680 ))),
681 }
682 }
683
684 async fn history_sync(
685 &self,
686 wallet_id: &str,
687 limit: usize,
688 ) -> Result<HistorySyncStats, PayError> {
689 let id = self.resolve_wallet_id(wallet_id)?;
690 let meta = self.load_btc_wallet(&id)?;
691 let mut bdk_wallet = open_bdk_wallet_with_dir(&self.data_dir, &meta)?;
692 Self::sync_wallet(&self.data_dir, &meta, &mut bdk_wallet).await?;
693
694 let local_records = self.store.load_wallet_transaction_records(&id)?;
695 let mut local_by_chain_txid: HashMap<String, HistoryRecord> = HashMap::new();
696 for record in local_records {
697 if record.network != Network::Btc {
698 continue;
699 }
700 if let Some(chain_txid) = chain_txid_from_record(&record) {
701 local_by_chain_txid.insert(chain_txid.to_string(), record);
702 }
703 }
704
705 let mut wallet_txs: Vec<_> = bdk_wallet.transactions().collect();
706 wallet_txs.sort_by(|a, b| {
707 let b_ts = chain_position_epoch_s(b.chain_position);
708 let a_ts = chain_position_epoch_s(a.chain_position);
709 b_ts.cmp(&a_ts)
710 });
711
712 let mut stats = HistorySyncStats::default();
713 let scan_limit = limit.max(1);
714 let tip_height = bdk_wallet.latest_checkpoint().height();
715 for wallet_tx in wallet_txs.into_iter().take(scan_limit) {
716 stats.records_scanned = stats.records_scanned.saturating_add(1);
717 let chain_txid = wallet_tx.tx_node.txid.to_string();
718 let (status, _confirmations) =
719 status_and_confirmations(wallet_tx.chain_position, tip_height);
720 let created_at_epoch_s = chain_position_epoch_s(wallet_tx.chain_position);
721 let confirmed_at_epoch_s = if status == TxStatus::Confirmed {
722 Some(created_at_epoch_s)
723 } else {
724 None
725 };
726
727 if let Some(existing) = local_by_chain_txid.get(&chain_txid) {
728 if existing.status != status
729 || existing.confirmed_at_epoch_s != confirmed_at_epoch_s
730 {
731 let _ = self.store.update_transaction_record_status(
732 &existing.transaction_id,
733 status,
734 confirmed_at_epoch_s,
735 );
736 stats.records_updated = stats.records_updated.saturating_add(1);
737 }
738 continue;
739 }
740
741 let tx = &wallet_tx.tx_node.tx;
742 let (sent, received) = bdk_wallet.sent_and_received(tx);
743 let sent_sats = sent.to_sat();
744 let received_sats = received.to_sat();
745 let (direction, amount_sats) = if received_sats >= sent_sats {
746 (Direction::Receive, received_sats.saturating_sub(sent_sats))
747 } else {
748 (Direction::Send, sent_sats.saturating_sub(received_sats))
749 };
750 if amount_sats == 0 {
751 continue;
752 }
753
754 let fee = bdk_wallet.calculate_fee(tx).map(|f| f.to_sat()).ok();
755 let record = HistoryRecord {
756 transaction_id: chain_txid.clone(),
757 wallet: id.clone(),
758 network: Network::Btc,
759 direction,
760 amount: Amount {
761 value: amount_sats,
762 token: "sats".to_string(),
763 },
764 status,
765 onchain_memo: Some(chain_txid.clone()),
766 local_memo: None,
767 remote_addr: None,
768 preimage: None,
769 created_at_epoch_s,
770 confirmed_at_epoch_s,
771 fee: fee.map(|value| Amount {
772 value,
773 token: "sats".to_string(),
774 }),
775 };
776 let _ = self.store.append_transaction_record(&record);
777 local_by_chain_txid.insert(chain_txid, record);
778 stats.records_added = stats.records_added.saturating_add(1);
779 }
780
781 Ok(stats)
782 }
783}
784
785#[cfg(test)]
786mod tests {
787 use super::common::*;
788 use super::BtcProvider;
789 use crate::provider::{PayError, PayProvider};
790 use crate::store::StorageBackend;
791 use crate::types::{BtcBackend, WalletCreateRequest};
792 use bdk_wallet::bitcoin::Network as BtcNetwork;
793 use std::sync::Arc;
794
795 #[cfg(feature = "redb")]
796 fn test_store(data_dir: &str) -> Arc<StorageBackend> {
797 Arc::new(StorageBackend::Redb(
798 crate::store::redb_store::RedbStore::new(data_dir),
799 ))
800 }
801
802 #[test]
803 fn parse_transfer_target_bitcoin_uri() {
804 let target = parse_transfer_target("bitcoin:bc1qtest123?amount=50000").unwrap();
805 assert_eq!(target.address, "bc1qtest123");
806 assert_eq!(target.amount_sats, 50000);
807 }
808
809 #[test]
810 fn parse_transfer_target_bare_address() {
811 let target = parse_transfer_target("bc1qtest123?amount=1000").unwrap();
812 assert_eq!(target.address, "bc1qtest123");
813 assert_eq!(target.amount_sats, 1000);
814 }
815
816 #[test]
817 fn parse_transfer_target_no_amount_fails() {
818 let result = parse_transfer_target("bc1qtest123");
819 assert!(result.is_err());
820 }
821
822 #[test]
823 fn descriptors_from_mnemonic_taproot() {
824 let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
825 let (external, internal) =
826 descriptors_from_mnemonic(mnemonic, BtcNetwork::Bitcoin, "taproot").unwrap();
827 assert!(external.starts_with("tr("));
828 assert!(external.contains("/86'/0'/0'/0/*)"));
829 assert!(internal.starts_with("tr("));
830 assert!(internal.contains("/86'/0'/0'/1/*)"));
831 }
832
833 #[test]
834 fn descriptors_from_mnemonic_segwit() {
835 let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
836 let (external, internal) =
837 descriptors_from_mnemonic(mnemonic, BtcNetwork::Bitcoin, "segwit").unwrap();
838 assert!(external.starts_with("wpkh("));
839 assert!(external.contains("/84'/0'/0'/0/*)"));
840 assert!(internal.starts_with("wpkh("));
841 assert!(internal.contains("/84'/0'/0'/1/*)"));
842 }
843
844 #[test]
845 fn descriptors_from_mnemonic_signet_coin_type() {
846 let mnemonic = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
847 let (external, _) =
848 descriptors_from_mnemonic(mnemonic, BtcNetwork::Signet, "taproot").unwrap();
849 assert!(
850 external.contains("/86'/1'/0'/0/*)"),
851 "signet should use coin_type=1"
852 );
853 }
854
855 fn signet_request(label: &str) -> WalletCreateRequest {
856 WalletCreateRequest {
857 label: label.to_string(),
858 mint_url: None,
859 rpc_endpoints: vec![],
860 chain_id: None,
861 mnemonic_secret: None,
862 btc_esplora_url: None,
863 btc_network: Some("signet".to_string()),
864 btc_address_type: Some("taproot".to_string()),
865 btc_backend: Some(BtcBackend::Esplora),
866 btc_core_url: None,
867 btc_core_auth_secret: None,
868 btc_electrum_url: None,
869 }
870 }
871
872 #[tokio::test]
873 async fn create_wallet_rejects_empty_esplora_url() {
874 let tmp = tempfile::tempdir().unwrap();
875 let data_dir = tmp.path().to_str().unwrap();
876 let provider = BtcProvider::new(data_dir, test_store(data_dir));
877 let mut req = signet_request("bad-esplora");
878 req.btc_esplora_url = Some(" ".to_string());
879
880 let err = provider.create_wallet(&req).await.unwrap_err();
881 assert!(
882 matches!(err, PayError::InvalidAmount(_)),
883 "expected InvalidAmount, got: {err}"
884 );
885 }
886
887 #[cfg(not(feature = "btc-core"))]
888 #[tokio::test]
889 async fn create_wallet_rejects_core_rpc_when_feature_disabled() {
890 let tmp = tempfile::tempdir().unwrap();
891 let data_dir = tmp.path().to_str().unwrap();
892 let provider = BtcProvider::new(data_dir, test_store(data_dir));
893 let mut req = signet_request("core-disabled");
894 req.btc_backend = Some(BtcBackend::CoreRpc);
895 req.btc_core_url = Some("http://127.0.0.1:18443".to_string());
896
897 let err = provider.create_wallet(&req).await.unwrap_err();
898 assert!(
899 matches!(err, PayError::NotImplemented(_)),
900 "expected NotImplemented, got: {err}"
901 );
902 }
903
904 #[cfg(feature = "btc-core")]
905 #[tokio::test]
906 async fn create_wallet_core_rpc_requires_url() {
907 let tmp = tempfile::tempdir().unwrap();
908 let data_dir = tmp.path().to_str().unwrap();
909 let provider = BtcProvider::new(data_dir, test_store(data_dir));
910 let mut req = signet_request("core-needs-url");
911 req.btc_backend = Some(BtcBackend::CoreRpc);
912 req.btc_core_url = None;
913
914 let err = provider.create_wallet(&req).await.unwrap_err();
915 assert!(
916 matches!(err, PayError::InvalidAmount(_)),
917 "expected InvalidAmount, got: {err}"
918 );
919 }
920
921 #[cfg(feature = "btc-electrum")]
922 #[tokio::test]
923 async fn create_wallet_electrum_requires_url() {
924 let tmp = tempfile::tempdir().unwrap();
925 let data_dir = tmp.path().to_str().unwrap();
926 let provider = BtcProvider::new(data_dir, test_store(data_dir));
927 let mut req = signet_request("electrum-needs-url");
928 req.btc_backend = Some(BtcBackend::Electrum);
929 req.btc_electrum_url = None;
930
931 let err = provider.create_wallet(&req).await.unwrap_err();
932 assert!(
933 matches!(err, PayError::InvalidAmount(_)),
934 "expected InvalidAmount, got: {err}"
935 );
936 }
937
938 #[tokio::test]
939 async fn send_quote_rejects_invalid_address() {
940 let tmp = tempfile::tempdir().unwrap();
941 let data_dir = tmp.path().to_str().unwrap();
942 let provider = BtcProvider::new(data_dir, test_store(data_dir));
943 let wallet = provider
944 .create_wallet(&signet_request("send-quote-invalid"))
945 .await
946 .unwrap();
947
948 let err = provider
949 .send_quote(&wallet.id, "bitcoin:not-a-btc-address?amount=1000", None)
950 .await
951 .unwrap_err();
952 assert!(
953 matches!(err, PayError::InvalidAmount(_)),
954 "expected InvalidAmount, got: {err}"
955 );
956 }
957
958 #[cfg(feature = "btc-esplora")]
959 #[tokio::test]
960 async fn restore_wallet_runs_full_scan_and_cleans_up_on_failure() {
961 let tmp = tempfile::tempdir().unwrap();
962 let data_dir = tmp.path().to_str().unwrap();
963 let provider = BtcProvider::new(data_dir, test_store(data_dir));
964 let mut req = signet_request("restore-full-scan");
965 req.mnemonic_secret = Some(
966 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
967 .to_string(),
968 );
969 req.btc_esplora_url = Some("http://127.0.0.1:1".to_string());
971
972 let err = provider.create_wallet(&req).await.unwrap_err();
973 assert!(
974 matches!(err, PayError::NetworkError(_)),
975 "expected NetworkError from full_scan, got: {err}"
976 );
977 let wallets = provider.list_wallets().await.unwrap();
978 assert!(wallets.is_empty(), "failed restore should cleanup wallet");
979 }
980
981 #[cfg(feature = "btc-esplora")]
982 #[tokio::test]
983 async fn non_restore_create_skips_full_scan() {
984 let tmp = tempfile::tempdir().unwrap();
985 let data_dir = tmp.path().to_str().unwrap();
986 let provider = BtcProvider::new(data_dir, test_store(data_dir));
987 let mut req = signet_request("create-no-fullscan");
988 req.btc_esplora_url = Some("http://127.0.0.1:1".to_string());
989
990 let wallet = provider.create_wallet(&req).await.unwrap();
991 assert!(wallet.id.starts_with("w_"));
992 }
993}