Skip to main content

agent_first_pay/provider/btc/
mod.rs

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// ═══════════════════════════════════════════
24// BtcChainSource trait
25// ═══════════════════════════════════════════
26
27#[async_trait]
28pub(crate) trait BtcChainSource: Send + Sync {
29    /// Sync wallet with revealed SPKs (incremental).
30    async fn sync(&self, wallet: &mut Wallet) -> Result<(), PayError>;
31    /// Full scan (gap limit based).
32    async fn full_scan(&self, wallet: &mut Wallet) -> Result<(), PayError>;
33    /// Broadcast a signed transaction.
34    async fn broadcast(&self, tx: &Transaction) -> Result<(), PayError>;
35}
36
37// ═══════════════════════════════════════════
38// Chain source resolver
39// ═══════════════════════════════════════════
40
41fn 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
179// ═══════════════════════════════════════════
180// BtcProvider
181// ═══════════════════════════════════════════
182
183pub 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        // Broadcast via resolved chain source
505        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        // Force full_scan to fail fast so we can assert restore path is exercised.
970        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}