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 if let Err(err) = Self::ensure_supported_payment_unit(unit) {
697 return Ok(Self::outgoing_payment_failure_response(
698 unit, "e_id, err,
699 ));
700 }
701
702 let amount_sat = match Self::payment_amount_to_sat(unit, &amount) {
703 Ok(amount_sat) => amount_sat,
704 Err(err) => {
705 return Ok(Self::outgoing_payment_failure_response(
706 unit, "e_id, err,
707 ));
708 }
709 };
710 if let Err(err) = self.validate_send_amount(&address, amount_sat) {
711 return Ok(Self::outgoing_payment_failure_response(
712 unit, "e_id, err,
713 ));
714 }
715
716 let max_fee_sat = match onchain_options.max_fee_amount {
717 Some(max_fee) => match Self::fee_limit_to_sat(unit, &max_fee) {
718 Ok(max_fee_sat) => max_fee_sat,
719 Err(err) => {
720 return Ok(Self::outgoing_payment_failure_response(
721 unit, "e_id, err,
722 ));
723 }
724 },
725 None => 1_000,
726 };
727 let tier = match self
731 .batch_config
732 .tier_for_fee_index(onchain_options.fee_index)
733 .map_err(Error::UnknownFeeIndex)
734 {
735 Ok(tier) => tier,
736 Err(err) => {
737 return Ok(Self::outgoing_payment_failure_response(
738 unit, "e_id, err,
739 ));
740 }
741 };
742 let metadata = PaymentMetadata::from_optional_json(onchain_options.metadata.as_deref());
743 let fee_estimate = match self
744 .estimate_onchain_fee_reserve(&address, amount_sat, tier)
745 .await
746 {
747 Ok(fee_estimate) => fee_estimate,
748 Err(err) => {
749 return Ok(Self::outgoing_payment_failure_response(
750 unit, "e_id, err,
751 ));
752 }
753 };
754 if fee_estimate.raw_fee_sat > max_fee_sat {
755 let err = Error::EstimatedFeeTooHigh {
756 estimated_fee: fee_estimate.raw_fee_sat,
757 max_fee: max_fee_sat,
758 };
759 return Ok(Self::outgoing_payment_failure_response(
760 unit, "e_id, err,
761 ));
762 }
763
764 crate::send::payment_intent::SendIntent::new(
765 &self.storage,
766 quote_id.to_string(),
767 address,
768 amount_sat,
769 max_fee_sat,
770 tier,
771 metadata,
772 )
773 .await?;
774
775 if tier == PaymentTier::Immediate {
776 self.batch_notify.notify_one();
777 }
778
779 Ok(MakePaymentResponse {
786 payment_lookup_id: PaymentIdentifier::QuoteId(quote_id),
787 payment_proof: None,
788 status: MeltQuoteState::Pending,
789 total_spent: Amount::new(0, unit.clone()),
790 })
791 }
792
793 async fn create_incoming_payment_request(
794 &self,
795 options: IncomingPaymentOptions,
796 ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
797 let onchain_options = match options {
798 IncomingPaymentOptions::Onchain(o) => o,
799 _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
800 };
801
802 let quote_id = onchain_options.quote_id;
803 let quote_id_string = quote_id.to_string();
804
805 let mut wallet_with_db = self.wallet_with_db.lock().await;
806
807 let address_str = 'reserve_address: {
811 for attempt in 1..=MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS {
812 let address = wallet_with_db
813 .wallet
814 .reveal_next_address(KeychainKind::External);
815 let candidate = address.address.to_string();
816
817 wallet_with_db.persist().map_err(|err| {
818 tracing::warn!("Could not persist to bdk db: {}", err);
819
820 Error::BdkPersist
821 })?;
822
823 if self
824 .storage
825 .track_receive_address(&candidate, "e_id_string)
826 .await?
827 {
828 break 'reserve_address candidate;
829 }
830
831 tracing::debug!(
832 quote_id = %quote_id,
833 attempt,
834 max_attempts = MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS,
835 "Receive address is already reserved for another quote"
836 );
837 }
838
839 return Err(Error::ReceiveAddressReservationExhausted {
840 attempts: MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS,
841 }
842 .into());
843 };
844
845 Ok(CreateIncomingPaymentResponse {
846 request_lookup_id: PaymentIdentifier::QuoteId(quote_id),
847 request: address_str,
848 expiry: None,
849 extra_json: None,
850 })
851 }
852
853 async fn wait_payment_event(
854 &self,
855 ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
856 self.wait_invoice_is_active.store(true, Ordering::SeqCst);
857
858 let receiver = self.payment_sender.subscribe();
859 let stream = PaymentEventStream {
860 receiver: BroadcastStream::new(receiver),
861 cancel: Box::pin(self.wait_invoice_cancel_token.clone().cancelled_owned()),
862 is_active: Arc::clone(&self.wait_invoice_is_active),
863 };
864
865 Ok(Box::pin(stream))
866 }
867
868 async fn check_incoming_payment_status(
869 &self,
870 payment_identifier: &PaymentIdentifier,
871 ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
872 let PaymentIdentifier::QuoteId(quote_id) = payment_identifier else {
873 return Err(Error::UnsupportedOnchain.into());
874 };
875
876 let quote_id_str = quote_id.to_string();
877 let mut results = Vec::new();
878
879 let finalized = self
882 .storage
883 .get_finalized_receive_intents_by_quote_id("e_id_str)
884 .await?;
885
886 for record in finalized {
887 results.push(WaitPaymentResponse {
888 payment_identifier: payment_identifier.clone(),
889 payment_amount: Amount::new(record.amount_sat, CurrencyUnit::Sat),
890 payment_id: record.outpoint,
891 });
892 }
893
894 Ok(results)
895 }
896
897 async fn check_outgoing_payment(
898 &self,
899 payment_identifier: &PaymentIdentifier,
900 ) -> Result<MakePaymentResponse, Self::Err> {
901 let quote_id = match payment_identifier {
902 PaymentIdentifier::QuoteId(id) => id.to_string(),
903 _ => return Err(Error::UnsupportedOnchain.into()),
904 };
905
906 if let Some(record) = self.storage.get_send_intent_by_quote_id("e_id).await? {
908 let total_spent = match &record.state {
914 crate::send::payment_intent::record::SendIntentState::Pending { .. }
915 | crate::send::payment_intent::record::SendIntentState::Batched { .. } => {
916 Amount::new(0, CurrencyUnit::Sat)
917 }
918 crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
919 fee_contribution_sat,
920 ..
921 } => Amount::new(record.amount_sat + fee_contribution_sat, CurrencyUnit::Sat),
922 crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
923 Amount::new(0, CurrencyUnit::Sat)
924 }
925 };
926 let status = match record.state {
927 crate::send::payment_intent::record::SendIntentState::Pending { .. }
928 | crate::send::payment_intent::record::SendIntentState::Batched { .. }
929 | crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
930 ..
931 } => MeltQuoteState::Pending,
932 crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
933 MeltQuoteState::Failed
934 }
935 };
936
937 return Ok(MakePaymentResponse {
938 payment_lookup_id: payment_identifier.clone(),
939 payment_proof: None,
940 status,
941 total_spent,
942 });
943 }
944
945 if let Some(record) = self
947 .storage
948 .get_finalized_intent_by_quote_id("e_id)
949 .await?
950 {
951 return Ok(MakePaymentResponse {
952 payment_lookup_id: payment_identifier.clone(),
953 payment_proof: Some(record.outpoint),
954 status: MeltQuoteState::Paid,
955 total_spent: Amount::new(record.total_spent_sat, CurrencyUnit::Sat),
956 });
957 }
958
959 Ok(MakePaymentResponse {
960 payment_lookup_id: payment_identifier.clone(),
961 payment_proof: None,
962 status: MeltQuoteState::Unknown,
963 total_spent: Amount::new(0, CurrencyUnit::Sat),
964 })
965 }
966
967 fn is_payment_event_stream_active(&self) -> bool {
968 self.wait_invoice_is_active.load(Ordering::SeqCst)
969 }
970
971 fn cancel_payment_event_stream(&self) {
972 self.wait_invoice_cancel_token.cancel();
973 }
974}
975
976#[cfg(test)]
977mod tests {
978 use std::fs;
979 use std::str::FromStr;
980
981 use bdk_wallet::bitcoin::hashes::Hash as _;
982 use bdk_wallet::bitcoin::{
983 absolute, transaction, Network, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness,
984 };
985 use bdk_wallet::keys::bip39::Mnemonic;
986 use cdk_common::common::FeeReserve;
987 use cdk_common::payment::{MintPayment, OnchainIncomingPaymentOptions};
988 use futures::StreamExt;
989
990 use super::*;
991 use crate::fee::apply_quote_fee_safety;
992
993 const TEST_MNEMONIC: &str =
994 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
995 const OTHER_TEST_MNEMONIC: &str =
996 "legal winner thank year wave sausage worth useful legal winner thank yellow";
997
998 #[tokio::test]
999 async fn existing_wallet_preflight_requires_matching_persisted_wallet() {
1000 let (backend, tempdir) = build_test_instance_with_tempdir(5).await;
1001 drop(backend);
1002
1003 let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic");
1004 validate_existing_wallet(mnemonic, Network::Regtest, tempdir.path())
1005 .expect("matching wallet should pass preflight");
1006
1007 let other_mnemonic = Mnemonic::from_str(OTHER_TEST_MNEMONIC).expect("other mnemonic");
1008 assert!(matches!(
1009 validate_existing_wallet(other_mnemonic, Network::Regtest, tempdir.path()),
1010 Err(Error::Wallet(_))
1011 ));
1012 assert!(matches!(
1013 validate_existing_wallet(
1014 Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic"),
1015 Network::Signet,
1016 tempdir.path(),
1017 ),
1018 Err(Error::Wallet(_))
1019 ));
1020 }
1021
1022 #[test]
1023 fn existing_wallet_preflight_does_not_create_missing_or_empty_wallet() {
1024 let tempdir = tempfile::tempdir().expect("tempdir");
1025 let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic");
1026 let wallet_path = tempdir.path().join("bdk_wallet/bdk_wallet.sqlite");
1027 assert!(matches!(
1028 validate_existing_wallet(mnemonic.clone(), Network::Regtest, tempdir.path()),
1029 Err(Error::ExistingWalletMissing { .. })
1030 ));
1031 assert!(!wallet_path.exists());
1032
1033 fs::create_dir_all(wallet_path.parent().expect("wallet directory"))
1034 .expect("create wallet directory");
1035 drop(Connection::open(&wallet_path).expect("create empty sqlite file"));
1036 assert!(matches!(
1037 validate_existing_wallet(mnemonic, Network::Regtest, tempdir.path()),
1038 Err(Error::ExistingWalletNotInitialized { .. })
1039 ));
1040 }
1041
1042 async fn build_test_instance(shutdown_timeout_secs: u64) -> CdkBdk {
1046 build_test_instance_with_tempdir(shutdown_timeout_secs)
1047 .await
1048 .0
1049 }
1050
1051 async fn build_test_instance_with_tempdir(
1052 shutdown_timeout_secs: u64,
1053 ) -> (CdkBdk, tempfile::TempDir) {
1054 build_test_instance_with_config(shutdown_timeout_secs, None, 60)
1055 .await
1056 .expect("build CdkBdk test instance")
1057 }
1058
1059 async fn build_test_instance_with_config(
1060 shutdown_timeout_secs: u64,
1061 batch_config: Option<BatchConfig>,
1062 sync_interval_secs: u64,
1063 ) -> Result<(CdkBdk, tempfile::TempDir), Error> {
1064 let chain_source = ChainSource::Esplora(EsploraConfig {
1065 url: "http://127.0.0.1:1".to_string(),
1066 parallel_requests: 1,
1067 });
1068
1069 build_test_instance_with_chain_source(
1070 shutdown_timeout_secs,
1071 batch_config,
1072 sync_interval_secs,
1073 chain_source,
1074 )
1075 .await
1076 }
1077
1078 async fn build_test_instance_with_chain_source(
1079 shutdown_timeout_secs: u64,
1080 batch_config: Option<BatchConfig>,
1081 sync_interval_secs: u64,
1082 chain_source: ChainSource,
1083 ) -> Result<(CdkBdk, tempfile::TempDir), Error> {
1084 let tmp = tempfile::tempdir().expect("tempdir");
1085 let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("mnemonic");
1086
1087 let kv = cdk_sqlite::mint::memory::empty()
1088 .await
1089 .expect("in-memory kv store");
1090
1091 let fee_reserve = FeeReserve {
1092 min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(),
1093 percent_fee_reserve: 0.02,
1094 };
1095
1096 let backend = CdkBdk::new(
1097 mnemonic,
1098 Network::Regtest,
1099 chain_source,
1100 tmp.path().to_string_lossy().into_owned(),
1101 fee_reserve,
1102 Arc::new(kv),
1103 batch_config,
1104 1,
1105 0,
1106 546,
1107 sync_interval_secs,
1108 Some(shutdown_timeout_secs),
1109 None,
1110 )?;
1111
1112 Ok((backend, tmp))
1113 }
1114
1115 async fn build_test_instance_with_shared_kv(
1119 kv: Arc<cdk_sqlite::mint::MintSqliteDatabase>,
1120 ) -> (CdkBdk, tempfile::TempDir) {
1121 let tmp = tempfile::tempdir().expect("tempdir");
1122 let mnemonic = Mnemonic::from_str(
1123 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
1124 )
1125 .expect("mnemonic");
1126 let chain_source = ChainSource::Esplora(EsploraConfig {
1127 url: "http://127.0.0.1:1".to_string(),
1128 parallel_requests: 1,
1129 });
1130 let fee_reserve = FeeReserve {
1131 min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(),
1132 percent_fee_reserve: 0.02,
1133 };
1134
1135 let backend = CdkBdk::new(
1136 mnemonic,
1137 Network::Regtest,
1138 chain_source,
1139 tmp.path().to_string_lossy().into_owned(),
1140 fee_reserve,
1141 kv,
1142 None,
1143 1,
1144 0,
1145 546,
1146 60,
1147 Some(5),
1148 None,
1149 )
1150 .expect("build CdkBdk test instance");
1151
1152 (backend, tmp)
1153 }
1154
1155 #[tokio::test]
1156 async fn instances_sharing_seed_and_kv_never_share_a_receive_address() {
1157 let kv = Arc::new(
1158 cdk_sqlite::mint::memory::empty()
1159 .await
1160 .expect("in-memory kv store"),
1161 );
1162 let (first, _tmp_first) = build_test_instance_with_shared_kv(kv.clone()).await;
1163 let (second, _tmp_second) = build_test_instance_with_shared_kv(kv).await;
1164
1165 let first_request = first
1166 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1167 OnchainIncomingPaymentOptions {
1168 quote_id: cdk_common::QuoteId::new(),
1169 },
1170 ))
1171 .await
1172 .expect("first receive request");
1173 let second_request = second
1174 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1175 OnchainIncomingPaymentOptions {
1176 quote_id: cdk_common::QuoteId::new(),
1177 },
1178 ))
1179 .await
1180 .expect("second receive request");
1181
1182 assert_ne!(
1183 first_request.request, second_request.request,
1184 "each instance must hand out a distinct receive address"
1185 );
1186 }
1187
1188 #[tokio::test]
1189 async fn operator_deposit_address_is_not_associated_with_a_quote() {
1190 let kv = Arc::new(
1191 cdk_sqlite::mint::memory::empty()
1192 .await
1193 .expect("in-memory kv store"),
1194 );
1195 let (backend, _tmp) = build_test_instance_with_shared_kv(kv).await;
1196
1197 let operator_address = backend
1198 .create_operator_deposit_address()
1199 .await
1200 .expect("create operator deposit address");
1201 let quote_request = backend
1202 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1203 OnchainIncomingPaymentOptions {
1204 quote_id: cdk_common::QuoteId::new(),
1205 },
1206 ))
1207 .await
1208 .expect("create quote receive request");
1209
1210 assert_ne!(operator_address, quote_request.request);
1211 assert!(backend
1212 .storage
1213 .get_quote_id_by_receive_address(&operator_address)
1214 .await
1215 .expect("look up operator address")
1216 .is_none());
1217 assert!(!backend
1218 .storage
1219 .get_tracked_receive_addresses()
1220 .await
1221 .expect("list quote receive addresses")
1222 .contains(&operator_address));
1223 }
1224
1225 #[tokio::test]
1226 async fn receive_address_reservation_stops_after_attempt_limit() {
1227 let kv = Arc::new(
1228 cdk_sqlite::mint::memory::empty()
1229 .await
1230 .expect("in-memory kv store"),
1231 );
1232 let (first, _tmp_first) = build_test_instance_with_shared_kv(kv.clone()).await;
1233 let (second, _tmp_second) = build_test_instance_with_shared_kv(kv).await;
1234
1235 for _ in 0..MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS {
1236 first
1237 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1238 OnchainIncomingPaymentOptions {
1239 quote_id: cdk_common::QuoteId::new(),
1240 },
1241 ))
1242 .await
1243 .expect("reserve receive address");
1244 }
1245
1246 let err = second
1247 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1248 OnchainIncomingPaymentOptions {
1249 quote_id: cdk_common::QuoteId::new(),
1250 },
1251 ))
1252 .await
1253 .expect_err("reservation should stop after the attempt limit");
1254
1255 let cdk_common::payment::Error::Onchain(inner) = err else {
1256 panic!("expected onchain error");
1257 };
1258 assert!(matches!(
1259 inner.downcast_ref::<Error>(),
1260 Some(Error::ReceiveAddressReservationExhausted { attempts })
1261 if *attempts == MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS
1262 ));
1263 }
1264
1265 #[tokio::test]
1266 async fn wallet_info_lists_revealed_addresses_without_revealing_more() {
1267 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1268
1269 let initial_addresses = backend
1270 .wallet_addresses(0, 100)
1271 .await
1272 .expect("list initial addresses");
1273 assert_eq!(initial_addresses.total, 0);
1274
1275 backend
1276 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1277 OnchainIncomingPaymentOptions {
1278 quote_id: cdk_common::QuoteId::new(),
1279 },
1280 ))
1281 .await
1282 .expect("create on-chain request");
1283
1284 let addresses = backend
1285 .wallet_addresses(0, 100)
1286 .await
1287 .expect("list revealed addresses");
1288 assert_eq!(addresses.total, 1);
1289 assert_eq!(addresses.items.len(), 1);
1290 assert_eq!(addresses.items[0].keychain, WalletKeychain::External);
1291 assert_eq!(addresses.items[0].derivation_index, 0);
1292 assert!(!addresses.items[0].used);
1293 assert_eq!(addresses.items[0].balance_sat, 0);
1294
1295 let balance = backend.wallet_balance().await;
1296 assert_eq!(balance.total_sat, 0);
1297 assert_eq!(
1298 backend
1299 .wallet_transactions(0, 20)
1300 .await
1301 .expect("list transactions")
1302 .total,
1303 0
1304 );
1305
1306 let addresses_again = backend
1307 .wallet_addresses(0, 100)
1308 .await
1309 .expect("list revealed addresses again");
1310 assert_eq!(addresses_again.total, 1);
1311 }
1312
1313 #[tokio::test]
1314 async fn wallet_info_paginates_revealed_addresses_across_keychains() {
1315 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1316
1317 {
1318 let mut wallet_with_db = backend.wallet_with_db.lock().await;
1319 let _ = wallet_with_db
1320 .wallet
1321 .reveal_addresses_to(KeychainKind::External, 1)
1322 .count();
1323 let _ = wallet_with_db
1324 .wallet
1325 .reveal_addresses_to(KeychainKind::Internal, 1)
1326 .count();
1327 wallet_with_db
1328 .persist()
1329 .expect("persist revealed addresses");
1330 }
1331
1332 let page = backend
1333 .wallet_addresses(1, 2)
1334 .await
1335 .expect("list paginated addresses");
1336
1337 assert_eq!(page.total, 4);
1338 assert_eq!(page.items.len(), 2);
1339 assert_eq!(page.items[0].keychain, WalletKeychain::External);
1340 assert_eq!(page.items[0].derivation_index, 1);
1341 assert_eq!(page.items[1].keychain, WalletKeychain::Internal);
1342 assert_eq!(page.items[1].derivation_index, 0);
1343 }
1344
1345 async fn fund_backend_wallet_transactions(backend: &CdkBdk, amounts_sat: &[u64]) -> Vec<Txid> {
1346 let mut wallet_with_db = backend.wallet_with_db.lock().await;
1347 let funding_script = wallet_with_db
1348 .wallet
1349 .reveal_next_address(KeychainKind::External)
1350 .address
1351 .script_pubkey();
1352 let funding_transactions = amounts_sat
1353 .iter()
1354 .enumerate()
1355 .map(|(index, amount_sat)| Transaction {
1356 version: transaction::Version::TWO,
1357 lock_time: absolute::LockTime::ZERO,
1358 input: vec![TxIn {
1359 previous_output: OutPoint::new(
1360 Txid::all_zeros(),
1361 u32::try_from(index).expect("test transaction index fits in u32"),
1362 ),
1363 script_sig: Default::default(),
1364 sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1365 witness: Witness::new(),
1366 }],
1367 output: vec![TxOut {
1368 value: bdk_wallet::bitcoin::Amount::from_sat(*amount_sat),
1369 script_pubkey: funding_script.clone(),
1370 }],
1371 })
1372 .collect::<Vec<_>>();
1373 let txids = funding_transactions
1374 .iter()
1375 .map(Transaction::compute_txid)
1376 .collect();
1377
1378 wallet_with_db
1379 .wallet
1380 .apply_unconfirmed_txs(funding_transactions.into_iter().map(|tx| (tx, 0)));
1381 wallet_with_db.persist().expect("persist funded wallet");
1382
1383 txids
1384 }
1385
1386 async fn fund_backend_wallet(backend: &CdkBdk, amount_sat: u64) {
1387 fund_backend_wallet_transactions(backend, &[amount_sat]).await;
1388 }
1389
1390 #[tokio::test]
1391 async fn wallet_info_reports_unconfirmed_funding() {
1392 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1393 fund_backend_wallet(&backend, 42_000).await;
1394
1395 let balance = backend.wallet_balance().await;
1396 assert_eq!(balance.untrusted_pending_sat, 42_000);
1397 assert_eq!(balance.total_sat, 42_000);
1398
1399 let transactions = backend
1400 .wallet_transactions(0, 20)
1401 .await
1402 .expect("list transactions");
1403 assert_eq!(transactions.total, 1);
1404 assert_eq!(transactions.items[0].received_sat, 42_000);
1405 assert_eq!(transactions.items[0].sent_sat, 0);
1406 assert_eq!(transactions.items[0].balance_delta_sat, 42_000);
1407 assert_eq!(transactions.items[0].confirmation_height, None);
1408 assert_eq!(transactions.items[0].first_seen, Some(0));
1409 assert_eq!(
1410 transactions.items[0].inputs,
1411 vec![WalletTransactionInput {
1412 txid: Txid::all_zeros().to_string(),
1413 vout: 0,
1414 amount_sat: None,
1415 address: None,
1416 }]
1417 );
1418
1419 let addresses = backend
1420 .wallet_addresses(0, 20)
1421 .await
1422 .expect("list addresses");
1423 assert_eq!(addresses.total, 1);
1424 assert!(addresses.items[0].used);
1425 assert_eq!(
1426 transactions.items[0].outputs,
1427 vec![WalletTransactionOutput {
1428 vout: 0,
1429 address: addresses.items[0].address.clone(),
1430 amount_sat: 42_000,
1431 quote_id: None,
1432 }]
1433 );
1434 assert_eq!(addresses.items[0].balance_sat, 42_000);
1435 assert_eq!(addresses.items[0].confirmed_balance_sat, 0);
1436
1437 let empty_page = backend
1438 .wallet_transactions(0, 0)
1439 .await
1440 .expect("list empty transaction page");
1441 assert_eq!(empty_page.total, 1);
1442 assert!(empty_page.items.is_empty());
1443 }
1444
1445 #[tokio::test]
1446 async fn wallet_info_pairs_unconfirmed_incoming_output_with_quote_id() {
1447 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1448 fund_backend_wallet(&backend, 42_000).await;
1449 let address = backend
1450 .wallet_addresses(0, 20)
1451 .await
1452 .expect("list addresses")
1453 .items[0]
1454 .address
1455 .clone();
1456 backend
1457 .storage
1458 .track_receive_address(&address, "mint-quote")
1459 .await
1460 .expect("track receive address");
1461
1462 let transactions = backend
1463 .wallet_transactions(0, 20)
1464 .await
1465 .expect("list transactions");
1466 assert_eq!(
1467 transactions.items[0].outputs,
1468 vec![WalletTransactionOutput {
1469 vout: 0,
1470 address,
1471 amount_sat: 42_000,
1472 quote_id: Some("mint-quote".to_string()),
1473 }]
1474 );
1475 }
1476
1477 #[tokio::test]
1478 async fn wallet_info_pairs_batched_outputs_with_quote_ids() {
1479 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1480 let funding_txids = fund_backend_wallet_transactions(&backend, &[20_000, 22_000]).await;
1481 let funding_address = backend
1482 .wallet_addresses(0, 20)
1483 .await
1484 .expect("list funding address")
1485 .items[0]
1486 .address
1487 .clone();
1488 let first_address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string();
1489 let second_address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x".to_string();
1490
1491 let mut wallet_with_db = backend.wallet_with_db.lock().await;
1492 let first_recipient_script = bdk_wallet::bitcoin::Address::from_str(&first_address)
1493 .expect("valid address")
1494 .require_network(Network::Regtest)
1495 .expect("regtest address")
1496 .script_pubkey();
1497 let second_recipient_script = bdk_wallet::bitcoin::Address::from_str(&second_address)
1498 .expect("valid address")
1499 .require_network(Network::Regtest)
1500 .expect("regtest address")
1501 .script_pubkey();
1502 let change_script = wallet_with_db
1503 .wallet
1504 .reveal_next_address(KeychainKind::Internal)
1505 .address
1506 .script_pubkey();
1507 let spending_transaction = Transaction {
1508 version: transaction::Version::TWO,
1509 lock_time: absolute::LockTime::ZERO,
1510 input: funding_txids
1511 .iter()
1512 .rev()
1513 .map(|txid| TxIn {
1514 previous_output: OutPoint::new(*txid, 0),
1515 script_sig: Default::default(),
1516 sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1517 witness: Witness::new(),
1518 })
1519 .collect(),
1520 output: vec![
1521 TxOut {
1522 value: bdk_wallet::bitcoin::Amount::from_sat(20_000),
1523 script_pubkey: first_recipient_script,
1524 },
1525 TxOut {
1526 value: bdk_wallet::bitcoin::Amount::from_sat(10_000),
1527 script_pubkey: second_recipient_script,
1528 },
1529 TxOut {
1530 value: bdk_wallet::bitcoin::Amount::from_sat(11_900),
1531 script_pubkey: change_script,
1532 },
1533 ],
1534 };
1535 let spending_txid = spending_transaction.compute_txid();
1536 wallet_with_db
1537 .wallet
1538 .apply_unconfirmed_txs([(spending_transaction, 1)]);
1539 wallet_with_db
1540 .persist()
1541 .expect("persist spending transaction");
1542 drop(wallet_with_db);
1543
1544 let batch_id = Uuid::new_v4();
1545 let intents = [
1546 (Uuid::new_v4(), "quote-first", &first_address, 20_000, 0),
1547 (Uuid::new_v4(), "quote-second", &second_address, 10_000, 1),
1548 ];
1549 for (intent_id, quote_id, address, amount_sat, vout) in &intents {
1550 backend
1551 .storage
1552 .create_send_intent_if_absent(
1553 &crate::send::payment_intent::record::SendIntentRecord {
1554 intent_id: *intent_id,
1555 quote_id: quote_id.to_string(),
1556 address: address.to_string(),
1557 amount_sat: *amount_sat,
1558 max_fee_amount_sat: 1_000,
1559 tier: PaymentTier::Immediate,
1560 metadata: PaymentMetadata::default(),
1561 state: crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
1562 batch_id,
1563 txid: spending_txid.to_string(),
1564 outpoint: format!("{spending_txid}:{vout}"),
1565 fee_contribution_sat: 50,
1566 created_at: 0,
1567 },
1568 },
1569 )
1570 .await
1571 .expect("store send intent");
1572 }
1573
1574 let transactions = backend
1575 .wallet_transactions(0, 20)
1576 .await
1577 .expect("list transactions");
1578
1579 assert_eq!(transactions.total, 3);
1580 assert_eq!(
1581 transactions.items[0].inputs,
1582 vec![
1583 WalletTransactionInput {
1584 txid: funding_txids[1].to_string(),
1585 vout: 0,
1586 amount_sat: Some(22_000),
1587 address: Some(funding_address.clone()),
1588 },
1589 WalletTransactionInput {
1590 txid: funding_txids[0].to_string(),
1591 vout: 0,
1592 amount_sat: Some(20_000),
1593 address: Some(funding_address),
1594 },
1595 ]
1596 );
1597 assert_eq!(
1598 transactions.items[0].outputs,
1599 vec![
1600 WalletTransactionOutput {
1601 vout: 0,
1602 address: first_address.clone(),
1603 amount_sat: 20_000,
1604 quote_id: Some("quote-first".to_string()),
1605 },
1606 WalletTransactionOutput {
1607 vout: 1,
1608 address: second_address.clone(),
1609 amount_sat: 10_000,
1610 quote_id: Some("quote-second".to_string()),
1611 },
1612 ]
1613 );
1614 assert_eq!(transactions.items[0].sent_sat, 42_000);
1615 assert_eq!(transactions.items[0].received_sat, 11_900);
1616
1617 for (intent_id, quote_id, _, amount_sat, vout) in &intents {
1618 backend
1619 .storage
1620 .finalize_send_intent(
1621 intent_id,
1622 &FinalizedSendIntentRecord {
1623 intent_id: *intent_id,
1624 quote_id: quote_id.to_string(),
1625 total_spent_sat: *amount_sat + 50,
1626 outpoint: format!("{spending_txid}:{vout}"),
1627 finalized_at: 0,
1628 },
1629 )
1630 .await
1631 .expect("finalize send intent");
1632 }
1633
1634 let finalized_transactions = backend
1635 .wallet_transactions(0, 20)
1636 .await
1637 .expect("list transactions after intent finalization");
1638 assert_eq!(
1639 finalized_transactions.items[0]
1640 .outputs
1641 .iter()
1642 .map(|output| output.quote_id.as_deref())
1643 .collect::<Vec<_>>(),
1644 vec![Some("quote-first"), Some("quote-second")]
1645 );
1646 }
1647
1648 #[tokio::test]
1649 async fn wallet_info_reports_multiple_incoming_outputs_in_vout_order() {
1650 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1651 let mut wallet_with_db = backend.wallet_with_db.lock().await;
1652 let output_script = wallet_with_db
1653 .wallet
1654 .reveal_next_address(KeychainKind::External)
1655 .address
1656 .script_pubkey();
1657 let funding_transaction = Transaction {
1658 version: transaction::Version::TWO,
1659 lock_time: absolute::LockTime::ZERO,
1660 input: vec![TxIn {
1661 previous_output: OutPoint::new(Txid::all_zeros(), 0),
1662 script_sig: Default::default(),
1663 sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1664 witness: Witness::new(),
1665 }],
1666 output: vec![
1667 TxOut {
1668 value: bdk_wallet::bitcoin::Amount::from_sat(21_000),
1669 script_pubkey: output_script.clone(),
1670 },
1671 TxOut {
1672 value: bdk_wallet::bitcoin::Amount::from_sat(21_000),
1673 script_pubkey: output_script,
1674 },
1675 ],
1676 };
1677 wallet_with_db
1678 .wallet
1679 .apply_unconfirmed_txs([(funding_transaction, 0)]);
1680 wallet_with_db
1681 .persist()
1682 .expect("persist funding transaction");
1683 drop(wallet_with_db);
1684
1685 let transactions = backend
1686 .wallet_transactions(0, 20)
1687 .await
1688 .expect("list transactions");
1689
1690 assert_eq!(transactions.total, 1);
1691 assert_eq!(transactions.items[0].outputs.len(), 2);
1692 assert_eq!(transactions.items[0].outputs[0].vout, 0);
1693 assert_eq!(transactions.items[0].outputs[0].amount_sat, 21_000);
1694 assert_eq!(transactions.items[0].outputs[1].vout, 1);
1695 assert_eq!(transactions.items[0].outputs[1].amount_sat, 21_000);
1696 }
1697
1698 #[tokio::test]
1699 async fn wallet_info_uses_txid_to_order_equal_chain_positions() {
1700 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1701 let mut expected_txids =
1702 fund_backend_wallet_transactions(&backend, &[21_000, 42_000]).await;
1703 expected_txids.sort_by(|left, right| right.cmp(left));
1704
1705 let first_page = backend
1706 .wallet_transactions(0, 1)
1707 .await
1708 .expect("list first transaction page");
1709 let second_page = backend
1710 .wallet_transactions(1, 1)
1711 .await
1712 .expect("list second transaction page");
1713
1714 assert_eq!(first_page.total, 2);
1715 assert_eq!(second_page.total, 2);
1716 assert_eq!(first_page.items[0].txid, expected_txids[0].to_string());
1717 assert_eq!(second_page.items[0].txid, expected_txids[1].to_string());
1718 }
1719
1720 #[tokio::test]
1721 async fn test_new_rejects_zero_sync_interval() {
1722 match build_test_instance_with_config(5, None, 0).await {
1723 Err(Error::InvalidConfig(message)) => {
1724 assert!(message.contains("sync_interval_secs"));
1725 }
1726 Ok(_) => panic!("zero sync interval should be rejected"),
1727 Err(err) => panic!("expected invalid config error, got {err}"),
1728 }
1729 }
1730
1731 #[tokio::test]
1732 async fn test_new_rejects_zero_batch_poll_interval() {
1733 let batch_config = BatchConfig {
1734 poll_interval: Duration::ZERO,
1735 ..BatchConfig::default()
1736 };
1737
1738 match build_test_instance_with_config(5, Some(batch_config), 60).await {
1739 Err(Error::InvalidConfig(message)) => {
1740 assert!(message.contains("poll_interval"));
1741 }
1742 Ok(_) => panic!("zero batch poll interval should be rejected"),
1743 Err(err) => panic!("expected invalid config error, got {err}"),
1744 }
1745 }
1746
1747 #[tokio::test]
1748 async fn test_new_rejects_zero_target_block_time() {
1749 let batch_config = BatchConfig {
1750 target_block_time: Duration::ZERO,
1751 ..BatchConfig::default()
1752 };
1753
1754 match build_test_instance_with_config(5, Some(batch_config), 60).await {
1755 Err(Error::InvalidConfig(message)) => {
1756 assert!(message.contains("target_block_time"));
1757 }
1758 Ok(_) => panic!("zero target block time should be rejected"),
1759 Err(err) => panic!("expected invalid config error, got {err}"),
1760 }
1761 }
1762
1763 #[tokio::test]
1764 async fn test_new_rejects_invalid_fallback_fee_rate() {
1765 let batch_config = BatchConfig {
1766 fee_estimation: FeeEstimationConfig {
1767 fallback_sat_per_vb: 0.0,
1768 ..FeeEstimationConfig::default()
1769 },
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("fallback_sat_per_vb"));
1776 }
1777 Ok(_) => panic!("invalid fallback fee rate should be rejected"),
1778 Err(err) => panic!("expected invalid config error, got {err}"),
1779 }
1780 }
1781
1782 #[test]
1783 fn test_default_batch_deadlines_match_advertised_blocks() {
1784 let batch_config = BatchConfig::default();
1785
1786 assert_eq!(batch_config.target_block_time, Duration::from_secs(600));
1787 assert_eq!(batch_config.standard_deadline, Duration::from_secs(3600));
1788 assert_eq!(batch_config.economy_deadline, Duration::from_secs(86_400));
1789 assert_eq!(
1790 batch_config.max_intent_age,
1791 Some(Duration::from_secs(86_430))
1792 );
1793 }
1794
1795 #[tokio::test]
1796 async fn test_start_then_stop_exits_promptly() {
1797 let backend = build_test_instance(5).await;
1798
1799 let started = tokio::time::timeout(Duration::from_secs(10), backend.start())
1800 .await
1801 .expect("start timed out");
1802 started.expect("start should succeed");
1803
1804 let stopped = tokio::time::timeout(Duration::from_secs(10), backend.stop())
1805 .await
1806 .expect("stop timed out");
1807 stopped.expect("stop should succeed");
1808 }
1809
1810 #[tokio::test]
1811 async fn test_double_start_returns_already_started() {
1812 let backend = build_test_instance(5).await;
1813 backend.start().await.expect("first start");
1814
1815 let second = backend.start().await;
1816 assert!(second.is_err(), "second start should error");
1817
1818 backend.stop().await.expect("stop");
1819 }
1820
1821 #[tokio::test]
1822 async fn test_stop_without_start_is_ok() {
1823 let backend = build_test_instance(5).await;
1824 backend.stop().await.expect("stop on never-started is ok");
1825 backend.stop().await.expect("double stop is ok");
1826 }
1827
1828 #[tokio::test]
1829 async fn test_restart_after_stop() {
1830 let backend = build_test_instance(5).await;
1831 backend.start().await.expect("first start");
1832 backend.stop().await.expect("first stop");
1833 backend.start().await.expect("second start");
1834 backend.stop().await.expect("second stop");
1835 }
1836
1837 #[tokio::test]
1838 async fn test_wait_payment_event_tracks_active_state_and_cancels() {
1839 let backend = build_test_instance(5).await;
1840 assert!(!backend.is_payment_event_stream_active());
1841
1842 let mut stream = backend
1843 .wait_payment_event()
1844 .await
1845 .expect("payment event stream");
1846 assert!(backend.is_payment_event_stream_active());
1847
1848 backend.cancel_payment_event_stream();
1849
1850 let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
1851 .await
1852 .expect("stream should observe cancellation promptly");
1853 assert!(next.is_none());
1854 assert!(!backend.is_payment_event_stream_active());
1855 }
1856
1857 #[test]
1858 fn test_quote_fee_safety_adds_multiplier_and_fixed_margin() {
1859 let config = FeeEstimationConfig {
1860 quote_safety_multiplier: 1.25,
1861 quote_fixed_safety_sat: 500,
1862 ..FeeEstimationConfig::default()
1863 };
1864
1865 assert_eq!(apply_quote_fee_safety(1_000, &config), 1_750);
1866 }
1867
1868 #[tokio::test]
1869 async fn test_fee_rate_cache_falls_back_on_error() {
1870 let backend = build_test_instance(5).await;
1875
1876 let tier_err = backend
1877 .estimate_fee_rate_sat_per_vb(PaymentTier::Immediate)
1878 .await;
1879 assert!(
1880 tier_err.is_err(),
1881 "fee rate estimation should fail against bogus Esplora URL"
1882 );
1883 }
1884
1885 #[tokio::test]
1886 async fn test_get_payment_quote_does_not_stage_wallet_changes() {
1887 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1888 fund_backend_wallet(&backend, 100_000).await;
1889 let (_quote_id, options) = onchain_options_for(10_000);
1890
1891 backend
1892 .get_payment_quote(&CurrencyUnit::Sat, options)
1893 .await
1894 .expect("quote should succeed with fallback fee rate");
1895
1896 let wallet_with_db = backend.wallet_with_db.lock().await;
1897 assert!(
1898 wallet_with_db.wallet.staged().is_none(),
1899 "quote estimation must not mutate or stage BDK wallet state"
1900 );
1901 }
1902
1903 #[tokio::test]
1904 async fn test_default_fee_options_emit_immediate_only() {
1905 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1906 fund_backend_wallet(&backend, 100_000).await;
1907 let (_quote_id, options) = onchain_options_for(10_000);
1908
1909 let quote = backend
1910 .get_payment_quote(&CurrencyUnit::Sat, options)
1911 .await
1912 .expect("quote should succeed");
1913
1914 let fee_options = quote.fee_options.expect("fee options");
1915 assert_eq!(fee_options.len(), 1);
1916 assert_eq!(fee_options[0].fee_index, 0);
1917 assert_eq!(fee_options[0].estimated_blocks, 1);
1918 }
1919
1920 #[tokio::test]
1921 async fn test_configured_fee_options_emit_indexes_in_order() {
1922 let batch_config = BatchConfig {
1923 fee_options: vec![
1924 PaymentTier::Immediate,
1925 PaymentTier::Standard,
1926 PaymentTier::Economy,
1927 ],
1928 ..BatchConfig::default()
1929 };
1930 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1931 .await
1932 .expect("build CdkBdk test instance");
1933 fund_backend_wallet(&backend, 100_000).await;
1934 let (_quote_id, options) = onchain_options_for(10_000);
1935
1936 let quote = backend
1937 .get_payment_quote(&CurrencyUnit::Sat, options)
1938 .await
1939 .expect("quote should succeed");
1940
1941 let fee_options = quote.fee_options.expect("fee options");
1942 let indexes: Vec<u32> = fee_options.iter().map(|option| option.fee_index).collect();
1943 let estimated_blocks: Vec<u32> = fee_options
1944 .iter()
1945 .map(|option| option.estimated_blocks)
1946 .collect();
1947
1948 assert_eq!(indexes, vec![0, 1, 2]);
1949 assert_eq!(estimated_blocks, vec![1, 6, 144]);
1950 }
1951
1952 #[tokio::test]
1953 async fn test_configured_fee_index_resolves_by_position() {
1954 let batch_config = BatchConfig {
1955 fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
1956 ..BatchConfig::default()
1957 };
1958 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1959 .await
1960 .expect("build CdkBdk test instance");
1961 fund_backend_wallet(&backend, 100_000).await;
1962 let (quote_id, mut options) = onchain_options_for(10_000);
1963 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1964 panic!("expected onchain options");
1965 };
1966 onchain.fee_index = Some(1);
1967 onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
1968
1969 backend
1970 .make_payment(&CurrencyUnit::Sat, options)
1971 .await
1972 .expect("make_payment should enqueue the intent");
1973
1974 let intent = backend
1975 .storage
1976 .get_send_intent_by_quote_id("e_id.to_string())
1977 .await
1978 .expect("lookup send intent by quote id")
1979 .expect("send intent should be persisted");
1980
1981 assert_eq!(intent.tier, PaymentTier::Economy);
1982 }
1983
1984 #[tokio::test]
1985 async fn test_make_payment_returns_failed_for_unknown_fee_index() {
1986 let backend = build_test_instance(5).await;
1987 let (quote_id, mut options) = onchain_options_for(10_000);
1988 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1989 panic!("expected onchain options");
1990 };
1991 onchain.fee_index = Some(99);
1992
1993 let response = backend
1994 .make_payment(&CurrencyUnit::Sat, options)
1995 .await
1996 .expect("definitive pre-dispatch rejection should return a payment response");
1997
1998 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
1999 assert!(
2000 backend
2001 .storage
2002 .get_send_intent_by_quote_id("e_id.to_string())
2003 .await
2004 .expect("lookup send intent by quote id")
2005 .is_none(),
2006 "unknown fee index rejection must not leave a pending send intent behind"
2007 );
2008 }
2009
2010 #[tokio::test]
2011 async fn test_make_payment_omitted_fee_index_defaults_to_immediate() {
2012 let batch_config = BatchConfig {
2013 fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
2014 ..BatchConfig::default()
2015 };
2016 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
2017 .await
2018 .expect("build CdkBdk test instance");
2019 fund_backend_wallet(&backend, 100_000).await;
2020 let (quote_id, options) = onchain_options_for(10_000);
2021
2022 backend
2023 .make_payment(&CurrencyUnit::Sat, options)
2024 .await
2025 .expect("make_payment should enqueue the intent");
2026
2027 let intent = backend
2028 .storage
2029 .get_send_intent_by_quote_id("e_id.to_string())
2030 .await
2031 .expect("lookup send intent by quote id")
2032 .expect("send intent should be persisted");
2033
2034 assert_eq!(intent.tier, PaymentTier::Immediate);
2035 }
2036
2037 #[tokio::test]
2038 async fn test_new_rejects_invalid_fee_option_lists() {
2039 for fee_options in [
2040 Vec::new(),
2041 vec![PaymentTier::Immediate, PaymentTier::Immediate],
2042 vec![
2043 PaymentTier::Immediate,
2044 PaymentTier::Standard,
2045 PaymentTier::Economy,
2046 PaymentTier::Immediate,
2047 ],
2048 ] {
2049 let batch_config = BatchConfig {
2050 fee_options,
2051 ..BatchConfig::default()
2052 };
2053 match build_test_instance_with_config(5, Some(batch_config), 60).await {
2054 Err(Error::InvalidConfig(message)) => {
2055 assert!(message.contains("fee_options"));
2056 }
2057 Ok(_) => panic!("invalid fee options should be rejected"),
2058 Err(err) => panic!("expected invalid config error, got {err}"),
2059 }
2060 }
2061 }
2062
2063 #[tokio::test]
2064 async fn test_get_payment_quote_rejects_empty_wallet() {
2065 let backend = build_test_instance(5).await;
2066 let (_quote_id, options) = onchain_options_for(10_000);
2067
2068 let err = backend
2069 .get_payment_quote(&CurrencyUnit::Sat, options)
2070 .await
2071 .expect_err("empty wallet should not receive an onchain quote");
2072
2073 let cdk_common::payment::Error::Onchain(inner) = err else {
2074 panic!("expected onchain error");
2075 };
2076
2077 let backend_err = inner
2078 .downcast_ref::<Error>()
2079 .expect("expected cdk-bdk backend error");
2080 assert!(matches!(backend_err, Error::NoSpendableUtxos));
2081 }
2082
2083 #[tokio::test]
2084 async fn test_make_payment_returns_failed_when_current_fee_exceeds_max_fee() {
2085 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2086 fund_backend_wallet(&backend, 100_000).await;
2087 let (quote_id, mut options) = onchain_options_for(10_000);
2088 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
2089 panic!("expected onchain options");
2090 };
2091 onchain.max_fee_amount = Some(Amount::new(1, CurrencyUnit::Sat));
2092
2093 let response = backend
2094 .make_payment(&CurrencyUnit::Sat, options)
2095 .await
2096 .expect("definitive pre-dispatch rejection should return a payment response");
2097
2098 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2099
2100 assert!(
2101 backend
2102 .storage
2103 .get_send_intent_by_quote_id("e_id.to_string())
2104 .await
2105 .expect("lookup send intent by quote id")
2106 .is_none(),
2107 "fee recheck rejection must not leave a pending send intent behind"
2108 );
2109 }
2110
2111 #[tokio::test]
2112 async fn test_get_settings_reports_min_send_amount() {
2113 let backend = build_test_instance(5).await;
2114
2115 let settings = backend.get_settings().await.expect("settings");
2116 let onchain = settings.onchain.expect("onchain settings");
2117
2118 assert_eq!(onchain.min_receive_amount_sat, 0);
2119 assert_eq!(onchain.min_send_amount_sat, 546);
2120 }
2121
2122 use cdk_common::payment::OnchainOutgoingPaymentOptions;
2131 use cdk_common::QuoteId;
2132 use uuid::Uuid;
2133
2134 fn onchain_options_for(amount_sat: u64) -> (QuoteId, OutgoingPaymentOptions) {
2136 let quote_id = QuoteId::UUID(Uuid::new_v4());
2137 (
2138 quote_id.clone(),
2139 onchain_options_for_quote(quote_id, amount_sat),
2140 )
2141 }
2142
2143 fn onchain_options_for_quote(quote_id: QuoteId, amount_sat: u64) -> OutgoingPaymentOptions {
2144 OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
2145 address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2146 amount: Amount::new(amount_sat, CurrencyUnit::Sat),
2147 max_fee_amount: Some(Amount::new(1_000, CurrencyUnit::Sat)),
2148 quote_id,
2149 fee_index: None,
2150 metadata: None,
2151 }))
2152 }
2153
2154 fn onchain_options_for_msat(
2155 quote_id: QuoteId,
2156 amount_msat: u64,
2157 max_fee_msat: u64,
2158 ) -> OutgoingPaymentOptions {
2159 OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
2160 address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2161 amount: Amount::new(amount_msat, CurrencyUnit::Msat),
2162 max_fee_amount: Some(Amount::new(max_fee_msat, CurrencyUnit::Msat)),
2163 quote_id,
2164 fee_index: None,
2165 metadata: None,
2166 }))
2167 }
2168
2169 fn assert_authoritative_failure_response(
2170 response: MakePaymentResponse,
2171 quote_id: QuoteId,
2172 unit: CurrencyUnit,
2173 ) {
2174 assert_eq!(
2175 response.payment_lookup_id,
2176 PaymentIdentifier::QuoteId(quote_id)
2177 );
2178 assert_eq!(response.status, MeltQuoteState::Failed);
2179 assert_eq!(response.total_spent, Amount::new(0, unit));
2180 assert!(response.payment_proof.is_none());
2181 }
2182
2183 #[tokio::test]
2184 async fn test_get_payment_quote_converts_fee_options_to_msat() {
2185 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2186 fund_backend_wallet(&backend, 100_000).await;
2187 let quote_id = QuoteId::UUID(Uuid::new_v4());
2188 let options = onchain_options_for_msat(quote_id, 10_000_000, 10_000_000);
2189
2190 let quote = backend
2191 .get_payment_quote(&CurrencyUnit::Msat, options)
2192 .await
2193 .expect("msat quote should succeed");
2194
2195 assert_eq!(quote.amount, Amount::new(10_000_000, CurrencyUnit::Msat));
2196 assert_eq!(quote.fee.unit(), &CurrencyUnit::Msat);
2197 assert_eq!(quote.fee.value() % MSAT_IN_SAT, 0);
2198
2199 let fee_options = quote.fee_options.expect("fee options");
2200 assert!(fee_options
2201 .iter()
2202 .all(|option| u64::from(option.fee_reserve) % MSAT_IN_SAT == 0));
2203 assert_eq!(
2204 quote.fee.value(),
2205 fee_options
2206 .iter()
2207 .map(|option| u64::from(option.fee_reserve))
2208 .min()
2209 .expect("non-empty fee options")
2210 );
2211 }
2212
2213 #[tokio::test]
2214 async fn test_make_payment_converts_msat_amount_and_fee_to_sat() {
2215 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2216 fund_backend_wallet(&backend, 100_000).await;
2217 let quote_id = QuoteId::UUID(Uuid::new_v4());
2218 let options = onchain_options_for_msat(quote_id.clone(), 10_000_000, 10_000_000);
2219
2220 let response = backend
2221 .make_payment(&CurrencyUnit::Msat, options)
2222 .await
2223 .expect("msat payment should enqueue a sat-native intent");
2224
2225 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Msat));
2226 let intent = backend
2227 .storage
2228 .get_send_intent_by_quote_id("e_id.to_string())
2229 .await
2230 .expect("lookup send intent")
2231 .expect("send intent should be persisted");
2232 assert_eq!(intent.amount_sat, 10_000);
2233 assert_eq!(intent.max_fee_amount_sat, 10_000);
2234 }
2235
2236 #[tokio::test]
2237 async fn test_make_payment_returns_failed_for_fractional_satoshi_amount() {
2238 let backend = build_test_instance(5).await;
2239 let quote_id = QuoteId::UUID(Uuid::new_v4());
2240 let options = onchain_options_for_msat(quote_id.clone(), 10_000_001, 10_000_000);
2241
2242 let response = backend
2243 .make_payment(&CurrencyUnit::Msat, options)
2244 .await
2245 .expect("definitive pre-dispatch rejection should return a payment response");
2246
2247 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Msat);
2248 assert!(backend
2249 .storage
2250 .get_send_intent_by_quote_id("e_id.to_string())
2251 .await
2252 .expect("lookup send intent")
2253 .is_none());
2254 }
2255
2256 #[tokio::test]
2257 async fn test_make_payment_returns_failed_for_mismatched_fee_unit() {
2258 let backend = build_test_instance(5).await;
2259 let quote_id = QuoteId::UUID(Uuid::new_v4());
2260 let mut options = onchain_options_for_msat(quote_id.clone(), 10_000_000, 10_000_000);
2261 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
2262 panic!("expected onchain options");
2263 };
2264 onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
2265
2266 let response = backend
2267 .make_payment(&CurrencyUnit::Msat, options)
2268 .await
2269 .expect("definitive pre-dispatch rejection should return a payment response");
2270
2271 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Msat);
2272 assert!(backend
2273 .storage
2274 .get_send_intent_by_quote_id("e_id.to_string())
2275 .await
2276 .expect("lookup send intent")
2277 .is_none());
2278 }
2279
2280 #[tokio::test]
2281 async fn test_make_payment_pending_total_spent_is_zero() {
2282 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2286 fund_backend_wallet(&backend, 100_000).await;
2287 let (quote_id, options) = onchain_options_for(10_000);
2288
2289 let response = backend
2290 .make_payment(&CurrencyUnit::Sat, options)
2291 .await
2292 .expect("make_payment should enqueue the intent");
2293
2294 assert_eq!(response.status, MeltQuoteState::Pending);
2295 assert_eq!(
2296 response.payment_lookup_id,
2297 PaymentIdentifier::QuoteId(quote_id)
2298 );
2299 assert_eq!(
2300 response.total_spent,
2301 Amount::new(0, CurrencyUnit::Sat),
2302 "Pending onchain response MUST use 0 sentinel; the real \
2303 total_spent is only known after the batch transaction is built"
2304 );
2305 }
2306
2307 #[tokio::test]
2308 async fn test_get_payment_quote_rejects_dust_output() {
2309 let backend = build_test_instance(5).await;
2310 let (_quote_id, options) = onchain_options_for(1);
2311
2312 let err = backend
2313 .get_payment_quote(&CurrencyUnit::Sat, options)
2314 .await
2315 .expect_err("dust output should be rejected at quote time");
2316
2317 let cdk_common::payment::Error::Onchain(inner) = err else {
2318 panic!("expected onchain error");
2319 };
2320
2321 let backend_err = inner
2322 .downcast_ref::<Error>()
2323 .expect("expected cdk-bdk backend error");
2324 assert!(matches!(backend_err, Error::DustOutput { .. }));
2325 }
2326
2327 #[tokio::test]
2328 async fn test_make_payment_returns_failed_for_dust_without_persisting_intent() {
2329 let backend = build_test_instance(5).await;
2330 let (quote_id, options) = onchain_options_for(1);
2331
2332 let response = backend
2333 .make_payment(&CurrencyUnit::Sat, options)
2334 .await
2335 .expect("definitive pre-dispatch rejection should return a payment response");
2336
2337 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2338 assert!(
2339 backend
2340 .storage
2341 .get_send_intent_by_quote_id("e_id.to_string())
2342 .await
2343 .expect("lookup send intent by quote id")
2344 .is_none(),
2345 "dust rejection must not leave a pending send intent behind"
2346 );
2347 }
2348
2349 #[tokio::test]
2350 async fn test_get_payment_quote_rejects_amount_below_minimum_send() {
2351 let backend = build_test_instance(5).await;
2352 let (_quote_id, options) = onchain_options_for(545);
2353
2354 let err = backend
2355 .get_payment_quote(&CurrencyUnit::Sat, options)
2356 .await
2357 .expect_err("amount below configured minimum should be rejected at quote time");
2358
2359 let cdk_common::payment::Error::Onchain(inner) = err else {
2360 panic!("expected onchain error");
2361 };
2362
2363 let backend_err = inner
2364 .downcast_ref::<Error>()
2365 .expect("expected cdk-bdk backend error");
2366 assert!(matches!(
2367 backend_err,
2368 Error::AmountBelowMinimumSend {
2369 amount: 545,
2370 min: 546
2371 }
2372 ));
2373 }
2374
2375 #[tokio::test]
2376 async fn test_make_payment_returns_failed_below_minimum_without_persisting_intent() {
2377 let backend = build_test_instance(5).await;
2378 let (quote_id, options) = onchain_options_for(545);
2379
2380 let response = backend
2381 .make_payment(&CurrencyUnit::Sat, options)
2382 .await
2383 .expect("definitive pre-dispatch rejection should return a payment response");
2384
2385 assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2386 assert!(
2387 backend
2388 .storage
2389 .get_send_intent_by_quote_id("e_id.to_string())
2390 .await
2391 .expect("lookup send intent by quote id")
2392 .is_none(),
2393 "minimum-send rejection must not leave a pending send intent behind"
2394 );
2395 }
2396
2397 #[tokio::test]
2398 async fn test_check_outgoing_payment_pending_intent_reports_zero_total_spent() {
2399 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2403 fund_backend_wallet(&backend, 100_000).await;
2404 let (quote_id, options) = onchain_options_for(12_345);
2405
2406 backend
2407 .make_payment(&CurrencyUnit::Sat, options)
2408 .await
2409 .expect("make_payment should enqueue the intent");
2410
2411 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2412 let response = backend
2413 .check_outgoing_payment(&payment_identifier)
2414 .await
2415 .expect("check_outgoing_payment for Pending intent");
2416
2417 assert_eq!(response.status, MeltQuoteState::Pending);
2418 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2419 assert_eq!(response.payment_proof, None);
2420 }
2421
2422 #[tokio::test]
2423 async fn test_check_outgoing_payment_batched_intent_reports_zero_total_spent() {
2424 use crate::send::payment_intent::SendIntent;
2428 use crate::types::{PaymentMetadata, PaymentTier};
2429
2430 let backend = build_test_instance(5).await;
2431 let quote_id = QuoteId::UUID(Uuid::new_v4());
2432
2433 let pending = SendIntent::new(
2434 &backend.storage,
2435 quote_id.to_string(),
2436 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2437 20_000,
2438 1_000,
2439 PaymentTier::Standard,
2440 PaymentMetadata::default(),
2441 )
2442 .await
2443 .expect("create Pending send intent");
2444
2445 pending
2446 .assign_to_batch(&backend.storage, Uuid::new_v4())
2447 .await
2448 .expect("transition Pending → Batched");
2449
2450 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2451 let response = backend
2452 .check_outgoing_payment(&payment_identifier)
2453 .await
2454 .expect("check_outgoing_payment for Batched intent");
2455
2456 assert_eq!(response.status, MeltQuoteState::Pending);
2457 assert_eq!(
2458 response.total_spent,
2459 Amount::new(0, CurrencyUnit::Sat),
2460 "Batched intents report total_spent = 0 until the batch \
2461 transaction is built and the per-intent fee is fixed"
2462 );
2463 }
2464
2465 #[tokio::test]
2466 async fn test_check_outgoing_payment_awaiting_confirmation_includes_fee() {
2467 use crate::send::payment_intent::SendIntent;
2473 use crate::types::{PaymentMetadata, PaymentTier};
2474
2475 let backend = build_test_instance(5).await;
2476 let quote_id = QuoteId::UUID(Uuid::new_v4());
2477
2478 let pending = SendIntent::new(
2479 &backend.storage,
2480 quote_id.to_string(),
2481 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2482 30_000,
2483 2_000,
2484 PaymentTier::Immediate,
2485 PaymentMetadata::default(),
2486 )
2487 .await
2488 .expect("create Pending send intent");
2489
2490 let batched = pending
2491 .assign_to_batch(&backend.storage, Uuid::new_v4())
2492 .await
2493 .expect("transition Pending → Batched");
2494
2495 let fee_contrib = 512_u64;
2496 batched
2497 .mark_broadcast(
2498 &backend.storage,
2499 "deadbeef".to_string(),
2500 "deadbeef:0".to_string(),
2501 fee_contrib,
2502 )
2503 .await
2504 .expect("transition Batched → AwaitingConfirmation");
2505
2506 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2507 let response = backend
2508 .check_outgoing_payment(&payment_identifier)
2509 .await
2510 .expect("check_outgoing_payment for AwaitingConfirmation intent");
2511
2512 assert_eq!(response.status, MeltQuoteState::Pending);
2513 assert_eq!(
2514 response.total_spent,
2515 Amount::new(30_000 + fee_contrib, CurrencyUnit::Sat),
2516 "AwaitingConfirmation intents know the per-intent fee \
2517 contribution and must report amount + fee"
2518 );
2519 }
2520
2521 #[tokio::test]
2522 async fn test_check_outgoing_payment_failed_intent_reports_failed() {
2523 use crate::send::payment_intent::SendIntent;
2524 use crate::types::{PaymentMetadata, PaymentTier};
2525
2526 let backend = build_test_instance(5).await;
2527 let quote_id = QuoteId::UUID(Uuid::new_v4());
2528
2529 let pending = SendIntent::new(
2530 &backend.storage,
2531 quote_id.to_string(),
2532 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2533 30_000,
2534 2_000,
2535 PaymentTier::Immediate,
2536 PaymentMetadata::default(),
2537 )
2538 .await
2539 .expect("create Pending send intent");
2540
2541 pending
2542 .fail(&backend.storage, "fee too high".to_string())
2543 .await
2544 .expect("transition Pending to Failed");
2545
2546 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2547 let response = backend
2548 .check_outgoing_payment(&payment_identifier)
2549 .await
2550 .expect("check_outgoing_payment for Failed intent");
2551
2552 assert_eq!(response.status, MeltQuoteState::Failed);
2553 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2554 assert_eq!(response.payment_proof, None);
2555 }
2556
2557 #[tokio::test]
2558 async fn test_make_payment_can_retry_failed_intent_with_same_quote_id() {
2559 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2560 fund_backend_wallet(&backend, 100_000).await;
2561 let (quote_id, options) = onchain_options_for(30_000);
2562
2563 backend
2564 .make_payment(&CurrencyUnit::Sat, options)
2565 .await
2566 .expect("initial make_payment should enqueue intent");
2567
2568 let initial = backend
2569 .storage
2570 .get_send_intent_by_quote_id("e_id.to_string())
2571 .await
2572 .expect("lookup initial intent")
2573 .expect("initial intent exists");
2574
2575 backend
2576 .storage
2577 .update_send_intent(
2578 &initial.intent_id,
2579 &crate::send::payment_intent::record::SendIntentState::Failed {
2580 reason: "pre-sign failure".to_string(),
2581 created_at: 1_700_000_000,
2582 failed_at: 1_700_000_100,
2583 },
2584 )
2585 .await
2586 .expect("mark failed");
2587
2588 let retry_options = onchain_options_for_quote(quote_id.clone(), 30_000);
2589 let response = backend
2590 .make_payment(&CurrencyUnit::Sat, retry_options)
2591 .await
2592 .expect("retry with same quote id should requeue failed intent");
2593
2594 assert_eq!(response.status, MeltQuoteState::Pending);
2595
2596 let retried = backend
2597 .storage
2598 .get_send_intent_by_quote_id("e_id.to_string())
2599 .await
2600 .expect("lookup retried intent")
2601 .expect("retried intent exists");
2602 assert_eq!(retried.intent_id, initial.intent_id);
2603 assert!(matches!(
2604 retried.state,
2605 crate::send::payment_intent::record::SendIntentState::Pending { .. }
2606 ));
2607 }
2608
2609 #[tokio::test]
2610 async fn test_check_outgoing_payment_unknown_quote_reports_zero() {
2611 let backend = build_test_instance(5).await;
2615 let quote_id = QuoteId::UUID(Uuid::new_v4());
2616 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2617
2618 let response = backend
2619 .check_outgoing_payment(&payment_identifier)
2620 .await
2621 .expect("check_outgoing_payment for unknown quote");
2622
2623 assert_eq!(response.status, MeltQuoteState::Unknown);
2624 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2625 assert_eq!(response.payment_proof, None);
2626 }
2627
2628 #[test]
2633 fn test_is_transient_classifies_network_errors() {
2634 let esplora_err = Error::Esplora(
2638 "HttpResponse { status: 525, message: \"error code: 525\" }".to_string(),
2639 );
2640 assert!(esplora_err.is_transient());
2641
2642 let esplora_404 = Error::Esplora(
2643 "HttpResponse { status: 404, message: \"Block not found\" }".to_string(),
2644 );
2645 assert!(esplora_404.is_transient());
2646
2647 let wallet_err = Error::Wallet("invalid checkpoint".to_string());
2650 assert!(!wallet_err.is_transient());
2651
2652 let vout_err = Error::VoutNotFound;
2653 assert!(!vout_err.is_transient());
2654
2655 let io_err = Error::Io(std::io::Error::new(
2657 std::io::ErrorKind::TimedOut,
2658 "network timeout",
2659 ));
2660 assert!(io_err.is_transient());
2661
2662 let io_other = Error::Io(std::io::Error::new(
2664 std::io::ErrorKind::InvalidData,
2665 "bad data",
2666 ));
2667 assert!(!io_other.is_transient());
2668 }
2669
2670 #[tokio::test]
2671 async fn test_supervisor_restarts_failing_task_with_backoff() {
2672 let cancel = CancellationToken::new();
2675 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
2676
2677 let counter_clone = Arc::clone(&counter);
2678 let cancel_inner = cancel.clone();
2679 let supervisor = tokio::spawn(async move {
2680 super::supervise("test", cancel_inner, move |_c| {
2681 let c = Arc::clone(&counter_clone);
2682 async move {
2683 c.fetch_add(1, Ordering::Relaxed);
2684 Err::<(), Error>(Error::Esplora("boom".to_string()))
2685 }
2686 })
2687 .await;
2688 });
2689
2690 tokio::time::sleep(Duration::from_millis(2_500)).await;
2692 cancel.cancel();
2693
2694 tokio::time::timeout(Duration::from_secs(5), supervisor)
2695 .await
2696 .expect("supervisor did not exit after cancel")
2697 .expect("supervisor task panicked");
2698
2699 let n = counter.load(Ordering::Relaxed);
2700 assert!(
2701 n >= 2,
2702 "supervisor should have restarted the task at least twice, got {n}"
2703 );
2704 }
2705
2706 #[tokio::test]
2707 async fn test_supervisor_exits_on_ok() {
2708 let cancel = CancellationToken::new();
2711 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
2712
2713 let counter_clone = Arc::clone(&counter);
2714 let cancel_inner = cancel.clone();
2715 let supervisor = tokio::spawn(async move {
2716 super::supervise("test", cancel_inner, move |_c| {
2717 let c = Arc::clone(&counter_clone);
2718 async move {
2719 c.fetch_add(1, Ordering::Relaxed);
2720 Ok::<(), Error>(())
2721 }
2722 })
2723 .await;
2724 });
2725
2726 tokio::time::timeout(Duration::from_secs(5), supervisor)
2727 .await
2728 .expect("supervisor did not exit after Ok(())")
2729 .expect("supervisor task panicked");
2730
2731 assert_eq!(
2732 counter.load(Ordering::Relaxed),
2733 1,
2734 "supervisor must not restart a task that returned Ok(())"
2735 );
2736 }
2737
2738 #[tokio::test]
2739 async fn test_supervisor_cancel_during_backoff() {
2740 let cancel = CancellationToken::new();
2743 let cancel_inner = cancel.clone();
2744 let supervisor = tokio::spawn(async move {
2745 super::supervise("test", cancel_inner, move |_c| async move {
2746 Err::<(), Error>(Error::Esplora("boom".to_string()))
2748 })
2749 .await;
2750 });
2751
2752 tokio::time::sleep(Duration::from_millis(200)).await;
2754 let cancel_at = std::time::Instant::now();
2755 cancel.cancel();
2756
2757 tokio::time::timeout(Duration::from_secs(2), supervisor)
2758 .await
2759 .expect("supervisor did not exit promptly after cancel")
2760 .expect("supervisor task panicked");
2761
2762 let elapsed = cancel_at.elapsed();
2763 assert!(
2764 elapsed < Duration::from_millis(500),
2765 "supervisor took {elapsed:?} to exit after cancel; expected < 500ms"
2766 );
2767 }
2768
2769 #[tokio::test]
2770 async fn test_sync_wallet_survives_unreachable_esplora() {
2771 let backend = build_test_instance(5).await;
2777 backend.start().await.expect("start");
2778
2779 tokio::time::sleep(Duration::from_millis(500)).await;
2784
2785 {
2787 let tasks = backend.tasks.lock().await;
2788 let bg = tasks.as_ref().expect("tasks running");
2789 assert!(
2790 !bg.sync.is_finished(),
2791 "sync task must not exit on transient Esplora errors"
2792 );
2793 }
2794
2795 backend.stop().await.expect("stop");
2796 }
2797
2798 #[cfg(feature = "electrum")]
2799 #[tokio::test]
2800 async fn test_sync_wallet_survives_unreachable_electrum() {
2801 let chain_source = ChainSource::Electrum(ElectrumConfig {
2802 url: "tcp://127.0.0.1:1".to_string(),
2803 batch_size: 5,
2804 });
2805 let (backend, _tmp) = build_test_instance_with_chain_source(5, None, 60, chain_source)
2806 .await
2807 .expect("build Electrum test instance");
2808
2809 backend.start().await.expect("start");
2810 tokio::time::sleep(Duration::from_millis(500)).await;
2811
2812 {
2813 let tasks = backend.tasks.lock().await;
2814 let background = tasks.as_ref().expect("tasks running");
2815 assert!(
2816 !background.sync.is_finished(),
2817 "sync task must not exit on transient Electrum errors"
2818 );
2819 }
2820
2821 backend.stop().await.expect("stop");
2822 }
2823}