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::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
63/// Wrapper struct that combines wallet and database to prevent deadlocks
64pub(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/// CDK onchain payment backend using BDK (Bitcoin Development Kit)
128#[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    /// Batch processor configuration
141    pub(crate) batch_config: BatchConfig,
142    /// Notify handle to wake up the batch processor immediately
143    pub(crate) batch_notify: Arc<Notify>,
144    /// Number of confirmations required for on-chain payments
145    pub(crate) num_confs: u32,
146    /// Minimum on-chain receive amount that should count toward minting
147    pub(crate) min_receive_amount_sat: u64,
148    /// Minimum on-chain send amount accepted for melts
149    pub(crate) min_send_amount_sat: u64,
150    /// Sync interval in seconds
151    pub(crate) sync_interval_secs: u64,
152    /// Blockchain sync configuration
153    pub(crate) sync_config: SyncConfig,
154    /// Cache for fee rate estimation: Tier -> (sat_per_vb, timestamp)
155    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    /// Return `true` when the wallet knows about the transaction and it
249    /// satisfies the configured confirmation threshold.
250    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    /// Create a new CdkBdk instance
281    #[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
369/// Supervise a long-running task, restarting it with exponential backoff
370/// (1s -> 60s, capped) whenever it returns `Err`. The backoff resets once
371/// the task has run for longer than [`SUPERVISOR_BACKOFF_RESET`]. Exits
372/// cleanly when `cancel` is triggered.
373///
374/// A task returning `Ok(())` is treated as a clean shutdown (e.g. the
375/// task observed the cancel token itself) and the supervisor exits.
376async 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                // Sleep with backoff, but wake immediately if cancelled.
423                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        // Estimate fee_reserve for each configured tier so the mint presents
546        // only the operator-enabled options. The configured order owns the
547        // `fee_index` values and resolves them back to tiers during payment.
548        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        // The `fee`/`estimated_blocks` mirror fields surface the cheapest
564        // available option as a sensible default, matching the mint's
565        // initialization in `MeltQuote::new_onchain`.
566        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        // Echo the mint-supplied `quote_id` verbatim per the
573        // `OnchainOutgoingPaymentOptions.quote_id` contract. The mint
574        // validates this echo; any deviation triggers
575        // `Error::OnchainQuoteLookupIdMismatch`.
576        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        // Resolve the wallet-selected `fee_index` back to a configured tier.
611        // Older callers that omit `fee_index` continue to default to
612        // Immediate.
613        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        // The intent has been queued but no batch has been built yet, so the
645        // per-intent fee contribution is not yet knowable. Following the
646        // convention used by other backends (LND/LDK-Node/CLN return `0` for
647        // `Unknown`/`NotFound`), we return `0` as a sentinel meaning "actual
648        // spent amount is not yet known". Callers should wait for the
649        // terminal `Paid` event to read the authoritative `total_spent`.
650        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, &quote_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        // Only return finalized payments. Active intents (Detected state) are
720        // not yet confirmed and should not be reported to the mint for processing.
721        let finalized = self
722            .storage
723            .get_finalized_receive_intents_by_quote_id(&quote_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        // 1. Check active intents
747        if let Some(record) = self.storage.get_send_intent_by_quote_id(&quote_id).await? {
748            // `total_spent` is the actual amount spent (amount + fee) and is
749            // only reported once the payment has been made. Before the batch
750            // transaction has been built, the per-intent fee contribution is
751            // unknown, so we return `0` as a sentinel. This matches the
752            // convention used by other backends for non-terminal states.
753            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        // 2. Check finalized tombstones
786        if let Some(record) = self
787            .storage
788            .get_finalized_intent_by_quote_id(&quote_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    /// Build a `CdkBdk` instance pointed at a bogus Esplora URL so the sync
833    /// loop spins without needing a real backend. The ticks are short so
834    /// shutdown tests run quickly.
835    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        // With an unreachable Esplora URL, estimate_fee_rate_sat_per_vb
1531        // returns an error. The quote path falls back to the configured
1532        // default. We exercise the fallback by invoking get_payment_quote
1533        // with a tier hint and observing that it returns a non-zero fee.
1534        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(&quote_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(&quote_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(&quote_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    // ------------------------------------------------------------------
1763    // Regression tests for Finding 5: total_spent is only authoritative
1764    // after the payment has been made. While the intent is queued but not
1765    // yet broadcast, the per-intent fee is unknown, so `total_spent` is
1766    // reported as 0 (sentinel), matching the LND/LDK/CLN convention for
1767    // non-terminal responses.
1768    // ------------------------------------------------------------------
1769
1770    use cdk_common::payment::OnchainOutgoingPaymentOptions;
1771    use cdk_common::QuoteId;
1772    use uuid::Uuid;
1773
1774    /// Build an onchain outgoing payment option with a fresh quote id.
1775    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(&quote_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(&quote_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(&quote_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        // make_payment queues the intent before a batch has been built, so
1926        // the per-intent fee is unknown. total_spent MUST be 0, not the
1927        // user-requested amount (which would imply no fee).
1928        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(&quote_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(&quote_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        // An intent freshly created via make_payment is in state Pending.
2063        // check_outgoing_payment must report total_spent = 0 because the
2064        // fee contribution is not yet knowable.
2065        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        // Driving an intent through Pending → Batched (fee still unknown at
2088        // the per-intent level until the batch transaction is built) must
2089        // still report total_spent = 0.
2090        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        // Once an intent reaches AwaitingConfirmation, the per-intent fee
2131        // contribution is persisted on the intent record. check_outgoing_payment
2132        // must now report total_spent = amount + fee_contribution_sat so that
2133        // downstream consumers (e.g. recovery / subscribers) see the
2134        // authoritative figure even though the payment is still unconfirmed.
2135        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(&quote_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(&quote_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        // A quote id with no active intent and no finalized tombstone must
2275        // return MeltQuoteState::Unknown with total_spent = 0 (existing
2276        // behaviour; pinned here for defence-in-depth).
2277        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    // ------------------------------------------------------------------
2292    // Chain-sync resilience tests
2293    // ------------------------------------------------------------------
2294
2295    #[test]
2296    fn test_is_transient_classifies_network_errors() {
2297        // Esplora errors are always classified as transient: the sync
2298        // loop should retry them on the next tick, and this classification
2299        // drives the log severity in the supervisor.
2300        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        // Local wallet/state errors are not transient: they indicate a
2311        // real defect that retrying will not resolve.
2312        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        // Timed-out I/O is transient.
2319        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        // An arbitrary I/O error kind is not.
2326        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        // The supervisor must keep calling the supplied future as long
2336        // as it returns Err, until the cancel token is triggered.
2337        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        // Let a few restart cycles happen (initial backoff is 1s).
2354        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        // Ok(()) from the task is treated as clean shutdown; the
2372        // supervisor exits immediately without restart.
2373        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        // Cancelling during the backoff sleep must exit promptly rather
2404        // than waiting for the sleep to expire.
2405        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                // Fail immediately so we enter the backoff sleep.
2410                Err::<(), Error>(Error::Esplora("boom".to_string()))
2411            })
2412            .await;
2413        });
2414
2415        // Give the supervisor a moment to enter its first backoff.
2416        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        // sync_wallet must not return Err when the Esplora endpoint is
2435        // unreachable — it should warn and continue. We prove this by
2436        // starting the backend (which spawns the sync task against a
2437        // bogus URL) and letting it run for long enough to tick at least
2438        // twice, then stop cleanly.
2439        let backend = build_test_instance(5).await;
2440        backend.start().await.expect("start");
2441
2442        // Sync interval is 60s per build_test_instance, so this test
2443        // only verifies the first synchronous tick path: the task must
2444        // stay alive and the supervisor must not log a "task failed"
2445        // line for a transient network error.
2446        tokio::time::sleep(Duration::from_millis(500)).await;
2447
2448        // The sync JoinHandle must still be running, not completed.
2449        {
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}