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