1#![doc = include_str!("../README.md")]
4
5use std::fs;
6use std::future::Future;
7use std::path::PathBuf;
8use std::pin::Pin;
9use std::str::FromStr;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12use std::task::{Context, Poll};
13use std::time::{Duration, Instant};
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;
20use bdk_wallet::template::Bip84;
21use bdk_wallet::{KeychainKind, PersistedWallet, 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, 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};
61
62pub(crate) struct WalletWithDb {
64 pub(crate) wallet: PersistedWallet<Connection>,
65 pub(crate) db: Connection,
66}
67
68pub(crate) struct BackgroundTasks {
69 pub(crate) cancel: CancellationToken,
70 pub(crate) sync: JoinHandle<()>,
71 pub(crate) batch: JoinHandle<()>,
72}
73
74struct PaymentEventStream {
75 receiver: BroadcastStream<Event>,
76 cancel: Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
77 is_active: Arc<AtomicBool>,
78}
79
80impl Stream for PaymentEventStream {
81 type Item = Event;
82
83 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
84 let this = self.get_mut();
85
86 if this.cancel.as_mut().poll(cx).is_ready() {
87 this.is_active.store(false, Ordering::SeqCst);
88 return Poll::Ready(None);
89 }
90
91 loop {
92 match Pin::new(&mut this.receiver).poll_next(cx) {
93 Poll::Ready(Some(Ok(event))) => return Poll::Ready(Some(event)),
94 Poll::Ready(Some(Err(err))) => {
95 tracing::warn!(
96 "cdk-bdk payment event subscriber lagged or errored: {}",
97 err
98 );
99 }
100 Poll::Ready(None) => {
101 this.is_active.store(false, Ordering::SeqCst);
102 return Poll::Ready(None);
103 }
104 Poll::Pending => return Poll::Pending,
105 }
106 }
107 }
108}
109
110impl Drop for PaymentEventStream {
111 fn drop(&mut self) {
112 self.is_active.store(false, Ordering::SeqCst);
113 }
114}
115
116impl WalletWithDb {
117 pub(crate) fn new(wallet: PersistedWallet<Connection>, db: Connection) -> Self {
118 Self { wallet, db }
119 }
120
121 pub(crate) fn persist(&mut self) -> Result<bool, bdk_wallet::rusqlite::Error> {
122 self.wallet.persist(&mut self.db)
123 }
124}
125
126#[derive(Clone)]
128pub struct CdkBdk {
129 pub(crate) fee_reserve: FeeReserve,
130 pub(crate) wait_invoice_cancel_token: CancellationToken,
131 pub(crate) wait_invoice_is_active: Arc<AtomicBool>,
132 pub(crate) payment_sender: tokio::sync::broadcast::Sender<Event>,
133 pub(crate) tasks: Arc<Mutex<Option<BackgroundTasks>>>,
134 pub(crate) shutdown_timeout: Duration,
135 pub(crate) wallet_with_db: Arc<Mutex<WalletWithDb>>,
136 pub(crate) chain_source: ChainSource,
137 pub(crate) storage: BdkStorage,
138 pub(crate) network: Network,
139 pub(crate) batch_config: BatchConfig,
141 pub(crate) batch_notify: Arc<Notify>,
143 pub(crate) num_confs: u32,
145 pub(crate) min_receive_amount_sat: u64,
147 pub(crate) min_send_amount_sat: u64,
149 pub(crate) sync_interval_secs: u64,
151 pub(crate) sync_config: SyncConfig,
153 pub(crate) fee_rate_cache: Arc<Mutex<std::collections::HashMap<PaymentTier, (f64, u64)>>>,
155}
156
157impl CdkBdk {
158 fn ensure_supported_payment_unit(
159 unit: &CurrencyUnit,
160 ) -> Result<(), cdk_common::payment::Error> {
161 match unit {
162 CurrencyUnit::Sat | CurrencyUnit::Msat => Ok(()),
163 _ => Err(cdk_common::payment::Error::UnsupportedUnit),
164 }
165 }
166
167 fn ensure_amount_unit(unit: &CurrencyUnit, amount: &Amount<CurrencyUnit>) -> Result<(), Error> {
168 if amount.unit() != unit {
169 return Err(Error::AmountUnitMismatch {
170 expected: unit.clone(),
171 actual: amount.unit().clone(),
172 });
173 }
174
175 Ok(())
176 }
177
178 fn payment_amount_to_sat(
179 unit: &CurrencyUnit,
180 amount: &Amount<CurrencyUnit>,
181 ) -> Result<u64, Error> {
182 Self::ensure_amount_unit(unit, amount)?;
183
184 if unit == &CurrencyUnit::Msat && amount.value() % MSAT_IN_SAT != 0 {
185 return Err(Error::FractionalSatoshiAmount {
186 amount_msat: amount.value(),
187 });
188 }
189
190 amount.to_sat().map_err(Error::from)
191 }
192
193 fn fee_limit_to_sat(unit: &CurrencyUnit, amount: &Amount<CurrencyUnit>) -> Result<u64, Error> {
194 Self::ensure_amount_unit(unit, amount)?;
195 amount.to_sat().map_err(Error::from)
196 }
197
198 pub(crate) fn validate_send_amount_against_dust(
199 &self,
200 address: &str,
201 amount_sat: u64,
202 ) -> Result<(), Error> {
203 let address = bdk_wallet::bitcoin::Address::from_str(address)
204 .map_err(|e| Error::Wallet(e.to_string()))?
205 .require_network(self.network)
206 .map_err(|e| Error::Wallet(e.to_string()))?;
207
208 let dust_limit = bdk_wallet::bitcoin::TxOut::minimal_non_dust(address.script_pubkey())
209 .value
210 .to_sat();
211
212 if amount_sat < dust_limit {
213 return Err(Error::DustOutput {
214 amount: amount_sat,
215 dust_limit,
216 });
217 }
218
219 Ok(())
220 }
221
222 pub(crate) fn validate_send_amount(&self, address: &str, amount_sat: u64) -> Result<(), Error> {
223 self.validate_send_amount_against_dust(address, amount_sat)?;
224
225 if amount_sat < self.min_send_amount_sat {
226 return Err(Error::AmountBelowMinimumSend {
227 amount: amount_sat,
228 min: self.min_send_amount_sat,
229 });
230 }
231
232 Ok(())
233 }
234
235 pub(crate) fn confirmations_satisfied(&self, tip_height: u32, anchor_height: u32) -> bool {
236 if tip_height < anchor_height {
237 return false;
238 }
239
240 tip_height - anchor_height + 1 >= self.num_confs
241 }
242
243 pub(crate) fn should_ignore_receive_amount(&self, amount_sat: u64) -> bool {
244 amount_sat < self.min_receive_amount_sat
245 }
246
247 pub(crate) fn txid_has_required_confirmations(
250 &self,
251 wallet: &PersistedWallet<Connection>,
252 txid_str: &str,
253 intent_kind: &str,
254 intent_id: &str,
255 ) -> bool {
256 let Ok(parsed_txid) = bdk_wallet::bitcoin::Txid::from_str(txid_str) else {
257 tracing::warn!(
258 intent_kind,
259 intent_id,
260 txid = txid_str,
261 "Could not parse txid during confirmation check"
262 );
263 return false;
264 };
265
266 let Some(tx_details) = wallet.get_tx(parsed_txid) else {
267 return false;
268 };
269
270 let check_point = wallet.latest_checkpoint().height();
271 match &tx_details.chain_position {
272 bdk_wallet::chain::ChainPosition::Confirmed { anchor, .. } => {
273 self.confirmations_satisfied(check_point, anchor.block_id.height)
274 }
275 bdk_wallet::chain::ChainPosition::Unconfirmed { .. } => false,
276 }
277 }
278
279 #[allow(clippy::too_many_arguments)]
281 pub fn new(
282 mnemonic: Mnemonic,
283 network: Network,
284 chain_source: ChainSource,
285 storage_dir_path: String,
286 fee_reserve: FeeReserve,
287 kv_store: Arc<dyn KVStore<Err = cdk_common::database::Error> + Send + Sync>,
288 batch_config: Option<BatchConfig>,
289 num_confs: u32,
290 min_receive_amount_sat: u64,
291 min_send_amount_sat: u64,
292 sync_interval_secs: u64,
293 shutdown_timeout_secs: Option<u64>,
294 sync_config: Option<SyncConfig>,
295 ) -> Result<Self, Error> {
296 let storage_dir_path = PathBuf::from(storage_dir_path);
297 let storage_dir_path = storage_dir_path.join("bdk_wallet");
298 fs::create_dir_all(&storage_dir_path)?;
299
300 let mut db = Connection::open(storage_dir_path.join("bdk_wallet.sqlite"))?;
301
302 let xkey: ExtendedKey = mnemonic.into_extended_key()?;
303 let xprv = xkey.into_xprv(network.into()).ok_or(Error::Path)?;
304
305 let descriptor = Bip84(xprv, KeychainKind::External);
306 let change_descriptor = Bip84(xprv, KeychainKind::Internal);
307
308 let wallet_opt = Wallet::load()
309 .descriptor(KeychainKind::External, Some(descriptor.clone()))
310 .descriptor(KeychainKind::Internal, Some(change_descriptor.clone()))
311 .extract_keys()
312 .check_network(network)
313 .load_wallet(&mut db)
314 .map_err(|e| Error::Wallet(e.to_string()))?;
315
316 let mut wallet = match wallet_opt {
317 Some(wallet) => wallet,
318 None => Wallet::create(descriptor, change_descriptor)
319 .network(network)
320 .create_wallet(&mut db)
321 .map_err(|e| Error::Wallet(e.to_string()))?,
322 };
323
324 wallet.persist(&mut db)?;
325
326 let wallet_with_db = WalletWithDb::new(wallet, db);
327
328 let batch_config = batch_config.unwrap_or_default();
329 if batch_config.poll_interval.is_zero() {
330 return Err(Error::InvalidConfig(
331 "batch_config.poll_interval must be greater than zero".to_string(),
332 ));
333 }
334 batch_config.validate().map_err(Error::InvalidConfig)?;
335
336 if sync_interval_secs == 0 {
337 return Err(Error::InvalidConfig(
338 "sync_interval_secs must be greater than zero".to_string(),
339 ));
340 }
341
342 let channel_capacity = batch_config.max_batch_size * 2 + 16;
343 let (payment_sender, _) = tokio::sync::broadcast::channel(channel_capacity);
344
345 Ok(Self {
346 fee_reserve,
347 wait_invoice_cancel_token: CancellationToken::new(),
348 wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
349 payment_sender,
350 tasks: Arc::new(Mutex::new(None)),
351 shutdown_timeout: Duration::from_secs(shutdown_timeout_secs.unwrap_or(30)),
352 wallet_with_db: Arc::new(Mutex::new(wallet_with_db)),
353 chain_source,
354 storage: BdkStorage::new(kv_store),
355 network,
356 batch_config,
357 batch_notify: Arc::new(Notify::new()),
358 num_confs,
359 min_receive_amount_sat,
360 min_send_amount_sat,
361 sync_interval_secs,
362 sync_config: sync_config.unwrap_or_default(),
363 fee_rate_cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
364 })
365 }
366}
367
368async fn supervise<F, Fut>(name: &'static str, cancel: CancellationToken, mut f: F)
376where
377 F: FnMut(CancellationToken) -> Fut,
378 Fut: Future<Output = Result<(), Error>>,
379{
380 const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
381 const MAX_BACKOFF: Duration = Duration::from_secs(60);
382 const SUPERVISOR_BACKOFF_RESET: Duration = Duration::from_secs(300);
383
384 let mut backoff = INITIAL_BACKOFF;
385
386 loop {
387 if cancel.is_cancelled() {
388 break;
389 }
390
391 let started = Instant::now();
392 let child_cancel = cancel.clone();
393
394 let result = tokio::select! {
395 _ = cancel.cancelled() => {
396 tracing::info!("{name} supervisor: cancelled");
397 return;
398 }
399 r = f(child_cancel) => r,
400 };
401
402 match result {
403 Ok(()) => {
404 tracing::info!("{name} supervisor: task exited cleanly");
405 return;
406 }
407 Err(e) => {
408 let ran_for = started.elapsed();
409 let transient = e.is_transient();
410 tracing::error!(
411 task = name,
412 ran_for_secs = ran_for.as_secs(),
413 transient,
414 "supervised task returned error: {e}; restarting with backoff"
415 );
416
417 if ran_for >= SUPERVISOR_BACKOFF_RESET {
418 backoff = INITIAL_BACKOFF;
419 }
420
421 tokio::select! {
423 _ = cancel.cancelled() => {
424 tracing::info!("{name} supervisor: cancelled during backoff");
425 return;
426 }
427 _ = tokio::time::sleep(backoff) => {}
428 }
429
430 backoff = (backoff * 2).min(MAX_BACKOFF);
431 }
432 }
433 }
434}
435
436#[async_trait]
437impl MintPayment for CdkBdk {
438 type Err = cdk_common::payment::Error;
439
440 #[tracing::instrument(skip_all)]
441 async fn start(&self) -> Result<(), Self::Err> {
442 let mut tasks_lock = self.tasks.lock().await;
443 if tasks_lock.is_some() {
444 return Err(Error::AlreadyStarted.into());
445 }
446
447 self.recover_receive_saga().await?;
448 self.recover_send_saga().await?;
449
450 let cancel = CancellationToken::new();
451
452 let sync_self = self.clone();
453 let sync_cancel = cancel.clone();
454 let sync_handle = tokio::spawn(async move {
455 supervise("wallet sync", sync_cancel, move |cancel| {
456 let me = sync_self.clone();
457 async move { me.sync_wallet(cancel).await }
458 })
459 .await;
460 });
461
462 let batch_self = self.clone();
463 let batch_cancel = cancel.clone();
464 let batch_handle = tokio::spawn(async move {
465 supervise("batch processor", batch_cancel, move |cancel| {
466 let me = batch_self.clone();
467 async move { me.run_batch_processor(cancel).await }
468 })
469 .await;
470 });
471
472 *tasks_lock = Some(BackgroundTasks {
473 cancel,
474 sync: sync_handle,
475 batch: batch_handle,
476 });
477
478 Ok(())
479 }
480
481 async fn stop(&self) -> Result<(), Self::Err> {
482 self.wait_invoice_cancel_token.cancel();
483
484 let tasks_opt = {
485 let mut tasks_lock = self.tasks.lock().await;
486 tasks_lock.take()
487 };
488
489 if let Some(bg) = tasks_opt {
490 bg.cancel.cancel();
491
492 let sync_aborter = bg.sync.abort_handle();
493 let batch_aborter = bg.batch.abort_handle();
494
495 let joined = tokio::time::timeout(self.shutdown_timeout, async move {
496 let _ = bg.sync.await;
497 let _ = bg.batch.await;
498 })
499 .await;
500
501 if joined.is_err() {
502 sync_aborter.abort();
503 batch_aborter.abort();
504 tracing::error!(
505 "cdk-bdk background tasks did not exit within {:?}; forced abort",
506 self.shutdown_timeout
507 );
508 }
509 }
510
511 Ok(())
512 }
513
514 async fn get_settings(&self) -> Result<SettingsResponse, Self::Err> {
515 Ok(SettingsResponse {
516 unit: "sat".to_string(),
517 bolt11: None,
518 bolt12: None,
519 onchain: Some(OnchainSettings {
520 confirmations: self.num_confs,
521 min_receive_amount_sat: self.min_receive_amount_sat,
522 min_send_amount_sat: self.min_send_amount_sat,
523 }),
524 custom: std::collections::HashMap::new(),
525 })
526 }
527
528 async fn get_payment_quote(
529 &self,
530 unit: &CurrencyUnit,
531 options: OutgoingPaymentOptions,
532 ) -> Result<PaymentQuoteResponse, Self::Err> {
533 Self::ensure_supported_payment_unit(unit)?;
534
535 let onchain_options = match options {
536 OutgoingPaymentOptions::Onchain(o) => o,
537 _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
538 };
539
540 let amount_sat = Self::payment_amount_to_sat(unit, &onchain_options.amount)?;
541 self.validate_send_amount(&onchain_options.address, amount_sat)?;
542
543 let mut fee_options = Vec::with_capacity(self.batch_config.fee_options.len());
547 for (idx, tier) in self.batch_config.fee_options.iter().enumerate() {
548 let fee_estimate = self
549 .estimate_onchain_fee_reserve(&onchain_options.address, amount_sat, *tier)
550 .await?;
551 let fee_reserve = Amount::new(fee_estimate.fee_reserve_sat, CurrencyUnit::Sat)
552 .convert_to(unit)
553 .map_err(Error::AmountConversion)?;
554 fee_options.push(MeltQuoteOnchainFeeOption {
555 fee_index: idx as u32,
556 fee_reserve: fee_reserve.into(),
557 estimated_blocks: tier.estimated_blocks(),
558 });
559 }
560
561 let cheapest = fee_options
565 .iter()
566 .min_by_key(|option| u64::from(option.fee_reserve))
567 .copied()
568 .expect("fee_options is validated as non-empty");
569
570 Ok(PaymentQuoteResponse {
575 request_lookup_id: Some(PaymentIdentifier::QuoteId(onchain_options.quote_id.clone())),
576 amount: onchain_options.amount,
577 fee: Amount::new(cheapest.fee_reserve.into(), unit.clone()),
578 state: MeltQuoteState::Unpaid,
579 extra_json: None,
580 estimated_blocks: Some(cheapest.estimated_blocks),
581 fee_options: Some(fee_options),
582 })
583 }
584
585 async fn make_payment(
586 &self,
587 unit: &CurrencyUnit,
588 options: OutgoingPaymentOptions,
589 ) -> Result<MakePaymentResponse, Self::Err> {
590 Self::ensure_supported_payment_unit(unit)?;
591
592 let onchain_options = match options {
593 OutgoingPaymentOptions::Onchain(o) => o,
594 _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
595 };
596
597 let address = onchain_options.address;
598 let amount = onchain_options.amount;
599 let quote_id = onchain_options.quote_id;
600
601 let amount_sat = Self::payment_amount_to_sat(unit, &amount)?;
602 self.validate_send_amount(&address, amount_sat)?;
603
604 let max_fee_sat = match onchain_options.max_fee_amount {
605 Some(max_fee) => Self::fee_limit_to_sat(unit, &max_fee)?,
606 None => 1_000,
607 };
608 let tier = self
612 .batch_config
613 .tier_for_fee_index(onchain_options.fee_index)
614 .map_err(Error::UnknownFeeIndex)?;
615 let metadata = PaymentMetadata::from_optional_json(onchain_options.metadata.as_deref());
616 let fee_estimate = self
617 .estimate_onchain_fee_reserve(&address, amount_sat, tier)
618 .await?;
619 if fee_estimate.raw_fee_sat > max_fee_sat {
620 return Err(Error::EstimatedFeeTooHigh {
621 estimated_fee: fee_estimate.raw_fee_sat,
622 max_fee: max_fee_sat,
623 }
624 .into());
625 }
626
627 crate::send::payment_intent::SendIntent::new(
628 &self.storage,
629 quote_id.to_string(),
630 address,
631 amount_sat,
632 max_fee_sat,
633 tier,
634 metadata,
635 )
636 .await?;
637
638 if tier == PaymentTier::Immediate {
639 self.batch_notify.notify_one();
640 }
641
642 Ok(MakePaymentResponse {
649 payment_lookup_id: PaymentIdentifier::QuoteId(quote_id),
650 payment_proof: None,
651 status: MeltQuoteState::Pending,
652 total_spent: Amount::new(0, unit.clone()),
653 })
654 }
655
656 async fn create_incoming_payment_request(
657 &self,
658 options: IncomingPaymentOptions,
659 ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
660 let onchain_options = match options {
661 IncomingPaymentOptions::Onchain(o) => o,
662 _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
663 };
664
665 let mut wallet_with_db = self.wallet_with_db.lock().await;
666 let address = wallet_with_db
667 .wallet
668 .reveal_next_address(KeychainKind::External);
669 let address_str = address.address.to_string();
670
671 wallet_with_db.persist().map_err(|err| {
672 tracing::error!("Could not persist to bdk db: {}", err);
673
674 Error::BdkPersist
675 })?;
676
677 let quote_id = onchain_options.quote_id;
678
679 self.storage
680 .track_receive_address(&address_str, "e_id.to_string())
681 .await?;
682
683 Ok(CreateIncomingPaymentResponse {
684 request_lookup_id: PaymentIdentifier::QuoteId(quote_id),
685 request: address_str,
686 expiry: None,
687 extra_json: None,
688 })
689 }
690
691 async fn wait_payment_event(
692 &self,
693 ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
694 self.wait_invoice_is_active.store(true, Ordering::SeqCst);
695
696 let receiver = self.payment_sender.subscribe();
697 let stream = PaymentEventStream {
698 receiver: BroadcastStream::new(receiver),
699 cancel: Box::pin(self.wait_invoice_cancel_token.clone().cancelled_owned()),
700 is_active: Arc::clone(&self.wait_invoice_is_active),
701 };
702
703 Ok(Box::pin(stream))
704 }
705
706 async fn check_incoming_payment_status(
707 &self,
708 payment_identifier: &PaymentIdentifier,
709 ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
710 let PaymentIdentifier::QuoteId(quote_id) = payment_identifier else {
711 return Err(Error::UnsupportedOnchain.into());
712 };
713
714 let quote_id_str = quote_id.to_string();
715 let mut results = Vec::new();
716
717 let finalized = self
720 .storage
721 .get_finalized_receive_intents_by_quote_id("e_id_str)
722 .await?;
723
724 for record in finalized {
725 results.push(WaitPaymentResponse {
726 payment_identifier: payment_identifier.clone(),
727 payment_amount: Amount::new(record.amount_sat, CurrencyUnit::Sat),
728 payment_id: record.outpoint,
729 });
730 }
731
732 Ok(results)
733 }
734
735 async fn check_outgoing_payment(
736 &self,
737 payment_identifier: &PaymentIdentifier,
738 ) -> Result<MakePaymentResponse, Self::Err> {
739 let quote_id = match payment_identifier {
740 PaymentIdentifier::QuoteId(id) => id.to_string(),
741 _ => return Err(Error::UnsupportedOnchain.into()),
742 };
743
744 if let Some(record) = self.storage.get_send_intent_by_quote_id("e_id).await? {
746 let total_spent = match &record.state {
752 crate::send::payment_intent::record::SendIntentState::Pending { .. }
753 | crate::send::payment_intent::record::SendIntentState::Batched { .. } => {
754 Amount::new(0, CurrencyUnit::Sat)
755 }
756 crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
757 fee_contribution_sat,
758 ..
759 } => Amount::new(record.amount_sat + fee_contribution_sat, CurrencyUnit::Sat),
760 crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
761 Amount::new(0, CurrencyUnit::Sat)
762 }
763 };
764 let status = match record.state {
765 crate::send::payment_intent::record::SendIntentState::Pending { .. }
766 | crate::send::payment_intent::record::SendIntentState::Batched { .. }
767 | crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
768 ..
769 } => MeltQuoteState::Pending,
770 crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
771 MeltQuoteState::Failed
772 }
773 };
774
775 return Ok(MakePaymentResponse {
776 payment_lookup_id: payment_identifier.clone(),
777 payment_proof: None,
778 status,
779 total_spent,
780 });
781 }
782
783 if let Some(record) = self
785 .storage
786 .get_finalized_intent_by_quote_id("e_id)
787 .await?
788 {
789 return Ok(MakePaymentResponse {
790 payment_lookup_id: payment_identifier.clone(),
791 payment_proof: Some(record.outpoint),
792 status: MeltQuoteState::Paid,
793 total_spent: Amount::new(record.total_spent_sat, CurrencyUnit::Sat),
794 });
795 }
796
797 Ok(MakePaymentResponse {
798 payment_lookup_id: payment_identifier.clone(),
799 payment_proof: None,
800 status: MeltQuoteState::Unknown,
801 total_spent: Amount::new(0, CurrencyUnit::Sat),
802 })
803 }
804
805 fn is_payment_event_stream_active(&self) -> bool {
806 self.wait_invoice_is_active.load(Ordering::SeqCst)
807 }
808
809 fn cancel_payment_event_stream(&self) {
810 self.wait_invoice_cancel_token.cancel();
811 }
812}
813
814#[cfg(test)]
815mod tests {
816 use std::str::FromStr;
817
818 use bdk_wallet::bitcoin::hashes::Hash as _;
819 use bdk_wallet::bitcoin::{
820 absolute, transaction, Network, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness,
821 };
822 use bdk_wallet::keys::bip39::Mnemonic;
823 use cdk_common::common::FeeReserve;
824 use cdk_common::payment::{MintPayment, OnchainIncomingPaymentOptions};
825 use futures::StreamExt;
826
827 use super::*;
828 use crate::fee::apply_quote_fee_safety;
829
830 async fn build_test_instance(shutdown_timeout_secs: u64) -> CdkBdk {
834 build_test_instance_with_tempdir(shutdown_timeout_secs)
835 .await
836 .0
837 }
838
839 async fn build_test_instance_with_tempdir(
840 shutdown_timeout_secs: u64,
841 ) -> (CdkBdk, tempfile::TempDir) {
842 build_test_instance_with_config(shutdown_timeout_secs, None, 60)
843 .await
844 .expect("build CdkBdk test instance")
845 }
846
847 async fn build_test_instance_with_config(
848 shutdown_timeout_secs: u64,
849 batch_config: Option<BatchConfig>,
850 sync_interval_secs: u64,
851 ) -> Result<(CdkBdk, tempfile::TempDir), Error> {
852 let tmp = tempfile::tempdir().expect("tempdir");
853 let mnemonic = Mnemonic::from_str(
854 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
855 )
856 .expect("mnemonic");
857
858 let kv = cdk_sqlite::mint::memory::empty()
859 .await
860 .expect("in-memory kv store");
861
862 let chain_source = ChainSource::Esplora(EsploraConfig {
863 url: "http://127.0.0.1:1".to_string(),
864 parallel_requests: 1,
865 });
866
867 let fee_reserve = FeeReserve {
868 min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(),
869 percent_fee_reserve: 0.02,
870 };
871
872 let backend = CdkBdk::new(
873 mnemonic,
874 Network::Regtest,
875 chain_source,
876 tmp.path().to_string_lossy().into_owned(),
877 fee_reserve,
878 Arc::new(kv),
879 batch_config,
880 1,
881 0,
882 546,
883 sync_interval_secs,
884 Some(shutdown_timeout_secs),
885 None,
886 )?;
887
888 Ok((backend, tmp))
889 }
890
891 #[tokio::test]
892 async fn wallet_info_lists_revealed_addresses_without_revealing_more() {
893 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
894
895 let initial_addresses = backend
896 .wallet_addresses(0, 100)
897 .await
898 .expect("list initial addresses");
899 assert_eq!(initial_addresses.total, 0);
900
901 backend
902 .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
903 OnchainIncomingPaymentOptions {
904 quote_id: cdk_common::QuoteId::new(),
905 },
906 ))
907 .await
908 .expect("create on-chain request");
909
910 let addresses = backend
911 .wallet_addresses(0, 100)
912 .await
913 .expect("list revealed addresses");
914 assert_eq!(addresses.total, 1);
915 assert_eq!(addresses.items.len(), 1);
916 assert_eq!(addresses.items[0].keychain, WalletKeychain::External);
917 assert_eq!(addresses.items[0].derivation_index, 0);
918 assert!(!addresses.items[0].used);
919 assert_eq!(addresses.items[0].balance_sat, 0);
920
921 let balance = backend.wallet_balance().await;
922 assert_eq!(balance.total_sat, 0);
923 assert_eq!(
924 backend
925 .wallet_transactions(0, 20)
926 .await
927 .expect("list transactions")
928 .total,
929 0
930 );
931
932 let addresses_again = backend
933 .wallet_addresses(0, 100)
934 .await
935 .expect("list revealed addresses again");
936 assert_eq!(addresses_again.total, 1);
937 }
938
939 #[tokio::test]
940 async fn wallet_info_paginates_revealed_addresses_across_keychains() {
941 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
942
943 {
944 let mut wallet_with_db = backend.wallet_with_db.lock().await;
945 let _ = wallet_with_db
946 .wallet
947 .reveal_addresses_to(KeychainKind::External, 1)
948 .count();
949 let _ = wallet_with_db
950 .wallet
951 .reveal_addresses_to(KeychainKind::Internal, 1)
952 .count();
953 wallet_with_db
954 .persist()
955 .expect("persist revealed addresses");
956 }
957
958 let page = backend
959 .wallet_addresses(1, 2)
960 .await
961 .expect("list paginated addresses");
962
963 assert_eq!(page.total, 4);
964 assert_eq!(page.items.len(), 2);
965 assert_eq!(page.items[0].keychain, WalletKeychain::External);
966 assert_eq!(page.items[0].derivation_index, 1);
967 assert_eq!(page.items[1].keychain, WalletKeychain::Internal);
968 assert_eq!(page.items[1].derivation_index, 0);
969 }
970
971 async fn fund_backend_wallet_transactions(backend: &CdkBdk, amounts_sat: &[u64]) -> Vec<Txid> {
972 let mut wallet_with_db = backend.wallet_with_db.lock().await;
973 let funding_script = wallet_with_db
974 .wallet
975 .reveal_next_address(KeychainKind::External)
976 .address
977 .script_pubkey();
978 let funding_transactions = amounts_sat
979 .iter()
980 .enumerate()
981 .map(|(index, amount_sat)| Transaction {
982 version: transaction::Version::TWO,
983 lock_time: absolute::LockTime::ZERO,
984 input: vec![TxIn {
985 previous_output: OutPoint::new(
986 Txid::all_zeros(),
987 u32::try_from(index).expect("test transaction index fits in u32"),
988 ),
989 script_sig: Default::default(),
990 sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
991 witness: Witness::new(),
992 }],
993 output: vec![TxOut {
994 value: bdk_wallet::bitcoin::Amount::from_sat(*amount_sat),
995 script_pubkey: funding_script.clone(),
996 }],
997 })
998 .collect::<Vec<_>>();
999 let txids = funding_transactions
1000 .iter()
1001 .map(Transaction::compute_txid)
1002 .collect();
1003
1004 wallet_with_db
1005 .wallet
1006 .apply_unconfirmed_txs(funding_transactions.into_iter().map(|tx| (tx, 0)));
1007 wallet_with_db.persist().expect("persist funded wallet");
1008
1009 txids
1010 }
1011
1012 async fn fund_backend_wallet(backend: &CdkBdk, amount_sat: u64) {
1013 fund_backend_wallet_transactions(backend, &[amount_sat]).await;
1014 }
1015
1016 #[tokio::test]
1017 async fn wallet_info_reports_unconfirmed_funding() {
1018 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1019 fund_backend_wallet(&backend, 42_000).await;
1020
1021 let balance = backend.wallet_balance().await;
1022 assert_eq!(balance.untrusted_pending_sat, 42_000);
1023 assert_eq!(balance.total_sat, 42_000);
1024
1025 let transactions = backend
1026 .wallet_transactions(0, 20)
1027 .await
1028 .expect("list transactions");
1029 assert_eq!(transactions.total, 1);
1030 assert_eq!(transactions.items[0].received_sat, 42_000);
1031 assert_eq!(transactions.items[0].sent_sat, 0);
1032 assert_eq!(transactions.items[0].balance_delta_sat, 42_000);
1033 assert_eq!(transactions.items[0].confirmation_height, None);
1034 assert_eq!(transactions.items[0].first_seen, Some(0));
1035
1036 let addresses = backend
1037 .wallet_addresses(0, 20)
1038 .await
1039 .expect("list addresses");
1040 assert_eq!(addresses.total, 1);
1041 assert!(addresses.items[0].used);
1042 assert_eq!(addresses.items[0].balance_sat, 42_000);
1043 assert_eq!(addresses.items[0].confirmed_balance_sat, 0);
1044
1045 let empty_page = backend
1046 .wallet_transactions(0, 0)
1047 .await
1048 .expect("list empty transaction page");
1049 assert_eq!(empty_page.total, 1);
1050 assert!(empty_page.items.is_empty());
1051 }
1052
1053 #[tokio::test]
1054 async fn wallet_info_uses_txid_to_order_equal_chain_positions() {
1055 let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1056 let mut expected_txids =
1057 fund_backend_wallet_transactions(&backend, &[21_000, 42_000]).await;
1058 expected_txids.sort_by(|left, right| right.cmp(left));
1059
1060 let first_page = backend
1061 .wallet_transactions(0, 1)
1062 .await
1063 .expect("list first transaction page");
1064 let second_page = backend
1065 .wallet_transactions(1, 1)
1066 .await
1067 .expect("list second transaction page");
1068
1069 assert_eq!(first_page.total, 2);
1070 assert_eq!(second_page.total, 2);
1071 assert_eq!(first_page.items[0].txid, expected_txids[0].to_string());
1072 assert_eq!(second_page.items[0].txid, expected_txids[1].to_string());
1073 }
1074
1075 #[tokio::test]
1076 async fn test_new_rejects_zero_sync_interval() {
1077 match build_test_instance_with_config(5, None, 0).await {
1078 Err(Error::InvalidConfig(message)) => {
1079 assert!(message.contains("sync_interval_secs"));
1080 }
1081 Ok(_) => panic!("zero sync interval should be rejected"),
1082 Err(err) => panic!("expected invalid config error, got {err}"),
1083 }
1084 }
1085
1086 #[tokio::test]
1087 async fn test_new_rejects_zero_batch_poll_interval() {
1088 let batch_config = BatchConfig {
1089 poll_interval: Duration::ZERO,
1090 ..BatchConfig::default()
1091 };
1092
1093 match build_test_instance_with_config(5, Some(batch_config), 60).await {
1094 Err(Error::InvalidConfig(message)) => {
1095 assert!(message.contains("poll_interval"));
1096 }
1097 Ok(_) => panic!("zero batch poll interval should be rejected"),
1098 Err(err) => panic!("expected invalid config error, got {err}"),
1099 }
1100 }
1101
1102 #[tokio::test]
1103 async fn test_new_rejects_zero_target_block_time() {
1104 let batch_config = BatchConfig {
1105 target_block_time: Duration::ZERO,
1106 ..BatchConfig::default()
1107 };
1108
1109 match build_test_instance_with_config(5, Some(batch_config), 60).await {
1110 Err(Error::InvalidConfig(message)) => {
1111 assert!(message.contains("target_block_time"));
1112 }
1113 Ok(_) => panic!("zero target block time should be rejected"),
1114 Err(err) => panic!("expected invalid config error, got {err}"),
1115 }
1116 }
1117
1118 #[tokio::test]
1119 async fn test_new_rejects_invalid_fallback_fee_rate() {
1120 let batch_config = BatchConfig {
1121 fee_estimation: FeeEstimationConfig {
1122 fallback_sat_per_vb: 0.0,
1123 ..FeeEstimationConfig::default()
1124 },
1125 ..BatchConfig::default()
1126 };
1127
1128 match build_test_instance_with_config(5, Some(batch_config), 60).await {
1129 Err(Error::InvalidConfig(message)) => {
1130 assert!(message.contains("fallback_sat_per_vb"));
1131 }
1132 Ok(_) => panic!("invalid fallback fee rate should be rejected"),
1133 Err(err) => panic!("expected invalid config error, got {err}"),
1134 }
1135 }
1136
1137 #[test]
1138 fn test_default_batch_deadlines_match_advertised_blocks() {
1139 let batch_config = BatchConfig::default();
1140
1141 assert_eq!(batch_config.target_block_time, Duration::from_secs(600));
1142 assert_eq!(batch_config.standard_deadline, Duration::from_secs(3600));
1143 assert_eq!(batch_config.economy_deadline, Duration::from_secs(86_400));
1144 assert_eq!(
1145 batch_config.max_intent_age,
1146 Some(Duration::from_secs(86_430))
1147 );
1148 }
1149
1150 #[tokio::test]
1151 async fn test_start_then_stop_exits_promptly() {
1152 let backend = build_test_instance(5).await;
1153
1154 let started = tokio::time::timeout(Duration::from_secs(10), backend.start())
1155 .await
1156 .expect("start timed out");
1157 started.expect("start should succeed");
1158
1159 let stopped = tokio::time::timeout(Duration::from_secs(10), backend.stop())
1160 .await
1161 .expect("stop timed out");
1162 stopped.expect("stop should succeed");
1163 }
1164
1165 #[tokio::test]
1166 async fn test_double_start_returns_already_started() {
1167 let backend = build_test_instance(5).await;
1168 backend.start().await.expect("first start");
1169
1170 let second = backend.start().await;
1171 assert!(second.is_err(), "second start should error");
1172
1173 backend.stop().await.expect("stop");
1174 }
1175
1176 #[tokio::test]
1177 async fn test_stop_without_start_is_ok() {
1178 let backend = build_test_instance(5).await;
1179 backend.stop().await.expect("stop on never-started is ok");
1180 backend.stop().await.expect("double stop is ok");
1181 }
1182
1183 #[tokio::test]
1184 async fn test_restart_after_stop() {
1185 let backend = build_test_instance(5).await;
1186 backend.start().await.expect("first start");
1187 backend.stop().await.expect("first stop");
1188 backend.start().await.expect("second start");
1189 backend.stop().await.expect("second stop");
1190 }
1191
1192 #[tokio::test]
1193 async fn test_wait_payment_event_tracks_active_state_and_cancels() {
1194 let backend = build_test_instance(5).await;
1195 assert!(!backend.is_payment_event_stream_active());
1196
1197 let mut stream = backend
1198 .wait_payment_event()
1199 .await
1200 .expect("payment event stream");
1201 assert!(backend.is_payment_event_stream_active());
1202
1203 backend.cancel_payment_event_stream();
1204
1205 let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
1206 .await
1207 .expect("stream should observe cancellation promptly");
1208 assert!(next.is_none());
1209 assert!(!backend.is_payment_event_stream_active());
1210 }
1211
1212 #[test]
1213 fn test_quote_fee_safety_adds_multiplier_and_fixed_margin() {
1214 let config = FeeEstimationConfig {
1215 quote_safety_multiplier: 1.25,
1216 quote_fixed_safety_sat: 500,
1217 ..FeeEstimationConfig::default()
1218 };
1219
1220 assert_eq!(apply_quote_fee_safety(1_000, &config), 1_750);
1221 }
1222
1223 #[tokio::test]
1224 async fn test_fee_rate_cache_falls_back_on_error() {
1225 let backend = build_test_instance(5).await;
1230
1231 let tier_err = backend
1232 .estimate_fee_rate_sat_per_vb(PaymentTier::Immediate)
1233 .await;
1234 assert!(
1235 tier_err.is_err(),
1236 "fee rate estimation should fail against bogus Esplora URL"
1237 );
1238 }
1239
1240 #[tokio::test]
1241 async fn test_get_payment_quote_does_not_stage_wallet_changes() {
1242 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1243 fund_backend_wallet(&backend, 100_000).await;
1244 let (_quote_id, options) = onchain_options_for(10_000);
1245
1246 backend
1247 .get_payment_quote(&CurrencyUnit::Sat, options)
1248 .await
1249 .expect("quote should succeed with fallback fee rate");
1250
1251 let wallet_with_db = backend.wallet_with_db.lock().await;
1252 assert!(
1253 wallet_with_db.wallet.staged().is_none(),
1254 "quote estimation must not mutate or stage BDK wallet state"
1255 );
1256 }
1257
1258 #[tokio::test]
1259 async fn test_default_fee_options_emit_immediate_only() {
1260 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1261 fund_backend_wallet(&backend, 100_000).await;
1262 let (_quote_id, options) = onchain_options_for(10_000);
1263
1264 let quote = backend
1265 .get_payment_quote(&CurrencyUnit::Sat, options)
1266 .await
1267 .expect("quote should succeed");
1268
1269 let fee_options = quote.fee_options.expect("fee options");
1270 assert_eq!(fee_options.len(), 1);
1271 assert_eq!(fee_options[0].fee_index, 0);
1272 assert_eq!(fee_options[0].estimated_blocks, 1);
1273 }
1274
1275 #[tokio::test]
1276 async fn test_configured_fee_options_emit_indexes_in_order() {
1277 let batch_config = BatchConfig {
1278 fee_options: vec![
1279 PaymentTier::Immediate,
1280 PaymentTier::Standard,
1281 PaymentTier::Economy,
1282 ],
1283 ..BatchConfig::default()
1284 };
1285 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1286 .await
1287 .expect("build CdkBdk test instance");
1288 fund_backend_wallet(&backend, 100_000).await;
1289 let (_quote_id, options) = onchain_options_for(10_000);
1290
1291 let quote = backend
1292 .get_payment_quote(&CurrencyUnit::Sat, options)
1293 .await
1294 .expect("quote should succeed");
1295
1296 let fee_options = quote.fee_options.expect("fee options");
1297 let indexes: Vec<u32> = fee_options.iter().map(|option| option.fee_index).collect();
1298 let estimated_blocks: Vec<u32> = fee_options
1299 .iter()
1300 .map(|option| option.estimated_blocks)
1301 .collect();
1302
1303 assert_eq!(indexes, vec![0, 1, 2]);
1304 assert_eq!(estimated_blocks, vec![1, 6, 144]);
1305 }
1306
1307 #[tokio::test]
1308 async fn test_configured_fee_index_resolves_by_position() {
1309 let batch_config = BatchConfig {
1310 fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
1311 ..BatchConfig::default()
1312 };
1313 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1314 .await
1315 .expect("build CdkBdk test instance");
1316 fund_backend_wallet(&backend, 100_000).await;
1317 let (quote_id, mut options) = onchain_options_for(10_000);
1318 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1319 panic!("expected onchain options");
1320 };
1321 onchain.fee_index = Some(1);
1322 onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
1323
1324 backend
1325 .make_payment(&CurrencyUnit::Sat, options)
1326 .await
1327 .expect("make_payment should enqueue the intent");
1328
1329 let intent = backend
1330 .storage
1331 .get_send_intent_by_quote_id("e_id.to_string())
1332 .await
1333 .expect("lookup send intent by quote id")
1334 .expect("send intent should be persisted");
1335
1336 assert_eq!(intent.tier, PaymentTier::Economy);
1337 }
1338
1339 #[tokio::test]
1340 async fn test_make_payment_omitted_fee_index_defaults_to_immediate() {
1341 let batch_config = BatchConfig {
1342 fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
1343 ..BatchConfig::default()
1344 };
1345 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1346 .await
1347 .expect("build CdkBdk test instance");
1348 fund_backend_wallet(&backend, 100_000).await;
1349 let (quote_id, options) = onchain_options_for(10_000);
1350
1351 backend
1352 .make_payment(&CurrencyUnit::Sat, options)
1353 .await
1354 .expect("make_payment should enqueue the intent");
1355
1356 let intent = backend
1357 .storage
1358 .get_send_intent_by_quote_id("e_id.to_string())
1359 .await
1360 .expect("lookup send intent by quote id")
1361 .expect("send intent should be persisted");
1362
1363 assert_eq!(intent.tier, PaymentTier::Immediate);
1364 }
1365
1366 #[tokio::test]
1367 async fn test_new_rejects_invalid_fee_option_lists() {
1368 for fee_options in [
1369 Vec::new(),
1370 vec![PaymentTier::Immediate, PaymentTier::Immediate],
1371 vec![
1372 PaymentTier::Immediate,
1373 PaymentTier::Standard,
1374 PaymentTier::Economy,
1375 PaymentTier::Immediate,
1376 ],
1377 ] {
1378 let batch_config = BatchConfig {
1379 fee_options,
1380 ..BatchConfig::default()
1381 };
1382 match build_test_instance_with_config(5, Some(batch_config), 60).await {
1383 Err(Error::InvalidConfig(message)) => {
1384 assert!(message.contains("fee_options"));
1385 }
1386 Ok(_) => panic!("invalid fee options should be rejected"),
1387 Err(err) => panic!("expected invalid config error, got {err}"),
1388 }
1389 }
1390 }
1391
1392 #[tokio::test]
1393 async fn test_get_payment_quote_rejects_empty_wallet() {
1394 let backend = build_test_instance(5).await;
1395 let (_quote_id, options) = onchain_options_for(10_000);
1396
1397 let err = backend
1398 .get_payment_quote(&CurrencyUnit::Sat, options)
1399 .await
1400 .expect_err("empty wallet should not receive an onchain quote");
1401
1402 let cdk_common::payment::Error::Onchain(inner) = err else {
1403 panic!("expected onchain error");
1404 };
1405
1406 let backend_err = inner
1407 .downcast_ref::<Error>()
1408 .expect("expected cdk-bdk backend error");
1409 assert!(matches!(backend_err, Error::NoSpendableUtxos));
1410 }
1411
1412 #[tokio::test]
1413 async fn test_make_payment_rechecks_current_fee_against_max_fee() {
1414 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1415 fund_backend_wallet(&backend, 100_000).await;
1416 let (quote_id, mut options) = onchain_options_for(10_000);
1417 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1418 panic!("expected onchain options");
1419 };
1420 onchain.max_fee_amount = Some(Amount::new(1, CurrencyUnit::Sat));
1421
1422 let err = backend
1423 .make_payment(&CurrencyUnit::Sat, options)
1424 .await
1425 .expect_err("payment should be rejected when current fee exceeds max");
1426
1427 let cdk_common::payment::Error::Onchain(inner) = err else {
1428 panic!("expected onchain error");
1429 };
1430 match inner.downcast_ref::<Error>() {
1431 Some(Error::EstimatedFeeTooHigh { max_fee, .. }) => assert_eq!(*max_fee, 1),
1432 other => panic!("expected EstimatedFeeTooHigh, got {other:?}"),
1433 }
1434
1435 assert!(
1436 backend
1437 .storage
1438 .get_send_intent_by_quote_id("e_id.to_string())
1439 .await
1440 .expect("lookup send intent by quote id")
1441 .is_none(),
1442 "fee recheck rejection must not leave a pending send intent behind"
1443 );
1444 }
1445
1446 #[tokio::test]
1447 async fn test_get_settings_reports_min_send_amount() {
1448 let backend = build_test_instance(5).await;
1449
1450 let settings = backend.get_settings().await.expect("settings");
1451 let onchain = settings.onchain.expect("onchain settings");
1452
1453 assert_eq!(onchain.min_receive_amount_sat, 0);
1454 assert_eq!(onchain.min_send_amount_sat, 546);
1455 }
1456
1457 use cdk_common::payment::OnchainOutgoingPaymentOptions;
1466 use cdk_common::QuoteId;
1467 use uuid::Uuid;
1468
1469 fn onchain_options_for(amount_sat: u64) -> (QuoteId, OutgoingPaymentOptions) {
1471 let quote_id = QuoteId::UUID(Uuid::new_v4());
1472 (
1473 quote_id.clone(),
1474 onchain_options_for_quote(quote_id, amount_sat),
1475 )
1476 }
1477
1478 fn onchain_options_for_quote(quote_id: QuoteId, amount_sat: u64) -> OutgoingPaymentOptions {
1479 OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
1480 address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
1481 amount: Amount::new(amount_sat, CurrencyUnit::Sat),
1482 max_fee_amount: Some(Amount::new(1_000, CurrencyUnit::Sat)),
1483 quote_id,
1484 fee_index: None,
1485 metadata: None,
1486 }))
1487 }
1488
1489 fn onchain_options_for_msat(
1490 quote_id: QuoteId,
1491 amount_msat: u64,
1492 max_fee_msat: u64,
1493 ) -> OutgoingPaymentOptions {
1494 OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
1495 address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
1496 amount: Amount::new(amount_msat, CurrencyUnit::Msat),
1497 max_fee_amount: Some(Amount::new(max_fee_msat, CurrencyUnit::Msat)),
1498 quote_id,
1499 fee_index: None,
1500 metadata: None,
1501 }))
1502 }
1503
1504 #[tokio::test]
1505 async fn test_get_payment_quote_converts_fee_options_to_msat() {
1506 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1507 fund_backend_wallet(&backend, 100_000).await;
1508 let quote_id = QuoteId::UUID(Uuid::new_v4());
1509 let options = onchain_options_for_msat(quote_id, 10_000_000, 10_000_000);
1510
1511 let quote = backend
1512 .get_payment_quote(&CurrencyUnit::Msat, options)
1513 .await
1514 .expect("msat quote should succeed");
1515
1516 assert_eq!(quote.amount, Amount::new(10_000_000, CurrencyUnit::Msat));
1517 assert_eq!(quote.fee.unit(), &CurrencyUnit::Msat);
1518 assert_eq!(quote.fee.value() % MSAT_IN_SAT, 0);
1519
1520 let fee_options = quote.fee_options.expect("fee options");
1521 assert!(fee_options
1522 .iter()
1523 .all(|option| u64::from(option.fee_reserve) % MSAT_IN_SAT == 0));
1524 assert_eq!(
1525 quote.fee.value(),
1526 fee_options
1527 .iter()
1528 .map(|option| u64::from(option.fee_reserve))
1529 .min()
1530 .expect("non-empty fee options")
1531 );
1532 }
1533
1534 #[tokio::test]
1535 async fn test_make_payment_converts_msat_amount_and_fee_to_sat() {
1536 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1537 fund_backend_wallet(&backend, 100_000).await;
1538 let quote_id = QuoteId::UUID(Uuid::new_v4());
1539 let options = onchain_options_for_msat(quote_id.clone(), 10_000_000, 10_000_000);
1540
1541 let response = backend
1542 .make_payment(&CurrencyUnit::Msat, options)
1543 .await
1544 .expect("msat payment should enqueue a sat-native intent");
1545
1546 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Msat));
1547 let intent = backend
1548 .storage
1549 .get_send_intent_by_quote_id("e_id.to_string())
1550 .await
1551 .expect("lookup send intent")
1552 .expect("send intent should be persisted");
1553 assert_eq!(intent.amount_sat, 10_000);
1554 assert_eq!(intent.max_fee_amount_sat, 10_000);
1555 }
1556
1557 #[tokio::test]
1558 async fn test_make_payment_rejects_fractional_satoshi_amount() {
1559 let backend = build_test_instance(5).await;
1560 let quote_id = QuoteId::UUID(Uuid::new_v4());
1561 let options = onchain_options_for_msat(quote_id.clone(), 10_000_001, 10_000_000);
1562
1563 let err = backend
1564 .make_payment(&CurrencyUnit::Msat, options)
1565 .await
1566 .expect_err("fractional-satoshi payment should be rejected");
1567
1568 let cdk_common::payment::Error::Onchain(inner) = err else {
1569 panic!("expected onchain error");
1570 };
1571 assert!(matches!(
1572 inner.downcast_ref::<Error>(),
1573 Some(Error::FractionalSatoshiAmount {
1574 amount_msat: 10_000_001
1575 })
1576 ));
1577 assert!(backend
1578 .storage
1579 .get_send_intent_by_quote_id("e_id.to_string())
1580 .await
1581 .expect("lookup send intent")
1582 .is_none());
1583 }
1584
1585 #[tokio::test]
1586 async fn test_make_payment_rejects_mismatched_fee_unit() {
1587 let backend = build_test_instance(5).await;
1588 let quote_id = QuoteId::UUID(Uuid::new_v4());
1589 let mut options = onchain_options_for_msat(quote_id.clone(), 10_000_000, 10_000_000);
1590 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1591 panic!("expected onchain options");
1592 };
1593 onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
1594
1595 let err = backend
1596 .make_payment(&CurrencyUnit::Msat, options)
1597 .await
1598 .expect_err("mismatched fee unit should be rejected");
1599
1600 let cdk_common::payment::Error::Onchain(inner) = err else {
1601 panic!("expected onchain error");
1602 };
1603 assert!(matches!(
1604 inner.downcast_ref::<Error>(),
1605 Some(Error::AmountUnitMismatch {
1606 expected: CurrencyUnit::Msat,
1607 actual: CurrencyUnit::Sat,
1608 })
1609 ));
1610 assert!(backend
1611 .storage
1612 .get_send_intent_by_quote_id("e_id.to_string())
1613 .await
1614 .expect("lookup send intent")
1615 .is_none());
1616 }
1617
1618 #[tokio::test]
1619 async fn test_make_payment_pending_total_spent_is_zero() {
1620 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1624 fund_backend_wallet(&backend, 100_000).await;
1625 let (quote_id, options) = onchain_options_for(10_000);
1626
1627 let response = backend
1628 .make_payment(&CurrencyUnit::Sat, options)
1629 .await
1630 .expect("make_payment should enqueue the intent");
1631
1632 assert_eq!(response.status, MeltQuoteState::Pending);
1633 assert_eq!(
1634 response.payment_lookup_id,
1635 PaymentIdentifier::QuoteId(quote_id)
1636 );
1637 assert_eq!(
1638 response.total_spent,
1639 Amount::new(0, CurrencyUnit::Sat),
1640 "Pending onchain response MUST use 0 sentinel; the real \
1641 total_spent is only known after the batch transaction is built"
1642 );
1643 }
1644
1645 #[tokio::test]
1646 async fn test_get_payment_quote_rejects_dust_output() {
1647 let backend = build_test_instance(5).await;
1648 let (_quote_id, options) = onchain_options_for(1);
1649
1650 let err = backend
1651 .get_payment_quote(&CurrencyUnit::Sat, options)
1652 .await
1653 .expect_err("dust output should be rejected at quote time");
1654
1655 let cdk_common::payment::Error::Onchain(inner) = err else {
1656 panic!("expected onchain error");
1657 };
1658
1659 let backend_err = inner
1660 .downcast_ref::<Error>()
1661 .expect("expected cdk-bdk backend error");
1662 assert!(matches!(backend_err, Error::DustOutput { .. }));
1663 }
1664
1665 #[tokio::test]
1666 async fn test_make_payment_rejects_dust_output_without_persisting_intent() {
1667 let backend = build_test_instance(5).await;
1668 let (quote_id, options) = onchain_options_for(1);
1669
1670 let err = backend
1671 .make_payment(&CurrencyUnit::Sat, options)
1672 .await
1673 .expect_err("dust output should be rejected before enqueue");
1674
1675 let cdk_common::payment::Error::Onchain(inner) = err else {
1676 panic!("expected onchain error");
1677 };
1678
1679 let backend_err = inner
1680 .downcast_ref::<Error>()
1681 .expect("expected cdk-bdk backend error");
1682 assert!(matches!(backend_err, Error::DustOutput { .. }));
1683 assert!(
1684 backend
1685 .storage
1686 .get_send_intent_by_quote_id("e_id.to_string())
1687 .await
1688 .expect("lookup send intent by quote id")
1689 .is_none(),
1690 "dust rejection must not leave a pending send intent behind"
1691 );
1692 }
1693
1694 #[tokio::test]
1695 async fn test_get_payment_quote_rejects_amount_below_minimum_send() {
1696 let backend = build_test_instance(5).await;
1697 let (_quote_id, options) = onchain_options_for(545);
1698
1699 let err = backend
1700 .get_payment_quote(&CurrencyUnit::Sat, options)
1701 .await
1702 .expect_err("amount below configured minimum should be rejected at quote time");
1703
1704 let cdk_common::payment::Error::Onchain(inner) = err else {
1705 panic!("expected onchain error");
1706 };
1707
1708 let backend_err = inner
1709 .downcast_ref::<Error>()
1710 .expect("expected cdk-bdk backend error");
1711 assert!(matches!(
1712 backend_err,
1713 Error::AmountBelowMinimumSend {
1714 amount: 545,
1715 min: 546
1716 }
1717 ));
1718 }
1719
1720 #[tokio::test]
1721 async fn test_make_payment_rejects_amount_below_minimum_send_without_persisting_intent() {
1722 let backend = build_test_instance(5).await;
1723 let (quote_id, options) = onchain_options_for(545);
1724
1725 let err = backend
1726 .make_payment(&CurrencyUnit::Sat, options)
1727 .await
1728 .expect_err("amount below configured minimum should be rejected before enqueue");
1729
1730 let cdk_common::payment::Error::Onchain(inner) = err else {
1731 panic!("expected onchain error");
1732 };
1733
1734 let backend_err = inner
1735 .downcast_ref::<Error>()
1736 .expect("expected cdk-bdk backend error");
1737 assert!(matches!(
1738 backend_err,
1739 Error::AmountBelowMinimumSend {
1740 amount: 545,
1741 min: 546
1742 }
1743 ));
1744 assert!(
1745 backend
1746 .storage
1747 .get_send_intent_by_quote_id("e_id.to_string())
1748 .await
1749 .expect("lookup send intent by quote id")
1750 .is_none(),
1751 "minimum-send rejection must not leave a pending send intent behind"
1752 );
1753 }
1754
1755 #[tokio::test]
1756 async fn test_check_outgoing_payment_pending_intent_reports_zero_total_spent() {
1757 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1761 fund_backend_wallet(&backend, 100_000).await;
1762 let (quote_id, options) = onchain_options_for(12_345);
1763
1764 backend
1765 .make_payment(&CurrencyUnit::Sat, options)
1766 .await
1767 .expect("make_payment should enqueue the intent");
1768
1769 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
1770 let response = backend
1771 .check_outgoing_payment(&payment_identifier)
1772 .await
1773 .expect("check_outgoing_payment for Pending intent");
1774
1775 assert_eq!(response.status, MeltQuoteState::Pending);
1776 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
1777 assert_eq!(response.payment_proof, None);
1778 }
1779
1780 #[tokio::test]
1781 async fn test_check_outgoing_payment_batched_intent_reports_zero_total_spent() {
1782 use crate::send::payment_intent::SendIntent;
1786 use crate::types::{PaymentMetadata, PaymentTier};
1787
1788 let backend = build_test_instance(5).await;
1789 let quote_id = QuoteId::UUID(Uuid::new_v4());
1790
1791 let pending = SendIntent::new(
1792 &backend.storage,
1793 quote_id.to_string(),
1794 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
1795 20_000,
1796 1_000,
1797 PaymentTier::Standard,
1798 PaymentMetadata::default(),
1799 )
1800 .await
1801 .expect("create Pending send intent");
1802
1803 pending
1804 .assign_to_batch(&backend.storage, Uuid::new_v4())
1805 .await
1806 .expect("transition Pending → Batched");
1807
1808 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
1809 let response = backend
1810 .check_outgoing_payment(&payment_identifier)
1811 .await
1812 .expect("check_outgoing_payment for Batched intent");
1813
1814 assert_eq!(response.status, MeltQuoteState::Pending);
1815 assert_eq!(
1816 response.total_spent,
1817 Amount::new(0, CurrencyUnit::Sat),
1818 "Batched intents report total_spent = 0 until the batch \
1819 transaction is built and the per-intent fee is fixed"
1820 );
1821 }
1822
1823 #[tokio::test]
1824 async fn test_check_outgoing_payment_awaiting_confirmation_includes_fee() {
1825 use crate::send::payment_intent::SendIntent;
1831 use crate::types::{PaymentMetadata, PaymentTier};
1832
1833 let backend = build_test_instance(5).await;
1834 let quote_id = QuoteId::UUID(Uuid::new_v4());
1835
1836 let pending = SendIntent::new(
1837 &backend.storage,
1838 quote_id.to_string(),
1839 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
1840 30_000,
1841 2_000,
1842 PaymentTier::Immediate,
1843 PaymentMetadata::default(),
1844 )
1845 .await
1846 .expect("create Pending send intent");
1847
1848 let batched = pending
1849 .assign_to_batch(&backend.storage, Uuid::new_v4())
1850 .await
1851 .expect("transition Pending → Batched");
1852
1853 let fee_contrib = 512_u64;
1854 batched
1855 .mark_broadcast(
1856 &backend.storage,
1857 "deadbeef".to_string(),
1858 "deadbeef:0".to_string(),
1859 fee_contrib,
1860 )
1861 .await
1862 .expect("transition Batched → AwaitingConfirmation");
1863
1864 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
1865 let response = backend
1866 .check_outgoing_payment(&payment_identifier)
1867 .await
1868 .expect("check_outgoing_payment for AwaitingConfirmation intent");
1869
1870 assert_eq!(response.status, MeltQuoteState::Pending);
1871 assert_eq!(
1872 response.total_spent,
1873 Amount::new(30_000 + fee_contrib, CurrencyUnit::Sat),
1874 "AwaitingConfirmation intents know the per-intent fee \
1875 contribution and must report amount + fee"
1876 );
1877 }
1878
1879 #[tokio::test]
1880 async fn test_check_outgoing_payment_failed_intent_reports_failed() {
1881 use crate::send::payment_intent::SendIntent;
1882 use crate::types::{PaymentMetadata, PaymentTier};
1883
1884 let backend = build_test_instance(5).await;
1885 let quote_id = QuoteId::UUID(Uuid::new_v4());
1886
1887 let pending = SendIntent::new(
1888 &backend.storage,
1889 quote_id.to_string(),
1890 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
1891 30_000,
1892 2_000,
1893 PaymentTier::Immediate,
1894 PaymentMetadata::default(),
1895 )
1896 .await
1897 .expect("create Pending send intent");
1898
1899 pending
1900 .fail(&backend.storage, "fee too high".to_string())
1901 .await
1902 .expect("transition Pending to Failed");
1903
1904 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
1905 let response = backend
1906 .check_outgoing_payment(&payment_identifier)
1907 .await
1908 .expect("check_outgoing_payment for Failed intent");
1909
1910 assert_eq!(response.status, MeltQuoteState::Failed);
1911 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
1912 assert_eq!(response.payment_proof, None);
1913 }
1914
1915 #[tokio::test]
1916 async fn test_make_payment_can_retry_failed_intent_with_same_quote_id() {
1917 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1918 fund_backend_wallet(&backend, 100_000).await;
1919 let (quote_id, options) = onchain_options_for(30_000);
1920
1921 backend
1922 .make_payment(&CurrencyUnit::Sat, options)
1923 .await
1924 .expect("initial make_payment should enqueue intent");
1925
1926 let initial = backend
1927 .storage
1928 .get_send_intent_by_quote_id("e_id.to_string())
1929 .await
1930 .expect("lookup initial intent")
1931 .expect("initial intent exists");
1932
1933 backend
1934 .storage
1935 .update_send_intent(
1936 &initial.intent_id,
1937 &crate::send::payment_intent::record::SendIntentState::Failed {
1938 reason: "pre-sign failure".to_string(),
1939 created_at: 1_700_000_000,
1940 failed_at: 1_700_000_100,
1941 },
1942 )
1943 .await
1944 .expect("mark failed");
1945
1946 let retry_options = onchain_options_for_quote(quote_id.clone(), 30_000);
1947 let response = backend
1948 .make_payment(&CurrencyUnit::Sat, retry_options)
1949 .await
1950 .expect("retry with same quote id should requeue failed intent");
1951
1952 assert_eq!(response.status, MeltQuoteState::Pending);
1953
1954 let retried = backend
1955 .storage
1956 .get_send_intent_by_quote_id("e_id.to_string())
1957 .await
1958 .expect("lookup retried intent")
1959 .expect("retried intent exists");
1960 assert_eq!(retried.intent_id, initial.intent_id);
1961 assert!(matches!(
1962 retried.state,
1963 crate::send::payment_intent::record::SendIntentState::Pending { .. }
1964 ));
1965 }
1966
1967 #[tokio::test]
1968 async fn test_check_outgoing_payment_unknown_quote_reports_zero() {
1969 let backend = build_test_instance(5).await;
1973 let quote_id = QuoteId::UUID(Uuid::new_v4());
1974 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
1975
1976 let response = backend
1977 .check_outgoing_payment(&payment_identifier)
1978 .await
1979 .expect("check_outgoing_payment for unknown quote");
1980
1981 assert_eq!(response.status, MeltQuoteState::Unknown);
1982 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
1983 assert_eq!(response.payment_proof, None);
1984 }
1985
1986 #[test]
1991 fn test_is_transient_classifies_network_errors() {
1992 let esplora_err = Error::Esplora(
1996 "HttpResponse { status: 525, message: \"error code: 525\" }".to_string(),
1997 );
1998 assert!(esplora_err.is_transient());
1999
2000 let esplora_404 = Error::Esplora(
2001 "HttpResponse { status: 404, message: \"Block not found\" }".to_string(),
2002 );
2003 assert!(esplora_404.is_transient());
2004
2005 let wallet_err = Error::Wallet("invalid checkpoint".to_string());
2008 assert!(!wallet_err.is_transient());
2009
2010 let vout_err = Error::VoutNotFound;
2011 assert!(!vout_err.is_transient());
2012
2013 let io_err = Error::Io(std::io::Error::new(
2015 std::io::ErrorKind::TimedOut,
2016 "network timeout",
2017 ));
2018 assert!(io_err.is_transient());
2019
2020 let io_other = Error::Io(std::io::Error::new(
2022 std::io::ErrorKind::InvalidData,
2023 "bad data",
2024 ));
2025 assert!(!io_other.is_transient());
2026 }
2027
2028 #[tokio::test]
2029 async fn test_supervisor_restarts_failing_task_with_backoff() {
2030 let cancel = CancellationToken::new();
2033 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
2034
2035 let counter_clone = Arc::clone(&counter);
2036 let cancel_inner = cancel.clone();
2037 let supervisor = tokio::spawn(async move {
2038 super::supervise("test", cancel_inner, move |_c| {
2039 let c = Arc::clone(&counter_clone);
2040 async move {
2041 c.fetch_add(1, Ordering::Relaxed);
2042 Err::<(), Error>(Error::Esplora("boom".to_string()))
2043 }
2044 })
2045 .await;
2046 });
2047
2048 tokio::time::sleep(Duration::from_millis(2_500)).await;
2050 cancel.cancel();
2051
2052 tokio::time::timeout(Duration::from_secs(5), supervisor)
2053 .await
2054 .expect("supervisor did not exit after cancel")
2055 .expect("supervisor task panicked");
2056
2057 let n = counter.load(Ordering::Relaxed);
2058 assert!(
2059 n >= 2,
2060 "supervisor should have restarted the task at least twice, got {n}"
2061 );
2062 }
2063
2064 #[tokio::test]
2065 async fn test_supervisor_exits_on_ok() {
2066 let cancel = CancellationToken::new();
2069 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
2070
2071 let counter_clone = Arc::clone(&counter);
2072 let cancel_inner = cancel.clone();
2073 let supervisor = tokio::spawn(async move {
2074 super::supervise("test", cancel_inner, move |_c| {
2075 let c = Arc::clone(&counter_clone);
2076 async move {
2077 c.fetch_add(1, Ordering::Relaxed);
2078 Ok::<(), Error>(())
2079 }
2080 })
2081 .await;
2082 });
2083
2084 tokio::time::timeout(Duration::from_secs(5), supervisor)
2085 .await
2086 .expect("supervisor did not exit after Ok(())")
2087 .expect("supervisor task panicked");
2088
2089 assert_eq!(
2090 counter.load(Ordering::Relaxed),
2091 1,
2092 "supervisor must not restart a task that returned Ok(())"
2093 );
2094 }
2095
2096 #[tokio::test]
2097 async fn test_supervisor_cancel_during_backoff() {
2098 let cancel = CancellationToken::new();
2101 let cancel_inner = cancel.clone();
2102 let supervisor = tokio::spawn(async move {
2103 super::supervise("test", cancel_inner, move |_c| async move {
2104 Err::<(), Error>(Error::Esplora("boom".to_string()))
2106 })
2107 .await;
2108 });
2109
2110 tokio::time::sleep(Duration::from_millis(200)).await;
2112 let cancel_at = std::time::Instant::now();
2113 cancel.cancel();
2114
2115 tokio::time::timeout(Duration::from_secs(2), supervisor)
2116 .await
2117 .expect("supervisor did not exit promptly after cancel")
2118 .expect("supervisor task panicked");
2119
2120 let elapsed = cancel_at.elapsed();
2121 assert!(
2122 elapsed < Duration::from_millis(500),
2123 "supervisor took {elapsed:?} to exit after cancel; expected < 500ms"
2124 );
2125 }
2126
2127 #[tokio::test]
2128 async fn test_sync_wallet_survives_unreachable_esplora() {
2129 let backend = build_test_instance(5).await;
2135 backend.start().await.expect("start");
2136
2137 tokio::time::sleep(Duration::from_millis(500)).await;
2142
2143 {
2145 let tasks = backend.tasks.lock().await;
2146 let bg = tasks.as_ref().expect("tasks running");
2147 assert!(
2148 !bg.sync.is_finished(),
2149 "sync task must not exit on transient Esplora errors"
2150 );
2151 }
2152
2153 backend.stop().await.expect("stop");
2154 }
2155}