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