Skip to main content

cdk_bdk/
lib.rs

1//! CDK onchain backend using BDK
2
3#![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
56/// Wrapper struct that combines wallet and database to prevent deadlocks
57pub(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/// CDK onchain payment backend using BDK (Bitcoin Development Kit)
121#[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    /// Batch processor configuration
134    pub(crate) batch_config: BatchConfig,
135    /// Notify handle to wake up the batch processor immediately
136    pub(crate) batch_notify: Arc<Notify>,
137    /// Number of confirmations required for on-chain payments
138    pub(crate) num_confs: u32,
139    /// Minimum on-chain receive amount that should count toward minting
140    pub(crate) min_receive_amount_sat: u64,
141    /// Minimum on-chain send amount accepted for melts
142    pub(crate) min_send_amount_sat: u64,
143    /// Sync interval in seconds
144    pub(crate) sync_interval_secs: u64,
145    /// Blockchain sync configuration
146    pub(crate) sync_config: SyncConfig,
147    /// Cache for fee rate estimation: Tier -> (sat_per_vb, timestamp)
148    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    /// Return `true` when the wallet knows about the transaction and it
202    /// satisfies the configured confirmation threshold.
203    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    /// Create a new CdkBdk instance
234    #[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
322/// Supervise a long-running task, restarting it with exponential backoff
323/// (1s -> 60s, capped) whenever it returns `Err`. The backoff resets once
324/// the task has run for longer than [`SUPERVISOR_BACKOFF_RESET`]. Exits
325/// cleanly when `cancel` is triggered.
326///
327/// A task returning `Ok(())` is treated as a clean shutdown (e.g. the
328/// task observed the cancel token itself) and the supervisor exits.
329async 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                // Sleep with backoff, but wake immediately if cancelled.
376                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        // Estimate fee_reserve for each configured tier so the mint presents
499        // only the operator-enabled options. The configured order owns the
500        // `fee_index` values and resolves them back to tiers during payment.
501        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        // The `fee`/`estimated_blocks` mirror fields surface the cheapest
514        // available option as a sensible default, matching the mint's
515        // initialization in `MeltQuote::new_onchain`.
516        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        // Echo the mint-supplied `quote_id` verbatim per the
523        // `OnchainOutgoingPaymentOptions.quote_id` contract. The mint
524        // validates this echo; any deviation triggers
525        // `Error::OnchainQuoteLookupIdMismatch`.
526        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        // Resolve the wallet-selected `fee_index` back to a configured tier.
559        // Older callers that omit `fee_index` continue to default to
560        // Immediate.
561        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        // The intent has been queued but no batch has been built yet, so the
593        // per-intent fee contribution is not yet knowable. Following the
594        // convention used by other backends (LND/LDK-Node/CLN return `0` for
595        // `Unknown`/`NotFound`), we return `0` as a sentinel meaning "actual
596        // spent amount is not yet known". Callers should wait for the
597        // terminal `Paid` event to read the authoritative `total_spent`.
598        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, &quote_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        // Only return finalized payments. Active intents (Detected state) are
668        // not yet confirmed and should not be reported to the mint for processing.
669        let finalized = self
670            .storage
671            .get_finalized_receive_intents_by_quote_id(&quote_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        // 1. Check active intents
695        if let Some(record) = self.storage.get_send_intent_by_quote_id(&quote_id).await? {
696            // `total_spent` is the actual amount spent (amount + fee) and is
697            // only reported once the payment has been made. Before the batch
698            // transaction has been built, the per-intent fee contribution is
699            // unknown, so we return `0` as a sentinel. This matches the
700            // convention used by other backends for non-terminal states.
701            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        // 2. Check finalized tombstones
734        if let Some(record) = self
735            .storage
736            .get_finalized_intent_by_quote_id(&quote_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    /// Build a `CdkBdk` instance pointed at a bogus Esplora URL so the sync
781    /// loop spins without needing a real backend. The ticks are short so
782    /// shutdown tests run quickly.
783    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        // With an unreachable Esplora URL, estimate_fee_rate_sat_per_vb
1020        // returns an error. The quote path falls back to the configured
1021        // default. We exercise the fallback by invoking get_payment_quote
1022        // with a tier hint and observing that it returns a non-zero fee.
1023        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(&quote_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(&quote_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(&quote_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    // ------------------------------------------------------------------
1252    // Regression tests for Finding 5: total_spent is only authoritative
1253    // after the payment has been made. While the intent is queued but not
1254    // yet broadcast, the per-intent fee is unknown, so `total_spent` is
1255    // reported as 0 (sentinel), matching the LND/LDK/CLN convention for
1256    // non-terminal responses.
1257    // ------------------------------------------------------------------
1258
1259    use cdk_common::payment::OnchainOutgoingPaymentOptions;
1260    use cdk_common::QuoteId;
1261    use uuid::Uuid;
1262
1263    /// Build an onchain outgoing payment option with a fresh quote id.
1264    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        // make_payment queues the intent before a batch has been built, so
1286        // the per-intent fee is unknown. total_spent MUST be 0, not the
1287        // user-requested amount (which would imply no fee).
1288        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(&quote_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(&quote_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        // An intent freshly created via make_payment is in state Pending.
1423        // check_outgoing_payment must report total_spent = 0 because the
1424        // fee contribution is not yet knowable.
1425        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        // Driving an intent through Pending → Batched (fee still unknown at
1448        // the per-intent level until the batch transaction is built) must
1449        // still report total_spent = 0.
1450        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        // Once an intent reaches AwaitingConfirmation, the per-intent fee
1491        // contribution is persisted on the intent record. check_outgoing_payment
1492        // must now report total_spent = amount + fee_contribution_sat so that
1493        // downstream consumers (e.g. recovery / subscribers) see the
1494        // authoritative figure even though the payment is still unconfirmed.
1495        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(&quote_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(&quote_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        // A quote id with no active intent and no finalized tombstone must
1635        // return MeltQuoteState::Unknown with total_spent = 0 (existing
1636        // behaviour; pinned here for defence-in-depth).
1637        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    // ------------------------------------------------------------------
1652    // Chain-sync resilience tests
1653    // ------------------------------------------------------------------
1654
1655    #[test]
1656    fn test_is_transient_classifies_network_errors() {
1657        // Esplora errors are always classified as transient: the sync
1658        // loop should retry them on the next tick, and this classification
1659        // drives the log severity in the supervisor.
1660        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        // Local wallet/state errors are not transient: they indicate a
1671        // real defect that retrying will not resolve.
1672        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        // Timed-out I/O is transient.
1679        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        // An arbitrary I/O error kind is not.
1686        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        // The supervisor must keep calling the supplied future as long
1696        // as it returns Err, until the cancel token is triggered.
1697        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        // Let a few restart cycles happen (initial backoff is 1s).
1714        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        // Ok(()) from the task is treated as clean shutdown; the
1732        // supervisor exits immediately without restart.
1733        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        // Cancelling during the backoff sleep must exit promptly rather
1764        // than waiting for the sleep to expire.
1765        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                // Fail immediately so we enter the backoff sleep.
1770                Err::<(), Error>(Error::Esplora("boom".to_string()))
1771            })
1772            .await;
1773        });
1774
1775        // Give the supervisor a moment to enter its first backoff.
1776        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        // sync_wallet must not return Err when the Esplora endpoint is
1795        // unreachable — it should warn and continue. We prove this by
1796        // starting the backend (which spawns the sync task against a
1797        // bogus URL) and letting it run for long enough to tick at least
1798        // twice, then stop cleanly.
1799        let backend = build_test_instance(5).await;
1800        backend.start().await.expect("start");
1801
1802        // Sync interval is 60s per build_test_instance, so this test
1803        // only verifies the first synchronous tick path: the task must
1804        // stay alive and the supervisor must not log a "task failed"
1805        // line for a transient network error.
1806        tokio::time::sleep(Duration::from_millis(500)).await;
1807
1808        // The sync JoinHandle must still be running, not completed.
1809        {
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}