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::common::FeeReserve;
23use cdk_common::database::KVStore;
24use cdk_common::nuts::nut30::MeltQuoteOnchainFeeOption;
25use cdk_common::payment::{
26 CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, MintPayment,
27 OnchainSettings, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse,
28 SettingsResponse, WaitPaymentResponse,
29};
30use cdk_common::{Amount, CurrencyUnit, MeltQuoteState};
31use futures::Stream;
32use tokio::sync::{Mutex, Notify};
33use tokio::task::JoinHandle;
34use tokio_stream::wrappers::BroadcastStream;
35use tokio_util::sync::CancellationToken;
36
37pub use crate::chain::{BitcoinRpcConfig, ChainSource, EsploraConfig};
38pub use crate::error::Error;
39pub use crate::storage::{BdkStorage, FinalizedReceiveIntentRecord, FinalizedSendIntentRecord};
40pub use crate::types::{
41 BatchConfig, FeeEstimationConfig, PaymentMetadata, PaymentTier, SyncConfig,
42 DEFAULT_TARGET_BLOCK_TIME_SECS,
43};
44
45pub mod chain;
46pub mod error;
47pub(crate) mod fee;
48pub mod receive;
49pub(crate) mod recovery;
50pub mod send;
51pub mod storage;
52pub(crate) mod sync;
53pub mod types;
54pub(crate) mod util;
55
56pub(crate) struct WalletWithDb {
58 pub(crate) wallet: PersistedWallet<Connection>,
59 pub(crate) db: Connection,
60}
61
62pub(crate) struct BackgroundTasks {
63 pub(crate) cancel: CancellationToken,
64 pub(crate) sync: JoinHandle<()>,
65 pub(crate) batch: JoinHandle<()>,
66}
67
68struct PaymentEventStream {
69 receiver: BroadcastStream<Event>,
70 cancel: Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
71 is_active: Arc<AtomicBool>,
72}
73
74impl Stream for PaymentEventStream {
75 type Item = Event;
76
77 fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
78 let this = self.get_mut();
79
80 if this.cancel.as_mut().poll(cx).is_ready() {
81 this.is_active.store(false, Ordering::SeqCst);
82 return Poll::Ready(None);
83 }
84
85 loop {
86 match Pin::new(&mut this.receiver).poll_next(cx) {
87 Poll::Ready(Some(Ok(event))) => return Poll::Ready(Some(event)),
88 Poll::Ready(Some(Err(err))) => {
89 tracing::warn!(
90 "cdk-bdk payment event subscriber lagged or errored: {}",
91 err
92 );
93 }
94 Poll::Ready(None) => {
95 this.is_active.store(false, Ordering::SeqCst);
96 return Poll::Ready(None);
97 }
98 Poll::Pending => return Poll::Pending,
99 }
100 }
101 }
102}
103
104impl Drop for PaymentEventStream {
105 fn drop(&mut self) {
106 self.is_active.store(false, Ordering::SeqCst);
107 }
108}
109
110impl WalletWithDb {
111 pub(crate) fn new(wallet: PersistedWallet<Connection>, db: Connection) -> Self {
112 Self { wallet, db }
113 }
114
115 pub(crate) fn persist(&mut self) -> Result<bool, bdk_wallet::rusqlite::Error> {
116 self.wallet.persist(&mut self.db)
117 }
118}
119
120#[derive(Clone)]
122pub struct CdkBdk {
123 pub(crate) fee_reserve: FeeReserve,
124 pub(crate) wait_invoice_cancel_token: CancellationToken,
125 pub(crate) wait_invoice_is_active: Arc<AtomicBool>,
126 pub(crate) payment_sender: tokio::sync::broadcast::Sender<Event>,
127 pub(crate) tasks: Arc<Mutex<Option<BackgroundTasks>>>,
128 pub(crate) shutdown_timeout: Duration,
129 pub(crate) wallet_with_db: Arc<Mutex<WalletWithDb>>,
130 pub(crate) chain_source: ChainSource,
131 pub(crate) storage: BdkStorage,
132 pub(crate) network: Network,
133 pub(crate) batch_config: BatchConfig,
135 pub(crate) batch_notify: Arc<Notify>,
137 pub(crate) num_confs: u32,
139 pub(crate) min_receive_amount_sat: u64,
141 pub(crate) min_send_amount_sat: u64,
143 pub(crate) sync_interval_secs: u64,
145 pub(crate) sync_config: SyncConfig,
147 pub(crate) fee_rate_cache: Arc<Mutex<std::collections::HashMap<PaymentTier, (f64, u64)>>>,
149}
150
151impl CdkBdk {
152 pub(crate) fn validate_send_amount_against_dust(
153 &self,
154 address: &str,
155 amount_sat: u64,
156 ) -> Result<(), Error> {
157 let address = bdk_wallet::bitcoin::Address::from_str(address)
158 .map_err(|e| Error::Wallet(e.to_string()))?
159 .require_network(self.network)
160 .map_err(|e| Error::Wallet(e.to_string()))?;
161
162 let dust_limit = bdk_wallet::bitcoin::TxOut::minimal_non_dust(address.script_pubkey())
163 .value
164 .to_sat();
165
166 if amount_sat < dust_limit {
167 return Err(Error::DustOutput {
168 amount: amount_sat,
169 dust_limit,
170 });
171 }
172
173 Ok(())
174 }
175
176 pub(crate) fn validate_send_amount(&self, address: &str, amount_sat: u64) -> Result<(), Error> {
177 self.validate_send_amount_against_dust(address, amount_sat)?;
178
179 if amount_sat < self.min_send_amount_sat {
180 return Err(Error::AmountBelowMinimumSend {
181 amount: amount_sat,
182 min: self.min_send_amount_sat,
183 });
184 }
185
186 Ok(())
187 }
188
189 pub(crate) fn confirmations_satisfied(&self, tip_height: u32, anchor_height: u32) -> bool {
190 if tip_height < anchor_height {
191 return false;
192 }
193
194 tip_height - anchor_height + 1 >= self.num_confs
195 }
196
197 pub(crate) fn should_ignore_receive_amount(&self, amount_sat: u64) -> bool {
198 amount_sat < self.min_receive_amount_sat
199 }
200
201 pub(crate) fn txid_has_required_confirmations(
204 &self,
205 wallet: &PersistedWallet<Connection>,
206 txid_str: &str,
207 intent_kind: &str,
208 intent_id: &str,
209 ) -> bool {
210 let Ok(parsed_txid) = bdk_wallet::bitcoin::Txid::from_str(txid_str) else {
211 tracing::warn!(
212 intent_kind,
213 intent_id,
214 txid = txid_str,
215 "Could not parse txid during confirmation check"
216 );
217 return false;
218 };
219
220 let Some(tx_details) = wallet.get_tx(parsed_txid) else {
221 return false;
222 };
223
224 let check_point = wallet.latest_checkpoint().height();
225 match &tx_details.chain_position {
226 bdk_wallet::chain::ChainPosition::Confirmed { anchor, .. } => {
227 self.confirmations_satisfied(check_point, anchor.block_id.height)
228 }
229 bdk_wallet::chain::ChainPosition::Unconfirmed { .. } => false,
230 }
231 }
232
233 #[allow(clippy::too_many_arguments)]
235 pub fn new(
236 mnemonic: Mnemonic,
237 network: Network,
238 chain_source: ChainSource,
239 storage_dir_path: String,
240 fee_reserve: FeeReserve,
241 kv_store: Arc<dyn KVStore<Err = cdk_common::database::Error> + Send + Sync>,
242 batch_config: Option<BatchConfig>,
243 num_confs: u32,
244 min_receive_amount_sat: u64,
245 min_send_amount_sat: u64,
246 sync_interval_secs: u64,
247 shutdown_timeout_secs: Option<u64>,
248 sync_config: Option<SyncConfig>,
249 ) -> Result<Self, Error> {
250 let storage_dir_path = PathBuf::from(storage_dir_path);
251 let storage_dir_path = storage_dir_path.join("bdk_wallet");
252 fs::create_dir_all(&storage_dir_path)?;
253
254 let mut db = Connection::open(storage_dir_path.join("bdk_wallet.sqlite"))?;
255
256 let xkey: ExtendedKey = mnemonic.into_extended_key()?;
257 let xprv = xkey.into_xprv(network.into()).ok_or(Error::Path)?;
258
259 let descriptor = Bip84(xprv, KeychainKind::External);
260 let change_descriptor = Bip84(xprv, KeychainKind::Internal);
261
262 let wallet_opt = Wallet::load()
263 .descriptor(KeychainKind::External, Some(descriptor.clone()))
264 .descriptor(KeychainKind::Internal, Some(change_descriptor.clone()))
265 .extract_keys()
266 .check_network(network)
267 .load_wallet(&mut db)
268 .map_err(|e| Error::Wallet(e.to_string()))?;
269
270 let mut wallet = match wallet_opt {
271 Some(wallet) => wallet,
272 None => Wallet::create(descriptor, change_descriptor)
273 .network(network)
274 .create_wallet(&mut db)
275 .map_err(|e| Error::Wallet(e.to_string()))?,
276 };
277
278 wallet.persist(&mut db)?;
279
280 let wallet_with_db = WalletWithDb::new(wallet, db);
281
282 let batch_config = batch_config.unwrap_or_default();
283 if batch_config.poll_interval.is_zero() {
284 return Err(Error::InvalidConfig(
285 "batch_config.poll_interval must be greater than zero".to_string(),
286 ));
287 }
288 batch_config.validate().map_err(Error::InvalidConfig)?;
289
290 if sync_interval_secs == 0 {
291 return Err(Error::InvalidConfig(
292 "sync_interval_secs must be greater than zero".to_string(),
293 ));
294 }
295
296 let channel_capacity = batch_config.max_batch_size * 2 + 16;
297 let (payment_sender, _) = tokio::sync::broadcast::channel(channel_capacity);
298
299 Ok(Self {
300 fee_reserve,
301 wait_invoice_cancel_token: CancellationToken::new(),
302 wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
303 payment_sender,
304 tasks: Arc::new(Mutex::new(None)),
305 shutdown_timeout: Duration::from_secs(shutdown_timeout_secs.unwrap_or(30)),
306 wallet_with_db: Arc::new(Mutex::new(wallet_with_db)),
307 chain_source,
308 storage: BdkStorage::new(kv_store),
309 network,
310 batch_config,
311 batch_notify: Arc::new(Notify::new()),
312 num_confs,
313 min_receive_amount_sat,
314 min_send_amount_sat,
315 sync_interval_secs,
316 sync_config: sync_config.unwrap_or_default(),
317 fee_rate_cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
318 })
319 }
320}
321
322async fn supervise<F, Fut>(name: &'static str, cancel: CancellationToken, mut f: F)
330where
331 F: FnMut(CancellationToken) -> Fut,
332 Fut: Future<Output = Result<(), Error>>,
333{
334 const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
335 const MAX_BACKOFF: Duration = Duration::from_secs(60);
336 const SUPERVISOR_BACKOFF_RESET: Duration = Duration::from_secs(300);
337
338 let mut backoff = INITIAL_BACKOFF;
339
340 loop {
341 if cancel.is_cancelled() {
342 break;
343 }
344
345 let started = Instant::now();
346 let child_cancel = cancel.clone();
347
348 let result = tokio::select! {
349 _ = cancel.cancelled() => {
350 tracing::info!("{name} supervisor: cancelled");
351 return;
352 }
353 r = f(child_cancel) => r,
354 };
355
356 match result {
357 Ok(()) => {
358 tracing::info!("{name} supervisor: task exited cleanly");
359 return;
360 }
361 Err(e) => {
362 let ran_for = started.elapsed();
363 let transient = e.is_transient();
364 tracing::error!(
365 task = name,
366 ran_for_secs = ran_for.as_secs(),
367 transient,
368 "supervised task returned error: {e}; restarting with backoff"
369 );
370
371 if ran_for >= SUPERVISOR_BACKOFF_RESET {
372 backoff = INITIAL_BACKOFF;
373 }
374
375 tokio::select! {
377 _ = cancel.cancelled() => {
378 tracing::info!("{name} supervisor: cancelled during backoff");
379 return;
380 }
381 _ = tokio::time::sleep(backoff) => {}
382 }
383
384 backoff = (backoff * 2).min(MAX_BACKOFF);
385 }
386 }
387 }
388}
389
390#[async_trait]
391impl MintPayment for CdkBdk {
392 type Err = cdk_common::payment::Error;
393
394 #[tracing::instrument(skip_all)]
395 async fn start(&self) -> Result<(), Self::Err> {
396 let mut tasks_lock = self.tasks.lock().await;
397 if tasks_lock.is_some() {
398 return Err(Error::AlreadyStarted.into());
399 }
400
401 self.recover_receive_saga().await?;
402 self.recover_send_saga().await?;
403
404 let cancel = CancellationToken::new();
405
406 let sync_self = self.clone();
407 let sync_cancel = cancel.clone();
408 let sync_handle = tokio::spawn(async move {
409 supervise("wallet sync", sync_cancel, move |cancel| {
410 let me = sync_self.clone();
411 async move { me.sync_wallet(cancel).await }
412 })
413 .await;
414 });
415
416 let batch_self = self.clone();
417 let batch_cancel = cancel.clone();
418 let batch_handle = tokio::spawn(async move {
419 supervise("batch processor", batch_cancel, move |cancel| {
420 let me = batch_self.clone();
421 async move { me.run_batch_processor(cancel).await }
422 })
423 .await;
424 });
425
426 *tasks_lock = Some(BackgroundTasks {
427 cancel,
428 sync: sync_handle,
429 batch: batch_handle,
430 });
431
432 Ok(())
433 }
434
435 async fn stop(&self) -> Result<(), Self::Err> {
436 self.wait_invoice_cancel_token.cancel();
437
438 let tasks_opt = {
439 let mut tasks_lock = self.tasks.lock().await;
440 tasks_lock.take()
441 };
442
443 if let Some(bg) = tasks_opt {
444 bg.cancel.cancel();
445
446 let sync_aborter = bg.sync.abort_handle();
447 let batch_aborter = bg.batch.abort_handle();
448
449 let joined = tokio::time::timeout(self.shutdown_timeout, async move {
450 let _ = bg.sync.await;
451 let _ = bg.batch.await;
452 })
453 .await;
454
455 if joined.is_err() {
456 sync_aborter.abort();
457 batch_aborter.abort();
458 tracing::error!(
459 "cdk-bdk background tasks did not exit within {:?}; forced abort",
460 self.shutdown_timeout
461 );
462 }
463 }
464
465 Ok(())
466 }
467
468 async fn get_settings(&self) -> Result<SettingsResponse, Self::Err> {
469 Ok(SettingsResponse {
470 unit: "sat".to_string(),
471 bolt11: None,
472 bolt12: None,
473 onchain: Some(OnchainSettings {
474 confirmations: self.num_confs,
475 min_receive_amount_sat: self.min_receive_amount_sat,
476 min_send_amount_sat: self.min_send_amount_sat,
477 }),
478 custom: std::collections::HashMap::new(),
479 })
480 }
481
482 async fn get_payment_quote(
483 &self,
484 _unit: &CurrencyUnit,
485 options: OutgoingPaymentOptions,
486 ) -> Result<PaymentQuoteResponse, Self::Err> {
487 let onchain_options = match options {
488 OutgoingPaymentOptions::Onchain(o) => o,
489 _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
490 };
491
492 self.validate_send_amount(
493 &onchain_options.address,
494 onchain_options.amount.clone().to_u64(),
495 )?;
496 let amount_sat = onchain_options.amount.clone().to_u64();
497
498 let mut fee_options = Vec::with_capacity(self.batch_config.fee_options.len());
502 for (idx, tier) in self.batch_config.fee_options.iter().enumerate() {
503 let fee_estimate = self
504 .estimate_onchain_fee_reserve(&onchain_options.address, amount_sat, *tier)
505 .await?;
506 fee_options.push(MeltQuoteOnchainFeeOption {
507 fee_index: idx as u32,
508 fee_reserve: Amount::from(fee_estimate.fee_reserve_sat),
509 estimated_blocks: tier.estimated_blocks(),
510 });
511 }
512
513 let cheapest = fee_options
517 .iter()
518 .min_by_key(|option| u64::from(option.fee_reserve))
519 .copied()
520 .expect("fee_options is validated as non-empty");
521
522 Ok(PaymentQuoteResponse {
527 request_lookup_id: Some(PaymentIdentifier::QuoteId(onchain_options.quote_id.clone())),
528 amount: onchain_options.amount,
529 fee: Amount::new(cheapest.fee_reserve.into(), CurrencyUnit::Sat),
530 state: MeltQuoteState::Unpaid,
531 extra_json: None,
532 estimated_blocks: Some(cheapest.estimated_blocks),
533 fee_options: Some(fee_options),
534 })
535 }
536
537 async fn make_payment(
538 &self,
539 _unit: &CurrencyUnit,
540 options: OutgoingPaymentOptions,
541 ) -> Result<MakePaymentResponse, Self::Err> {
542 let onchain_options = match options {
543 OutgoingPaymentOptions::Onchain(o) => o,
544 _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
545 };
546
547 let address = onchain_options.address;
548 let amount = onchain_options.amount;
549 let quote_id = onchain_options.quote_id;
550
551 self.validate_send_amount(&address, amount.clone().to_u64())?;
552
553 let max_fee = onchain_options
554 .max_fee_amount
555 .unwrap_or(Amount::new(1000, CurrencyUnit::Sat));
556 let amount_sat = amount.clone().to_u64();
557 let max_fee_sat = max_fee.clone().to_u64();
558 let tier = self
562 .batch_config
563 .tier_for_fee_index(onchain_options.fee_index)
564 .map_err(Error::UnknownFeeIndex)?;
565 let metadata = PaymentMetadata::from_optional_json(onchain_options.metadata.as_deref());
566 let fee_estimate = self
567 .estimate_onchain_fee_reserve(&address, amount_sat, tier)
568 .await?;
569 if fee_estimate.raw_fee_sat > max_fee_sat {
570 return Err(Error::EstimatedFeeTooHigh {
571 estimated_fee: fee_estimate.raw_fee_sat,
572 max_fee: max_fee_sat,
573 }
574 .into());
575 }
576
577 crate::send::payment_intent::SendIntent::new(
578 &self.storage,
579 quote_id.to_string(),
580 address,
581 amount_sat,
582 max_fee_sat,
583 tier,
584 metadata,
585 )
586 .await?;
587
588 if tier == PaymentTier::Immediate {
589 self.batch_notify.notify_one();
590 }
591
592 Ok(MakePaymentResponse {
599 payment_lookup_id: PaymentIdentifier::QuoteId(quote_id),
600 payment_proof: None,
601 status: MeltQuoteState::Pending,
602 total_spent: Amount::new(0, CurrencyUnit::Sat),
603 })
604 }
605
606 async fn create_incoming_payment_request(
607 &self,
608 options: IncomingPaymentOptions,
609 ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
610 let onchain_options = match options {
611 IncomingPaymentOptions::Onchain(o) => o,
612 _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
613 };
614
615 let mut wallet_with_db = self.wallet_with_db.lock().await;
616 let address = wallet_with_db
617 .wallet
618 .reveal_next_address(KeychainKind::External);
619 let address_str = address.address.to_string();
620
621 wallet_with_db.persist().map_err(|err| {
622 tracing::error!("Could not persist to bdk db: {}", err);
623
624 Error::BdkPersist
625 })?;
626
627 let quote_id = onchain_options.quote_id;
628
629 self.storage
630 .track_receive_address(&address_str, "e_id.to_string())
631 .await?;
632
633 Ok(CreateIncomingPaymentResponse {
634 request_lookup_id: PaymentIdentifier::QuoteId(quote_id),
635 request: address_str,
636 expiry: None,
637 extra_json: None,
638 })
639 }
640
641 async fn wait_payment_event(
642 &self,
643 ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
644 self.wait_invoice_is_active.store(true, Ordering::SeqCst);
645
646 let receiver = self.payment_sender.subscribe();
647 let stream = PaymentEventStream {
648 receiver: BroadcastStream::new(receiver),
649 cancel: Box::pin(self.wait_invoice_cancel_token.clone().cancelled_owned()),
650 is_active: Arc::clone(&self.wait_invoice_is_active),
651 };
652
653 Ok(Box::pin(stream))
654 }
655
656 async fn check_incoming_payment_status(
657 &self,
658 payment_identifier: &PaymentIdentifier,
659 ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
660 let PaymentIdentifier::QuoteId(quote_id) = payment_identifier else {
661 return Err(Error::UnsupportedOnchain.into());
662 };
663
664 let quote_id_str = quote_id.to_string();
665 let mut results = Vec::new();
666
667 let finalized = self
670 .storage
671 .get_finalized_receive_intents_by_quote_id("e_id_str)
672 .await?;
673
674 for record in finalized {
675 results.push(WaitPaymentResponse {
676 payment_identifier: payment_identifier.clone(),
677 payment_amount: Amount::new(record.amount_sat, CurrencyUnit::Sat),
678 payment_id: record.outpoint,
679 });
680 }
681
682 Ok(results)
683 }
684
685 async fn check_outgoing_payment(
686 &self,
687 payment_identifier: &PaymentIdentifier,
688 ) -> Result<MakePaymentResponse, Self::Err> {
689 let quote_id = match payment_identifier {
690 PaymentIdentifier::QuoteId(id) => id.to_string(),
691 _ => return Err(Error::UnsupportedOnchain.into()),
692 };
693
694 if let Some(record) = self.storage.get_send_intent_by_quote_id("e_id).await? {
696 let total_spent = match &record.state {
702 crate::send::payment_intent::record::SendIntentState::Pending { .. }
703 | crate::send::payment_intent::record::SendIntentState::Batched { .. } => {
704 Amount::new(0, CurrencyUnit::Sat)
705 }
706 crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
707 fee_contribution_sat,
708 ..
709 } => Amount::new(record.amount_sat + fee_contribution_sat, CurrencyUnit::Sat),
710 crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
711 Amount::new(0, CurrencyUnit::Sat)
712 }
713 };
714 let status = match record.state {
715 crate::send::payment_intent::record::SendIntentState::Pending { .. }
716 | crate::send::payment_intent::record::SendIntentState::Batched { .. }
717 | crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
718 ..
719 } => MeltQuoteState::Pending,
720 crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
721 MeltQuoteState::Failed
722 }
723 };
724
725 return Ok(MakePaymentResponse {
726 payment_lookup_id: payment_identifier.clone(),
727 payment_proof: None,
728 status,
729 total_spent,
730 });
731 }
732
733 if let Some(record) = self
735 .storage
736 .get_finalized_intent_by_quote_id("e_id)
737 .await?
738 {
739 return Ok(MakePaymentResponse {
740 payment_lookup_id: payment_identifier.clone(),
741 payment_proof: Some(record.outpoint),
742 status: MeltQuoteState::Paid,
743 total_spent: Amount::new(record.total_spent_sat, CurrencyUnit::Sat),
744 });
745 }
746
747 Ok(MakePaymentResponse {
748 payment_lookup_id: payment_identifier.clone(),
749 payment_proof: None,
750 status: MeltQuoteState::Unknown,
751 total_spent: Amount::new(0, CurrencyUnit::Sat),
752 })
753 }
754
755 fn is_payment_event_stream_active(&self) -> bool {
756 self.wait_invoice_is_active.load(Ordering::SeqCst)
757 }
758
759 fn cancel_payment_event_stream(&self) {
760 self.wait_invoice_cancel_token.cancel();
761 }
762}
763
764#[cfg(test)]
765mod tests {
766 use std::str::FromStr;
767
768 use bdk_wallet::bitcoin::hashes::Hash as _;
769 use bdk_wallet::bitcoin::{
770 absolute, transaction, Network, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness,
771 };
772 use bdk_wallet::keys::bip39::Mnemonic;
773 use cdk_common::common::FeeReserve;
774 use cdk_common::payment::MintPayment;
775 use futures::StreamExt;
776
777 use super::*;
778 use crate::fee::apply_quote_fee_safety;
779
780 async fn build_test_instance(shutdown_timeout_secs: u64) -> CdkBdk {
784 build_test_instance_with_tempdir(shutdown_timeout_secs)
785 .await
786 .0
787 }
788
789 async fn build_test_instance_with_tempdir(
790 shutdown_timeout_secs: u64,
791 ) -> (CdkBdk, tempfile::TempDir) {
792 build_test_instance_with_config(shutdown_timeout_secs, None, 60)
793 .await
794 .expect("build CdkBdk test instance")
795 }
796
797 async fn build_test_instance_with_config(
798 shutdown_timeout_secs: u64,
799 batch_config: Option<BatchConfig>,
800 sync_interval_secs: u64,
801 ) -> Result<(CdkBdk, tempfile::TempDir), Error> {
802 let tmp = tempfile::tempdir().expect("tempdir");
803 let mnemonic = Mnemonic::from_str(
804 "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
805 )
806 .expect("mnemonic");
807
808 let kv = cdk_sqlite::mint::memory::empty()
809 .await
810 .expect("in-memory kv store");
811
812 let chain_source = ChainSource::Esplora(EsploraConfig {
813 url: "http://127.0.0.1:1".to_string(),
814 parallel_requests: 1,
815 });
816
817 let fee_reserve = FeeReserve {
818 min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(),
819 percent_fee_reserve: 0.02,
820 };
821
822 let backend = CdkBdk::new(
823 mnemonic,
824 Network::Regtest,
825 chain_source,
826 tmp.path().to_string_lossy().into_owned(),
827 fee_reserve,
828 Arc::new(kv),
829 batch_config,
830 1,
831 0,
832 546,
833 sync_interval_secs,
834 Some(shutdown_timeout_secs),
835 None,
836 )?;
837
838 Ok((backend, tmp))
839 }
840
841 async fn fund_backend_wallet(backend: &CdkBdk, amount_sat: u64) {
842 let mut wallet_with_db = backend.wallet_with_db.lock().await;
843 let funding_script = wallet_with_db
844 .wallet
845 .reveal_next_address(KeychainKind::External)
846 .address
847 .script_pubkey();
848 let funding_tx = Transaction {
849 version: transaction::Version::TWO,
850 lock_time: absolute::LockTime::ZERO,
851 input: vec![TxIn {
852 previous_output: OutPoint::new(Txid::all_zeros(), 0),
853 script_sig: Default::default(),
854 sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
855 witness: Witness::new(),
856 }],
857 output: vec![TxOut {
858 value: bdk_wallet::bitcoin::Amount::from_sat(amount_sat),
859 script_pubkey: funding_script,
860 }],
861 };
862
863 wallet_with_db
864 .wallet
865 .apply_unconfirmed_txs([(funding_tx, 0)]);
866 wallet_with_db.persist().expect("persist funded wallet");
867 }
868
869 #[tokio::test]
870 async fn test_new_rejects_zero_sync_interval() {
871 match build_test_instance_with_config(5, None, 0).await {
872 Err(Error::InvalidConfig(message)) => {
873 assert!(message.contains("sync_interval_secs"));
874 }
875 Ok(_) => panic!("zero sync interval should be rejected"),
876 Err(err) => panic!("expected invalid config error, got {err}"),
877 }
878 }
879
880 #[tokio::test]
881 async fn test_new_rejects_zero_batch_poll_interval() {
882 let batch_config = BatchConfig {
883 poll_interval: Duration::ZERO,
884 ..BatchConfig::default()
885 };
886
887 match build_test_instance_with_config(5, Some(batch_config), 60).await {
888 Err(Error::InvalidConfig(message)) => {
889 assert!(message.contains("poll_interval"));
890 }
891 Ok(_) => panic!("zero batch poll interval should be rejected"),
892 Err(err) => panic!("expected invalid config error, got {err}"),
893 }
894 }
895
896 #[tokio::test]
897 async fn test_new_rejects_zero_target_block_time() {
898 let batch_config = BatchConfig {
899 target_block_time: Duration::ZERO,
900 ..BatchConfig::default()
901 };
902
903 match build_test_instance_with_config(5, Some(batch_config), 60).await {
904 Err(Error::InvalidConfig(message)) => {
905 assert!(message.contains("target_block_time"));
906 }
907 Ok(_) => panic!("zero target block time should be rejected"),
908 Err(err) => panic!("expected invalid config error, got {err}"),
909 }
910 }
911
912 #[tokio::test]
913 async fn test_new_rejects_invalid_fallback_fee_rate() {
914 let batch_config = BatchConfig {
915 fee_estimation: FeeEstimationConfig {
916 fallback_sat_per_vb: 0.0,
917 ..FeeEstimationConfig::default()
918 },
919 ..BatchConfig::default()
920 };
921
922 match build_test_instance_with_config(5, Some(batch_config), 60).await {
923 Err(Error::InvalidConfig(message)) => {
924 assert!(message.contains("fallback_sat_per_vb"));
925 }
926 Ok(_) => panic!("invalid fallback fee rate should be rejected"),
927 Err(err) => panic!("expected invalid config error, got {err}"),
928 }
929 }
930
931 #[test]
932 fn test_default_batch_deadlines_match_advertised_blocks() {
933 let batch_config = BatchConfig::default();
934
935 assert_eq!(batch_config.target_block_time, Duration::from_secs(600));
936 assert_eq!(batch_config.standard_deadline, Duration::from_secs(3600));
937 assert_eq!(batch_config.economy_deadline, Duration::from_secs(86_400));
938 assert_eq!(
939 batch_config.max_intent_age,
940 Some(Duration::from_secs(86_430))
941 );
942 }
943
944 #[tokio::test]
945 async fn test_start_then_stop_exits_promptly() {
946 let backend = build_test_instance(5).await;
947
948 let started = tokio::time::timeout(Duration::from_secs(10), backend.start())
949 .await
950 .expect("start timed out");
951 started.expect("start should succeed");
952
953 let stopped = tokio::time::timeout(Duration::from_secs(10), backend.stop())
954 .await
955 .expect("stop timed out");
956 stopped.expect("stop should succeed");
957 }
958
959 #[tokio::test]
960 async fn test_double_start_returns_already_started() {
961 let backend = build_test_instance(5).await;
962 backend.start().await.expect("first start");
963
964 let second = backend.start().await;
965 assert!(second.is_err(), "second start should error");
966
967 backend.stop().await.expect("stop");
968 }
969
970 #[tokio::test]
971 async fn test_stop_without_start_is_ok() {
972 let backend = build_test_instance(5).await;
973 backend.stop().await.expect("stop on never-started is ok");
974 backend.stop().await.expect("double stop is ok");
975 }
976
977 #[tokio::test]
978 async fn test_restart_after_stop() {
979 let backend = build_test_instance(5).await;
980 backend.start().await.expect("first start");
981 backend.stop().await.expect("first stop");
982 backend.start().await.expect("second start");
983 backend.stop().await.expect("second stop");
984 }
985
986 #[tokio::test]
987 async fn test_wait_payment_event_tracks_active_state_and_cancels() {
988 let backend = build_test_instance(5).await;
989 assert!(!backend.is_payment_event_stream_active());
990
991 let mut stream = backend
992 .wait_payment_event()
993 .await
994 .expect("payment event stream");
995 assert!(backend.is_payment_event_stream_active());
996
997 backend.cancel_payment_event_stream();
998
999 let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
1000 .await
1001 .expect("stream should observe cancellation promptly");
1002 assert!(next.is_none());
1003 assert!(!backend.is_payment_event_stream_active());
1004 }
1005
1006 #[test]
1007 fn test_quote_fee_safety_adds_multiplier_and_fixed_margin() {
1008 let config = FeeEstimationConfig {
1009 quote_safety_multiplier: 1.25,
1010 quote_fixed_safety_sat: 500,
1011 ..FeeEstimationConfig::default()
1012 };
1013
1014 assert_eq!(apply_quote_fee_safety(1_000, &config), 1_750);
1015 }
1016
1017 #[tokio::test]
1018 async fn test_fee_rate_cache_falls_back_on_error() {
1019 let backend = build_test_instance(5).await;
1024
1025 let tier_err = backend
1026 .estimate_fee_rate_sat_per_vb(PaymentTier::Immediate)
1027 .await;
1028 assert!(
1029 tier_err.is_err(),
1030 "fee rate estimation should fail against bogus Esplora URL"
1031 );
1032 }
1033
1034 #[tokio::test]
1035 async fn test_get_payment_quote_does_not_stage_wallet_changes() {
1036 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1037 fund_backend_wallet(&backend, 100_000).await;
1038 let (_quote_id, options) = onchain_options_for(10_000);
1039
1040 backend
1041 .get_payment_quote(&CurrencyUnit::Sat, options)
1042 .await
1043 .expect("quote should succeed with fallback fee rate");
1044
1045 let wallet_with_db = backend.wallet_with_db.lock().await;
1046 assert!(
1047 wallet_with_db.wallet.staged().is_none(),
1048 "quote estimation must not mutate or stage BDK wallet state"
1049 );
1050 }
1051
1052 #[tokio::test]
1053 async fn test_default_fee_options_emit_immediate_only() {
1054 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1055 fund_backend_wallet(&backend, 100_000).await;
1056 let (_quote_id, options) = onchain_options_for(10_000);
1057
1058 let quote = backend
1059 .get_payment_quote(&CurrencyUnit::Sat, options)
1060 .await
1061 .expect("quote should succeed");
1062
1063 let fee_options = quote.fee_options.expect("fee options");
1064 assert_eq!(fee_options.len(), 1);
1065 assert_eq!(fee_options[0].fee_index, 0);
1066 assert_eq!(fee_options[0].estimated_blocks, 1);
1067 }
1068
1069 #[tokio::test]
1070 async fn test_configured_fee_options_emit_indexes_in_order() {
1071 let batch_config = BatchConfig {
1072 fee_options: vec![
1073 PaymentTier::Immediate,
1074 PaymentTier::Standard,
1075 PaymentTier::Economy,
1076 ],
1077 ..BatchConfig::default()
1078 };
1079 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1080 .await
1081 .expect("build CdkBdk test instance");
1082 fund_backend_wallet(&backend, 100_000).await;
1083 let (_quote_id, options) = onchain_options_for(10_000);
1084
1085 let quote = backend
1086 .get_payment_quote(&CurrencyUnit::Sat, options)
1087 .await
1088 .expect("quote should succeed");
1089
1090 let fee_options = quote.fee_options.expect("fee options");
1091 let indexes: Vec<u32> = fee_options.iter().map(|option| option.fee_index).collect();
1092 let estimated_blocks: Vec<u32> = fee_options
1093 .iter()
1094 .map(|option| option.estimated_blocks)
1095 .collect();
1096
1097 assert_eq!(indexes, vec![0, 1, 2]);
1098 assert_eq!(estimated_blocks, vec![1, 6, 144]);
1099 }
1100
1101 #[tokio::test]
1102 async fn test_configured_fee_index_resolves_by_position() {
1103 let batch_config = BatchConfig {
1104 fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
1105 ..BatchConfig::default()
1106 };
1107 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1108 .await
1109 .expect("build CdkBdk test instance");
1110 fund_backend_wallet(&backend, 100_000).await;
1111 let (quote_id, mut options) = onchain_options_for(10_000);
1112 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1113 panic!("expected onchain options");
1114 };
1115 onchain.fee_index = Some(1);
1116 onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
1117
1118 backend
1119 .make_payment(&CurrencyUnit::Sat, options)
1120 .await
1121 .expect("make_payment should enqueue the intent");
1122
1123 let intent = backend
1124 .storage
1125 .get_send_intent_by_quote_id("e_id.to_string())
1126 .await
1127 .expect("lookup send intent by quote id")
1128 .expect("send intent should be persisted");
1129
1130 assert_eq!(intent.tier, PaymentTier::Economy);
1131 }
1132
1133 #[tokio::test]
1134 async fn test_make_payment_omitted_fee_index_defaults_to_immediate() {
1135 let batch_config = BatchConfig {
1136 fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
1137 ..BatchConfig::default()
1138 };
1139 let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1140 .await
1141 .expect("build CdkBdk test instance");
1142 fund_backend_wallet(&backend, 100_000).await;
1143 let (quote_id, options) = onchain_options_for(10_000);
1144
1145 backend
1146 .make_payment(&CurrencyUnit::Sat, options)
1147 .await
1148 .expect("make_payment should enqueue the intent");
1149
1150 let intent = backend
1151 .storage
1152 .get_send_intent_by_quote_id("e_id.to_string())
1153 .await
1154 .expect("lookup send intent by quote id")
1155 .expect("send intent should be persisted");
1156
1157 assert_eq!(intent.tier, PaymentTier::Immediate);
1158 }
1159
1160 #[tokio::test]
1161 async fn test_new_rejects_invalid_fee_option_lists() {
1162 for fee_options in [
1163 Vec::new(),
1164 vec![PaymentTier::Immediate, PaymentTier::Immediate],
1165 vec![
1166 PaymentTier::Immediate,
1167 PaymentTier::Standard,
1168 PaymentTier::Economy,
1169 PaymentTier::Immediate,
1170 ],
1171 ] {
1172 let batch_config = BatchConfig {
1173 fee_options,
1174 ..BatchConfig::default()
1175 };
1176 match build_test_instance_with_config(5, Some(batch_config), 60).await {
1177 Err(Error::InvalidConfig(message)) => {
1178 assert!(message.contains("fee_options"));
1179 }
1180 Ok(_) => panic!("invalid fee options should be rejected"),
1181 Err(err) => panic!("expected invalid config error, got {err}"),
1182 }
1183 }
1184 }
1185
1186 #[tokio::test]
1187 async fn test_get_payment_quote_rejects_empty_wallet() {
1188 let backend = build_test_instance(5).await;
1189 let (_quote_id, options) = onchain_options_for(10_000);
1190
1191 let err = backend
1192 .get_payment_quote(&CurrencyUnit::Sat, options)
1193 .await
1194 .expect_err("empty wallet should not receive an onchain quote");
1195
1196 let cdk_common::payment::Error::Onchain(inner) = err else {
1197 panic!("expected onchain error");
1198 };
1199
1200 let backend_err = inner
1201 .downcast_ref::<Error>()
1202 .expect("expected cdk-bdk backend error");
1203 assert!(matches!(backend_err, Error::NoSpendableUtxos));
1204 }
1205
1206 #[tokio::test]
1207 async fn test_make_payment_rechecks_current_fee_against_max_fee() {
1208 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1209 fund_backend_wallet(&backend, 100_000).await;
1210 let (quote_id, mut options) = onchain_options_for(10_000);
1211 let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1212 panic!("expected onchain options");
1213 };
1214 onchain.max_fee_amount = Some(Amount::new(1, CurrencyUnit::Sat));
1215
1216 let err = backend
1217 .make_payment(&CurrencyUnit::Sat, options)
1218 .await
1219 .expect_err("payment should be rejected when current fee exceeds max");
1220
1221 let cdk_common::payment::Error::Onchain(inner) = err else {
1222 panic!("expected onchain error");
1223 };
1224 match inner.downcast_ref::<Error>() {
1225 Some(Error::EstimatedFeeTooHigh { max_fee, .. }) => assert_eq!(*max_fee, 1),
1226 other => panic!("expected EstimatedFeeTooHigh, got {other:?}"),
1227 }
1228
1229 assert!(
1230 backend
1231 .storage
1232 .get_send_intent_by_quote_id("e_id.to_string())
1233 .await
1234 .expect("lookup send intent by quote id")
1235 .is_none(),
1236 "fee recheck rejection must not leave a pending send intent behind"
1237 );
1238 }
1239
1240 #[tokio::test]
1241 async fn test_get_settings_reports_min_send_amount() {
1242 let backend = build_test_instance(5).await;
1243
1244 let settings = backend.get_settings().await.expect("settings");
1245 let onchain = settings.onchain.expect("onchain settings");
1246
1247 assert_eq!(onchain.min_receive_amount_sat, 0);
1248 assert_eq!(onchain.min_send_amount_sat, 546);
1249 }
1250
1251 use cdk_common::payment::OnchainOutgoingPaymentOptions;
1260 use cdk_common::QuoteId;
1261 use uuid::Uuid;
1262
1263 fn onchain_options_for(amount_sat: u64) -> (QuoteId, OutgoingPaymentOptions) {
1265 let quote_id = QuoteId::UUID(Uuid::new_v4());
1266 (
1267 quote_id.clone(),
1268 onchain_options_for_quote(quote_id, amount_sat),
1269 )
1270 }
1271
1272 fn onchain_options_for_quote(quote_id: QuoteId, amount_sat: u64) -> OutgoingPaymentOptions {
1273 OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
1274 address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
1275 amount: Amount::new(amount_sat, CurrencyUnit::Sat),
1276 max_fee_amount: Some(Amount::new(1_000, CurrencyUnit::Sat)),
1277 quote_id,
1278 fee_index: None,
1279 metadata: None,
1280 }))
1281 }
1282
1283 #[tokio::test]
1284 async fn test_make_payment_pending_total_spent_is_zero() {
1285 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1289 fund_backend_wallet(&backend, 100_000).await;
1290 let (quote_id, options) = onchain_options_for(10_000);
1291
1292 let response = backend
1293 .make_payment(&CurrencyUnit::Sat, options)
1294 .await
1295 .expect("make_payment should enqueue the intent");
1296
1297 assert_eq!(response.status, MeltQuoteState::Pending);
1298 assert_eq!(
1299 response.payment_lookup_id,
1300 PaymentIdentifier::QuoteId(quote_id)
1301 );
1302 assert_eq!(
1303 response.total_spent,
1304 Amount::new(0, CurrencyUnit::Sat),
1305 "Pending onchain response MUST use 0 sentinel; the real \
1306 total_spent is only known after the batch transaction is built"
1307 );
1308 }
1309
1310 #[tokio::test]
1311 async fn test_get_payment_quote_rejects_dust_output() {
1312 let backend = build_test_instance(5).await;
1313 let (_quote_id, options) = onchain_options_for(1);
1314
1315 let err = backend
1316 .get_payment_quote(&CurrencyUnit::Sat, options)
1317 .await
1318 .expect_err("dust output should be rejected at quote time");
1319
1320 let cdk_common::payment::Error::Onchain(inner) = err else {
1321 panic!("expected onchain error");
1322 };
1323
1324 let backend_err = inner
1325 .downcast_ref::<Error>()
1326 .expect("expected cdk-bdk backend error");
1327 assert!(matches!(backend_err, Error::DustOutput { .. }));
1328 }
1329
1330 #[tokio::test]
1331 async fn test_make_payment_rejects_dust_output_without_persisting_intent() {
1332 let backend = build_test_instance(5).await;
1333 let (quote_id, options) = onchain_options_for(1);
1334
1335 let err = backend
1336 .make_payment(&CurrencyUnit::Sat, options)
1337 .await
1338 .expect_err("dust output should be rejected before enqueue");
1339
1340 let cdk_common::payment::Error::Onchain(inner) = err else {
1341 panic!("expected onchain error");
1342 };
1343
1344 let backend_err = inner
1345 .downcast_ref::<Error>()
1346 .expect("expected cdk-bdk backend error");
1347 assert!(matches!(backend_err, Error::DustOutput { .. }));
1348 assert!(
1349 backend
1350 .storage
1351 .get_send_intent_by_quote_id("e_id.to_string())
1352 .await
1353 .expect("lookup send intent by quote id")
1354 .is_none(),
1355 "dust rejection must not leave a pending send intent behind"
1356 );
1357 }
1358
1359 #[tokio::test]
1360 async fn test_get_payment_quote_rejects_amount_below_minimum_send() {
1361 let backend = build_test_instance(5).await;
1362 let (_quote_id, options) = onchain_options_for(545);
1363
1364 let err = backend
1365 .get_payment_quote(&CurrencyUnit::Sat, options)
1366 .await
1367 .expect_err("amount below configured minimum should be rejected at quote time");
1368
1369 let cdk_common::payment::Error::Onchain(inner) = err else {
1370 panic!("expected onchain error");
1371 };
1372
1373 let backend_err = inner
1374 .downcast_ref::<Error>()
1375 .expect("expected cdk-bdk backend error");
1376 assert!(matches!(
1377 backend_err,
1378 Error::AmountBelowMinimumSend {
1379 amount: 545,
1380 min: 546
1381 }
1382 ));
1383 }
1384
1385 #[tokio::test]
1386 async fn test_make_payment_rejects_amount_below_minimum_send_without_persisting_intent() {
1387 let backend = build_test_instance(5).await;
1388 let (quote_id, options) = onchain_options_for(545);
1389
1390 let err = backend
1391 .make_payment(&CurrencyUnit::Sat, options)
1392 .await
1393 .expect_err("amount below configured minimum should be rejected before enqueue");
1394
1395 let cdk_common::payment::Error::Onchain(inner) = err else {
1396 panic!("expected onchain error");
1397 };
1398
1399 let backend_err = inner
1400 .downcast_ref::<Error>()
1401 .expect("expected cdk-bdk backend error");
1402 assert!(matches!(
1403 backend_err,
1404 Error::AmountBelowMinimumSend {
1405 amount: 545,
1406 min: 546
1407 }
1408 ));
1409 assert!(
1410 backend
1411 .storage
1412 .get_send_intent_by_quote_id("e_id.to_string())
1413 .await
1414 .expect("lookup send intent by quote id")
1415 .is_none(),
1416 "minimum-send rejection must not leave a pending send intent behind"
1417 );
1418 }
1419
1420 #[tokio::test]
1421 async fn test_check_outgoing_payment_pending_intent_reports_zero_total_spent() {
1422 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1426 fund_backend_wallet(&backend, 100_000).await;
1427 let (quote_id, options) = onchain_options_for(12_345);
1428
1429 backend
1430 .make_payment(&CurrencyUnit::Sat, options)
1431 .await
1432 .expect("make_payment should enqueue the intent");
1433
1434 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
1435 let response = backend
1436 .check_outgoing_payment(&payment_identifier)
1437 .await
1438 .expect("check_outgoing_payment for Pending intent");
1439
1440 assert_eq!(response.status, MeltQuoteState::Pending);
1441 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
1442 assert_eq!(response.payment_proof, None);
1443 }
1444
1445 #[tokio::test]
1446 async fn test_check_outgoing_payment_batched_intent_reports_zero_total_spent() {
1447 use crate::send::payment_intent::SendIntent;
1451 use crate::types::{PaymentMetadata, PaymentTier};
1452
1453 let backend = build_test_instance(5).await;
1454 let quote_id = QuoteId::UUID(Uuid::new_v4());
1455
1456 let pending = SendIntent::new(
1457 &backend.storage,
1458 quote_id.to_string(),
1459 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
1460 20_000,
1461 1_000,
1462 PaymentTier::Standard,
1463 PaymentMetadata::default(),
1464 )
1465 .await
1466 .expect("create Pending send intent");
1467
1468 pending
1469 .assign_to_batch(&backend.storage, Uuid::new_v4())
1470 .await
1471 .expect("transition Pending → Batched");
1472
1473 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
1474 let response = backend
1475 .check_outgoing_payment(&payment_identifier)
1476 .await
1477 .expect("check_outgoing_payment for Batched intent");
1478
1479 assert_eq!(response.status, MeltQuoteState::Pending);
1480 assert_eq!(
1481 response.total_spent,
1482 Amount::new(0, CurrencyUnit::Sat),
1483 "Batched intents report total_spent = 0 until the batch \
1484 transaction is built and the per-intent fee is fixed"
1485 );
1486 }
1487
1488 #[tokio::test]
1489 async fn test_check_outgoing_payment_awaiting_confirmation_includes_fee() {
1490 use crate::send::payment_intent::SendIntent;
1496 use crate::types::{PaymentMetadata, PaymentTier};
1497
1498 let backend = build_test_instance(5).await;
1499 let quote_id = QuoteId::UUID(Uuid::new_v4());
1500
1501 let pending = SendIntent::new(
1502 &backend.storage,
1503 quote_id.to_string(),
1504 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
1505 30_000,
1506 2_000,
1507 PaymentTier::Immediate,
1508 PaymentMetadata::default(),
1509 )
1510 .await
1511 .expect("create Pending send intent");
1512
1513 let batched = pending
1514 .assign_to_batch(&backend.storage, Uuid::new_v4())
1515 .await
1516 .expect("transition Pending → Batched");
1517
1518 let fee_contrib = 512_u64;
1519 batched
1520 .mark_broadcast(
1521 &backend.storage,
1522 "deadbeef".to_string(),
1523 "deadbeef:0".to_string(),
1524 fee_contrib,
1525 )
1526 .await
1527 .expect("transition Batched → AwaitingConfirmation");
1528
1529 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
1530 let response = backend
1531 .check_outgoing_payment(&payment_identifier)
1532 .await
1533 .expect("check_outgoing_payment for AwaitingConfirmation intent");
1534
1535 assert_eq!(response.status, MeltQuoteState::Pending);
1536 assert_eq!(
1537 response.total_spent,
1538 Amount::new(30_000 + fee_contrib, CurrencyUnit::Sat),
1539 "AwaitingConfirmation intents know the per-intent fee \
1540 contribution and must report amount + fee"
1541 );
1542 }
1543
1544 #[tokio::test]
1545 async fn test_check_outgoing_payment_failed_intent_reports_failed() {
1546 use crate::send::payment_intent::SendIntent;
1547 use crate::types::{PaymentMetadata, PaymentTier};
1548
1549 let backend = build_test_instance(5).await;
1550 let quote_id = QuoteId::UUID(Uuid::new_v4());
1551
1552 let pending = SendIntent::new(
1553 &backend.storage,
1554 quote_id.to_string(),
1555 "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
1556 30_000,
1557 2_000,
1558 PaymentTier::Immediate,
1559 PaymentMetadata::default(),
1560 )
1561 .await
1562 .expect("create Pending send intent");
1563
1564 pending
1565 .fail(&backend.storage, "fee too high".to_string())
1566 .await
1567 .expect("transition Pending to Failed");
1568
1569 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
1570 let response = backend
1571 .check_outgoing_payment(&payment_identifier)
1572 .await
1573 .expect("check_outgoing_payment for Failed intent");
1574
1575 assert_eq!(response.status, MeltQuoteState::Failed);
1576 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
1577 assert_eq!(response.payment_proof, None);
1578 }
1579
1580 #[tokio::test]
1581 async fn test_make_payment_can_retry_failed_intent_with_same_quote_id() {
1582 let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1583 fund_backend_wallet(&backend, 100_000).await;
1584 let (quote_id, options) = onchain_options_for(30_000);
1585
1586 backend
1587 .make_payment(&CurrencyUnit::Sat, options)
1588 .await
1589 .expect("initial make_payment should enqueue intent");
1590
1591 let initial = backend
1592 .storage
1593 .get_send_intent_by_quote_id("e_id.to_string())
1594 .await
1595 .expect("lookup initial intent")
1596 .expect("initial intent exists");
1597
1598 backend
1599 .storage
1600 .update_send_intent(
1601 &initial.intent_id,
1602 &crate::send::payment_intent::record::SendIntentState::Failed {
1603 reason: "pre-sign failure".to_string(),
1604 created_at: 1_700_000_000,
1605 failed_at: 1_700_000_100,
1606 },
1607 )
1608 .await
1609 .expect("mark failed");
1610
1611 let retry_options = onchain_options_for_quote(quote_id.clone(), 30_000);
1612 let response = backend
1613 .make_payment(&CurrencyUnit::Sat, retry_options)
1614 .await
1615 .expect("retry with same quote id should requeue failed intent");
1616
1617 assert_eq!(response.status, MeltQuoteState::Pending);
1618
1619 let retried = backend
1620 .storage
1621 .get_send_intent_by_quote_id("e_id.to_string())
1622 .await
1623 .expect("lookup retried intent")
1624 .expect("retried intent exists");
1625 assert_eq!(retried.intent_id, initial.intent_id);
1626 assert!(matches!(
1627 retried.state,
1628 crate::send::payment_intent::record::SendIntentState::Pending { .. }
1629 ));
1630 }
1631
1632 #[tokio::test]
1633 async fn test_check_outgoing_payment_unknown_quote_reports_zero() {
1634 let backend = build_test_instance(5).await;
1638 let quote_id = QuoteId::UUID(Uuid::new_v4());
1639 let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
1640
1641 let response = backend
1642 .check_outgoing_payment(&payment_identifier)
1643 .await
1644 .expect("check_outgoing_payment for unknown quote");
1645
1646 assert_eq!(response.status, MeltQuoteState::Unknown);
1647 assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
1648 assert_eq!(response.payment_proof, None);
1649 }
1650
1651 #[test]
1656 fn test_is_transient_classifies_network_errors() {
1657 let esplora_err = Error::Esplora(
1661 "HttpResponse { status: 525, message: \"error code: 525\" }".to_string(),
1662 );
1663 assert!(esplora_err.is_transient());
1664
1665 let esplora_404 = Error::Esplora(
1666 "HttpResponse { status: 404, message: \"Block not found\" }".to_string(),
1667 );
1668 assert!(esplora_404.is_transient());
1669
1670 let wallet_err = Error::Wallet("invalid checkpoint".to_string());
1673 assert!(!wallet_err.is_transient());
1674
1675 let vout_err = Error::VoutNotFound;
1676 assert!(!vout_err.is_transient());
1677
1678 let io_err = Error::Io(std::io::Error::new(
1680 std::io::ErrorKind::TimedOut,
1681 "network timeout",
1682 ));
1683 assert!(io_err.is_transient());
1684
1685 let io_other = Error::Io(std::io::Error::new(
1687 std::io::ErrorKind::InvalidData,
1688 "bad data",
1689 ));
1690 assert!(!io_other.is_transient());
1691 }
1692
1693 #[tokio::test]
1694 async fn test_supervisor_restarts_failing_task_with_backoff() {
1695 let cancel = CancellationToken::new();
1698 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
1699
1700 let counter_clone = Arc::clone(&counter);
1701 let cancel_inner = cancel.clone();
1702 let supervisor = tokio::spawn(async move {
1703 super::supervise("test", cancel_inner, move |_c| {
1704 let c = Arc::clone(&counter_clone);
1705 async move {
1706 c.fetch_add(1, Ordering::Relaxed);
1707 Err::<(), Error>(Error::Esplora("boom".to_string()))
1708 }
1709 })
1710 .await;
1711 });
1712
1713 tokio::time::sleep(Duration::from_millis(2_500)).await;
1715 cancel.cancel();
1716
1717 tokio::time::timeout(Duration::from_secs(5), supervisor)
1718 .await
1719 .expect("supervisor did not exit after cancel")
1720 .expect("supervisor task panicked");
1721
1722 let n = counter.load(Ordering::Relaxed);
1723 assert!(
1724 n >= 2,
1725 "supervisor should have restarted the task at least twice, got {n}"
1726 );
1727 }
1728
1729 #[tokio::test]
1730 async fn test_supervisor_exits_on_ok() {
1731 let cancel = CancellationToken::new();
1734 let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
1735
1736 let counter_clone = Arc::clone(&counter);
1737 let cancel_inner = cancel.clone();
1738 let supervisor = tokio::spawn(async move {
1739 super::supervise("test", cancel_inner, move |_c| {
1740 let c = Arc::clone(&counter_clone);
1741 async move {
1742 c.fetch_add(1, Ordering::Relaxed);
1743 Ok::<(), Error>(())
1744 }
1745 })
1746 .await;
1747 });
1748
1749 tokio::time::timeout(Duration::from_secs(5), supervisor)
1750 .await
1751 .expect("supervisor did not exit after Ok(())")
1752 .expect("supervisor task panicked");
1753
1754 assert_eq!(
1755 counter.load(Ordering::Relaxed),
1756 1,
1757 "supervisor must not restart a task that returned Ok(())"
1758 );
1759 }
1760
1761 #[tokio::test]
1762 async fn test_supervisor_cancel_during_backoff() {
1763 let cancel = CancellationToken::new();
1766 let cancel_inner = cancel.clone();
1767 let supervisor = tokio::spawn(async move {
1768 super::supervise("test", cancel_inner, move |_c| async move {
1769 Err::<(), Error>(Error::Esplora("boom".to_string()))
1771 })
1772 .await;
1773 });
1774
1775 tokio::time::sleep(Duration::from_millis(200)).await;
1777 let cancel_at = std::time::Instant::now();
1778 cancel.cancel();
1779
1780 tokio::time::timeout(Duration::from_secs(2), supervisor)
1781 .await
1782 .expect("supervisor did not exit promptly after cancel")
1783 .expect("supervisor task panicked");
1784
1785 let elapsed = cancel_at.elapsed();
1786 assert!(
1787 elapsed < Duration::from_millis(500),
1788 "supervisor took {elapsed:?} to exit after cancel; expected < 500ms"
1789 );
1790 }
1791
1792 #[tokio::test]
1793 async fn test_sync_wallet_survives_unreachable_esplora() {
1794 let backend = build_test_instance(5).await;
1800 backend.start().await.expect("start");
1801
1802 tokio::time::sleep(Duration::from_millis(500)).await;
1807
1808 {
1810 let tasks = backend.tasks.lock().await;
1811 let bg = tasks.as_ref().expect("tasks running");
1812 assert!(
1813 !bg.sync.is_finished(),
1814 "sync task must not exit on transient Esplora errors"
1815 );
1816 }
1817
1818 backend.stop().await.expect("stop");
1819 }
1820}