1#![doc = include_str!("../README.md")]
4
5use std::future::Future;
6use std::path::{Path, PathBuf};
7use std::pin::Pin;
8use std::str::FromStr;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::task::{Context, Poll};
12use std::time::{Duration, Instant};
13use std::{fmt, fs};
14
15use async_trait::async_trait;
16use bdk_wallet::bitcoin::Network;
17use bdk_wallet::keys::bip39::Mnemonic;
18use bdk_wallet::keys::{DerivableKey, ExtendedKey};
19use bdk_wallet::rusqlite::{Connection, OpenFlags};
20use bdk_wallet::template::Bip84;
21use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Update, Wallet};
22use cdk_common::amount::MSAT_IN_SAT;
23use cdk_common::common::FeeReserve;
24use cdk_common::database::KVStore;
25use cdk_common::nuts::nut30::MeltQuoteOnchainFeeOption;
26use cdk_common::payment::{
27 CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, MintPayment,
28 OnchainSettings, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse,
29 SettingsResponse, WaitPaymentResponse,
30};
31use cdk_common::{Amount, CurrencyUnit, MeltQuoteState};
32use futures::Stream;
33use tokio::sync::{Mutex, Notify};
34use tokio::task::JoinHandle;
35use tokio_stream::wrappers::BroadcastStream;
36use tokio_util::sync::CancellationToken;
37
38pub use crate::chain::{BitcoinRpcConfig, ChainSource, ElectrumConfig, EsploraConfig};
39pub use crate::error::Error;
40pub use crate::storage::{BdkStorage, FinalizedReceiveIntentRecord, FinalizedSendIntentRecord};
41pub use crate::types::{
42 BatchConfig, FeeEstimationConfig, PaymentMetadata, PaymentTier, SyncConfig,
43 DEFAULT_TARGET_BLOCK_TIME_SECS,
44};
45
46pub mod chain;
47pub mod error;
48pub(crate) mod fee;
49pub mod receive;
50pub(crate) mod recovery;
51pub mod send;
52pub mod storage;
53pub(crate) mod sync;
54pub mod types;
55pub(crate) mod util;
56pub mod wallet_info;
57
58pub use crate::wallet_info::{
59 WalletAddress, WalletBalance, WalletKeychain, WalletPage, WalletTransaction,
60 WalletTransactionInput, WalletTransactionOutput,
61};
62
63const MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS: usize = 100;
64
65pub fn validate_existing_wallet(
68 mnemonic: Mnemonic,
69 network: Network,
70 storage_dir_path: &Path,
71) -> Result<(), Error> {
72 let wallet_path = storage_dir_path.join("bdk_wallet/bdk_wallet.sqlite");
73 match fs::metadata(&wallet_path) {
74 Ok(metadata) if metadata.is_file() => {}
75 Ok(_) => return Err(Error::ExistingWalletNotInitialized { path: wallet_path }),
76 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
77 return Err(Error::ExistingWalletMissing { path: wallet_path });
78 }
79 Err(error) => return Err(Error::Io(error)),
80 }
81
82 let mut db = Connection::open_with_flags(&wallet_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
83 let has_wallet_table = db.query_row(
84 "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'bdk_wallet')",
85 [],
86 |row| row.get::<_, bool>(0),
87 )?;
88 if !has_wallet_table {
89 return Err(Error::ExistingWalletNotInitialized { path: wallet_path });
90 }
91
92 let changeset = {
93 let transaction = db.transaction()?;
94 ChangeSet::from_sqlite(&transaction)?
95 };
96
97 let xkey: ExtendedKey = mnemonic.into_extended_key()?;
98 let xprv = xkey.into_xprv(network.into()).ok_or(Error::Path)?;
99 let descriptor = Bip84(xprv, KeychainKind::External);
100 let change_descriptor = Bip84(xprv, KeychainKind::Internal);
101 let wallet = Wallet::load()
102 .descriptor(KeychainKind::External, Some(descriptor))
103 .descriptor(KeychainKind::Internal, Some(change_descriptor))
104 .extract_keys()
105 .check_network(network)
106 .load_wallet_no_persist(changeset)
107 .map_err(|e| Error::Wallet(e.to_string()))?;
108
109 match wallet {
110 Some(_) => Ok(()),
111 None => Err(Error::ExistingWalletNotInitialized { path: wallet_path }),
112 }
113}
114
115pub(crate) struct WalletWithDb {
117 pub(crate) wallet: PersistedWallet<Connection>,
118 pub(crate) db: Connection,
119}
120
121pub(crate) struct BackgroundTasks {
122 pub(crate) cancel: CancellationToken,
123 pub(crate) sync: JoinHandle<()>,
124 pub(crate) batch: JoinHandle<()>,
125}
126
127struct PaymentEventStream {
128 receiver: BroadcastStream<Event>,
129 cancel: Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
130 is_active: Arc<AtomicBool>,
131}
132
133impl Stream for PaymentEventStream {
134 type Item = Event;
135
136 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
137 let this = self.get_mut();
138
139 if this.cancel.as_mut().poll(cx).is_ready() {
140 this.is_active.store(false, Ordering::SeqCst);
141 return Poll::Ready(None);
142 }
143
144 loop {
145 match Pin::new(&mut this.receiver).poll_next(cx) {
146 Poll::Ready(Some(Ok(event))) => return Poll::Ready(Some(event)),
147 Poll::Ready(Some(Err(err))) => {
148 tracing::warn!(
149 "cdk-bdk payment event subscriber lagged or errored: {}",
150 err
151 );
152 }
153 Poll::Ready(None) => {
154 this.is_active.store(false, Ordering::SeqCst);
155 return Poll::Ready(None);
156 }
157 Poll::Pending => return Poll::Pending,
158 }
159 }
160 }
161}
162
163impl Drop for PaymentEventStream {
164 fn drop(&mut self) {
165 self.is_active.store(false, Ordering::SeqCst);
166 }
167}
168
169impl WalletWithDb {
170 pub(crate) fn new(wallet: PersistedWallet<Connection>, db: Connection) -> Self {
171 Self { wallet, db }
172 }
173
174 pub(crate) fn persist(&mut self) -> Result<bool, bdk_wallet::rusqlite::Error> {
175 self.wallet.persist(&mut self.db)
176 }
177}
178
179#[derive(Clone)]
181pub struct CdkBdk {
182 pub(crate) fee_reserve: FeeReserve,
183 pub(crate) wait_invoice_cancel_token: CancellationToken,
184 pub(crate) wait_invoice_is_active: Arc<AtomicBool>,
185 pub(crate) payment_sender: tokio::sync::broadcast::Sender<Event>,
186 pub(crate) tasks: Arc<Mutex<Option<BackgroundTasks>>>,
187 pub(crate) shutdown_timeout: Duration,
188 pub(crate) wallet_with_db: Arc<Mutex<WalletWithDb>>,
189 pub(crate) chain_source: ChainSource,
190 pub(crate) storage: BdkStorage,
191 pub(crate) network: Network,
192 pub(crate) batch_config: BatchConfig,
194 pub(crate) batch_notify: Arc<Notify>,
196 pub(crate) num_confs: u32,
198 pub(crate) min_receive_amount_sat: u64,
200 pub(crate) min_send_amount_sat: u64,
202 pub(crate) sync_interval_secs: u64,
204 pub(crate) sync_config: SyncConfig,
206 pub(crate) fee_rate_cache: Arc<Mutex<std::collections::HashMap<PaymentTier, (f64, u64)>>>,
208}
209
210impl CdkBdk {
211 fn outgoing_payment_failure_response(
212 unit: &CurrencyUnit,
213 quote_id: &cdk_common::QuoteId,
214 reason: impl fmt::Display,
215 ) -> MakePaymentResponse {
216 tracing::warn!(
217 quote_id = %quote_id,
218 "BDK rejected onchain payment before dispatch: {reason}"
219 );
220 MakePaymentResponse {
221 payment_lookup_id: PaymentIdentifier::QuoteId(quote_id.clone()),
222 payment_proof: None,
223 status: MeltQuoteState::Failed,
224 total_spent: Amount::new(0, unit.clone()),
225 }
226 }
227
228 fn ensure_supported_payment_unit(
229 unit: &CurrencyUnit,
230 ) -> Result<(), cdk_common::payment::Error> {
231 match unit {
232 CurrencyUnit::Sat | CurrencyUnit::Msat => Ok(()),
233 _ => Err(cdk_common::payment::Error::UnsupportedUnit),
234 }
235 }
236
237 fn ensure_amount_unit(unit: &CurrencyUnit, amount: &Amount<CurrencyUnit>) -> Result<(), Error> {
238 if amount.unit() != unit {
239 return Err(Error::AmountUnitMismatch {
240 expected: unit.clone(),
241 actual: amount.unit().clone(),
242 });
243 }
244
245 Ok(())
246 }
247
248 fn payment_amount_to_sat(
249 unit: &CurrencyUnit,
250 amount: &Amount<CurrencyUnit>,
251 ) -> Result<u64, Error> {
252 Self::ensure_amount_unit(unit, amount)?;
253
254 if unit == &CurrencyUnit::Msat && amount.value() % MSAT_IN_SAT != 0 {
255 return Err(Error::FractionalSatoshiAmount {
256 amount_msat: amount.value(),
257 });
258 }
259
260 amount.to_sat().map_err(Error::from)
261 }
262
263 fn fee_limit_to_sat(unit: &CurrencyUnit, amount: &Amount<CurrencyUnit>) -> Result<u64, Error> {
264 Self::ensure_amount_unit(unit, amount)?;
265 amount.to_sat().map_err(Error::from)
266 }
267
268 pub(crate) fn validate_send_amount_against_dust(
269 &self,
270 address: &str,
271 amount_sat: u64,
272 ) -> Result<(), Error> {
273 let address = bdk_wallet::bitcoin::Address::from_str(address)
274 .map_err(|e| Error::Wallet(e.to_string()))?
275 .require_network(self.network)
276 .map_err(|e| Error::Wallet(e.to_string()))?;
277
278 let dust_limit = bdk_wallet::bitcoin::TxOut::minimal_non_dust(address.script_pubkey())
279 .value
280 .to_sat();
281
282 if amount_sat < dust_limit {
283 return Err(Error::DustOutput {
284 amount: amount_sat,
285 dust_limit,
286 });
287 }
288
289 Ok(())
290 }
291
292 pub(crate) fn validate_send_amount(&self, address: &str, amount_sat: u64) -> Result<(), Error> {
293 self.validate_send_amount_against_dust(address, amount_sat)?;
294
295 if amount_sat < self.min_send_amount_sat {
296 return Err(Error::AmountBelowMinimumSend {
297 amount: amount_sat,
298 min: self.min_send_amount_sat,
299 });
300 }
301
302 Ok(())
303 }
304
305 pub(crate) fn confirmations_satisfied(&self, tip_height: u32, anchor_height: u32) -> bool {
306 if tip_height < anchor_height {
307 return false;
308 }
309
310 tip_height - anchor_height + 1 >= self.num_confs
311 }
312
313 pub(crate) fn should_ignore_receive_amount(&self, amount_sat: u64) -> bool {
314 amount_sat < self.min_receive_amount_sat
315 }
316
317 pub(crate) fn txid_has_required_confirmations(
320 &self,
321 wallet: &PersistedWallet<Connection>,
322 txid_str: &str,
323 intent_kind: &str,
324 intent_id: &str,
325 ) -> bool {
326 let Ok(parsed_txid) = bdk_wallet::bitcoin::Txid::from_str(txid_str) else {
327 tracing::warn!(
328 intent_kind,
329 intent_id,
330 txid = txid_str,
331 "Could not parse txid during confirmation check"
332 );
333 return false;
334 };
335
336 let Some(tx_details) = wallet.get_tx(parsed_txid) else {
337 return false;
338 };
339
340 let check_point = wallet.latest_checkpoint().height();
341 match &tx_details.chain_position {
342 bdk_wallet::chain::ChainPosition::Confirmed { anchor, .. } => {
343 self.confirmations_satisfied(check_point, anchor.block_id.height)
344 }
345 bdk_wallet::chain::ChainPosition::Unconfirmed { .. } => false,
346 }
347 }
348
349 #[allow(clippy::too_many_arguments)]
351 pub fn new(
352 mnemonic: Mnemonic,
353 network: Network,
354 chain_source: ChainSource,
355 storage_dir_path: String,
356 fee_reserve: FeeReserve,
357 kv_store: Arc<dyn KVStore<Err = cdk_common::database::Error> + Send + Sync>,
358 batch_config: Option<BatchConfig>,
359 num_confs: u32,
360 min_receive_amount_sat: u64,
361 min_send_amount_sat: u64,
362 sync_interval_secs: u64,
363 shutdown_timeout_secs: Option<u64>,
364 sync_config: Option<SyncConfig>,
365 ) -> Result<Self, Error> {
366 chain_source.validate()?;
367
368 let storage_dir_path = PathBuf::from(storage_dir_path);
369 let storage_dir_path = storage_dir_path.join("bdk_wallet");
370 fs::create_dir_all(&storage_dir_path)?;
371
372 let mut db = Connection::open(storage_dir_path.join("bdk_wallet.sqlite"))?;
373
374 let xkey: ExtendedKey = mnemonic.into_extended_key()?;
375 let xprv = xkey.into_xprv(network.into()).ok_or(Error::Path)?;
376
377 let descriptor = Bip84(xprv, KeychainKind::External);
378 let change_descriptor = Bip84(xprv, KeychainKind::Internal);
379
380 let wallet_opt = Wallet::load()
381 .descriptor(KeychainKind::External, Some(descriptor.clone()))
382 .descriptor(KeychainKind::Internal, Some(change_descriptor.clone()))
383 .extract_keys()
384 .check_network(network)
385 .load_wallet(&mut db)
386 .map_err(|e| Error::Wallet(e.to_string()))?;
387
388 let initial_checkpoint = match wallet_opt.is_none() {
394 true => chain_source.initial_checkpoint()?,
395 false => None,
396 };
397
398 let mut wallet = match wallet_opt {
399 Some(wallet) => wallet,
400 None => {
401 let mut wallet = Wallet::create(descriptor, change_descriptor)
402 .network(network)
403 .create_wallet(&mut db)
404 .map_err(|e| Error::Wallet(e.to_string()))?;
405
406 if let Some(block_id) = initial_checkpoint {
407 let checkpoint = wallet.latest_checkpoint().insert(block_id);
408 wallet
409 .apply_update(Update {
410 chain: Some(checkpoint),
411 ..Default::default()
412 })
413 .map_err(|e| Error::Wallet(e.to_string()))?;
414 }
415
416 wallet
417 }
418 };
419
420 wallet.persist(&mut db)?;
421
422 let wallet_with_db = WalletWithDb::new(wallet, db);
423
424 let batch_config = batch_config.unwrap_or_default();
425 if batch_config.poll_interval.is_zero() {
426 return Err(Error::InvalidConfig(
427 "batch_config.poll_interval must be greater than zero".to_string(),
428 ));
429 }
430 batch_config.validate().map_err(Error::InvalidConfig)?;
431
432 if sync_interval_secs == 0 {
433 return Err(Error::InvalidConfig(
434 "sync_interval_secs must be greater than zero".to_string(),
435 ));
436 }
437
438 let channel_capacity = batch_config.max_batch_size * 2 + 16;
439 let (payment_sender, _) = tokio::sync::broadcast::channel(channel_capacity);
440
441 Ok(Self {
442 fee_reserve,
443 wait_invoice_cancel_token: CancellationToken::new(),
444 wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
445 payment_sender,
446 tasks: Arc::new(Mutex::new(None)),
447 shutdown_timeout: Duration::from_secs(shutdown_timeout_secs.unwrap_or(30)),
448 wallet_with_db: Arc::new(Mutex::new(wallet_with_db)),
449 chain_source,
450 storage: BdkStorage::new(kv_store),
451 network,
452 batch_config,
453 batch_notify: Arc::new(Notify::new()),
454 num_confs,
455 min_receive_amount_sat,
456 min_send_amount_sat,
457 sync_interval_secs,
458 sync_config: sync_config.unwrap_or_default(),
459 fee_rate_cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
460 })
461 }
462}
463
464async fn supervise<F, Fut>(name: &'static str, cancel: CancellationToken, mut f: F)
472where
473 F: FnMut(CancellationToken) -> Fut,
474 Fut: Future<Output = Result<(), Error>>,
475{
476 const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
477 const MAX_BACKOFF: Duration = Duration::from_secs(60);
478 const SUPERVISOR_BACKOFF_RESET: Duration = Duration::from_secs(300);
479
480 let mut backoff = INITIAL_BACKOFF;
481
482 loop {
483 if cancel.is_cancelled() {
484 break;
485 }
486
487 let started = Instant::now();
488 let child_cancel = cancel.clone();
489
490 let result = tokio::select! {
491 _ = cancel.cancelled() => {
492 tracing::info!("{name} supervisor: cancelled");
493 return;
494 }
495 r = f(child_cancel) => r,
496 };
497
498 match result {
499 Ok(()) => {
500 tracing::info!("{name} supervisor: task exited cleanly");
501 return;
502 }
503 Err(e) => {
504 let ran_for = started.elapsed();
505 let transient = e.is_transient();
506 tracing::error!(
507 task = name,
508 ran_for_secs = ran_for.as_secs(),
509 transient,
510 "supervised task returned error: {e}; restarting with backoff"
511 );
512
513 if ran_for >= SUPERVISOR_BACKOFF_RESET {
514 backoff = INITIAL_BACKOFF;
515 }
516
517 tokio::select! {
519 _ = cancel.cancelled() => {
520 tracing::info!("{name} supervisor: cancelled during backoff");
521 return;
522 }
523 _ = tokio::time::sleep(backoff) => {}
524 }
525
526 backoff = (backoff * 2).min(MAX_BACKOFF);
527 }
528 }
529 }
530}
531
532#[async_trait]
533impl MintPayment for CdkBdk {
534 type Err = cdk_common::payment::Error;
535
536 #[tracing::instrument(skip_all)]
537 async fn start(&self) -> Result<(), Self::Err> {
538 let mut tasks_lock = self.tasks.lock().await;
539 if tasks_lock.is_some() {
540 return Err(Error::AlreadyStarted.into());
541 }
542
543 self.recover_receive_saga().await?;
544 self.recover_send_saga().await?;
545 self.storage.ensure_send_outpoint_quote_id_index().await?;
546
547 let cancel = CancellationToken::new();
548
549 let sync_self = self.clone();
550 let sync_cancel = cancel.clone();
551 let sync_handle = tokio::spawn(async move {
552 supervise("wallet sync", sync_cancel, move |cancel| {
553 let me = sync_self.clone();
554 async move { me.sync_wallet(cancel).await }
555 })
556 .await;
557 });
558
559 let batch_self = self.clone();
560 let batch_cancel = cancel.clone();
561 let batch_handle = tokio::spawn(async move {
562 supervise("batch processor", batch_cancel, move |cancel| {
563 let me = batch_self.clone();
564 async move { me.run_batch_processor(cancel).await }
565 })
566 .await;
567 });
568
569 *tasks_lock = Some(BackgroundTasks {
570 cancel,
571 sync: sync_handle,
572 batch: batch_handle,
573 });
574
575 Ok(())
576 }
577
578 async fn stop(&self) -> Result<(), Self::Err> {
579 self.wait_invoice_cancel_token.cancel();
580
581 let tasks_opt = {
582 let mut tasks_lock = self.tasks.lock().await;
583 tasks_lock.take()
584 };
585
586 if let Some(bg) = tasks_opt {
587 bg.cancel.cancel();
588
589 let sync_aborter = bg.sync.abort_handle();
590 let batch_aborter = bg.batch.abort_handle();
591
592 let joined = tokio::time::timeout(self.shutdown_timeout, async move {
593 let _ = bg.sync.await;
594 let _ = bg.batch.await;
595 })
596 .await;
597
598 if joined.is_err() {
599 sync_aborter.abort();
600 batch_aborter.abort();
601 tracing::error!(
602 "cdk-bdk background tasks did not exit within {:?}; forced abort",
603 self.shutdown_timeout
604 );
605 }
606 }
607
608 Ok(())
609 }
610
611 async fn get_settings(&self) -> Result<SettingsResponse, Self::Err> {
612 Ok(SettingsResponse {
613 unit: "sat".to_string(),
614 bolt11: None,
615 bolt12: None,
616 onchain: Some(OnchainSettings {
617 confirmations: self.num_confs,
618 min_receive_amount_sat: self.min_receive_amount_sat,
619 min_send_amount_sat: self.min_send_amount_sat,
620 }),
621 custom: std::collections::HashMap::new(),
622 })
623 }
624
625 async fn get_payment_quote(
626 &self,
627 unit: &CurrencyUnit,
628 options: OutgoingPaymentOptions,
629 ) -> Result<PaymentQuoteResponse, Self::Err> {
630 Self::ensure_supported_payment_unit(unit)?;
631
632 let onchain_options = match options {
633 OutgoingPaymentOptions::Onchain(o) => o,
634 _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
635 };
636
637 let amount_sat = Self::payment_amount_to_sat(unit, &onchain_options.amount)?;
638 self.validate_send_amount(&onchain_options.address, amount_sat)?;
639
640 let mut fee_options = Vec::with_capacity(self.batch_config.fee_options.len());
644 for (idx, tier) in self.batch_config.fee_options.iter().enumerate() {
645 let fee_estimate = self
646 .estimate_onchain_fee_reserve(&onchain_options.address, amount_sat, *tier)
647 .await?;
648 let fee_reserve = Amount::new(fee_estimate.fee_reserve_sat, CurrencyUnit::Sat)
649 .convert_to(unit)
650 .map_err(Error::AmountConversion)?;
651 fee_options.push(MeltQuoteOnchainFeeOption {
652 fee_index: idx as u32,
653 fee_reserve: fee_reserve.into(),
654 estimated_blocks: tier.estimated_blocks(),
655 });
656 }
657
658 let cheapest = fee_options
662 .iter()
663 .min_by_key(|option| u64::from(option.fee_reserve))
664 .copied()
665 .expect("fee_options is validated as non-empty");
666
667 Ok(PaymentQuoteResponse {
672 request_lookup_id: Some(PaymentIdentifier::QuoteId(onchain_options.quote_id.clone())),
673 amount: onchain_options.amount,
674 fee: Amount::new(cheapest.fee_reserve.into(), unit.clone()),
675 state: MeltQuoteState::Unpaid,
676 extra_json: None,
677 estimated_blocks: Some(cheapest.estimated_blocks),
678 fee_options: Some(fee_options),
679 })
680 }
681
682 async fn make_payment(
683 &self,
684 unit: &CurrencyUnit,
685 options: OutgoingPaymentOptions,
686 ) -> Result<MakePaymentResponse, Self::Err> {
687 let onchain_options = match options {
688 OutgoingPaymentOptions::Onchain(o) => o,
689 _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
690 };
691
692 let address = onchain_options.address;
693 let amount = onchain_options.amount;
694 let quote_id = onchain_options.quote_id;
695
696 let payment_lookup_id = PaymentIdentifier::QuoteId(quote_id.clone());
700 let pay_state = self.check_outgoing_payment(&payment_lookup_id).await?;
701 match pay_state.status {
702 MeltQuoteState::Paid | MeltQuoteState::Pending => {
703 let total_spent = pay_state
704 .total_spent
705 .convert_to(unit)
706 .map_err(Error::AmountConversion)?;
707 return Ok(MakePaymentResponse {
708 total_spent,
709 ..pay_state
710 });
711 }
712 MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => {}
713 }
714
715 if let Err(err) = Self::ensure_supported_payment_unit(unit) {
716 return Ok(Self::outgoing_payment_failure_response(
717 unit, "e_id, err,
718 ));
719 }
720
721 let amount_sat = match Self::payment_amount_to_sat(unit, &amount) {
722 Ok(amount_sat) => amount_sat,
723 Err(err) => {
724 return Ok(Self::outgoing_payment_failure_response(
725 unit, "e_id, err,
726 ));
727 }
728 };
729 if let Err(err) = self.validate_send_amount(&address, amount_sat) {
730 return Ok(Self::outgoing_payment_failure_response(
731 unit, "e_id, err,
732 ));
733 }
734
735 let max_fee_sat = match onchain_options.max_fee_amount {
736 Some(max_fee) => match Self::fee_limit_to_sat(unit, &max_fee) {
737 Ok(max_fee_sat) => max_fee_sat,
738 Err(err) => {
739 return Ok(Self::outgoing_payment_failure_response(
740 unit, "e_id, err,
741 ));
742 }
743 },
744 None => 1_000,
745 };
746 let tier = match self
750 .batch_config
751 .tier_for_fee_index(onchain_options.fee_index)
752 .map_err(Error::UnknownFeeIndex)
753 {
754 Ok(tier) => tier,
755 Err(err) => {
756 return Ok(Self::outgoing_payment_failure_response(
757 unit, "e_id, err,
758 ));
759 }
760 };
761 let metadata = PaymentMetadata::from_optional_json(onchain_options.metadata.as_deref());
762 let fee_estimate = match self
763 .estimate_onchain_fee_reserve(&address, amount_sat, tier)
764 .await
765 {
766 Ok(fee_estimate) => fee_estimate,
767 Err(err) => {
768 return Ok(Self::outgoing_payment_failure_response(
769 unit, "e_id, err,
770 ));
771 }
772 };
773 if fee_estimate.raw_fee_sat > max_fee_sat {
774 let err = Error::EstimatedFeeTooHigh {
775 estimated_fee: fee_estimate.raw_fee_sat,
776 max_fee: max_fee_sat,
777 };
778 return Ok(Self::outgoing_payment_failure_response(
779 unit, "e_id, err,
780 ));
781 }
782
783 crate::send::payment_intent::SendIntent::new(
784 &self.storage,
785 quote_id.to_string(),
786 address,
787 amount_sat,
788 max_fee_sat,
789 tier,
790 metadata,
791 )
792 .await?;
793
794 if tier == PaymentTier::Immediate {
795 self.batch_notify.notify_one();
796 }
797
798 Ok(MakePaymentResponse {
805 payment_lookup_id: PaymentIdentifier::QuoteId(quote_id),
806 payment_proof: None,
807 status: MeltQuoteState::Pending,
808 total_spent: Amount::new(0, unit.clone()),
809 })
810 }
811
812 async fn create_incoming_payment_request(
813 &self,
814 options: IncomingPaymentOptions,
815 ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
816 let onchain_options = match options {
817 IncomingPaymentOptions::Onchain(o) => o,
818 _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
819 };
820
821 let quote_id = onchain_options.quote_id;
822 let quote_id_string = quote_id.to_string();
823
824 let mut wallet_with_db = self.wallet_with_db.lock().await;
825
826 let address_str = 'reserve_address: {
830 for attempt in 1..=MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS {
831 let address = wallet_with_db
832 .wallet
833 .reveal_next_address(KeychainKind::External);
834 let candidate = address.address.to_string();
835
836 wallet_with_db.persist().map_err(|err| {
837 tracing::warn!("Could not persist to bdk db: {}", err);
838
839 Error::BdkPersist
840 })?;
841
842 if self
843 .storage
844 .track_receive_address(&candidate, "e_id_string)
845 .await?
846 {
847 break 'reserve_address candidate;
848 }
849
850 tracing::debug!(
851 quote_id = %quote_id,
852 attempt,
853 max_attempts = MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS,
854 "Receive address is already reserved for another quote"
855 );
856 }
857
858 return Err(Error::ReceiveAddressReservationExhausted {
859 attempts: MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS,
860 }
861 .into());
862 };
863
864 Ok(CreateIncomingPaymentResponse {
865 request_lookup_id: PaymentIdentifier::QuoteId(quote_id),
866 request: address_str,
867 expiry: None,
868 extra_json: None,
869 })
870 }
871
872 async fn wait_payment_event(
873 &self,
874 ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
875 self.wait_invoice_is_active.store(true, Ordering::SeqCst);
876
877 let receiver = self.payment_sender.subscribe();
878 let stream = PaymentEventStream {
879 receiver: BroadcastStream::new(receiver),
880 cancel: Box::pin(self.wait_invoice_cancel_token.clone().cancelled_owned()),
881 is_active: Arc::clone(&self.wait_invoice_is_active),
882 };
883
884 Ok(Box::pin(stream))
885 }
886
887 async fn check_incoming_payment_status(
888 &self,
889 payment_identifier: &PaymentIdentifier,
890 ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
891 let PaymentIdentifier::QuoteId(quote_id) = payment_identifier else {
892 return Err(Error::UnsupportedOnchain.into());
893 };
894
895 let quote_id_str = quote_id.to_string();
896 let mut results = Vec::new();
897
898 let finalized = self
901 .storage
902 .get_finalized_receive_intents_by_quote_id("e_id_str)
903 .await?;
904
905 for record in finalized {
906 results.push(WaitPaymentResponse {
907 payment_identifier: payment_identifier.clone(),
908 payment_amount: Amount::new(record.amount_sat, CurrencyUnit::Sat),
909 payment_id: record.outpoint,
910 });
911 }
912
913 Ok(results)
914 }
915
916 async fn check_outgoing_payment(
917 &self,
918 payment_identifier: &PaymentIdentifier,
919 ) -> Result<MakePaymentResponse, Self::Err> {
920 let quote_id = match payment_identifier {
921 PaymentIdentifier::QuoteId(id) => id.to_string(),
922 _ => return Err(Error::UnsupportedOnchain.into()),
923 };
924
925 if let Some(record) = self.storage.get_send_intent_by_quote_id("e_id).await? {
927 let total_spent = match &record.state {
933 crate::send::payment_intent::record::SendIntentState::Pending { .. }
934 | crate::send::payment_intent::record::SendIntentState::Batched { .. } => {
935 Amount::new(0, CurrencyUnit::Sat)
936 }
937 crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
938 fee_contribution_sat,
939 ..
940 } => Amount::new(record.amount_sat + fee_contribution_sat, CurrencyUnit::Sat),
941 crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
942 Amount::new(0, CurrencyUnit::Sat)
943 }
944 };
945 let status = match record.state {
946 crate::send::payment_intent::record::SendIntentState::Pending { .. }
947 | crate::send::payment_intent::record::SendIntentState::Batched { .. }
948 | crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
949 ..
950 } => MeltQuoteState::Pending,
951 crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
952 MeltQuoteState::Failed
953 }
954 };
955
956 return Ok(MakePaymentResponse {
957 payment_lookup_id: payment_identifier.clone(),
958 payment_proof: None,
959 status,
960 total_spent,
961 });
962 }
963
964 if let Some(record) = self
966 .storage
967 .get_finalized_intent_by_quote_id("e_id)
968 .await?
969 {
970 return Ok(MakePaymentResponse {
971 payment_lookup_id: payment_identifier.clone(),
972 payment_proof: Some(record.outpoint),
973 status: MeltQuoteState::Paid,
974 total_spent: Amount::new(record.total_spent_sat, CurrencyUnit::Sat),
975 });
976 }
977
978 Ok(MakePaymentResponse {
979 payment_lookup_id: payment_identifier.clone(),
980 payment_proof: None,
981 status: MeltQuoteState::Unknown,
982 total_spent: Amount::new(0, CurrencyUnit::Sat),
983 })
984 }
985
986 fn is_payment_event_stream_active(&self) -> bool {
987 self.wait_invoice_is_active.load(Ordering::SeqCst)
988 }
989
990 fn cancel_payment_event_stream(&self) {
991 self.wait_invoice_cancel_token.cancel();
992 }
993}
994
995#[cfg(test)]
996mod tests {
997 use std::fs;
998 use std::str::FromStr;
999
1000 use bdk_wallet::bitcoin::hashes::Hash as _;
1001 use bdk_wallet::bitcoin::{
1002 absolute, transaction, Network, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness,
1003 };
1004 use bdk_wallet::keys::bip39::Mnemonic;
1005 use cdk_common::common::FeeReserve;
1006 use cdk_common::payment::{MintPayment, OnchainIncomingPaymentOptions};
1007 use futures::StreamExt;
1008
1009 use super::*;
1010 use crate::fee::apply_quote_fee_safety;
1011
1012 const TEST_MNEMONIC: &str =
1013 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1014 const OTHER_TEST_MNEMONIC: &str =
1015 "legal winner thank year wave sausage worth useful legal winner thank yellow";
1016
1017 #[tokio::test]
1018 async fn existing_wallet_preflight_requires_matching_persisted_wallet() {
1019 let (backend, tempdir) = build_test_instance_with_tempdir(5).await;
1020 drop(backend);
1021
1022 let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic");
1023 validate_existing_wallet(mnemonic, Network::Regtest, tempdir.path())
1024 .expect("matching wallet should pass preflight");
1025
1026 let other_mnemonic = Mnemonic::from_str(OTHER_TEST_MNEMONIC).expect("other mnemonic");
1027 assert!(matches!(
1028 validate_existing_wallet(other_mnemonic, Network::Regtest, tempdir.path()),
1029 Err(Error::Wallet(_))
1030 ));
1031 assert!(matches!(
1032 validate_existing_wallet(
1033 Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic"),
1034 Network::Signet,
1035 tempdir.path(),
1036 ),
1037 Err(Error::Wallet(_))
1038 ));
1039 }
1040
1041 #[test]
1042 fn existing_wallet_preflight_does_not_create_missing_or_empty_wallet() {
1043 let tempdir = tempfile::tempdir().expect("tempdir");
1044 let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic");
1045 let wallet_path = tempdir.path().join("bdk_wallet/bdk_wallet.sqlite");
1046 assert!(matches!(
1047 validate_existing_wallet(mnemonic.clone(), Network::Regtest, tempdir.path()),
1048 Err(Error::ExistingWalletMissing { .. })
1049 ));
1050 assert!(!wallet_path.exists());
1051
1052 fs::create_dir_all(wallet_path.parent().expect("wallet directory"))
1053 .expect("create wallet directory");
1054 drop(Connection::open(&wallet_path).expect("create empty sqlite file"));
1055 assert!(matches!(
1056 validate_existing_wallet(mnemonic, Network::Regtest, tempdir.path()),
1057 Err(Error::ExistingWalletNotInitialized { .. })
1058 ));
1059 }
1060
1061 async fn build_test_instance(shutdown_timeout_secs: u64) -> CdkBdk {
1065 build_test_instance_with_tempdir(shutdown_timeout_secs)
1066 .await
1067 .0
1068 }
1069
1070 async fn build_test_instance_with_tempdir(
1071 shutdown_timeout_secs: u64,
1072 ) -> (CdkBdk, tempfile::TempDir) {
1073 build_test_instance_with_config(shutdown_timeout_secs, None, 60)
1074 .await
1075 .expect("build CdkBdk test instance")
1076 }
1077
1078 async fn build_test_instance_with_config(
1079 shutdown_timeout_secs: u64,
1080 batch_config: Option<BatchConfig>,
1081 sync_interval_secs: u64,
1082 ) -> Result<(CdkBdk, tempfile::TempDir), Error> {
1083 let chain_source = ChainSource::Esplora(EsploraConfig {
1084 url: "http://127.0.0.1:1".to_string(),
1085 parallel_requests: 1,
1086 });
1087
1088 build_test_instance_with_chain_source(
1089 shutdown_timeout_secs,
1090 batch_config,
1091 sync_interval_secs,
1092 chain_source,
1093 )
1094 .await
1095 }
1096
1097 async fn build_test_instance_with_chain_source(
1098 shutdown_timeout_secs: u64,
1099 batch_config: Option<BatchConfig>,
1100 sync_interval_secs: u64,
1101 chain_source: ChainSource,
1102 ) -> Result<(CdkBdk, tempfile::TempDir), Error> {
1103 let tmp = tempfile::tempdir().expect("tempdir");
1104 let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("mnemonic");
1105
1106 let kv = cdk_sqlite::mint::memory::empty()
1107 .await
1108 .expect("in-memory kv store");
1109
1110 let fee_reserve = FeeReserve {
1111 min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(),
1112 percent_fee_reserve: 0.02,
1113 };
1114
1115 let backend = CdkBdk::new(
1116 mnemonic,
1117 Network::Regtest,
1118 chain_source,
1119 tmp.path().to_string_lossy().into_owned(),
1120 fee_reserve,
1121 Arc::new(kv),
1122 batch_config,
1123 1,
1124 0,
1125 546,
1126 sync_interval_secs,
1127 Some(shutdown_timeout_secs),
1128 None,
1129 )?;
1130
1131 Ok((backend, tmp))
1132 }
1133
1134 async fn build_test_instance_with_shared_kv(
1138 kv: Arc<cdk_sqlite::mint::MintSqliteDatabase>,
1139 ) -> (CdkBdk, tempfile::TempDir) {
1140 let tmp = tempfile::tempdir().expect("tempdir");
1141 let mnemonic = Mnemonic::from_str(
1142 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
1143 )
1144 .expect("mnemonic");
1145 let chain_source = ChainSource::Esplora(EsploraConfig {
1146 url: "http://127.0.0.1:1".to_string(),
1147 parallel_requests: 1,
1148 });
1149 let fee_reserve = FeeReserve {
1150 min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(),
1151 percent_fee_reserve: 0.02,
1152 };
1153
1154 let backend = CdkBdk::new(
1155 mnemonic,
1156 Network::Regtest,
1157 chain_source,
1158 tmp.path().to_string_lossy().into_owned(),
1159 fee_reserve,
1160 kv,
1161 None,
1162 1,
1163 0,
1164 546,
1165 60,
1166 Some(5),
1167 None,
1168 )
1169 .expect("build CdkBdk test instance");
1170
1171 (backend, tmp)
1172 }
1173
1174 #[tokio::test]
1175 async fn instances_sharing_seed_and_kv_never_share_a_receive_address() {
1176 let kv = Arc::new(
1177 cdk_sqlite::mint::memory::empty()
1178 .await
1179 .expect("in-memory kv store"),
1180 );
1181 let (first, _tmp_first) = build_test_instance_with_shared_kv(kv.clone()).await;
1182 let (second, _tmp_second) = build_test_instance_with_shared_kv(kv).await;
1183
1184 let first_request = first
1185 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1186 OnchainIncomingPaymentOptions {
1187 quote_id: cdk_common::QuoteId::new(),
1188 },
1189 ))
1190 .await
1191 .expect("first receive request");
1192 let second_request = second
1193 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1194 OnchainIncomingPaymentOptions {
1195 quote_id: cdk_common::QuoteId::new(),
1196 },
1197 ))
1198 .await
1199 .expect("second receive request");
1200
1201 assert_ne!(
1202 first_request.request, second_request.request,
1203 "each instance must hand out a distinct receive address"
1204 );
1205 }
1206
1207 #[tokio::test]
1208 async fn operator_deposit_address_is_not_associated_with_a_quote() {
1209 let kv = Arc::new(
1210 cdk_sqlite::mint::memory::empty()
1211 .await
1212 .expect("in-memory kv store"),
1213 );
1214 let (backend, _tmp) = build_test_instance_with_shared_kv(kv).await;
1215
1216 let operator_address = backend
1217 .create_operator_deposit_address()
1218 .await
1219 .expect("create operator deposit address");
1220 let quote_request = backend
1221 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1222 OnchainIncomingPaymentOptions {
1223 quote_id: cdk_common::QuoteId::new(),
1224 },
1225 ))
1226 .await
1227 .expect("create quote receive request");
1228
1229 assert_ne!(operator_address, quote_request.request);
1230 assert!(backend
1231 .storage
1232 .get_quote_id_by_receive_address(&operator_address)
1233 .await
1234 .expect("look up operator address")
1235 .is_none());
1236 assert!(!backend
1237 .storage
1238 .get_tracked_receive_addresses()
1239 .await
1240 .expect("list quote receive addresses")
1241 .contains(&operator_address));
1242 }
1243
1244 #[tokio::test]
1245 async fn receive_address_reservation_stops_after_attempt_limit() {
1246 let kv = Arc::new(
1247 cdk_sqlite::mint::memory::empty()
1248 .await
1249 .expect("in-memory kv store"),
1250 );
1251 let (first, _tmp_first) = build_test_instance_with_shared_kv(kv.clone()).await;
1252 let (second, _tmp_second) = build_test_instance_with_shared_kv(kv).await;
1253
1254 for _ in 0..MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS {
1255 first
1256 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1257 OnchainIncomingPaymentOptions {
1258 quote_id: cdk_common::QuoteId::new(),
1259 },
1260 ))
1261 .await
1262 .expect("reserve receive address");
1263 }
1264
1265 let err = second
1266 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1267 OnchainIncomingPaymentOptions {
1268 quote_id: cdk_common::QuoteId::new(),
1269 },
1270 ))
1271 .await
1272 .expect_err("reservation should stop after the attempt limit");
1273
1274 let cdk_common::payment::Error::Onchain(inner) = err else {
1275 panic!("expected onchain error");
1276 };
1277 assert!(matches!(
1278 inner.downcast_ref::<Error>(),
1279 Some(Error::ReceiveAddressReservationExhausted { attempts })
1280 if *attempts == MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS
1281 ));
1282 }
1283
1284 #[tokio::test]
1285 async fn wallet_info_lists_revealed_addresses_without_revealing_more() {
1286 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1287
1288 let initial_addresses = backend
1289 .wallet_addresses(0, 100)
1290 .await
1291 .expect("list initial addresses");
1292 assert_eq!(initial_addresses.total, 0);
1293
1294 backend
1295 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1296 OnchainIncomingPaymentOptions {
1297 quote_id: cdk_common::QuoteId::new(),
1298 },
1299 ))
1300 .await
1301 .expect("create on-chain request");
1302
1303 let addresses = backend
1304 .wallet_addresses(0, 100)
1305 .await
1306 .expect("list revealed addresses");
1307 assert_eq!(addresses.total, 1);
1308 assert_eq!(addresses.items.len(), 1);
1309 assert_eq!(addresses.items[0].keychain, WalletKeychain::External);
1310 assert_eq!(addresses.items[0].derivation_index, 0);
1311 assert!(!addresses.items[0].used);
1312 assert_eq!(addresses.items[0].balance_sat, 0);
1313
1314 let balance = backend.wallet_balance().await;
1315 assert_eq!(balance.total_sat, 0);
1316 assert_eq!(
1317 backend
1318 .wallet_transactions(0, 20)
1319 .await
1320 .expect("list transactions")
1321 .total,
1322 0
1323 );
1324
1325 let addresses_again = backend
1326 .wallet_addresses(0, 100)
1327 .await
1328 .expect("list revealed addresses again");
1329 assert_eq!(addresses_again.total, 1);
1330 }
1331
1332 #[tokio::test]
1333 async fn wallet_info_paginates_revealed_addresses_across_keychains() {
1334 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1335
1336 {
1337 let mut wallet_with_db = backend.wallet_with_db.lock().await;
1338 let _ = wallet_with_db
1339 .wallet
1340 .reveal_addresses_to(KeychainKind::External, 1)
1341 .count();
1342 let _ = wallet_with_db
1343 .wallet
1344 .reveal_addresses_to(KeychainKind::Internal, 1)
1345 .count();
1346 wallet_with_db
1347 .persist()
1348 .expect("persist revealed addresses");
1349 }
1350
1351 let page = backend
1352 .wallet_addresses(1, 2)
1353 .await
1354 .expect("list paginated addresses");
1355
1356 assert_eq!(page.total, 4);
1357 assert_eq!(page.items.len(), 2);
1358 assert_eq!(page.items[0].keychain, WalletKeychain::External);
1359 assert_eq!(page.items[0].derivation_index, 1);
1360 assert_eq!(page.items[1].keychain, WalletKeychain::Internal);
1361 assert_eq!(page.items[1].derivation_index, 0);
1362 }
1363
1364 async fn fund_backend_wallet_transactions(backend: &CdkBdk, amounts_sat: &[u64]) -> Vec<Txid> {
1365 let mut wallet_with_db = backend.wallet_with_db.lock().await;
1366 let funding_script = wallet_with_db
1367 .wallet
1368 .reveal_next_address(KeychainKind::External)
1369 .address
1370 .script_pubkey();
1371 let funding_transactions = amounts_sat
1372 .iter()
1373 .enumerate()
1374 .map(|(index, amount_sat)| Transaction {
1375 version: transaction::Version::TWO,
1376 lock_time: absolute::LockTime::ZERO,
1377 input: vec![TxIn {
1378 previous_output: OutPoint::new(
1379 Txid::all_zeros(),
1380 u32::try_from(index).expect("test transaction index fits in u32"),
1381 ),
1382 script_sig: Default::default(),
1383 sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1384 witness: Witness::new(),
1385 }],
1386 output: vec![TxOut {
1387 value: bdk_wallet::bitcoin::Amount::from_sat(*amount_sat),
1388 script_pubkey: funding_script.clone(),
1389 }],
1390 })
1391 .collect::<Vec<_>>();
1392 let txids = funding_transactions
1393 .iter()
1394 .map(Transaction::compute_txid)
1395 .collect();
1396
1397 wallet_with_db
1398 .wallet
1399 .apply_unconfirmed_txs(funding_transactions.into_iter().map(|tx| (tx, 0)));
1400 wallet_with_db.persist().expect("persist funded wallet");
1401
1402 txids
1403 }
1404
1405 async fn fund_backend_wallet(backend: &CdkBdk, amount_sat: u64) {
1406 fund_backend_wallet_transactions(backend, &[amount_sat]).await;
1407 }
1408
1409 #[tokio::test]
1410 async fn wallet_info_reports_unconfirmed_funding() {
1411 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1412 fund_backend_wallet(&backend, 42_000).await;
1413
1414 let balance = backend.wallet_balance().await;
1415 assert_eq!(balance.untrusted_pending_sat, 42_000);
1416 assert_eq!(balance.total_sat, 42_000);
1417
1418 let transactions = backend
1419 .wallet_transactions(0, 20)
1420 .await
1421 .expect("list transactions");
1422 assert_eq!(transactions.total, 1);
1423 assert_eq!(transactions.items[0].received_sat, 42_000);
1424 assert_eq!(transactions.items[0].sent_sat, 0);
1425 assert_eq!(transactions.items[0].balance_delta_sat, 42_000);
1426 assert_eq!(transactions.items[0].confirmation_height, None);
1427 assert_eq!(transactions.items[0].first_seen, Some(0));
1428 assert_eq!(
1429 transactions.items[0].inputs,
1430 vec![WalletTransactionInput {
1431 txid: Txid::all_zeros().to_string(),
1432 vout: 0,
1433 amount_sat: None,
1434 address: None,
1435 }]
1436 );
1437
1438 let addresses = backend
1439 .wallet_addresses(0, 20)
1440 .await
1441 .expect("list addresses");
1442 assert_eq!(addresses.total, 1);
1443 assert!(addresses.items[0].used);
1444 assert_eq!(
1445 transactions.items[0].outputs,
1446 vec![WalletTransactionOutput {
1447 vout: 0,
1448 address: addresses.items[0].address.clone(),
1449 amount_sat: 42_000,
1450 quote_id: None,
1451 }]
1452 );
1453 assert_eq!(addresses.items[0].balance_sat, 42_000);
1454 assert_eq!(addresses.items[0].confirmed_balance_sat, 0);
1455
1456 let empty_page = backend
1457 .wallet_transactions(0, 0)
1458 .await
1459 .expect("list empty transaction page");
1460 assert_eq!(empty_page.total, 1);
1461 assert!(empty_page.items.is_empty());
1462 }
1463
1464 #[tokio::test]
1465 async fn wallet_info_pairs_unconfirmed_incoming_output_with_quote_id() {
1466 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1467 fund_backend_wallet(&backend, 42_000).await;
1468 let address = backend
1469 .wallet_addresses(0, 20)
1470 .await
1471 .expect("list addresses")
1472 .items[0]
1473 .address
1474 .clone();
1475 backend
1476 .storage
1477 .track_receive_address(&address, "mint-quote")
1478 .await
1479 .expect("track receive address");
1480
1481 let transactions = backend
1482 .wallet_transactions(0, 20)
1483 .await
1484 .expect("list transactions");
1485 assert_eq!(
1486 transactions.items[0].outputs,
1487 vec![WalletTransactionOutput {
1488 vout: 0,
1489 address,
1490 amount_sat: 42_000,
1491 quote_id: Some("mint-quote".to_string()),
1492 }]
1493 );
1494 }
1495
1496 #[tokio::test]
1497 async fn wallet_info_pairs_batched_outputs_with_quote_ids() {
1498 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1499 let funding_txids = fund_backend_wallet_transactions(&backend, &[20_000, 22_000]).await;
1500 let funding_address = backend
1501 .wallet_addresses(0, 20)
1502 .await
1503 .expect("list funding address")
1504 .items[0]
1505 .address
1506 .clone();
1507 let first_address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string();
1508 let second_address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x".to_string();
1509
1510 let mut wallet_with_db = backend.wallet_with_db.lock().await;
1511 let first_recipient_script = bdk_wallet::bitcoin::Address::from_str(&first_address)
1512 .expect("valid address")
1513 .require_network(Network::Regtest)
1514 .expect("regtest address")
1515 .script_pubkey();
1516 let second_recipient_script = bdk_wallet::bitcoin::Address::from_str(&second_address)
1517 .expect("valid address")
1518 .require_network(Network::Regtest)
1519 .expect("regtest address")
1520 .script_pubkey();
1521 let change_script = wallet_with_db
1522 .wallet
1523 .reveal_next_address(KeychainKind::Internal)
1524 .address
1525 .script_pubkey();
1526 let spending_transaction = Transaction {
1527 version: transaction::Version::TWO,
1528 lock_time: absolute::LockTime::ZERO,
1529 input: funding_txids
1530 .iter()
1531 .rev()
1532 .map(|txid| TxIn {
1533 previous_output: OutPoint::new(*txid, 0),
1534 script_sig: Default::default(),
1535 sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1536 witness: Witness::new(),
1537 })
1538 .collect(),
1539 output: vec![
1540 TxOut {
1541 value: bdk_wallet::bitcoin::Amount::from_sat(20_000),
1542 script_pubkey: first_recipient_script,
1543 },
1544 TxOut {
1545 value: bdk_wallet::bitcoin::Amount::from_sat(10_000),
1546 script_pubkey: second_recipient_script,
1547 },
1548 TxOut {
1549 value: bdk_wallet::bitcoin::Amount::from_sat(11_900),
1550 script_pubkey: change_script,
1551 },
1552 ],
1553 };
1554 let spending_txid = spending_transaction.compute_txid();
1555 wallet_with_db
1556 .wallet
1557 .apply_unconfirmed_txs([(spending_transaction, 1)]);
1558 wallet_with_db
1559 .persist()
1560 .expect("persist spending transaction");
1561 drop(wallet_with_db);
1562
1563 let batch_id = Uuid::new_v4();
1564 let intents = [
1565 (Uuid::new_v4(), "quote-first", &first_address, 20_000, 0),
1566 (Uuid::new_v4(), "quote-second", &second_address, 10_000, 1),
1567 ];
1568 for (intent_id, quote_id, address, amount_sat, vout) in &intents {
1569 backend
1570 .storage
1571 .create_send_intent_if_absent(
1572 &crate::send::payment_intent::record::SendIntentRecord {
1573 intent_id: *intent_id,
1574 quote_id: quote_id.to_string(),
1575 address: address.to_string(),
1576 amount_sat: *amount_sat,
1577 max_fee_amount_sat: 1_000,
1578 tier: PaymentTier::Immediate,
1579 metadata: PaymentMetadata::default(),
1580 state: crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
1581 batch_id,
1582 txid: spending_txid.to_string(),
1583 outpoint: format!("{spending_txid}:{vout}"),
1584 fee_contribution_sat: 50,
1585 created_at: 0,
1586 },
1587 },
1588 )
1589 .await
1590 .expect("store send intent");
1591 }
1592
1593 let transactions = backend
1594 .wallet_transactions(0, 20)
1595 .await
1596 .expect("list transactions");
1597
1598 assert_eq!(transactions.total, 3);
1599 assert_eq!(
1600 transactions.items[0].inputs,
1601 vec![
1602 WalletTransactionInput {
1603 txid: funding_txids[1].to_string(),
1604 vout: 0,
1605 amount_sat: Some(22_000),
1606 address: Some(funding_address.clone()),
1607 },
1608 WalletTransactionInput {
1609 txid: funding_txids[0].to_string(),
1610 vout: 0,
1611 amount_sat: Some(20_000),
1612 address: Some(funding_address),
1613 },
1614 ]
1615 );
1616 assert_eq!(
1617 transactions.items[0].outputs,
1618 vec![
1619 WalletTransactionOutput {
1620 vout: 0,
1621 address: first_address.clone(),
1622 amount_sat: 20_000,
1623 quote_id: Some("quote-first".to_string()),
1624 },
1625 WalletTransactionOutput {
1626 vout: 1,
1627 address: second_address.clone(),
1628 amount_sat: 10_000,
1629 quote_id: Some("quote-second".to_string()),
1630 },
1631 ]
1632 );
1633 assert_eq!(transactions.items[0].sent_sat, 42_000);
1634 assert_eq!(transactions.items[0].received_sat, 11_900);
1635
1636 for (intent_id, quote_id, _, amount_sat, vout) in &intents {
1637 backend
1638 .storage
1639 .finalize_send_intent(
1640 intent_id,
1641 &FinalizedSendIntentRecord {
1642 intent_id: *intent_id,
1643 quote_id: quote_id.to_string(),
1644 total_spent_sat: *amount_sat + 50,
1645 outpoint: format!("{spending_txid}:{vout}"),
1646 finalized_at: 0,
1647 },
1648 )
1649 .await
1650 .expect("finalize send intent");
1651 }
1652
1653 let finalized_transactions = backend
1654 .wallet_transactions(0, 20)
1655 .await
1656 .expect("list transactions after intent finalization");
1657 assert_eq!(
1658 finalized_transactions.items[0]
1659 .outputs
1660 .iter()
1661 .map(|output| output.quote_id.as_deref())
1662 .collect::<Vec<_>>(),
1663 vec![Some("quote-first"), Some("quote-second")]
1664 );
1665 }
1666
1667 #[tokio::test]
1668 async fn wallet_info_reports_multiple_incoming_outputs_in_vout_order() {
1669 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1670 let mut wallet_with_db = backend.wallet_with_db.lock().await;
1671 let output_script = wallet_with_db
1672 .wallet
1673 .reveal_next_address(KeychainKind::External)
1674 .address
1675 .script_pubkey();
1676 let funding_transaction = Transaction {
1677 version: transaction::Version::TWO,
1678 lock_time: absolute::LockTime::ZERO,
1679 input: vec![TxIn {
1680 previous_output: OutPoint::new(Txid::all_zeros(), 0),
1681 script_sig: Default::default(),
1682 sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1683 witness: Witness::new(),
1684 }],
1685 output: vec![
1686 TxOut {
1687 value: bdk_wallet::bitcoin::Amount::from_sat(21_000),
1688 script_pubkey: output_script.clone(),
1689 },
1690 TxOut {
1691 value: bdk_wallet::bitcoin::Amount::from_sat(21_000),
1692 script_pubkey: output_script,
1693 },
1694 ],
1695 };
1696 wallet_with_db
1697 .wallet
1698 .apply_unconfirmed_txs([(funding_transaction, 0)]);
1699 wallet_with_db
1700 .persist()
1701 .expect("persist funding transaction");
1702 drop(wallet_with_db);
1703
1704 let transactions = backend
1705 .wallet_transactions(0, 20)
1706 .await
1707 .expect("list transactions");
1708
1709 assert_eq!(transactions.total, 1);
1710 assert_eq!(transactions.items[0].outputs.len(), 2);
1711 assert_eq!(transactions.items[0].outputs[0].vout, 0);
1712 assert_eq!(transactions.items[0].outputs[0].amount_sat, 21_000);
1713 assert_eq!(transactions.items[0].outputs[1].vout, 1);
1714 assert_eq!(transactions.items[0].outputs[1].amount_sat, 21_000);
1715 }
1716
1717 #[tokio::test]
1718 async fn wallet_info_uses_txid_to_order_equal_chain_positions() {
1719 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1720 let mut expected_txids =
1721 fund_backend_wallet_transactions(&backend, &[21_000, 42_000]).await;
1722 expected_txids.sort_by(|left, right| right.cmp(left));
1723
1724 let first_page = backend
1725 .wallet_transactions(0, 1)
1726 .await
1727 .expect("list first transaction page");
1728 let second_page = backend
1729 .wallet_transactions(1, 1)
1730 .await
1731 .expect("list second transaction page");
1732
1733 assert_eq!(first_page.total, 2);
1734 assert_eq!(second_page.total, 2);
1735 assert_eq!(first_page.items[0].txid, expected_txids[0].to_string());
1736 assert_eq!(second_page.items[0].txid, expected_txids[1].to_string());
1737 }
1738
1739 #[tokio::test]
1740 async fn test_new_rejects_zero_sync_interval() {
1741 match build_test_instance_with_config(5, None, 0).await {
1742 Err(Error::InvalidConfig(message)) => {
1743 assert!(message.contains("sync_interval_secs"));
1744 }
1745 Ok(_) => panic!("zero sync interval should be rejected"),
1746 Err(err) => panic!("expected invalid config error, got {err}"),
1747 }
1748 }
1749
1750 #[tokio::test]
1751 async fn test_new_rejects_zero_batch_poll_interval() {
1752 let batch_config = BatchConfig {
1753 poll_interval: Duration::ZERO,
1754 ..BatchConfig::default()
1755 };
1756
1757 match build_test_instance_with_config(5, Some(batch_config), 60).await {
1758 Err(Error::InvalidConfig(message)) => {
1759 assert!(message.contains("poll_interval"));
1760 }
1761 Ok(_) => panic!("zero batch poll interval should be rejected"),
1762 Err(err) => panic!("expected invalid config error, got {err}"),
1763 }
1764 }
1765
1766 #[tokio::test]
1767 async fn test_new_rejects_zero_target_block_time() {
1768 let batch_config = BatchConfig {
1769 target_block_time: Duration::ZERO,
1770 ..BatchConfig::default()
1771 };
1772
1773 match build_test_instance_with_config(5, Some(batch_config), 60).await {
1774 Err(Error::InvalidConfig(message)) => {
1775 assert!(message.contains("target_block_time"));
1776 }
1777 Ok(_) => panic!("zero target block time should be rejected"),
1778 Err(err) => panic!("expected invalid config error, got {err}"),
1779 }
1780 }
1781
1782 #[tokio::test]
1783 async fn test_new_rejects_invalid_fallback_fee_rate() {
1784 let batch_config = BatchConfig {
1785 fee_estimation: FeeEstimationConfig {
1786 fallback_sat_per_vb: 0.0,
1787 ..FeeEstimationConfig::default()
1788 },
1789 ..BatchConfig::default()
1790 };
1791
1792 match build_test_instance_with_config(5, Some(batch_config), 60).await {
1793 Err(Error::InvalidConfig(message)) => {
1794 assert!(message.contains("fallback_sat_per_vb"));
1795 }
1796 Ok(_) => panic!("invalid fallback fee rate should be rejected"),
1797 Err(err) => panic!("expected invalid config error, got {err}"),
1798 }
1799 }
1800
1801 #[test]
1802 fn test_default_batch_deadlines_match_advertised_blocks() {
1803 let batch_config = BatchConfig::default();
1804
1805 assert_eq!(batch_config.target_block_time, Duration::from_secs(600));
1806 assert_eq!(batch_config.standard_deadline, Duration::from_secs(3600));
1807 assert_eq!(batch_config.economy_deadline, Duration::from_secs(86_400));
1808 assert_eq!(
1809 batch_config.max_intent_age,
1810 Some(Duration::from_secs(86_430))
1811 );
1812 }
1813
1814 #[tokio::test]
1815 async fn test_start_then_stop_exits_promptly() {
1816 let backend = build_test_instance(5).await;
1817
1818 let started = tokio::time::timeout(Duration::from_secs(10), backend.start())
1819 .await
1820 .expect("start timed out");
1821 started.expect("start should succeed");
1822
1823 let stopped = tokio::time::timeout(Duration::from_secs(10), backend.stop())
1824 .await
1825 .expect("stop timed out");
1826 stopped.expect("stop should succeed");
1827 }
1828
1829 #[tokio::test]
1830 async fn test_double_start_returns_already_started() {
1831 let backend = build_test_instance(5).await;
1832 backend.start().await.expect("first start");
1833
1834 let second = backend.start().await;
1835 assert!(second.is_err(), "second start should error");
1836
1837 backend.stop().await.expect("stop");
1838 }
1839
1840 #[tokio::test]
1841 async fn test_stop_without_start_is_ok() {
1842 let backend = build_test_instance(5).await;
1843 backend.stop().await.expect("stop on never-started is ok");
1844 backend.stop().await.expect("double stop is ok");
1845 }
1846
1847 #[tokio::test]
1848 async fn test_restart_after_stop() {
1849 let backend = build_test_instance(5).await;
1850 backend.start().await.expect("first start");
1851 backend.stop().await.expect("first stop");
1852 backend.start().await.expect("second start");
1853 backend.stop().await.expect("second stop");
1854 }
1855
1856 #[tokio::test]
1857 async fn test_wait_payment_event_tracks_active_state_and_cancels() {
1858 let backend = build_test_instance(5).await;
1859 assert!(!backend.is_payment_event_stream_active());
1860
1861 let mut stream = backend
1862 .wait_payment_event()
1863 .await
1864 .expect("payment event stream");
1865 assert!(backend.is_payment_event_stream_active());
1866
1867 backend.cancel_payment_event_stream();
1868
1869 let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
1870 .await
1871 .expect("stream should observe cancellation promptly");
1872 assert!(next.is_none());
1873 assert!(!backend.is_payment_event_stream_active());
1874 }
1875
1876 #[test]
1877 fn test_quote_fee_safety_adds_multiplier_and_fixed_margin() {
1878 let config = FeeEstimationConfig {
1879 quote_safety_multiplier: 1.25,
1880 quote_fixed_safety_sat: 500,
1881 ..FeeEstimationConfig::default()
1882 };
1883
1884 assert_eq!(apply_quote_fee_safety(1_000, &config), 1_750);
1885 }
1886
1887 #[tokio::test]
1888 async fn test_fee_rate_cache_falls_back_on_error() {
1889 let backend = build_test_instance(5).await;
1894
1895 let tier_err = backend
1896 .estimate_fee_rate_sat_per_vb(PaymentTier::Immediate)
1897 .await;
1898 assert!(
1899 tier_err.is_err(),
1900 "fee rate estimation should fail against bogus Esplora URL"
1901 );
1902 }
1903
1904 #[tokio::test]
1905 async fn test_get_payment_quote_does_not_stage_wallet_changes() {
1906 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1907 fund_backend_wallet(&backend, 100_000).await;
1908 let (_quote_id, options) = onchain_options_for(10_000);
1909
1910 backend
1911 .get_payment_quote(&CurrencyUnit::Sat, options)
1912 .await
1913 .expect("quote should succeed with fallback fee rate");
1914
1915 let wallet_with_db = backend.wallet_with_db.lock().await;
1916 assert!(
1917 wallet_with_db.wallet.staged().is_none(),
1918 "quote estimation must not mutate or stage BDK wallet state"
1919 );
1920 }
1921
1922 #[tokio::test]
1923 async fn test_default_fee_options_emit_immediate_only() {
1924 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1925 fund_backend_wallet(&backend, 100_000).await;
1926 let (_quote_id, options) = onchain_options_for(10_000);
1927
1928 let quote = backend
1929 .get_payment_quote(&CurrencyUnit::Sat, options)
1930 .await
1931 .expect("quote should succeed");
1932
1933 let fee_options = quote.fee_options.expect("fee options");
1934 assert_eq!(fee_options.len(), 1);
1935 assert_eq!(fee_options[0].fee_index, 0);
1936 assert_eq!(fee_options[0].estimated_blocks, 1);
1937 }
1938
1939 #[tokio::test]
1940 async fn test_configured_fee_options_emit_indexes_in_order() {
1941 let batch_config = BatchConfig {
1942 fee_options: vec![
1943 PaymentTier::Immediate,
1944 PaymentTier::Standard,
1945 PaymentTier::Economy,
1946 ],
1947 ..BatchConfig::default()
1948 };
1949 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1950 .await
1951 .expect("build CdkBdk test instance");
1952 fund_backend_wallet(&backend, 100_000).await;
1953 let (_quote_id, options) = onchain_options_for(10_000);
1954
1955 let quote = backend
1956 .get_payment_quote(&CurrencyUnit::Sat, options)
1957 .await
1958 .expect("quote should succeed");
1959
1960 let fee_options = quote.fee_options.expect("fee options");
1961 let indexes: Vec<u32> = fee_options.iter().map(|option| option.fee_index).collect();
1962 let estimated_blocks: Vec<u32> = fee_options
1963 .iter()
1964 .map(|option| option.estimated_blocks)
1965 .collect();
1966
1967 assert_eq!(indexes, vec![0, 1, 2]);
1968 assert_eq!(estimated_blocks, vec![1, 6, 144]);
1969 }
1970
1971 #[tokio::test]
1972 async fn test_configured_fee_index_resolves_by_position() {
1973 let batch_config = BatchConfig {
1974 fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
1975 ..BatchConfig::default()
1976 };
1977 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1978 .await
1979 .expect("build CdkBdk test instance");
1980 fund_backend_wallet(&backend, 100_000).await;
1981 let (quote_id, mut options) = onchain_options_for(10_000);
1982 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1983 panic!("expected onchain options");
1984 };
1985 onchain.fee_index = Some(1);
1986 onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
1987
1988 backend
1989 .make_payment(&CurrencyUnit::Sat, options)
1990 .await
1991 .expect("make_payment should enqueue the intent");
1992
1993 let intent = backend
1994 .storage
1995 .get_send_intent_by_quote_id("e_id.to_string())
1996 .await
1997 .expect("lookup send intent by quote id")
1998 .expect("send intent should be persisted");
1999
2000 assert_eq!(intent.tier, PaymentTier::Economy);
2001 }
2002
2003 #[tokio::test]
2004 async fn test_make_payment_returns_failed_for_unknown_fee_index() {
2005 let backend = build_test_instance(5).await;
2006 let (quote_id, mut options) = onchain_options_for(10_000);
2007 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
2008 panic!("expected onchain options");
2009 };
2010 onchain.fee_index = Some(99);
2011
2012 let response = backend
2013 .make_payment(&CurrencyUnit::Sat, options)
2014 .await
2015 .expect("definitive pre-dispatch rejection should return a payment response");
2016
2017 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2018 assert!(
2019 backend
2020 .storage
2021 .get_send_intent_by_quote_id("e_id.to_string())
2022 .await
2023 .expect("lookup send intent by quote id")
2024 .is_none(),
2025 "unknown fee index rejection must not leave a pending send intent behind"
2026 );
2027 }
2028
2029 #[tokio::test]
2030 async fn test_make_payment_omitted_fee_index_defaults_to_immediate() {
2031 let batch_config = BatchConfig {
2032 fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
2033 ..BatchConfig::default()
2034 };
2035 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
2036 .await
2037 .expect("build CdkBdk test instance");
2038 fund_backend_wallet(&backend, 100_000).await;
2039 let (quote_id, options) = onchain_options_for(10_000);
2040
2041 backend
2042 .make_payment(&CurrencyUnit::Sat, options)
2043 .await
2044 .expect("make_payment should enqueue the intent");
2045
2046 let intent = backend
2047 .storage
2048 .get_send_intent_by_quote_id("e_id.to_string())
2049 .await
2050 .expect("lookup send intent by quote id")
2051 .expect("send intent should be persisted");
2052
2053 assert_eq!(intent.tier, PaymentTier::Immediate);
2054 }
2055
2056 #[tokio::test]
2057 async fn test_new_rejects_invalid_fee_option_lists() {
2058 for fee_options in [
2059 Vec::new(),
2060 vec![PaymentTier::Immediate, PaymentTier::Immediate],
2061 vec![
2062 PaymentTier::Immediate,
2063 PaymentTier::Standard,
2064 PaymentTier::Economy,
2065 PaymentTier::Immediate,
2066 ],
2067 ] {
2068 let batch_config = BatchConfig {
2069 fee_options,
2070 ..BatchConfig::default()
2071 };
2072 match build_test_instance_with_config(5, Some(batch_config), 60).await {
2073 Err(Error::InvalidConfig(message)) => {
2074 assert!(message.contains("fee_options"));
2075 }
2076 Ok(_) => panic!("invalid fee options should be rejected"),
2077 Err(err) => panic!("expected invalid config error, got {err}"),
2078 }
2079 }
2080 }
2081
2082 #[tokio::test]
2083 async fn test_get_payment_quote_rejects_empty_wallet() {
2084 let backend = build_test_instance(5).await;
2085 let (_quote_id, options) = onchain_options_for(10_000);
2086
2087 let err = backend
2088 .get_payment_quote(&CurrencyUnit::Sat, options)
2089 .await
2090 .expect_err("empty wallet should not receive an onchain quote");
2091
2092 let cdk_common::payment::Error::Onchain(inner) = err else {
2093 panic!("expected onchain error");
2094 };
2095
2096 let backend_err = inner
2097 .downcast_ref::<Error>()
2098 .expect("expected cdk-bdk backend error");
2099 assert!(matches!(backend_err, Error::NoSpendableUtxos));
2100 }
2101
2102 #[tokio::test]
2103 async fn test_make_payment_returns_failed_when_current_fee_exceeds_max_fee() {
2104 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2105 fund_backend_wallet(&backend, 100_000).await;
2106 let (quote_id, mut options) = onchain_options_for(10_000);
2107 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
2108 panic!("expected onchain options");
2109 };
2110 onchain.max_fee_amount = Some(Amount::new(1, CurrencyUnit::Sat));
2111
2112 let response = backend
2113 .make_payment(&CurrencyUnit::Sat, options)
2114 .await
2115 .expect("definitive pre-dispatch rejection should return a payment response");
2116
2117 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2118
2119 assert!(
2120 backend
2121 .storage
2122 .get_send_intent_by_quote_id("e_id.to_string())
2123 .await
2124 .expect("lookup send intent by quote id")
2125 .is_none(),
2126 "fee recheck rejection must not leave a pending send intent behind"
2127 );
2128 }
2129
2130 #[tokio::test]
2131 async fn test_get_settings_reports_min_send_amount() {
2132 let backend = build_test_instance(5).await;
2133
2134 let settings = backend.get_settings().await.expect("settings");
2135 let onchain = settings.onchain.expect("onchain settings");
2136
2137 assert_eq!(onchain.min_receive_amount_sat, 0);
2138 assert_eq!(onchain.min_send_amount_sat, 546);
2139 }
2140
2141 use cdk_common::payment::OnchainOutgoingPaymentOptions;
2150 use cdk_common::QuoteId;
2151 use uuid::Uuid;
2152
2153 fn onchain_options_for(amount_sat: u64) -> (QuoteId, OutgoingPaymentOptions) {
2155 let quote_id = QuoteId::UUID(Uuid::new_v4());
2156 (
2157 quote_id.clone(),
2158 onchain_options_for_quote(quote_id, amount_sat),
2159 )
2160 }
2161
2162 fn onchain_options_for_quote(quote_id: QuoteId, amount_sat: u64) -> OutgoingPaymentOptions {
2163 OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
2164 address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2165 amount: Amount::new(amount_sat, CurrencyUnit::Sat),
2166 max_fee_amount: Some(Amount::new(1_000, CurrencyUnit::Sat)),
2167 quote_id,
2168 fee_index: None,
2169 metadata: None,
2170 }))
2171 }
2172
2173 fn onchain_options_for_msat(
2174 quote_id: QuoteId,
2175 amount_msat: u64,
2176 max_fee_msat: u64,
2177 ) -> OutgoingPaymentOptions {
2178 OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
2179 address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2180 amount: Amount::new(amount_msat, CurrencyUnit::Msat),
2181 max_fee_amount: Some(Amount::new(max_fee_msat, CurrencyUnit::Msat)),
2182 quote_id,
2183 fee_index: None,
2184 metadata: None,
2185 }))
2186 }
2187
2188 fn assert_authoritative_failure_response(
2189 response: MakePaymentResponse,
2190 quote_id: QuoteId,
2191 unit: CurrencyUnit,
2192 ) {
2193 assert_eq!(
2194 response.payment_lookup_id,
2195 PaymentIdentifier::QuoteId(quote_id)
2196 );
2197 assert_eq!(response.status, MeltQuoteState::Failed);
2198 assert_eq!(response.total_spent, Amount::new(0, unit));
2199 assert!(response.payment_proof.is_none());
2200 }
2201
2202 #[tokio::test]
2203 async fn test_get_payment_quote_converts_fee_options_to_msat() {
2204 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2205 fund_backend_wallet(&backend, 100_000).await;
2206 let quote_id = QuoteId::UUID(Uuid::new_v4());
2207 let options = onchain_options_for_msat(quote_id, 10_000_000, 10_000_000);
2208
2209 let quote = backend
2210 .get_payment_quote(&CurrencyUnit::Msat, options)
2211 .await
2212 .expect("msat quote should succeed");
2213
2214 assert_eq!(quote.amount, Amount::new(10_000_000, CurrencyUnit::Msat));
2215 assert_eq!(quote.fee.unit(), &CurrencyUnit::Msat);
2216 assert_eq!(quote.fee.value() % MSAT_IN_SAT, 0);
2217
2218 let fee_options = quote.fee_options.expect("fee options");
2219 assert!(fee_options
2220 .iter()
2221 .all(|option| u64::from(option.fee_reserve) % MSAT_IN_SAT == 0));
2222 assert_eq!(
2223 quote.fee.value(),
2224 fee_options
2225 .iter()
2226 .map(|option| u64::from(option.fee_reserve))
2227 .min()
2228 .expect("non-empty fee options")
2229 );
2230 }
2231
2232 #[tokio::test]
2233 async fn test_make_payment_converts_msat_amount_and_fee_to_sat() {
2234 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2235 fund_backend_wallet(&backend, 100_000).await;
2236 let quote_id = QuoteId::UUID(Uuid::new_v4());
2237 let options = onchain_options_for_msat(quote_id.clone(), 10_000_000, 10_000_000);
2238
2239 let response = backend
2240 .make_payment(&CurrencyUnit::Msat, options)
2241 .await
2242 .expect("msat payment should enqueue a sat-native intent");
2243
2244 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Msat));
2245 let intent = backend
2246 .storage
2247 .get_send_intent_by_quote_id("e_id.to_string())
2248 .await
2249 .expect("lookup send intent")
2250 .expect("send intent should be persisted");
2251 assert_eq!(intent.amount_sat, 10_000);
2252 assert_eq!(intent.max_fee_amount_sat, 10_000);
2253 }
2254
2255 #[tokio::test]
2256 async fn test_make_payment_returns_failed_for_fractional_satoshi_amount() {
2257 let backend = build_test_instance(5).await;
2258 let quote_id = QuoteId::UUID(Uuid::new_v4());
2259 let options = onchain_options_for_msat(quote_id.clone(), 10_000_001, 10_000_000);
2260
2261 let response = backend
2262 .make_payment(&CurrencyUnit::Msat, options)
2263 .await
2264 .expect("definitive pre-dispatch rejection should return a payment response");
2265
2266 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Msat);
2267 assert!(backend
2268 .storage
2269 .get_send_intent_by_quote_id("e_id.to_string())
2270 .await
2271 .expect("lookup send intent")
2272 .is_none());
2273 }
2274
2275 #[tokio::test]
2276 async fn test_make_payment_returns_failed_for_mismatched_fee_unit() {
2277 let backend = build_test_instance(5).await;
2278 let quote_id = QuoteId::UUID(Uuid::new_v4());
2279 let mut options = onchain_options_for_msat(quote_id.clone(), 10_000_000, 10_000_000);
2280 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
2281 panic!("expected onchain options");
2282 };
2283 onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
2284
2285 let response = backend
2286 .make_payment(&CurrencyUnit::Msat, options)
2287 .await
2288 .expect("definitive pre-dispatch rejection should return a payment response");
2289
2290 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Msat);
2291 assert!(backend
2292 .storage
2293 .get_send_intent_by_quote_id("e_id.to_string())
2294 .await
2295 .expect("lookup send intent")
2296 .is_none());
2297 }
2298
2299 #[tokio::test]
2300 async fn test_make_payment_pending_total_spent_is_zero() {
2301 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2305 fund_backend_wallet(&backend, 100_000).await;
2306 let (quote_id, options) = onchain_options_for(10_000);
2307
2308 let response = backend
2309 .make_payment(&CurrencyUnit::Sat, options)
2310 .await
2311 .expect("make_payment should enqueue the intent");
2312
2313 assert_eq!(response.status, MeltQuoteState::Pending);
2314 assert_eq!(
2315 response.payment_lookup_id,
2316 PaymentIdentifier::QuoteId(quote_id)
2317 );
2318 assert_eq!(
2319 response.total_spent,
2320 Amount::new(0, CurrencyUnit::Sat),
2321 "Pending onchain response MUST use 0 sentinel; the real \
2322 total_spent is only known after the batch transaction is built"
2323 );
2324 }
2325
2326 #[tokio::test]
2327 async fn test_get_payment_quote_rejects_dust_output() {
2328 let backend = build_test_instance(5).await;
2329 let (_quote_id, options) = onchain_options_for(1);
2330
2331 let err = backend
2332 .get_payment_quote(&CurrencyUnit::Sat, options)
2333 .await
2334 .expect_err("dust output should be rejected at quote time");
2335
2336 let cdk_common::payment::Error::Onchain(inner) = err else {
2337 panic!("expected onchain error");
2338 };
2339
2340 let backend_err = inner
2341 .downcast_ref::<Error>()
2342 .expect("expected cdk-bdk backend error");
2343 assert!(matches!(backend_err, Error::DustOutput { .. }));
2344 }
2345
2346 #[tokio::test]
2347 async fn test_make_payment_returns_failed_for_dust_without_persisting_intent() {
2348 let backend = build_test_instance(5).await;
2349 let (quote_id, options) = onchain_options_for(1);
2350
2351 let response = backend
2352 .make_payment(&CurrencyUnit::Sat, options)
2353 .await
2354 .expect("definitive pre-dispatch rejection should return a payment response");
2355
2356 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2357 assert!(
2358 backend
2359 .storage
2360 .get_send_intent_by_quote_id("e_id.to_string())
2361 .await
2362 .expect("lookup send intent by quote id")
2363 .is_none(),
2364 "dust rejection must not leave a pending send intent behind"
2365 );
2366 }
2367
2368 #[tokio::test]
2369 async fn test_get_payment_quote_rejects_amount_below_minimum_send() {
2370 let backend = build_test_instance(5).await;
2371 let (_quote_id, options) = onchain_options_for(545);
2372
2373 let err = backend
2374 .get_payment_quote(&CurrencyUnit::Sat, options)
2375 .await
2376 .expect_err("amount below configured minimum should be rejected at quote time");
2377
2378 let cdk_common::payment::Error::Onchain(inner) = err else {
2379 panic!("expected onchain error");
2380 };
2381
2382 let backend_err = inner
2383 .downcast_ref::<Error>()
2384 .expect("expected cdk-bdk backend error");
2385 assert!(matches!(
2386 backend_err,
2387 Error::AmountBelowMinimumSend {
2388 amount: 545,
2389 min: 546
2390 }
2391 ));
2392 }
2393
2394 #[tokio::test]
2395 async fn test_make_payment_returns_failed_below_minimum_without_persisting_intent() {
2396 let backend = build_test_instance(5).await;
2397 let (quote_id, options) = onchain_options_for(545);
2398
2399 let response = backend
2400 .make_payment(&CurrencyUnit::Sat, options)
2401 .await
2402 .expect("definitive pre-dispatch rejection should return a payment response");
2403
2404 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2405 assert!(
2406 backend
2407 .storage
2408 .get_send_intent_by_quote_id("e_id.to_string())
2409 .await
2410 .expect("lookup send intent by quote id")
2411 .is_none(),
2412 "minimum-send rejection must not leave a pending send intent behind"
2413 );
2414 }
2415
2416 #[tokio::test]
2417 async fn test_check_outgoing_payment_pending_intent_reports_zero_total_spent() {
2418 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2422 fund_backend_wallet(&backend, 100_000).await;
2423 let (quote_id, options) = onchain_options_for(12_345);
2424
2425 backend
2426 .make_payment(&CurrencyUnit::Sat, options)
2427 .await
2428 .expect("make_payment should enqueue the intent");
2429
2430 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2431 let response = backend
2432 .check_outgoing_payment(&payment_identifier)
2433 .await
2434 .expect("check_outgoing_payment for Pending intent");
2435
2436 assert_eq!(response.status, MeltQuoteState::Pending);
2437 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2438 assert_eq!(response.payment_proof, None);
2439 }
2440
2441 #[tokio::test]
2442 async fn test_check_outgoing_payment_batched_intent_reports_zero_total_spent() {
2443 use crate::send::payment_intent::SendIntent;
2447 use crate::types::{PaymentMetadata, PaymentTier};
2448
2449 let backend = build_test_instance(5).await;
2450 let quote_id = QuoteId::UUID(Uuid::new_v4());
2451
2452 let pending = SendIntent::new(
2453 &backend.storage,
2454 quote_id.to_string(),
2455 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2456 20_000,
2457 1_000,
2458 PaymentTier::Standard,
2459 PaymentMetadata::default(),
2460 )
2461 .await
2462 .expect("create Pending send intent");
2463
2464 pending
2465 .assign_to_batch(&backend.storage, Uuid::new_v4())
2466 .await
2467 .expect("transition Pending → Batched");
2468
2469 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2470 let response = backend
2471 .check_outgoing_payment(&payment_identifier)
2472 .await
2473 .expect("check_outgoing_payment for Batched intent");
2474
2475 assert_eq!(response.status, MeltQuoteState::Pending);
2476 assert_eq!(
2477 response.total_spent,
2478 Amount::new(0, CurrencyUnit::Sat),
2479 "Batched intents report total_spent = 0 until the batch \
2480 transaction is built and the per-intent fee is fixed"
2481 );
2482 }
2483
2484 #[tokio::test]
2485 async fn test_check_outgoing_payment_awaiting_confirmation_includes_fee() {
2486 use crate::send::payment_intent::SendIntent;
2492 use crate::types::{PaymentMetadata, PaymentTier};
2493
2494 let backend = build_test_instance(5).await;
2495 let quote_id = QuoteId::UUID(Uuid::new_v4());
2496
2497 let pending = SendIntent::new(
2498 &backend.storage,
2499 quote_id.to_string(),
2500 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2501 30_000,
2502 2_000,
2503 PaymentTier::Immediate,
2504 PaymentMetadata::default(),
2505 )
2506 .await
2507 .expect("create Pending send intent");
2508
2509 let batched = pending
2510 .assign_to_batch(&backend.storage, Uuid::new_v4())
2511 .await
2512 .expect("transition Pending → Batched");
2513
2514 let fee_contrib = 512_u64;
2515 batched
2516 .mark_broadcast(
2517 &backend.storage,
2518 "deadbeef".to_string(),
2519 "deadbeef:0".to_string(),
2520 fee_contrib,
2521 )
2522 .await
2523 .expect("transition Batched → AwaitingConfirmation");
2524
2525 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2526 let response = backend
2527 .check_outgoing_payment(&payment_identifier)
2528 .await
2529 .expect("check_outgoing_payment for AwaitingConfirmation intent");
2530
2531 assert_eq!(response.status, MeltQuoteState::Pending);
2532 assert_eq!(
2533 response.total_spent,
2534 Amount::new(30_000 + fee_contrib, CurrencyUnit::Sat),
2535 "AwaitingConfirmation intents know the per-intent fee \
2536 contribution and must report amount + fee"
2537 );
2538 }
2539
2540 #[tokio::test]
2541 async fn test_check_outgoing_payment_failed_intent_reports_failed() {
2542 use crate::send::payment_intent::SendIntent;
2543 use crate::types::{PaymentMetadata, PaymentTier};
2544
2545 let backend = build_test_instance(5).await;
2546 let quote_id = QuoteId::UUID(Uuid::new_v4());
2547
2548 let pending = SendIntent::new(
2549 &backend.storage,
2550 quote_id.to_string(),
2551 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2552 30_000,
2553 2_000,
2554 PaymentTier::Immediate,
2555 PaymentMetadata::default(),
2556 )
2557 .await
2558 .expect("create Pending send intent");
2559
2560 pending
2561 .fail(&backend.storage, "fee too high".to_string())
2562 .await
2563 .expect("transition Pending to Failed");
2564
2565 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2566 let response = backend
2567 .check_outgoing_payment(&payment_identifier)
2568 .await
2569 .expect("check_outgoing_payment for Failed intent");
2570
2571 assert_eq!(response.status, MeltQuoteState::Failed);
2572 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2573 assert_eq!(response.payment_proof, None);
2574 }
2575
2576 #[tokio::test]
2577 async fn test_make_payment_replay_preserves_durable_state_without_spendable_utxos() {
2578 use crate::send::payment_intent::SendIntent;
2579
2580 async fn assert_replay(
2581 backend: &CdkBdk,
2582 quote_id: &QuoteId,
2583 status: MeltQuoteState,
2584 spent_sat: u64,
2585 proof: Option<&str>,
2586 ) {
2587 for unit in [CurrencyUnit::Sat, CurrencyUnit::Msat] {
2588 let options = match unit {
2589 CurrencyUnit::Sat => onchain_options_for_quote(quote_id.clone(), 10_000),
2590 _ => onchain_options_for_msat(quote_id.clone(), 10_000_000, 1_000_000),
2591 };
2592 let response = backend
2593 .make_payment(&unit, options)
2594 .await
2595 .expect("replay should return durable payment state");
2596
2597 assert_eq!(response.status, status);
2598 assert_eq!(
2599 response.payment_lookup_id,
2600 PaymentIdentifier::QuoteId(quote_id.clone())
2601 );
2602 assert_eq!(response.payment_proof.as_deref(), proof);
2603 assert_eq!(
2604 response.total_spent,
2605 Amount::new(spent_sat, CurrencyUnit::Sat)
2606 .convert_to(&unit)
2607 .expect("convert expected spent amount")
2608 );
2609 }
2610 }
2611
2612 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2615 let quote_id = QuoteId::UUID(Uuid::new_v4());
2616 let pending = SendIntent::new(
2617 &backend.storage,
2618 quote_id.to_string(),
2619 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2620 10_000,
2621 1_000,
2622 PaymentTier::Immediate,
2623 PaymentMetadata::default(),
2624 )
2625 .await
2626 .expect("create original intent");
2627 assert_replay(&backend, "e_id, MeltQuoteState::Pending, 0, None).await;
2628
2629 let batched = pending
2630 .assign_to_batch(&backend.storage, Uuid::new_v4())
2631 .await
2632 .expect("batch original intent");
2633 assert_replay(&backend, "e_id, MeltQuoteState::Pending, 0, None).await;
2634
2635 let broadcast = batched
2636 .mark_broadcast(
2637 &backend.storage,
2638 "deadbeef".to_string(),
2639 "deadbeef:0".to_string(),
2640 250,
2641 )
2642 .await
2643 .expect("broadcast original intent");
2644 assert_replay(&backend, "e_id, MeltQuoteState::Pending, 10_250, None).await;
2645
2646 broadcast
2647 .finalize(&backend.storage)
2648 .await
2649 .expect("finalize original intent");
2650 assert_replay(
2651 &backend,
2652 "e_id,
2653 MeltQuoteState::Paid,
2654 10_250,
2655 Some("deadbeef:0"),
2656 )
2657 .await;
2658 }
2659
2660 #[tokio::test]
2661 async fn test_make_payment_can_retry_failed_intent_with_same_quote_id() {
2662 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2663 fund_backend_wallet(&backend, 100_000).await;
2664 let (quote_id, options) = onchain_options_for(30_000);
2665
2666 backend
2667 .make_payment(&CurrencyUnit::Sat, options)
2668 .await
2669 .expect("initial make_payment should enqueue intent");
2670
2671 let initial = backend
2672 .storage
2673 .get_send_intent_by_quote_id("e_id.to_string())
2674 .await
2675 .expect("lookup initial intent")
2676 .expect("initial intent exists");
2677
2678 backend
2679 .storage
2680 .update_send_intent(
2681 &initial.intent_id,
2682 &crate::send::payment_intent::record::SendIntentState::Failed {
2683 reason: "pre-sign failure".to_string(),
2684 created_at: 1_700_000_000,
2685 failed_at: 1_700_000_100,
2686 },
2687 )
2688 .await
2689 .expect("mark failed");
2690
2691 let retry_options = onchain_options_for_quote(quote_id.clone(), 30_000);
2692 let response = backend
2693 .make_payment(&CurrencyUnit::Sat, retry_options)
2694 .await
2695 .expect("retry with same quote id should requeue failed intent");
2696
2697 assert_eq!(response.status, MeltQuoteState::Pending);
2698
2699 let retried = backend
2700 .storage
2701 .get_send_intent_by_quote_id("e_id.to_string())
2702 .await
2703 .expect("lookup retried intent")
2704 .expect("retried intent exists");
2705 assert_eq!(retried.intent_id, initial.intent_id);
2706 assert!(matches!(
2707 retried.state,
2708 crate::send::payment_intent::record::SendIntentState::Pending { .. }
2709 ));
2710 }
2711
2712 #[tokio::test]
2713 async fn test_check_outgoing_payment_unknown_quote_reports_zero() {
2714 let backend = build_test_instance(5).await;
2718 let quote_id = QuoteId::UUID(Uuid::new_v4());
2719 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2720
2721 let response = backend
2722 .check_outgoing_payment(&payment_identifier)
2723 .await
2724 .expect("check_outgoing_payment for unknown quote");
2725
2726 assert_eq!(response.status, MeltQuoteState::Unknown);
2727 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2728 assert_eq!(response.payment_proof, None);
2729 }
2730
2731 #[test]
2736 fn test_is_transient_classifies_network_errors() {
2737 let esplora_err = Error::Esplora(
2741 "HttpResponse { status: 525, message: \"error code: 525\" }".to_string(),
2742 );
2743 assert!(esplora_err.is_transient());
2744
2745 let esplora_404 = Error::Esplora(
2746 "HttpResponse { status: 404, message: \"Block not found\" }".to_string(),
2747 );
2748 assert!(esplora_404.is_transient());
2749
2750 let wallet_err = Error::Wallet("invalid checkpoint".to_string());
2753 assert!(!wallet_err.is_transient());
2754
2755 let vout_err = Error::VoutNotFound;
2756 assert!(!vout_err.is_transient());
2757
2758 let io_err = Error::Io(std::io::Error::new(
2760 std::io::ErrorKind::TimedOut,
2761 "network timeout",
2762 ));
2763 assert!(io_err.is_transient());
2764
2765 let io_other = Error::Io(std::io::Error::new(
2767 std::io::ErrorKind::InvalidData,
2768 "bad data",
2769 ));
2770 assert!(!io_other.is_transient());
2771 }
2772
2773 #[tokio::test]
2774 async fn test_supervisor_restarts_failing_task_with_backoff() {
2775 let cancel = CancellationToken::new();
2778 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
2779
2780 let counter_clone = Arc::clone(&counter);
2781 let cancel_inner = cancel.clone();
2782 let supervisor = tokio::spawn(async move {
2783 super::supervise("test", cancel_inner, move |_c| {
2784 let c = Arc::clone(&counter_clone);
2785 async move {
2786 c.fetch_add(1, Ordering::Relaxed);
2787 Err::<(), Error>(Error::Esplora("boom".to_string()))
2788 }
2789 })
2790 .await;
2791 });
2792
2793 tokio::time::sleep(Duration::from_millis(2_500)).await;
2795 cancel.cancel();
2796
2797 tokio::time::timeout(Duration::from_secs(5), supervisor)
2798 .await
2799 .expect("supervisor did not exit after cancel")
2800 .expect("supervisor task panicked");
2801
2802 let n = counter.load(Ordering::Relaxed);
2803 assert!(
2804 n >= 2,
2805 "supervisor should have restarted the task at least twice, got {n}"
2806 );
2807 }
2808
2809 #[tokio::test]
2810 async fn test_supervisor_exits_on_ok() {
2811 let cancel = CancellationToken::new();
2814 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
2815
2816 let counter_clone = Arc::clone(&counter);
2817 let cancel_inner = cancel.clone();
2818 let supervisor = tokio::spawn(async move {
2819 super::supervise("test", cancel_inner, move |_c| {
2820 let c = Arc::clone(&counter_clone);
2821 async move {
2822 c.fetch_add(1, Ordering::Relaxed);
2823 Ok::<(), Error>(())
2824 }
2825 })
2826 .await;
2827 });
2828
2829 tokio::time::timeout(Duration::from_secs(5), supervisor)
2830 .await
2831 .expect("supervisor did not exit after Ok(())")
2832 .expect("supervisor task panicked");
2833
2834 assert_eq!(
2835 counter.load(Ordering::Relaxed),
2836 1,
2837 "supervisor must not restart a task that returned Ok(())"
2838 );
2839 }
2840
2841 #[tokio::test]
2842 async fn test_supervisor_cancel_during_backoff() {
2843 let cancel = CancellationToken::new();
2846 let cancel_inner = cancel.clone();
2847 let supervisor = tokio::spawn(async move {
2848 super::supervise("test", cancel_inner, move |_c| async move {
2849 Err::<(), Error>(Error::Esplora("boom".to_string()))
2851 })
2852 .await;
2853 });
2854
2855 tokio::time::sleep(Duration::from_millis(200)).await;
2857 let cancel_at = std::time::Instant::now();
2858 cancel.cancel();
2859
2860 tokio::time::timeout(Duration::from_secs(2), supervisor)
2861 .await
2862 .expect("supervisor did not exit promptly after cancel")
2863 .expect("supervisor task panicked");
2864
2865 let elapsed = cancel_at.elapsed();
2866 assert!(
2867 elapsed < Duration::from_millis(500),
2868 "supervisor took {elapsed:?} to exit after cancel; expected < 500ms"
2869 );
2870 }
2871
2872 #[tokio::test]
2873 async fn test_sync_wallet_survives_unreachable_esplora() {
2874 let backend = build_test_instance(5).await;
2880 backend.start().await.expect("start");
2881
2882 tokio::time::sleep(Duration::from_millis(500)).await;
2887
2888 {
2890 let tasks = backend.tasks.lock().await;
2891 let bg = tasks.as_ref().expect("tasks running");
2892 assert!(
2893 !bg.sync.is_finished(),
2894 "sync task must not exit on transient Esplora errors"
2895 );
2896 }
2897
2898 backend.stop().await.expect("stop");
2899 }
2900
2901 #[cfg(feature = "electrum")]
2902 #[tokio::test]
2903 async fn test_sync_wallet_survives_unreachable_electrum() {
2904 let chain_source = ChainSource::Electrum(ElectrumConfig {
2905 url: "tcp://127.0.0.1:1".to_string(),
2906 batch_size: 5,
2907 });
2908 let (backend, _tmp) = build_test_instance_with_chain_source(5, None, 60, chain_source)
2909 .await
2910 .expect("build Electrum test instance");
2911
2912 backend.start().await.expect("start");
2913 tokio::time::sleep(Duration::from_millis(500)).await;
2914
2915 {
2916 let tasks = backend.tasks.lock().await;
2917 let background = tasks.as_ref().expect("tasks running");
2918 assert!(
2919 !background.sync.is_finished(),
2920 "sync task must not exit on transient Electrum errors"
2921 );
2922 }
2923
2924 backend.stop().await.expect("stop");
2925 }
2926}