Skip to main content

cdk_bdk/
lib.rs

1//! CDK onchain backend using BDK
2
3#![doc = include_str!("../README.md")]
4
5use std::future::Future;
6use std::path::{Path, PathBuf};
7use std::pin::Pin;
8use std::str::FromStr;
9use std::sync::atomic::{AtomicBool, Ordering};
10use std::sync::Arc;
11use std::task::{Context, Poll};
12use std::time::{Duration, Instant};
13use std::{fmt, fs};
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, OpenFlags};
20use bdk_wallet::template::Bip84;
21use bdk_wallet::{ChangeSet, KeychainKind, PersistedWallet, Update, 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, ElectrumConfig, 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
63const MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS: usize = 100;
64
65/// Verify that an existing BDK wallet is present and belongs to the configured
66/// mnemonic and network without modifying its database.
67pub fn validate_existing_wallet(
68    mnemonic: Mnemonic,
69    network: Network,
70    storage_dir_path: &Path,
71) -> Result<(), Error> {
72    let wallet_path = storage_dir_path.join("bdk_wallet/bdk_wallet.sqlite");
73    match fs::metadata(&wallet_path) {
74        Ok(metadata) if metadata.is_file() => {}
75        Ok(_) => return Err(Error::ExistingWalletNotInitialized { path: wallet_path }),
76        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
77            return Err(Error::ExistingWalletMissing { path: wallet_path });
78        }
79        Err(error) => return Err(Error::Io(error)),
80    }
81
82    let mut db = Connection::open_with_flags(&wallet_path, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
83    let has_wallet_table = db.query_row(
84        "SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'bdk_wallet')",
85        [],
86        |row| row.get::<_, bool>(0),
87    )?;
88    if !has_wallet_table {
89        return Err(Error::ExistingWalletNotInitialized { path: wallet_path });
90    }
91
92    let changeset = {
93        let transaction = db.transaction()?;
94        ChangeSet::from_sqlite(&transaction)?
95    };
96
97    let xkey: ExtendedKey = mnemonic.into_extended_key()?;
98    let xprv = xkey.into_xprv(network.into()).ok_or(Error::Path)?;
99    let descriptor = Bip84(xprv, KeychainKind::External);
100    let change_descriptor = Bip84(xprv, KeychainKind::Internal);
101    let wallet = Wallet::load()
102        .descriptor(KeychainKind::External, Some(descriptor))
103        .descriptor(KeychainKind::Internal, Some(change_descriptor))
104        .extract_keys()
105        .check_network(network)
106        .load_wallet_no_persist(changeset)
107        .map_err(|e| Error::Wallet(e.to_string()))?;
108
109    match wallet {
110        Some(_) => Ok(()),
111        None => Err(Error::ExistingWalletNotInitialized { path: wallet_path }),
112    }
113}
114
115/// Wrapper struct that combines wallet and database to prevent deadlocks
116pub(crate) struct WalletWithDb {
117    pub(crate) wallet: PersistedWallet<Connection>,
118    pub(crate) db: Connection,
119}
120
121pub(crate) struct BackgroundTasks {
122    pub(crate) cancel: CancellationToken,
123    pub(crate) sync: JoinHandle<()>,
124    pub(crate) batch: JoinHandle<()>,
125}
126
127struct PaymentEventStream {
128    receiver: BroadcastStream<Event>,
129    cancel: Pin<Box<dyn Future<Output = ()> + Send + 'static>>,
130    is_active: Arc<AtomicBool>,
131}
132
133impl Stream for PaymentEventStream {
134    type Item = Event;
135
136    fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> {
137        let this = self.get_mut();
138
139        if this.cancel.as_mut().poll(cx).is_ready() {
140            this.is_active.store(false, Ordering::SeqCst);
141            return Poll::Ready(None);
142        }
143
144        loop {
145            match Pin::new(&mut this.receiver).poll_next(cx) {
146                Poll::Ready(Some(Ok(event))) => return Poll::Ready(Some(event)),
147                Poll::Ready(Some(Err(err))) => {
148                    tracing::warn!(
149                        "cdk-bdk payment event subscriber lagged or errored: {}",
150                        err
151                    );
152                }
153                Poll::Ready(None) => {
154                    this.is_active.store(false, Ordering::SeqCst);
155                    return Poll::Ready(None);
156                }
157                Poll::Pending => return Poll::Pending,
158            }
159        }
160    }
161}
162
163impl Drop for PaymentEventStream {
164    fn drop(&mut self) {
165        self.is_active.store(false, Ordering::SeqCst);
166    }
167}
168
169impl WalletWithDb {
170    pub(crate) fn new(wallet: PersistedWallet<Connection>, db: Connection) -> Self {
171        Self { wallet, db }
172    }
173
174    pub(crate) fn persist(&mut self) -> Result<bool, bdk_wallet::rusqlite::Error> {
175        self.wallet.persist(&mut self.db)
176    }
177}
178
179/// CDK onchain payment backend using BDK (Bitcoin Development Kit)
180#[derive(Clone)]
181pub struct CdkBdk {
182    pub(crate) fee_reserve: FeeReserve,
183    pub(crate) wait_invoice_cancel_token: CancellationToken,
184    pub(crate) wait_invoice_is_active: Arc<AtomicBool>,
185    pub(crate) payment_sender: tokio::sync::broadcast::Sender<Event>,
186    pub(crate) tasks: Arc<Mutex<Option<BackgroundTasks>>>,
187    pub(crate) shutdown_timeout: Duration,
188    pub(crate) wallet_with_db: Arc<Mutex<WalletWithDb>>,
189    pub(crate) chain_source: ChainSource,
190    pub(crate) storage: BdkStorage,
191    pub(crate) network: Network,
192    /// Batch processor configuration
193    pub(crate) batch_config: BatchConfig,
194    /// Notify handle to wake up the batch processor immediately
195    pub(crate) batch_notify: Arc<Notify>,
196    /// Number of confirmations required for on-chain payments
197    pub(crate) num_confs: u32,
198    /// Minimum on-chain receive amount that should count toward minting
199    pub(crate) min_receive_amount_sat: u64,
200    /// Minimum on-chain send amount accepted for melts
201    pub(crate) min_send_amount_sat: u64,
202    /// Sync interval in seconds
203    pub(crate) sync_interval_secs: u64,
204    /// Blockchain sync configuration
205    pub(crate) sync_config: SyncConfig,
206    /// Cache for fee rate estimation: Tier -> (sat_per_vb, timestamp)
207    pub(crate) fee_rate_cache: Arc<Mutex<std::collections::HashMap<PaymentTier, (f64, u64)>>>,
208}
209
210impl CdkBdk {
211    fn outgoing_payment_failure_response(
212        unit: &CurrencyUnit,
213        quote_id: &cdk_common::QuoteId,
214        reason: impl fmt::Display,
215    ) -> MakePaymentResponse {
216        tracing::warn!(
217            quote_id = %quote_id,
218            "BDK rejected onchain payment before dispatch: {reason}"
219        );
220        MakePaymentResponse {
221            payment_lookup_id: PaymentIdentifier::QuoteId(quote_id.clone()),
222            payment_proof: None,
223            status: MeltQuoteState::Failed,
224            total_spent: Amount::new(0, unit.clone()),
225        }
226    }
227
228    fn ensure_supported_payment_unit(
229        unit: &CurrencyUnit,
230    ) -> Result<(), cdk_common::payment::Error> {
231        match unit {
232            CurrencyUnit::Sat | CurrencyUnit::Msat => Ok(()),
233            _ => Err(cdk_common::payment::Error::UnsupportedUnit),
234        }
235    }
236
237    fn ensure_amount_unit(unit: &CurrencyUnit, amount: &Amount<CurrencyUnit>) -> Result<(), Error> {
238        if amount.unit() != unit {
239            return Err(Error::AmountUnitMismatch {
240                expected: unit.clone(),
241                actual: amount.unit().clone(),
242            });
243        }
244
245        Ok(())
246    }
247
248    fn payment_amount_to_sat(
249        unit: &CurrencyUnit,
250        amount: &Amount<CurrencyUnit>,
251    ) -> Result<u64, Error> {
252        Self::ensure_amount_unit(unit, amount)?;
253
254        if unit == &CurrencyUnit::Msat && amount.value() % MSAT_IN_SAT != 0 {
255            return Err(Error::FractionalSatoshiAmount {
256                amount_msat: amount.value(),
257            });
258        }
259
260        amount.to_sat().map_err(Error::from)
261    }
262
263    fn fee_limit_to_sat(unit: &CurrencyUnit, amount: &Amount<CurrencyUnit>) -> Result<u64, Error> {
264        Self::ensure_amount_unit(unit, amount)?;
265        amount.to_sat().map_err(Error::from)
266    }
267
268    pub(crate) fn validate_send_amount_against_dust(
269        &self,
270        address: &str,
271        amount_sat: u64,
272    ) -> Result<(), Error> {
273        let address = bdk_wallet::bitcoin::Address::from_str(address)
274            .map_err(|e| Error::Wallet(e.to_string()))?
275            .require_network(self.network)
276            .map_err(|e| Error::Wallet(e.to_string()))?;
277
278        let dust_limit = bdk_wallet::bitcoin::TxOut::minimal_non_dust(address.script_pubkey())
279            .value
280            .to_sat();
281
282        if amount_sat < dust_limit {
283            return Err(Error::DustOutput {
284                amount: amount_sat,
285                dust_limit,
286            });
287        }
288
289        Ok(())
290    }
291
292    pub(crate) fn validate_send_amount(&self, address: &str, amount_sat: u64) -> Result<(), Error> {
293        self.validate_send_amount_against_dust(address, amount_sat)?;
294
295        if amount_sat < self.min_send_amount_sat {
296            return Err(Error::AmountBelowMinimumSend {
297                amount: amount_sat,
298                min: self.min_send_amount_sat,
299            });
300        }
301
302        Ok(())
303    }
304
305    pub(crate) fn confirmations_satisfied(&self, tip_height: u32, anchor_height: u32) -> bool {
306        if tip_height < anchor_height {
307            return false;
308        }
309
310        tip_height - anchor_height + 1 >= self.num_confs
311    }
312
313    pub(crate) fn should_ignore_receive_amount(&self, amount_sat: u64) -> bool {
314        amount_sat < self.min_receive_amount_sat
315    }
316
317    /// Return `true` when the wallet knows about the transaction and it
318    /// satisfies the configured confirmation threshold.
319    pub(crate) fn txid_has_required_confirmations(
320        &self,
321        wallet: &PersistedWallet<Connection>,
322        txid_str: &str,
323        intent_kind: &str,
324        intent_id: &str,
325    ) -> bool {
326        let Ok(parsed_txid) = bdk_wallet::bitcoin::Txid::from_str(txid_str) else {
327            tracing::warn!(
328                intent_kind,
329                intent_id,
330                txid = txid_str,
331                "Could not parse txid during confirmation check"
332            );
333            return false;
334        };
335
336        let Some(tx_details) = wallet.get_tx(parsed_txid) else {
337            return false;
338        };
339
340        let check_point = wallet.latest_checkpoint().height();
341        match &tx_details.chain_position {
342            bdk_wallet::chain::ChainPosition::Confirmed { anchor, .. } => {
343                self.confirmations_satisfied(check_point, anchor.block_id.height)
344            }
345            bdk_wallet::chain::ChainPosition::Unconfirmed { .. } => false,
346        }
347    }
348
349    /// Create a new CdkBdk instance
350    #[allow(clippy::too_many_arguments)]
351    pub fn new(
352        mnemonic: Mnemonic,
353        network: Network,
354        chain_source: ChainSource,
355        storage_dir_path: String,
356        fee_reserve: FeeReserve,
357        kv_store: Arc<dyn KVStore<Err = cdk_common::database::Error> + Send + Sync>,
358        batch_config: Option<BatchConfig>,
359        num_confs: u32,
360        min_receive_amount_sat: u64,
361        min_send_amount_sat: u64,
362        sync_interval_secs: u64,
363        shutdown_timeout_secs: Option<u64>,
364        sync_config: Option<SyncConfig>,
365    ) -> Result<Self, Error> {
366        chain_source.validate()?;
367
368        let storage_dir_path = PathBuf::from(storage_dir_path);
369        let storage_dir_path = storage_dir_path.join("bdk_wallet");
370        fs::create_dir_all(&storage_dir_path)?;
371
372        let mut db = Connection::open(storage_dir_path.join("bdk_wallet.sqlite"))?;
373
374        let xkey: ExtendedKey = mnemonic.into_extended_key()?;
375        let xprv = xkey.into_xprv(network.into()).ok_or(Error::Path)?;
376
377        let descriptor = Bip84(xprv, KeychainKind::External);
378        let change_descriptor = Bip84(xprv, KeychainKind::Internal);
379
380        let wallet_opt = Wallet::load()
381            .descriptor(KeychainKind::External, Some(descriptor.clone()))
382            .descriptor(KeychainKind::Internal, Some(change_descriptor.clone()))
383            .extract_keys()
384            .check_network(network)
385            .load_wallet(&mut db)
386            .map_err(|e| Error::Wallet(e.to_string()))?;
387
388        // A fresh Bitcoin Core wallet should start at the current tip rather
389        // than scanning from genesis. An explicit rescan height overrides the
390        // tip for seed recovery. Fetch this before creating the wallet so an
391        // unreachable or misconfigured node cannot persist a genesis-pinned
392        // wallet by accident.
393        let initial_checkpoint = match wallet_opt.is_none() {
394            true => chain_source.initial_checkpoint()?,
395            false => None,
396        };
397
398        let mut wallet = match wallet_opt {
399            Some(wallet) => wallet,
400            None => {
401                let mut wallet = Wallet::create(descriptor, change_descriptor)
402                    .network(network)
403                    .create_wallet(&mut db)
404                    .map_err(|e| Error::Wallet(e.to_string()))?;
405
406                if let Some(block_id) = initial_checkpoint {
407                    let checkpoint = wallet.latest_checkpoint().insert(block_id);
408                    wallet
409                        .apply_update(Update {
410                            chain: Some(checkpoint),
411                            ..Default::default()
412                        })
413                        .map_err(|e| Error::Wallet(e.to_string()))?;
414                }
415
416                wallet
417            }
418        };
419
420        wallet.persist(&mut db)?;
421
422        let wallet_with_db = WalletWithDb::new(wallet, db);
423
424        let batch_config = batch_config.unwrap_or_default();
425        if batch_config.poll_interval.is_zero() {
426            return Err(Error::InvalidConfig(
427                "batch_config.poll_interval must be greater than zero".to_string(),
428            ));
429        }
430        batch_config.validate().map_err(Error::InvalidConfig)?;
431
432        if sync_interval_secs == 0 {
433            return Err(Error::InvalidConfig(
434                "sync_interval_secs must be greater than zero".to_string(),
435            ));
436        }
437
438        let channel_capacity = batch_config.max_batch_size * 2 + 16;
439        let (payment_sender, _) = tokio::sync::broadcast::channel(channel_capacity);
440
441        Ok(Self {
442            fee_reserve,
443            wait_invoice_cancel_token: CancellationToken::new(),
444            wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
445            payment_sender,
446            tasks: Arc::new(Mutex::new(None)),
447            shutdown_timeout: Duration::from_secs(shutdown_timeout_secs.unwrap_or(30)),
448            wallet_with_db: Arc::new(Mutex::new(wallet_with_db)),
449            chain_source,
450            storage: BdkStorage::new(kv_store),
451            network,
452            batch_config,
453            batch_notify: Arc::new(Notify::new()),
454            num_confs,
455            min_receive_amount_sat,
456            min_send_amount_sat,
457            sync_interval_secs,
458            sync_config: sync_config.unwrap_or_default(),
459            fee_rate_cache: Arc::new(Mutex::new(std::collections::HashMap::new())),
460        })
461    }
462}
463
464/// Supervise a long-running task, restarting it with exponential backoff
465/// (1s -> 60s, capped) whenever it returns `Err`. The backoff resets once
466/// the task has run for longer than [`SUPERVISOR_BACKOFF_RESET`]. Exits
467/// cleanly when `cancel` is triggered.
468///
469/// A task returning `Ok(())` is treated as a clean shutdown (e.g. the
470/// task observed the cancel token itself) and the supervisor exits.
471async fn supervise<F, Fut>(name: &'static str, cancel: CancellationToken, mut f: F)
472where
473    F: FnMut(CancellationToken) -> Fut,
474    Fut: Future<Output = Result<(), Error>>,
475{
476    const INITIAL_BACKOFF: Duration = Duration::from_secs(1);
477    const MAX_BACKOFF: Duration = Duration::from_secs(60);
478    const SUPERVISOR_BACKOFF_RESET: Duration = Duration::from_secs(300);
479
480    let mut backoff = INITIAL_BACKOFF;
481
482    loop {
483        if cancel.is_cancelled() {
484            break;
485        }
486
487        let started = Instant::now();
488        let child_cancel = cancel.clone();
489
490        let result = tokio::select! {
491            _ = cancel.cancelled() => {
492                tracing::info!("{name} supervisor: cancelled");
493                return;
494            }
495            r = f(child_cancel) => r,
496        };
497
498        match result {
499            Ok(()) => {
500                tracing::info!("{name} supervisor: task exited cleanly");
501                return;
502            }
503            Err(e) => {
504                let ran_for = started.elapsed();
505                let transient = e.is_transient();
506                tracing::error!(
507                    task = name,
508                    ran_for_secs = ran_for.as_secs(),
509                    transient,
510                    "supervised task returned error: {e}; restarting with backoff"
511                );
512
513                if ran_for >= SUPERVISOR_BACKOFF_RESET {
514                    backoff = INITIAL_BACKOFF;
515                }
516
517                // Sleep with backoff, but wake immediately if cancelled.
518                tokio::select! {
519                    _ = cancel.cancelled() => {
520                        tracing::info!("{name} supervisor: cancelled during backoff");
521                        return;
522                    }
523                    _ = tokio::time::sleep(backoff) => {}
524                }
525
526                backoff = (backoff * 2).min(MAX_BACKOFF);
527            }
528        }
529    }
530}
531
532#[async_trait]
533impl MintPayment for CdkBdk {
534    type Err = cdk_common::payment::Error;
535
536    #[tracing::instrument(skip_all)]
537    async fn start(&self) -> Result<(), Self::Err> {
538        let mut tasks_lock = self.tasks.lock().await;
539        if tasks_lock.is_some() {
540            return Err(Error::AlreadyStarted.into());
541        }
542
543        self.recover_receive_saga().await?;
544        self.recover_send_saga().await?;
545        self.storage.ensure_send_outpoint_quote_id_index().await?;
546
547        let cancel = CancellationToken::new();
548
549        let sync_self = self.clone();
550        let sync_cancel = cancel.clone();
551        let sync_handle = tokio::spawn(async move {
552            supervise("wallet sync", sync_cancel, move |cancel| {
553                let me = sync_self.clone();
554                async move { me.sync_wallet(cancel).await }
555            })
556            .await;
557        });
558
559        let batch_self = self.clone();
560        let batch_cancel = cancel.clone();
561        let batch_handle = tokio::spawn(async move {
562            supervise("batch processor", batch_cancel, move |cancel| {
563                let me = batch_self.clone();
564                async move { me.run_batch_processor(cancel).await }
565            })
566            .await;
567        });
568
569        *tasks_lock = Some(BackgroundTasks {
570            cancel,
571            sync: sync_handle,
572            batch: batch_handle,
573        });
574
575        Ok(())
576    }
577
578    async fn stop(&self) -> Result<(), Self::Err> {
579        self.wait_invoice_cancel_token.cancel();
580
581        let tasks_opt = {
582            let mut tasks_lock = self.tasks.lock().await;
583            tasks_lock.take()
584        };
585
586        if let Some(bg) = tasks_opt {
587            bg.cancel.cancel();
588
589            let sync_aborter = bg.sync.abort_handle();
590            let batch_aborter = bg.batch.abort_handle();
591
592            let joined = tokio::time::timeout(self.shutdown_timeout, async move {
593                let _ = bg.sync.await;
594                let _ = bg.batch.await;
595            })
596            .await;
597
598            if joined.is_err() {
599                sync_aborter.abort();
600                batch_aborter.abort();
601                tracing::error!(
602                    "cdk-bdk background tasks did not exit within {:?}; forced abort",
603                    self.shutdown_timeout
604                );
605            }
606        }
607
608        Ok(())
609    }
610
611    async fn get_settings(&self) -> Result<SettingsResponse, Self::Err> {
612        Ok(SettingsResponse {
613            unit: "sat".to_string(),
614            bolt11: None,
615            bolt12: None,
616            onchain: Some(OnchainSettings {
617                confirmations: self.num_confs,
618                min_receive_amount_sat: self.min_receive_amount_sat,
619                min_send_amount_sat: self.min_send_amount_sat,
620            }),
621            custom: std::collections::HashMap::new(),
622        })
623    }
624
625    async fn get_payment_quote(
626        &self,
627        unit: &CurrencyUnit,
628        options: OutgoingPaymentOptions,
629    ) -> Result<PaymentQuoteResponse, Self::Err> {
630        Self::ensure_supported_payment_unit(unit)?;
631
632        let onchain_options = match options {
633            OutgoingPaymentOptions::Onchain(o) => o,
634            _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
635        };
636
637        let amount_sat = Self::payment_amount_to_sat(unit, &onchain_options.amount)?;
638        self.validate_send_amount(&onchain_options.address, amount_sat)?;
639
640        // Estimate fee_reserve for each configured tier so the mint presents
641        // only the operator-enabled options. The configured order owns the
642        // `fee_index` values and resolves them back to tiers during payment.
643        let mut fee_options = Vec::with_capacity(self.batch_config.fee_options.len());
644        for (idx, tier) in self.batch_config.fee_options.iter().enumerate() {
645            let fee_estimate = self
646                .estimate_onchain_fee_reserve(&onchain_options.address, amount_sat, *tier)
647                .await?;
648            let fee_reserve = Amount::new(fee_estimate.fee_reserve_sat, CurrencyUnit::Sat)
649                .convert_to(unit)
650                .map_err(Error::AmountConversion)?;
651            fee_options.push(MeltQuoteOnchainFeeOption {
652                fee_index: idx as u32,
653                fee_reserve: fee_reserve.into(),
654                estimated_blocks: tier.estimated_blocks(),
655            });
656        }
657
658        // The `fee`/`estimated_blocks` mirror fields surface the cheapest
659        // available option as a sensible default, matching the mint's
660        // initialization in `MeltQuote::new_onchain`.
661        let cheapest = fee_options
662            .iter()
663            .min_by_key(|option| u64::from(option.fee_reserve))
664            .copied()
665            .expect("fee_options is validated as non-empty");
666
667        // Echo the mint-supplied `quote_id` verbatim per the
668        // `OnchainOutgoingPaymentOptions.quote_id` contract. The mint
669        // validates this echo; any deviation triggers
670        // `Error::OnchainQuoteLookupIdMismatch`.
671        Ok(PaymentQuoteResponse {
672            request_lookup_id: Some(PaymentIdentifier::QuoteId(onchain_options.quote_id.clone())),
673            amount: onchain_options.amount,
674            fee: Amount::new(cheapest.fee_reserve.into(), unit.clone()),
675            state: MeltQuoteState::Unpaid,
676            extra_json: None,
677            estimated_blocks: Some(cheapest.estimated_blocks),
678            fee_options: Some(fee_options),
679        })
680    }
681
682    async fn make_payment(
683        &self,
684        unit: &CurrencyUnit,
685        options: OutgoingPaymentOptions,
686    ) -> Result<MakePaymentResponse, Self::Err> {
687        let onchain_options = match options {
688            OutgoingPaymentOptions::Onchain(o) => o,
689            _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
690        };
691
692        let address = onchain_options.address;
693        let amount = onchain_options.amount;
694        let quote_id = onchain_options.quote_id;
695
696        if let Err(err) = Self::ensure_supported_payment_unit(unit) {
697            return Ok(Self::outgoing_payment_failure_response(
698                unit, &quote_id, err,
699            ));
700        }
701
702        let amount_sat = match Self::payment_amount_to_sat(unit, &amount) {
703            Ok(amount_sat) => amount_sat,
704            Err(err) => {
705                return Ok(Self::outgoing_payment_failure_response(
706                    unit, &quote_id, err,
707                ));
708            }
709        };
710        if let Err(err) = self.validate_send_amount(&address, amount_sat) {
711            return Ok(Self::outgoing_payment_failure_response(
712                unit, &quote_id, err,
713            ));
714        }
715
716        let max_fee_sat = match onchain_options.max_fee_amount {
717            Some(max_fee) => match Self::fee_limit_to_sat(unit, &max_fee) {
718                Ok(max_fee_sat) => max_fee_sat,
719                Err(err) => {
720                    return Ok(Self::outgoing_payment_failure_response(
721                        unit, &quote_id, err,
722                    ));
723                }
724            },
725            None => 1_000,
726        };
727        // Resolve the wallet-selected `fee_index` back to a configured tier.
728        // Older callers that omit `fee_index` continue to default to
729        // Immediate.
730        let tier = match self
731            .batch_config
732            .tier_for_fee_index(onchain_options.fee_index)
733            .map_err(Error::UnknownFeeIndex)
734        {
735            Ok(tier) => tier,
736            Err(err) => {
737                return Ok(Self::outgoing_payment_failure_response(
738                    unit, &quote_id, err,
739                ));
740            }
741        };
742        let metadata = PaymentMetadata::from_optional_json(onchain_options.metadata.as_deref());
743        let fee_estimate = match self
744            .estimate_onchain_fee_reserve(&address, amount_sat, tier)
745            .await
746        {
747            Ok(fee_estimate) => fee_estimate,
748            Err(err) => {
749                return Ok(Self::outgoing_payment_failure_response(
750                    unit, &quote_id, err,
751                ));
752            }
753        };
754        if fee_estimate.raw_fee_sat > max_fee_sat {
755            let err = Error::EstimatedFeeTooHigh {
756                estimated_fee: fee_estimate.raw_fee_sat,
757                max_fee: max_fee_sat,
758            };
759            return Ok(Self::outgoing_payment_failure_response(
760                unit, &quote_id, err,
761            ));
762        }
763
764        crate::send::payment_intent::SendIntent::new(
765            &self.storage,
766            quote_id.to_string(),
767            address,
768            amount_sat,
769            max_fee_sat,
770            tier,
771            metadata,
772        )
773        .await?;
774
775        if tier == PaymentTier::Immediate {
776            self.batch_notify.notify_one();
777        }
778
779        // The intent has been queued but no batch has been built yet, so the
780        // per-intent fee contribution is not yet knowable. Following the
781        // convention used by other backends (LND/LDK-Node/CLN return `0` for
782        // `Unknown`/`NotFound`), we return `0` as a sentinel meaning "actual
783        // spent amount is not yet known". Callers should wait for the
784        // terminal `Paid` event to read the authoritative `total_spent`.
785        Ok(MakePaymentResponse {
786            payment_lookup_id: PaymentIdentifier::QuoteId(quote_id),
787            payment_proof: None,
788            status: MeltQuoteState::Pending,
789            total_spent: Amount::new(0, unit.clone()),
790        })
791    }
792
793    async fn create_incoming_payment_request(
794        &self,
795        options: IncomingPaymentOptions,
796    ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
797        let onchain_options = match options {
798            IncomingPaymentOptions::Onchain(o) => o,
799            _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
800        };
801
802        let quote_id = onchain_options.quote_id;
803        let quote_id_string = quote_id.to_string();
804
805        let mut wallet_with_db = self.wallet_with_db.lock().await;
806
807        // An address already tracked for another quote (derived by another
808        // instance sharing this seed, or by a re-initialised local wallet)
809        // must not be handed out again; advance to a fresh address.
810        let address_str = 'reserve_address: {
811            for attempt in 1..=MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS {
812                let address = wallet_with_db
813                    .wallet
814                    .reveal_next_address(KeychainKind::External);
815                let candidate = address.address.to_string();
816
817                wallet_with_db.persist().map_err(|err| {
818                    tracing::warn!("Could not persist to bdk db: {}", err);
819
820                    Error::BdkPersist
821                })?;
822
823                if self
824                    .storage
825                    .track_receive_address(&candidate, &quote_id_string)
826                    .await?
827                {
828                    break 'reserve_address candidate;
829                }
830
831                tracing::debug!(
832                    quote_id = %quote_id,
833                    attempt,
834                    max_attempts = MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS,
835                    "Receive address is already reserved for another quote"
836                );
837            }
838
839            return Err(Error::ReceiveAddressReservationExhausted {
840                attempts: MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS,
841            }
842            .into());
843        };
844
845        Ok(CreateIncomingPaymentResponse {
846            request_lookup_id: PaymentIdentifier::QuoteId(quote_id),
847            request: address_str,
848            expiry: None,
849            extra_json: None,
850        })
851    }
852
853    async fn wait_payment_event(
854        &self,
855    ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
856        self.wait_invoice_is_active.store(true, Ordering::SeqCst);
857
858        let receiver = self.payment_sender.subscribe();
859        let stream = PaymentEventStream {
860            receiver: BroadcastStream::new(receiver),
861            cancel: Box::pin(self.wait_invoice_cancel_token.clone().cancelled_owned()),
862            is_active: Arc::clone(&self.wait_invoice_is_active),
863        };
864
865        Ok(Box::pin(stream))
866    }
867
868    async fn check_incoming_payment_status(
869        &self,
870        payment_identifier: &PaymentIdentifier,
871    ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
872        let PaymentIdentifier::QuoteId(quote_id) = payment_identifier else {
873            return Err(Error::UnsupportedOnchain.into());
874        };
875
876        let quote_id_str = quote_id.to_string();
877        let mut results = Vec::new();
878
879        // Only return finalized payments. Active intents (Detected state) are
880        // not yet confirmed and should not be reported to the mint for processing.
881        let finalized = self
882            .storage
883            .get_finalized_receive_intents_by_quote_id(&quote_id_str)
884            .await?;
885
886        for record in finalized {
887            results.push(WaitPaymentResponse {
888                payment_identifier: payment_identifier.clone(),
889                payment_amount: Amount::new(record.amount_sat, CurrencyUnit::Sat),
890                payment_id: record.outpoint,
891            });
892        }
893
894        Ok(results)
895    }
896
897    async fn check_outgoing_payment(
898        &self,
899        payment_identifier: &PaymentIdentifier,
900    ) -> Result<MakePaymentResponse, Self::Err> {
901        let quote_id = match payment_identifier {
902            PaymentIdentifier::QuoteId(id) => id.to_string(),
903            _ => return Err(Error::UnsupportedOnchain.into()),
904        };
905
906        // 1. Check active intents
907        if let Some(record) = self.storage.get_send_intent_by_quote_id(&quote_id).await? {
908            // `total_spent` is the actual amount spent (amount + fee) and is
909            // only reported once the payment has been made. Before the batch
910            // transaction has been built, the per-intent fee contribution is
911            // unknown, so we return `0` as a sentinel. This matches the
912            // convention used by other backends for non-terminal states.
913            let total_spent = match &record.state {
914                crate::send::payment_intent::record::SendIntentState::Pending { .. }
915                | crate::send::payment_intent::record::SendIntentState::Batched { .. } => {
916                    Amount::new(0, CurrencyUnit::Sat)
917                }
918                crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
919                    fee_contribution_sat,
920                    ..
921                } => Amount::new(record.amount_sat + fee_contribution_sat, CurrencyUnit::Sat),
922                crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
923                    Amount::new(0, CurrencyUnit::Sat)
924                }
925            };
926            let status = match record.state {
927                crate::send::payment_intent::record::SendIntentState::Pending { .. }
928                | crate::send::payment_intent::record::SendIntentState::Batched { .. }
929                | crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
930                    ..
931                } => MeltQuoteState::Pending,
932                crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
933                    MeltQuoteState::Failed
934                }
935            };
936
937            return Ok(MakePaymentResponse {
938                payment_lookup_id: payment_identifier.clone(),
939                payment_proof: None,
940                status,
941                total_spent,
942            });
943        }
944
945        // 2. Check finalized tombstones
946        if let Some(record) = self
947            .storage
948            .get_finalized_intent_by_quote_id(&quote_id)
949            .await?
950        {
951            return Ok(MakePaymentResponse {
952                payment_lookup_id: payment_identifier.clone(),
953                payment_proof: Some(record.outpoint),
954                status: MeltQuoteState::Paid,
955                total_spent: Amount::new(record.total_spent_sat, CurrencyUnit::Sat),
956            });
957        }
958
959        Ok(MakePaymentResponse {
960            payment_lookup_id: payment_identifier.clone(),
961            payment_proof: None,
962            status: MeltQuoteState::Unknown,
963            total_spent: Amount::new(0, CurrencyUnit::Sat),
964        })
965    }
966
967    fn is_payment_event_stream_active(&self) -> bool {
968        self.wait_invoice_is_active.load(Ordering::SeqCst)
969    }
970
971    fn cancel_payment_event_stream(&self) {
972        self.wait_invoice_cancel_token.cancel();
973    }
974}
975
976#[cfg(test)]
977mod tests {
978    use std::fs;
979    use std::str::FromStr;
980
981    use bdk_wallet::bitcoin::hashes::Hash as _;
982    use bdk_wallet::bitcoin::{
983        absolute, transaction, Network, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness,
984    };
985    use bdk_wallet::keys::bip39::Mnemonic;
986    use cdk_common::common::FeeReserve;
987    use cdk_common::payment::{MintPayment, OnchainIncomingPaymentOptions};
988    use futures::StreamExt;
989
990    use super::*;
991    use crate::fee::apply_quote_fee_safety;
992
993    const TEST_MNEMONIC: &str =
994        "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
995    const OTHER_TEST_MNEMONIC: &str =
996        "legal winner thank year wave sausage worth useful legal winner thank yellow";
997
998    #[tokio::test]
999    async fn existing_wallet_preflight_requires_matching_persisted_wallet() {
1000        let (backend, tempdir) = build_test_instance_with_tempdir(5).await;
1001        drop(backend);
1002
1003        let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic");
1004        validate_existing_wallet(mnemonic, Network::Regtest, tempdir.path())
1005            .expect("matching wallet should pass preflight");
1006
1007        let other_mnemonic = Mnemonic::from_str(OTHER_TEST_MNEMONIC).expect("other mnemonic");
1008        assert!(matches!(
1009            validate_existing_wallet(other_mnemonic, Network::Regtest, tempdir.path()),
1010            Err(Error::Wallet(_))
1011        ));
1012        assert!(matches!(
1013            validate_existing_wallet(
1014                Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic"),
1015                Network::Signet,
1016                tempdir.path(),
1017            ),
1018            Err(Error::Wallet(_))
1019        ));
1020    }
1021
1022    #[test]
1023    fn existing_wallet_preflight_does_not_create_missing_or_empty_wallet() {
1024        let tempdir = tempfile::tempdir().expect("tempdir");
1025        let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic");
1026        let wallet_path = tempdir.path().join("bdk_wallet/bdk_wallet.sqlite");
1027        assert!(matches!(
1028            validate_existing_wallet(mnemonic.clone(), Network::Regtest, tempdir.path()),
1029            Err(Error::ExistingWalletMissing { .. })
1030        ));
1031        assert!(!wallet_path.exists());
1032
1033        fs::create_dir_all(wallet_path.parent().expect("wallet directory"))
1034            .expect("create wallet directory");
1035        drop(Connection::open(&wallet_path).expect("create empty sqlite file"));
1036        assert!(matches!(
1037            validate_existing_wallet(mnemonic, Network::Regtest, tempdir.path()),
1038            Err(Error::ExistingWalletNotInitialized { .. })
1039        ));
1040    }
1041
1042    /// Build a `CdkBdk` instance pointed at a bogus Esplora URL so the sync
1043    /// loop spins without needing a real backend. The ticks are short so
1044    /// shutdown tests run quickly.
1045    async fn build_test_instance(shutdown_timeout_secs: u64) -> CdkBdk {
1046        build_test_instance_with_tempdir(shutdown_timeout_secs)
1047            .await
1048            .0
1049    }
1050
1051    async fn build_test_instance_with_tempdir(
1052        shutdown_timeout_secs: u64,
1053    ) -> (CdkBdk, tempfile::TempDir) {
1054        build_test_instance_with_config(shutdown_timeout_secs, None, 60)
1055            .await
1056            .expect("build CdkBdk test instance")
1057    }
1058
1059    async fn build_test_instance_with_config(
1060        shutdown_timeout_secs: u64,
1061        batch_config: Option<BatchConfig>,
1062        sync_interval_secs: u64,
1063    ) -> Result<(CdkBdk, tempfile::TempDir), Error> {
1064        let chain_source = ChainSource::Esplora(EsploraConfig {
1065            url: "http://127.0.0.1:1".to_string(),
1066            parallel_requests: 1,
1067        });
1068
1069        build_test_instance_with_chain_source(
1070            shutdown_timeout_secs,
1071            batch_config,
1072            sync_interval_secs,
1073            chain_source,
1074        )
1075        .await
1076    }
1077
1078    async fn build_test_instance_with_chain_source(
1079        shutdown_timeout_secs: u64,
1080        batch_config: Option<BatchConfig>,
1081        sync_interval_secs: u64,
1082        chain_source: ChainSource,
1083    ) -> Result<(CdkBdk, tempfile::TempDir), Error> {
1084        let tmp = tempfile::tempdir().expect("tempdir");
1085        let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("mnemonic");
1086
1087        let kv = cdk_sqlite::mint::memory::empty()
1088            .await
1089            .expect("in-memory kv store");
1090
1091        let fee_reserve = FeeReserve {
1092            min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(),
1093            percent_fee_reserve: 0.02,
1094        };
1095
1096        let backend = CdkBdk::new(
1097            mnemonic,
1098            Network::Regtest,
1099            chain_source,
1100            tmp.path().to_string_lossy().into_owned(),
1101            fee_reserve,
1102            Arc::new(kv),
1103            batch_config,
1104            1,
1105            0,
1106            546,
1107            sync_interval_secs,
1108            Some(shutdown_timeout_secs),
1109            None,
1110        )?;
1111
1112        Ok((backend, tmp))
1113    }
1114
1115    /// Build a `CdkBdk` instance with its own local wallet directory but a
1116    /// shared KV store, like a second replica or a re-initialised node using
1117    /// the same seed.
1118    async fn build_test_instance_with_shared_kv(
1119        kv: Arc<cdk_sqlite::mint::MintSqliteDatabase>,
1120    ) -> (CdkBdk, tempfile::TempDir) {
1121        let tmp = tempfile::tempdir().expect("tempdir");
1122        let mnemonic = Mnemonic::from_str(
1123            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
1124        )
1125        .expect("mnemonic");
1126        let chain_source = ChainSource::Esplora(EsploraConfig {
1127            url: "http://127.0.0.1:1".to_string(),
1128            parallel_requests: 1,
1129        });
1130        let fee_reserve = FeeReserve {
1131            min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(),
1132            percent_fee_reserve: 0.02,
1133        };
1134
1135        let backend = CdkBdk::new(
1136            mnemonic,
1137            Network::Regtest,
1138            chain_source,
1139            tmp.path().to_string_lossy().into_owned(),
1140            fee_reserve,
1141            kv,
1142            None,
1143            1,
1144            0,
1145            546,
1146            60,
1147            Some(5),
1148            None,
1149        )
1150        .expect("build CdkBdk test instance");
1151
1152        (backend, tmp)
1153    }
1154
1155    #[tokio::test]
1156    async fn instances_sharing_seed_and_kv_never_share_a_receive_address() {
1157        let kv = Arc::new(
1158            cdk_sqlite::mint::memory::empty()
1159                .await
1160                .expect("in-memory kv store"),
1161        );
1162        let (first, _tmp_first) = build_test_instance_with_shared_kv(kv.clone()).await;
1163        let (second, _tmp_second) = build_test_instance_with_shared_kv(kv).await;
1164
1165        let first_request = first
1166            .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1167                OnchainIncomingPaymentOptions {
1168                    quote_id: cdk_common::QuoteId::new(),
1169                },
1170            ))
1171            .await
1172            .expect("first receive request");
1173        let second_request = second
1174            .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1175                OnchainIncomingPaymentOptions {
1176                    quote_id: cdk_common::QuoteId::new(),
1177                },
1178            ))
1179            .await
1180            .expect("second receive request");
1181
1182        assert_ne!(
1183            first_request.request, second_request.request,
1184            "each instance must hand out a distinct receive address"
1185        );
1186    }
1187
1188    #[tokio::test]
1189    async fn operator_deposit_address_is_not_associated_with_a_quote() {
1190        let kv = Arc::new(
1191            cdk_sqlite::mint::memory::empty()
1192                .await
1193                .expect("in-memory kv store"),
1194        );
1195        let (backend, _tmp) = build_test_instance_with_shared_kv(kv).await;
1196
1197        let operator_address = backend
1198            .create_operator_deposit_address()
1199            .await
1200            .expect("create operator deposit address");
1201        let quote_request = backend
1202            .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1203                OnchainIncomingPaymentOptions {
1204                    quote_id: cdk_common::QuoteId::new(),
1205                },
1206            ))
1207            .await
1208            .expect("create quote receive request");
1209
1210        assert_ne!(operator_address, quote_request.request);
1211        assert!(backend
1212            .storage
1213            .get_quote_id_by_receive_address(&operator_address)
1214            .await
1215            .expect("look up operator address")
1216            .is_none());
1217        assert!(!backend
1218            .storage
1219            .get_tracked_receive_addresses()
1220            .await
1221            .expect("list quote receive addresses")
1222            .contains(&operator_address));
1223    }
1224
1225    #[tokio::test]
1226    async fn receive_address_reservation_stops_after_attempt_limit() {
1227        let kv = Arc::new(
1228            cdk_sqlite::mint::memory::empty()
1229                .await
1230                .expect("in-memory kv store"),
1231        );
1232        let (first, _tmp_first) = build_test_instance_with_shared_kv(kv.clone()).await;
1233        let (second, _tmp_second) = build_test_instance_with_shared_kv(kv).await;
1234
1235        for _ in 0..MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS {
1236            first
1237                .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1238                    OnchainIncomingPaymentOptions {
1239                        quote_id: cdk_common::QuoteId::new(),
1240                    },
1241                ))
1242                .await
1243                .expect("reserve receive address");
1244        }
1245
1246        let err = second
1247            .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1248                OnchainIncomingPaymentOptions {
1249                    quote_id: cdk_common::QuoteId::new(),
1250                },
1251            ))
1252            .await
1253            .expect_err("reservation should stop after the attempt limit");
1254
1255        let cdk_common::payment::Error::Onchain(inner) = err else {
1256            panic!("expected onchain error");
1257        };
1258        assert!(matches!(
1259            inner.downcast_ref::<Error>(),
1260            Some(Error::ReceiveAddressReservationExhausted { attempts })
1261                if *attempts == MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS
1262        ));
1263    }
1264
1265    #[tokio::test]
1266    async fn wallet_info_lists_revealed_addresses_without_revealing_more() {
1267        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1268
1269        let initial_addresses = backend
1270            .wallet_addresses(0, 100)
1271            .await
1272            .expect("list initial addresses");
1273        assert_eq!(initial_addresses.total, 0);
1274
1275        backend
1276            .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1277                OnchainIncomingPaymentOptions {
1278                    quote_id: cdk_common::QuoteId::new(),
1279                },
1280            ))
1281            .await
1282            .expect("create on-chain request");
1283
1284        let addresses = backend
1285            .wallet_addresses(0, 100)
1286            .await
1287            .expect("list revealed addresses");
1288        assert_eq!(addresses.total, 1);
1289        assert_eq!(addresses.items.len(), 1);
1290        assert_eq!(addresses.items[0].keychain, WalletKeychain::External);
1291        assert_eq!(addresses.items[0].derivation_index, 0);
1292        assert!(!addresses.items[0].used);
1293        assert_eq!(addresses.items[0].balance_sat, 0);
1294
1295        let balance = backend.wallet_balance().await;
1296        assert_eq!(balance.total_sat, 0);
1297        assert_eq!(
1298            backend
1299                .wallet_transactions(0, 20)
1300                .await
1301                .expect("list transactions")
1302                .total,
1303            0
1304        );
1305
1306        let addresses_again = backend
1307            .wallet_addresses(0, 100)
1308            .await
1309            .expect("list revealed addresses again");
1310        assert_eq!(addresses_again.total, 1);
1311    }
1312
1313    #[tokio::test]
1314    async fn wallet_info_paginates_revealed_addresses_across_keychains() {
1315        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1316
1317        {
1318            let mut wallet_with_db = backend.wallet_with_db.lock().await;
1319            let _ = wallet_with_db
1320                .wallet
1321                .reveal_addresses_to(KeychainKind::External, 1)
1322                .count();
1323            let _ = wallet_with_db
1324                .wallet
1325                .reveal_addresses_to(KeychainKind::Internal, 1)
1326                .count();
1327            wallet_with_db
1328                .persist()
1329                .expect("persist revealed addresses");
1330        }
1331
1332        let page = backend
1333            .wallet_addresses(1, 2)
1334            .await
1335            .expect("list paginated addresses");
1336
1337        assert_eq!(page.total, 4);
1338        assert_eq!(page.items.len(), 2);
1339        assert_eq!(page.items[0].keychain, WalletKeychain::External);
1340        assert_eq!(page.items[0].derivation_index, 1);
1341        assert_eq!(page.items[1].keychain, WalletKeychain::Internal);
1342        assert_eq!(page.items[1].derivation_index, 0);
1343    }
1344
1345    async fn fund_backend_wallet_transactions(backend: &CdkBdk, amounts_sat: &[u64]) -> Vec<Txid> {
1346        let mut wallet_with_db = backend.wallet_with_db.lock().await;
1347        let funding_script = wallet_with_db
1348            .wallet
1349            .reveal_next_address(KeychainKind::External)
1350            .address
1351            .script_pubkey();
1352        let funding_transactions = amounts_sat
1353            .iter()
1354            .enumerate()
1355            .map(|(index, amount_sat)| Transaction {
1356                version: transaction::Version::TWO,
1357                lock_time: absolute::LockTime::ZERO,
1358                input: vec![TxIn {
1359                    previous_output: OutPoint::new(
1360                        Txid::all_zeros(),
1361                        u32::try_from(index).expect("test transaction index fits in u32"),
1362                    ),
1363                    script_sig: Default::default(),
1364                    sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1365                    witness: Witness::new(),
1366                }],
1367                output: vec![TxOut {
1368                    value: bdk_wallet::bitcoin::Amount::from_sat(*amount_sat),
1369                    script_pubkey: funding_script.clone(),
1370                }],
1371            })
1372            .collect::<Vec<_>>();
1373        let txids = funding_transactions
1374            .iter()
1375            .map(Transaction::compute_txid)
1376            .collect();
1377
1378        wallet_with_db
1379            .wallet
1380            .apply_unconfirmed_txs(funding_transactions.into_iter().map(|tx| (tx, 0)));
1381        wallet_with_db.persist().expect("persist funded wallet");
1382
1383        txids
1384    }
1385
1386    async fn fund_backend_wallet(backend: &CdkBdk, amount_sat: u64) {
1387        fund_backend_wallet_transactions(backend, &[amount_sat]).await;
1388    }
1389
1390    #[tokio::test]
1391    async fn wallet_info_reports_unconfirmed_funding() {
1392        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1393        fund_backend_wallet(&backend, 42_000).await;
1394
1395        let balance = backend.wallet_balance().await;
1396        assert_eq!(balance.untrusted_pending_sat, 42_000);
1397        assert_eq!(balance.total_sat, 42_000);
1398
1399        let transactions = backend
1400            .wallet_transactions(0, 20)
1401            .await
1402            .expect("list transactions");
1403        assert_eq!(transactions.total, 1);
1404        assert_eq!(transactions.items[0].received_sat, 42_000);
1405        assert_eq!(transactions.items[0].sent_sat, 0);
1406        assert_eq!(transactions.items[0].balance_delta_sat, 42_000);
1407        assert_eq!(transactions.items[0].confirmation_height, None);
1408        assert_eq!(transactions.items[0].first_seen, Some(0));
1409        assert_eq!(
1410            transactions.items[0].inputs,
1411            vec![WalletTransactionInput {
1412                txid: Txid::all_zeros().to_string(),
1413                vout: 0,
1414                amount_sat: None,
1415                address: None,
1416            }]
1417        );
1418
1419        let addresses = backend
1420            .wallet_addresses(0, 20)
1421            .await
1422            .expect("list addresses");
1423        assert_eq!(addresses.total, 1);
1424        assert!(addresses.items[0].used);
1425        assert_eq!(
1426            transactions.items[0].outputs,
1427            vec![WalletTransactionOutput {
1428                vout: 0,
1429                address: addresses.items[0].address.clone(),
1430                amount_sat: 42_000,
1431                quote_id: None,
1432            }]
1433        );
1434        assert_eq!(addresses.items[0].balance_sat, 42_000);
1435        assert_eq!(addresses.items[0].confirmed_balance_sat, 0);
1436
1437        let empty_page = backend
1438            .wallet_transactions(0, 0)
1439            .await
1440            .expect("list empty transaction page");
1441        assert_eq!(empty_page.total, 1);
1442        assert!(empty_page.items.is_empty());
1443    }
1444
1445    #[tokio::test]
1446    async fn wallet_info_pairs_unconfirmed_incoming_output_with_quote_id() {
1447        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1448        fund_backend_wallet(&backend, 42_000).await;
1449        let address = backend
1450            .wallet_addresses(0, 20)
1451            .await
1452            .expect("list addresses")
1453            .items[0]
1454            .address
1455            .clone();
1456        backend
1457            .storage
1458            .track_receive_address(&address, "mint-quote")
1459            .await
1460            .expect("track receive address");
1461
1462        let transactions = backend
1463            .wallet_transactions(0, 20)
1464            .await
1465            .expect("list transactions");
1466        assert_eq!(
1467            transactions.items[0].outputs,
1468            vec![WalletTransactionOutput {
1469                vout: 0,
1470                address,
1471                amount_sat: 42_000,
1472                quote_id: Some("mint-quote".to_string()),
1473            }]
1474        );
1475    }
1476
1477    #[tokio::test]
1478    async fn wallet_info_pairs_batched_outputs_with_quote_ids() {
1479        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1480        let funding_txids = fund_backend_wallet_transactions(&backend, &[20_000, 22_000]).await;
1481        let funding_address = backend
1482            .wallet_addresses(0, 20)
1483            .await
1484            .expect("list funding address")
1485            .items[0]
1486            .address
1487            .clone();
1488        let first_address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string();
1489        let second_address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x".to_string();
1490
1491        let mut wallet_with_db = backend.wallet_with_db.lock().await;
1492        let first_recipient_script = bdk_wallet::bitcoin::Address::from_str(&first_address)
1493            .expect("valid address")
1494            .require_network(Network::Regtest)
1495            .expect("regtest address")
1496            .script_pubkey();
1497        let second_recipient_script = bdk_wallet::bitcoin::Address::from_str(&second_address)
1498            .expect("valid address")
1499            .require_network(Network::Regtest)
1500            .expect("regtest address")
1501            .script_pubkey();
1502        let change_script = wallet_with_db
1503            .wallet
1504            .reveal_next_address(KeychainKind::Internal)
1505            .address
1506            .script_pubkey();
1507        let spending_transaction = Transaction {
1508            version: transaction::Version::TWO,
1509            lock_time: absolute::LockTime::ZERO,
1510            input: funding_txids
1511                .iter()
1512                .rev()
1513                .map(|txid| TxIn {
1514                    previous_output: OutPoint::new(*txid, 0),
1515                    script_sig: Default::default(),
1516                    sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1517                    witness: Witness::new(),
1518                })
1519                .collect(),
1520            output: vec![
1521                TxOut {
1522                    value: bdk_wallet::bitcoin::Amount::from_sat(20_000),
1523                    script_pubkey: first_recipient_script,
1524                },
1525                TxOut {
1526                    value: bdk_wallet::bitcoin::Amount::from_sat(10_000),
1527                    script_pubkey: second_recipient_script,
1528                },
1529                TxOut {
1530                    value: bdk_wallet::bitcoin::Amount::from_sat(11_900),
1531                    script_pubkey: change_script,
1532                },
1533            ],
1534        };
1535        let spending_txid = spending_transaction.compute_txid();
1536        wallet_with_db
1537            .wallet
1538            .apply_unconfirmed_txs([(spending_transaction, 1)]);
1539        wallet_with_db
1540            .persist()
1541            .expect("persist spending transaction");
1542        drop(wallet_with_db);
1543
1544        let batch_id = Uuid::new_v4();
1545        let intents = [
1546            (Uuid::new_v4(), "quote-first", &first_address, 20_000, 0),
1547            (Uuid::new_v4(), "quote-second", &second_address, 10_000, 1),
1548        ];
1549        for (intent_id, quote_id, address, amount_sat, vout) in &intents {
1550            backend
1551                .storage
1552                .create_send_intent_if_absent(
1553                    &crate::send::payment_intent::record::SendIntentRecord {
1554                        intent_id: *intent_id,
1555                        quote_id: quote_id.to_string(),
1556                        address: address.to_string(),
1557                        amount_sat: *amount_sat,
1558                        max_fee_amount_sat: 1_000,
1559                        tier: PaymentTier::Immediate,
1560                        metadata: PaymentMetadata::default(),
1561                        state: crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
1562                            batch_id,
1563                            txid: spending_txid.to_string(),
1564                            outpoint: format!("{spending_txid}:{vout}"),
1565                            fee_contribution_sat: 50,
1566                            created_at: 0,
1567                        },
1568                    },
1569                )
1570                .await
1571                .expect("store send intent");
1572        }
1573
1574        let transactions = backend
1575            .wallet_transactions(0, 20)
1576            .await
1577            .expect("list transactions");
1578
1579        assert_eq!(transactions.total, 3);
1580        assert_eq!(
1581            transactions.items[0].inputs,
1582            vec![
1583                WalletTransactionInput {
1584                    txid: funding_txids[1].to_string(),
1585                    vout: 0,
1586                    amount_sat: Some(22_000),
1587                    address: Some(funding_address.clone()),
1588                },
1589                WalletTransactionInput {
1590                    txid: funding_txids[0].to_string(),
1591                    vout: 0,
1592                    amount_sat: Some(20_000),
1593                    address: Some(funding_address),
1594                },
1595            ]
1596        );
1597        assert_eq!(
1598            transactions.items[0].outputs,
1599            vec![
1600                WalletTransactionOutput {
1601                    vout: 0,
1602                    address: first_address.clone(),
1603                    amount_sat: 20_000,
1604                    quote_id: Some("quote-first".to_string()),
1605                },
1606                WalletTransactionOutput {
1607                    vout: 1,
1608                    address: second_address.clone(),
1609                    amount_sat: 10_000,
1610                    quote_id: Some("quote-second".to_string()),
1611                },
1612            ]
1613        );
1614        assert_eq!(transactions.items[0].sent_sat, 42_000);
1615        assert_eq!(transactions.items[0].received_sat, 11_900);
1616
1617        for (intent_id, quote_id, _, amount_sat, vout) in &intents {
1618            backend
1619                .storage
1620                .finalize_send_intent(
1621                    intent_id,
1622                    &FinalizedSendIntentRecord {
1623                        intent_id: *intent_id,
1624                        quote_id: quote_id.to_string(),
1625                        total_spent_sat: *amount_sat + 50,
1626                        outpoint: format!("{spending_txid}:{vout}"),
1627                        finalized_at: 0,
1628                    },
1629                )
1630                .await
1631                .expect("finalize send intent");
1632        }
1633
1634        let finalized_transactions = backend
1635            .wallet_transactions(0, 20)
1636            .await
1637            .expect("list transactions after intent finalization");
1638        assert_eq!(
1639            finalized_transactions.items[0]
1640                .outputs
1641                .iter()
1642                .map(|output| output.quote_id.as_deref())
1643                .collect::<Vec<_>>(),
1644            vec![Some("quote-first"), Some("quote-second")]
1645        );
1646    }
1647
1648    #[tokio::test]
1649    async fn wallet_info_reports_multiple_incoming_outputs_in_vout_order() {
1650        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1651        let mut wallet_with_db = backend.wallet_with_db.lock().await;
1652        let output_script = wallet_with_db
1653            .wallet
1654            .reveal_next_address(KeychainKind::External)
1655            .address
1656            .script_pubkey();
1657        let funding_transaction = Transaction {
1658            version: transaction::Version::TWO,
1659            lock_time: absolute::LockTime::ZERO,
1660            input: vec![TxIn {
1661                previous_output: OutPoint::new(Txid::all_zeros(), 0),
1662                script_sig: Default::default(),
1663                sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1664                witness: Witness::new(),
1665            }],
1666            output: vec![
1667                TxOut {
1668                    value: bdk_wallet::bitcoin::Amount::from_sat(21_000),
1669                    script_pubkey: output_script.clone(),
1670                },
1671                TxOut {
1672                    value: bdk_wallet::bitcoin::Amount::from_sat(21_000),
1673                    script_pubkey: output_script,
1674                },
1675            ],
1676        };
1677        wallet_with_db
1678            .wallet
1679            .apply_unconfirmed_txs([(funding_transaction, 0)]);
1680        wallet_with_db
1681            .persist()
1682            .expect("persist funding transaction");
1683        drop(wallet_with_db);
1684
1685        let transactions = backend
1686            .wallet_transactions(0, 20)
1687            .await
1688            .expect("list transactions");
1689
1690        assert_eq!(transactions.total, 1);
1691        assert_eq!(transactions.items[0].outputs.len(), 2);
1692        assert_eq!(transactions.items[0].outputs[0].vout, 0);
1693        assert_eq!(transactions.items[0].outputs[0].amount_sat, 21_000);
1694        assert_eq!(transactions.items[0].outputs[1].vout, 1);
1695        assert_eq!(transactions.items[0].outputs[1].amount_sat, 21_000);
1696    }
1697
1698    #[tokio::test]
1699    async fn wallet_info_uses_txid_to_order_equal_chain_positions() {
1700        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1701        let mut expected_txids =
1702            fund_backend_wallet_transactions(&backend, &[21_000, 42_000]).await;
1703        expected_txids.sort_by(|left, right| right.cmp(left));
1704
1705        let first_page = backend
1706            .wallet_transactions(0, 1)
1707            .await
1708            .expect("list first transaction page");
1709        let second_page = backend
1710            .wallet_transactions(1, 1)
1711            .await
1712            .expect("list second transaction page");
1713
1714        assert_eq!(first_page.total, 2);
1715        assert_eq!(second_page.total, 2);
1716        assert_eq!(first_page.items[0].txid, expected_txids[0].to_string());
1717        assert_eq!(second_page.items[0].txid, expected_txids[1].to_string());
1718    }
1719
1720    #[tokio::test]
1721    async fn test_new_rejects_zero_sync_interval() {
1722        match build_test_instance_with_config(5, None, 0).await {
1723            Err(Error::InvalidConfig(message)) => {
1724                assert!(message.contains("sync_interval_secs"));
1725            }
1726            Ok(_) => panic!("zero sync interval should be rejected"),
1727            Err(err) => panic!("expected invalid config error, got {err}"),
1728        }
1729    }
1730
1731    #[tokio::test]
1732    async fn test_new_rejects_zero_batch_poll_interval() {
1733        let batch_config = BatchConfig {
1734            poll_interval: Duration::ZERO,
1735            ..BatchConfig::default()
1736        };
1737
1738        match build_test_instance_with_config(5, Some(batch_config), 60).await {
1739            Err(Error::InvalidConfig(message)) => {
1740                assert!(message.contains("poll_interval"));
1741            }
1742            Ok(_) => panic!("zero batch poll interval should be rejected"),
1743            Err(err) => panic!("expected invalid config error, got {err}"),
1744        }
1745    }
1746
1747    #[tokio::test]
1748    async fn test_new_rejects_zero_target_block_time() {
1749        let batch_config = BatchConfig {
1750            target_block_time: Duration::ZERO,
1751            ..BatchConfig::default()
1752        };
1753
1754        match build_test_instance_with_config(5, Some(batch_config), 60).await {
1755            Err(Error::InvalidConfig(message)) => {
1756                assert!(message.contains("target_block_time"));
1757            }
1758            Ok(_) => panic!("zero target block time should be rejected"),
1759            Err(err) => panic!("expected invalid config error, got {err}"),
1760        }
1761    }
1762
1763    #[tokio::test]
1764    async fn test_new_rejects_invalid_fallback_fee_rate() {
1765        let batch_config = BatchConfig {
1766            fee_estimation: FeeEstimationConfig {
1767                fallback_sat_per_vb: 0.0,
1768                ..FeeEstimationConfig::default()
1769            },
1770            ..BatchConfig::default()
1771        };
1772
1773        match build_test_instance_with_config(5, Some(batch_config), 60).await {
1774            Err(Error::InvalidConfig(message)) => {
1775                assert!(message.contains("fallback_sat_per_vb"));
1776            }
1777            Ok(_) => panic!("invalid fallback fee rate should be rejected"),
1778            Err(err) => panic!("expected invalid config error, got {err}"),
1779        }
1780    }
1781
1782    #[test]
1783    fn test_default_batch_deadlines_match_advertised_blocks() {
1784        let batch_config = BatchConfig::default();
1785
1786        assert_eq!(batch_config.target_block_time, Duration::from_secs(600));
1787        assert_eq!(batch_config.standard_deadline, Duration::from_secs(3600));
1788        assert_eq!(batch_config.economy_deadline, Duration::from_secs(86_400));
1789        assert_eq!(
1790            batch_config.max_intent_age,
1791            Some(Duration::from_secs(86_430))
1792        );
1793    }
1794
1795    #[tokio::test]
1796    async fn test_start_then_stop_exits_promptly() {
1797        let backend = build_test_instance(5).await;
1798
1799        let started = tokio::time::timeout(Duration::from_secs(10), backend.start())
1800            .await
1801            .expect("start timed out");
1802        started.expect("start should succeed");
1803
1804        let stopped = tokio::time::timeout(Duration::from_secs(10), backend.stop())
1805            .await
1806            .expect("stop timed out");
1807        stopped.expect("stop should succeed");
1808    }
1809
1810    #[tokio::test]
1811    async fn test_double_start_returns_already_started() {
1812        let backend = build_test_instance(5).await;
1813        backend.start().await.expect("first start");
1814
1815        let second = backend.start().await;
1816        assert!(second.is_err(), "second start should error");
1817
1818        backend.stop().await.expect("stop");
1819    }
1820
1821    #[tokio::test]
1822    async fn test_stop_without_start_is_ok() {
1823        let backend = build_test_instance(5).await;
1824        backend.stop().await.expect("stop on never-started is ok");
1825        backend.stop().await.expect("double stop is ok");
1826    }
1827
1828    #[tokio::test]
1829    async fn test_restart_after_stop() {
1830        let backend = build_test_instance(5).await;
1831        backend.start().await.expect("first start");
1832        backend.stop().await.expect("first stop");
1833        backend.start().await.expect("second start");
1834        backend.stop().await.expect("second stop");
1835    }
1836
1837    #[tokio::test]
1838    async fn test_wait_payment_event_tracks_active_state_and_cancels() {
1839        let backend = build_test_instance(5).await;
1840        assert!(!backend.is_payment_event_stream_active());
1841
1842        let mut stream = backend
1843            .wait_payment_event()
1844            .await
1845            .expect("payment event stream");
1846        assert!(backend.is_payment_event_stream_active());
1847
1848        backend.cancel_payment_event_stream();
1849
1850        let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
1851            .await
1852            .expect("stream should observe cancellation promptly");
1853        assert!(next.is_none());
1854        assert!(!backend.is_payment_event_stream_active());
1855    }
1856
1857    #[test]
1858    fn test_quote_fee_safety_adds_multiplier_and_fixed_margin() {
1859        let config = FeeEstimationConfig {
1860            quote_safety_multiplier: 1.25,
1861            quote_fixed_safety_sat: 500,
1862            ..FeeEstimationConfig::default()
1863        };
1864
1865        assert_eq!(apply_quote_fee_safety(1_000, &config), 1_750);
1866    }
1867
1868    #[tokio::test]
1869    async fn test_fee_rate_cache_falls_back_on_error() {
1870        // With an unreachable Esplora URL, estimate_fee_rate_sat_per_vb
1871        // returns an error. The quote path falls back to the configured
1872        // default. We exercise the fallback by invoking get_payment_quote
1873        // with a tier hint and observing that it returns a non-zero fee.
1874        let backend = build_test_instance(5).await;
1875
1876        let tier_err = backend
1877            .estimate_fee_rate_sat_per_vb(PaymentTier::Immediate)
1878            .await;
1879        assert!(
1880            tier_err.is_err(),
1881            "fee rate estimation should fail against bogus Esplora URL"
1882        );
1883    }
1884
1885    #[tokio::test]
1886    async fn test_get_payment_quote_does_not_stage_wallet_changes() {
1887        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1888        fund_backend_wallet(&backend, 100_000).await;
1889        let (_quote_id, options) = onchain_options_for(10_000);
1890
1891        backend
1892            .get_payment_quote(&CurrencyUnit::Sat, options)
1893            .await
1894            .expect("quote should succeed with fallback fee rate");
1895
1896        let wallet_with_db = backend.wallet_with_db.lock().await;
1897        assert!(
1898            wallet_with_db.wallet.staged().is_none(),
1899            "quote estimation must not mutate or stage BDK wallet state"
1900        );
1901    }
1902
1903    #[tokio::test]
1904    async fn test_default_fee_options_emit_immediate_only() {
1905        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1906        fund_backend_wallet(&backend, 100_000).await;
1907        let (_quote_id, options) = onchain_options_for(10_000);
1908
1909        let quote = backend
1910            .get_payment_quote(&CurrencyUnit::Sat, options)
1911            .await
1912            .expect("quote should succeed");
1913
1914        let fee_options = quote.fee_options.expect("fee options");
1915        assert_eq!(fee_options.len(), 1);
1916        assert_eq!(fee_options[0].fee_index, 0);
1917        assert_eq!(fee_options[0].estimated_blocks, 1);
1918    }
1919
1920    #[tokio::test]
1921    async fn test_configured_fee_options_emit_indexes_in_order() {
1922        let batch_config = BatchConfig {
1923            fee_options: vec![
1924                PaymentTier::Immediate,
1925                PaymentTier::Standard,
1926                PaymentTier::Economy,
1927            ],
1928            ..BatchConfig::default()
1929        };
1930        let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1931            .await
1932            .expect("build CdkBdk test instance");
1933        fund_backend_wallet(&backend, 100_000).await;
1934        let (_quote_id, options) = onchain_options_for(10_000);
1935
1936        let quote = backend
1937            .get_payment_quote(&CurrencyUnit::Sat, options)
1938            .await
1939            .expect("quote should succeed");
1940
1941        let fee_options = quote.fee_options.expect("fee options");
1942        let indexes: Vec<u32> = fee_options.iter().map(|option| option.fee_index).collect();
1943        let estimated_blocks: Vec<u32> = fee_options
1944            .iter()
1945            .map(|option| option.estimated_blocks)
1946            .collect();
1947
1948        assert_eq!(indexes, vec![0, 1, 2]);
1949        assert_eq!(estimated_blocks, vec![1, 6, 144]);
1950    }
1951
1952    #[tokio::test]
1953    async fn test_configured_fee_index_resolves_by_position() {
1954        let batch_config = BatchConfig {
1955            fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
1956            ..BatchConfig::default()
1957        };
1958        let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1959            .await
1960            .expect("build CdkBdk test instance");
1961        fund_backend_wallet(&backend, 100_000).await;
1962        let (quote_id, mut options) = onchain_options_for(10_000);
1963        let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1964            panic!("expected onchain options");
1965        };
1966        onchain.fee_index = Some(1);
1967        onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
1968
1969        backend
1970            .make_payment(&CurrencyUnit::Sat, options)
1971            .await
1972            .expect("make_payment should enqueue the intent");
1973
1974        let intent = backend
1975            .storage
1976            .get_send_intent_by_quote_id(&quote_id.to_string())
1977            .await
1978            .expect("lookup send intent by quote id")
1979            .expect("send intent should be persisted");
1980
1981        assert_eq!(intent.tier, PaymentTier::Economy);
1982    }
1983
1984    #[tokio::test]
1985    async fn test_make_payment_returns_failed_for_unknown_fee_index() {
1986        let backend = build_test_instance(5).await;
1987        let (quote_id, mut options) = onchain_options_for(10_000);
1988        let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1989            panic!("expected onchain options");
1990        };
1991        onchain.fee_index = Some(99);
1992
1993        let response = backend
1994            .make_payment(&CurrencyUnit::Sat, options)
1995            .await
1996            .expect("definitive pre-dispatch rejection should return a payment response");
1997
1998        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
1999        assert!(
2000            backend
2001                .storage
2002                .get_send_intent_by_quote_id(&quote_id.to_string())
2003                .await
2004                .expect("lookup send intent by quote id")
2005                .is_none(),
2006            "unknown fee index rejection must not leave a pending send intent behind"
2007        );
2008    }
2009
2010    #[tokio::test]
2011    async fn test_make_payment_omitted_fee_index_defaults_to_immediate() {
2012        let batch_config = BatchConfig {
2013            fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
2014            ..BatchConfig::default()
2015        };
2016        let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
2017            .await
2018            .expect("build CdkBdk test instance");
2019        fund_backend_wallet(&backend, 100_000).await;
2020        let (quote_id, options) = onchain_options_for(10_000);
2021
2022        backend
2023            .make_payment(&CurrencyUnit::Sat, options)
2024            .await
2025            .expect("make_payment should enqueue the intent");
2026
2027        let intent = backend
2028            .storage
2029            .get_send_intent_by_quote_id(&quote_id.to_string())
2030            .await
2031            .expect("lookup send intent by quote id")
2032            .expect("send intent should be persisted");
2033
2034        assert_eq!(intent.tier, PaymentTier::Immediate);
2035    }
2036
2037    #[tokio::test]
2038    async fn test_new_rejects_invalid_fee_option_lists() {
2039        for fee_options in [
2040            Vec::new(),
2041            vec![PaymentTier::Immediate, PaymentTier::Immediate],
2042            vec![
2043                PaymentTier::Immediate,
2044                PaymentTier::Standard,
2045                PaymentTier::Economy,
2046                PaymentTier::Immediate,
2047            ],
2048        ] {
2049            let batch_config = BatchConfig {
2050                fee_options,
2051                ..BatchConfig::default()
2052            };
2053            match build_test_instance_with_config(5, Some(batch_config), 60).await {
2054                Err(Error::InvalidConfig(message)) => {
2055                    assert!(message.contains("fee_options"));
2056                }
2057                Ok(_) => panic!("invalid fee options should be rejected"),
2058                Err(err) => panic!("expected invalid config error, got {err}"),
2059            }
2060        }
2061    }
2062
2063    #[tokio::test]
2064    async fn test_get_payment_quote_rejects_empty_wallet() {
2065        let backend = build_test_instance(5).await;
2066        let (_quote_id, options) = onchain_options_for(10_000);
2067
2068        let err = backend
2069            .get_payment_quote(&CurrencyUnit::Sat, options)
2070            .await
2071            .expect_err("empty wallet should not receive an onchain quote");
2072
2073        let cdk_common::payment::Error::Onchain(inner) = err else {
2074            panic!("expected onchain error");
2075        };
2076
2077        let backend_err = inner
2078            .downcast_ref::<Error>()
2079            .expect("expected cdk-bdk backend error");
2080        assert!(matches!(backend_err, Error::NoSpendableUtxos));
2081    }
2082
2083    #[tokio::test]
2084    async fn test_make_payment_returns_failed_when_current_fee_exceeds_max_fee() {
2085        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2086        fund_backend_wallet(&backend, 100_000).await;
2087        let (quote_id, mut options) = onchain_options_for(10_000);
2088        let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
2089            panic!("expected onchain options");
2090        };
2091        onchain.max_fee_amount = Some(Amount::new(1, CurrencyUnit::Sat));
2092
2093        let response = backend
2094            .make_payment(&CurrencyUnit::Sat, options)
2095            .await
2096            .expect("definitive pre-dispatch rejection should return a payment response");
2097
2098        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2099
2100        assert!(
2101            backend
2102                .storage
2103                .get_send_intent_by_quote_id(&quote_id.to_string())
2104                .await
2105                .expect("lookup send intent by quote id")
2106                .is_none(),
2107            "fee recheck rejection must not leave a pending send intent behind"
2108        );
2109    }
2110
2111    #[tokio::test]
2112    async fn test_get_settings_reports_min_send_amount() {
2113        let backend = build_test_instance(5).await;
2114
2115        let settings = backend.get_settings().await.expect("settings");
2116        let onchain = settings.onchain.expect("onchain settings");
2117
2118        assert_eq!(onchain.min_receive_amount_sat, 0);
2119        assert_eq!(onchain.min_send_amount_sat, 546);
2120    }
2121
2122    // ------------------------------------------------------------------
2123    // Regression tests for Finding 5: total_spent is only authoritative
2124    // after the payment has been made. While the intent is queued but not
2125    // yet broadcast, the per-intent fee is unknown, so `total_spent` is
2126    // reported as 0 (sentinel), matching the LND/LDK/CLN convention for
2127    // non-terminal responses.
2128    // ------------------------------------------------------------------
2129
2130    use cdk_common::payment::OnchainOutgoingPaymentOptions;
2131    use cdk_common::QuoteId;
2132    use uuid::Uuid;
2133
2134    /// Build an onchain outgoing payment option with a fresh quote id.
2135    fn onchain_options_for(amount_sat: u64) -> (QuoteId, OutgoingPaymentOptions) {
2136        let quote_id = QuoteId::UUID(Uuid::new_v4());
2137        (
2138            quote_id.clone(),
2139            onchain_options_for_quote(quote_id, amount_sat),
2140        )
2141    }
2142
2143    fn onchain_options_for_quote(quote_id: QuoteId, amount_sat: u64) -> OutgoingPaymentOptions {
2144        OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
2145            address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2146            amount: Amount::new(amount_sat, CurrencyUnit::Sat),
2147            max_fee_amount: Some(Amount::new(1_000, CurrencyUnit::Sat)),
2148            quote_id,
2149            fee_index: None,
2150            metadata: None,
2151        }))
2152    }
2153
2154    fn onchain_options_for_msat(
2155        quote_id: QuoteId,
2156        amount_msat: u64,
2157        max_fee_msat: u64,
2158    ) -> OutgoingPaymentOptions {
2159        OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
2160            address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2161            amount: Amount::new(amount_msat, CurrencyUnit::Msat),
2162            max_fee_amount: Some(Amount::new(max_fee_msat, CurrencyUnit::Msat)),
2163            quote_id,
2164            fee_index: None,
2165            metadata: None,
2166        }))
2167    }
2168
2169    fn assert_authoritative_failure_response(
2170        response: MakePaymentResponse,
2171        quote_id: QuoteId,
2172        unit: CurrencyUnit,
2173    ) {
2174        assert_eq!(
2175            response.payment_lookup_id,
2176            PaymentIdentifier::QuoteId(quote_id)
2177        );
2178        assert_eq!(response.status, MeltQuoteState::Failed);
2179        assert_eq!(response.total_spent, Amount::new(0, unit));
2180        assert!(response.payment_proof.is_none());
2181    }
2182
2183    #[tokio::test]
2184    async fn test_get_payment_quote_converts_fee_options_to_msat() {
2185        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2186        fund_backend_wallet(&backend, 100_000).await;
2187        let quote_id = QuoteId::UUID(Uuid::new_v4());
2188        let options = onchain_options_for_msat(quote_id, 10_000_000, 10_000_000);
2189
2190        let quote = backend
2191            .get_payment_quote(&CurrencyUnit::Msat, options)
2192            .await
2193            .expect("msat quote should succeed");
2194
2195        assert_eq!(quote.amount, Amount::new(10_000_000, CurrencyUnit::Msat));
2196        assert_eq!(quote.fee.unit(), &CurrencyUnit::Msat);
2197        assert_eq!(quote.fee.value() % MSAT_IN_SAT, 0);
2198
2199        let fee_options = quote.fee_options.expect("fee options");
2200        assert!(fee_options
2201            .iter()
2202            .all(|option| u64::from(option.fee_reserve) % MSAT_IN_SAT == 0));
2203        assert_eq!(
2204            quote.fee.value(),
2205            fee_options
2206                .iter()
2207                .map(|option| u64::from(option.fee_reserve))
2208                .min()
2209                .expect("non-empty fee options")
2210        );
2211    }
2212
2213    #[tokio::test]
2214    async fn test_make_payment_converts_msat_amount_and_fee_to_sat() {
2215        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2216        fund_backend_wallet(&backend, 100_000).await;
2217        let quote_id = QuoteId::UUID(Uuid::new_v4());
2218        let options = onchain_options_for_msat(quote_id.clone(), 10_000_000, 10_000_000);
2219
2220        let response = backend
2221            .make_payment(&CurrencyUnit::Msat, options)
2222            .await
2223            .expect("msat payment should enqueue a sat-native intent");
2224
2225        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Msat));
2226        let intent = backend
2227            .storage
2228            .get_send_intent_by_quote_id(&quote_id.to_string())
2229            .await
2230            .expect("lookup send intent")
2231            .expect("send intent should be persisted");
2232        assert_eq!(intent.amount_sat, 10_000);
2233        assert_eq!(intent.max_fee_amount_sat, 10_000);
2234    }
2235
2236    #[tokio::test]
2237    async fn test_make_payment_returns_failed_for_fractional_satoshi_amount() {
2238        let backend = build_test_instance(5).await;
2239        let quote_id = QuoteId::UUID(Uuid::new_v4());
2240        let options = onchain_options_for_msat(quote_id.clone(), 10_000_001, 10_000_000);
2241
2242        let response = backend
2243            .make_payment(&CurrencyUnit::Msat, options)
2244            .await
2245            .expect("definitive pre-dispatch rejection should return a payment response");
2246
2247        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Msat);
2248        assert!(backend
2249            .storage
2250            .get_send_intent_by_quote_id(&quote_id.to_string())
2251            .await
2252            .expect("lookup send intent")
2253            .is_none());
2254    }
2255
2256    #[tokio::test]
2257    async fn test_make_payment_returns_failed_for_mismatched_fee_unit() {
2258        let backend = build_test_instance(5).await;
2259        let quote_id = QuoteId::UUID(Uuid::new_v4());
2260        let mut options = onchain_options_for_msat(quote_id.clone(), 10_000_000, 10_000_000);
2261        let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
2262            panic!("expected onchain options");
2263        };
2264        onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
2265
2266        let response = backend
2267            .make_payment(&CurrencyUnit::Msat, options)
2268            .await
2269            .expect("definitive pre-dispatch rejection should return a payment response");
2270
2271        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Msat);
2272        assert!(backend
2273            .storage
2274            .get_send_intent_by_quote_id(&quote_id.to_string())
2275            .await
2276            .expect("lookup send intent")
2277            .is_none());
2278    }
2279
2280    #[tokio::test]
2281    async fn test_make_payment_pending_total_spent_is_zero() {
2282        // make_payment queues the intent before a batch has been built, so
2283        // the per-intent fee is unknown. total_spent MUST be 0, not the
2284        // user-requested amount (which would imply no fee).
2285        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2286        fund_backend_wallet(&backend, 100_000).await;
2287        let (quote_id, options) = onchain_options_for(10_000);
2288
2289        let response = backend
2290            .make_payment(&CurrencyUnit::Sat, options)
2291            .await
2292            .expect("make_payment should enqueue the intent");
2293
2294        assert_eq!(response.status, MeltQuoteState::Pending);
2295        assert_eq!(
2296            response.payment_lookup_id,
2297            PaymentIdentifier::QuoteId(quote_id)
2298        );
2299        assert_eq!(
2300            response.total_spent,
2301            Amount::new(0, CurrencyUnit::Sat),
2302            "Pending onchain response MUST use 0 sentinel; the real \
2303             total_spent is only known after the batch transaction is built"
2304        );
2305    }
2306
2307    #[tokio::test]
2308    async fn test_get_payment_quote_rejects_dust_output() {
2309        let backend = build_test_instance(5).await;
2310        let (_quote_id, options) = onchain_options_for(1);
2311
2312        let err = backend
2313            .get_payment_quote(&CurrencyUnit::Sat, options)
2314            .await
2315            .expect_err("dust output should be rejected at quote time");
2316
2317        let cdk_common::payment::Error::Onchain(inner) = err else {
2318            panic!("expected onchain error");
2319        };
2320
2321        let backend_err = inner
2322            .downcast_ref::<Error>()
2323            .expect("expected cdk-bdk backend error");
2324        assert!(matches!(backend_err, Error::DustOutput { .. }));
2325    }
2326
2327    #[tokio::test]
2328    async fn test_make_payment_returns_failed_for_dust_without_persisting_intent() {
2329        let backend = build_test_instance(5).await;
2330        let (quote_id, options) = onchain_options_for(1);
2331
2332        let response = backend
2333            .make_payment(&CurrencyUnit::Sat, options)
2334            .await
2335            .expect("definitive pre-dispatch rejection should return a payment response");
2336
2337        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2338        assert!(
2339            backend
2340                .storage
2341                .get_send_intent_by_quote_id(&quote_id.to_string())
2342                .await
2343                .expect("lookup send intent by quote id")
2344                .is_none(),
2345            "dust rejection must not leave a pending send intent behind"
2346        );
2347    }
2348
2349    #[tokio::test]
2350    async fn test_get_payment_quote_rejects_amount_below_minimum_send() {
2351        let backend = build_test_instance(5).await;
2352        let (_quote_id, options) = onchain_options_for(545);
2353
2354        let err = backend
2355            .get_payment_quote(&CurrencyUnit::Sat, options)
2356            .await
2357            .expect_err("amount below configured minimum should be rejected at quote time");
2358
2359        let cdk_common::payment::Error::Onchain(inner) = err else {
2360            panic!("expected onchain error");
2361        };
2362
2363        let backend_err = inner
2364            .downcast_ref::<Error>()
2365            .expect("expected cdk-bdk backend error");
2366        assert!(matches!(
2367            backend_err,
2368            Error::AmountBelowMinimumSend {
2369                amount: 545,
2370                min: 546
2371            }
2372        ));
2373    }
2374
2375    #[tokio::test]
2376    async fn test_make_payment_returns_failed_below_minimum_without_persisting_intent() {
2377        let backend = build_test_instance(5).await;
2378        let (quote_id, options) = onchain_options_for(545);
2379
2380        let response = backend
2381            .make_payment(&CurrencyUnit::Sat, options)
2382            .await
2383            .expect("definitive pre-dispatch rejection should return a payment response");
2384
2385        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2386        assert!(
2387            backend
2388                .storage
2389                .get_send_intent_by_quote_id(&quote_id.to_string())
2390                .await
2391                .expect("lookup send intent by quote id")
2392                .is_none(),
2393            "minimum-send rejection must not leave a pending send intent behind"
2394        );
2395    }
2396
2397    #[tokio::test]
2398    async fn test_check_outgoing_payment_pending_intent_reports_zero_total_spent() {
2399        // An intent freshly created via make_payment is in state Pending.
2400        // check_outgoing_payment must report total_spent = 0 because the
2401        // fee contribution is not yet knowable.
2402        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2403        fund_backend_wallet(&backend, 100_000).await;
2404        let (quote_id, options) = onchain_options_for(12_345);
2405
2406        backend
2407            .make_payment(&CurrencyUnit::Sat, options)
2408            .await
2409            .expect("make_payment should enqueue the intent");
2410
2411        let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2412        let response = backend
2413            .check_outgoing_payment(&payment_identifier)
2414            .await
2415            .expect("check_outgoing_payment for Pending intent");
2416
2417        assert_eq!(response.status, MeltQuoteState::Pending);
2418        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2419        assert_eq!(response.payment_proof, None);
2420    }
2421
2422    #[tokio::test]
2423    async fn test_check_outgoing_payment_batched_intent_reports_zero_total_spent() {
2424        // Driving an intent through Pending → Batched (fee still unknown at
2425        // the per-intent level until the batch transaction is built) must
2426        // still report total_spent = 0.
2427        use crate::send::payment_intent::SendIntent;
2428        use crate::types::{PaymentMetadata, PaymentTier};
2429
2430        let backend = build_test_instance(5).await;
2431        let quote_id = QuoteId::UUID(Uuid::new_v4());
2432
2433        let pending = SendIntent::new(
2434            &backend.storage,
2435            quote_id.to_string(),
2436            "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2437            20_000,
2438            1_000,
2439            PaymentTier::Standard,
2440            PaymentMetadata::default(),
2441        )
2442        .await
2443        .expect("create Pending send intent");
2444
2445        pending
2446            .assign_to_batch(&backend.storage, Uuid::new_v4())
2447            .await
2448            .expect("transition Pending → Batched");
2449
2450        let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2451        let response = backend
2452            .check_outgoing_payment(&payment_identifier)
2453            .await
2454            .expect("check_outgoing_payment for Batched intent");
2455
2456        assert_eq!(response.status, MeltQuoteState::Pending);
2457        assert_eq!(
2458            response.total_spent,
2459            Amount::new(0, CurrencyUnit::Sat),
2460            "Batched intents report total_spent = 0 until the batch \
2461             transaction is built and the per-intent fee is fixed"
2462        );
2463    }
2464
2465    #[tokio::test]
2466    async fn test_check_outgoing_payment_awaiting_confirmation_includes_fee() {
2467        // Once an intent reaches AwaitingConfirmation, the per-intent fee
2468        // contribution is persisted on the intent record. check_outgoing_payment
2469        // must now report total_spent = amount + fee_contribution_sat so that
2470        // downstream consumers (e.g. recovery / subscribers) see the
2471        // authoritative figure even though the payment is still unconfirmed.
2472        use crate::send::payment_intent::SendIntent;
2473        use crate::types::{PaymentMetadata, PaymentTier};
2474
2475        let backend = build_test_instance(5).await;
2476        let quote_id = QuoteId::UUID(Uuid::new_v4());
2477
2478        let pending = SendIntent::new(
2479            &backend.storage,
2480            quote_id.to_string(),
2481            "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2482            30_000,
2483            2_000,
2484            PaymentTier::Immediate,
2485            PaymentMetadata::default(),
2486        )
2487        .await
2488        .expect("create Pending send intent");
2489
2490        let batched = pending
2491            .assign_to_batch(&backend.storage, Uuid::new_v4())
2492            .await
2493            .expect("transition Pending → Batched");
2494
2495        let fee_contrib = 512_u64;
2496        batched
2497            .mark_broadcast(
2498                &backend.storage,
2499                "deadbeef".to_string(),
2500                "deadbeef:0".to_string(),
2501                fee_contrib,
2502            )
2503            .await
2504            .expect("transition Batched → AwaitingConfirmation");
2505
2506        let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2507        let response = backend
2508            .check_outgoing_payment(&payment_identifier)
2509            .await
2510            .expect("check_outgoing_payment for AwaitingConfirmation intent");
2511
2512        assert_eq!(response.status, MeltQuoteState::Pending);
2513        assert_eq!(
2514            response.total_spent,
2515            Amount::new(30_000 + fee_contrib, CurrencyUnit::Sat),
2516            "AwaitingConfirmation intents know the per-intent fee \
2517             contribution and must report amount + fee"
2518        );
2519    }
2520
2521    #[tokio::test]
2522    async fn test_check_outgoing_payment_failed_intent_reports_failed() {
2523        use crate::send::payment_intent::SendIntent;
2524        use crate::types::{PaymentMetadata, PaymentTier};
2525
2526        let backend = build_test_instance(5).await;
2527        let quote_id = QuoteId::UUID(Uuid::new_v4());
2528
2529        let pending = SendIntent::new(
2530            &backend.storage,
2531            quote_id.to_string(),
2532            "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2533            30_000,
2534            2_000,
2535            PaymentTier::Immediate,
2536            PaymentMetadata::default(),
2537        )
2538        .await
2539        .expect("create Pending send intent");
2540
2541        pending
2542            .fail(&backend.storage, "fee too high".to_string())
2543            .await
2544            .expect("transition Pending to Failed");
2545
2546        let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2547        let response = backend
2548            .check_outgoing_payment(&payment_identifier)
2549            .await
2550            .expect("check_outgoing_payment for Failed intent");
2551
2552        assert_eq!(response.status, MeltQuoteState::Failed);
2553        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2554        assert_eq!(response.payment_proof, None);
2555    }
2556
2557    #[tokio::test]
2558    async fn test_make_payment_can_retry_failed_intent_with_same_quote_id() {
2559        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2560        fund_backend_wallet(&backend, 100_000).await;
2561        let (quote_id, options) = onchain_options_for(30_000);
2562
2563        backend
2564            .make_payment(&CurrencyUnit::Sat, options)
2565            .await
2566            .expect("initial make_payment should enqueue intent");
2567
2568        let initial = backend
2569            .storage
2570            .get_send_intent_by_quote_id(&quote_id.to_string())
2571            .await
2572            .expect("lookup initial intent")
2573            .expect("initial intent exists");
2574
2575        backend
2576            .storage
2577            .update_send_intent(
2578                &initial.intent_id,
2579                &crate::send::payment_intent::record::SendIntentState::Failed {
2580                    reason: "pre-sign failure".to_string(),
2581                    created_at: 1_700_000_000,
2582                    failed_at: 1_700_000_100,
2583                },
2584            )
2585            .await
2586            .expect("mark failed");
2587
2588        let retry_options = onchain_options_for_quote(quote_id.clone(), 30_000);
2589        let response = backend
2590            .make_payment(&CurrencyUnit::Sat, retry_options)
2591            .await
2592            .expect("retry with same quote id should requeue failed intent");
2593
2594        assert_eq!(response.status, MeltQuoteState::Pending);
2595
2596        let retried = backend
2597            .storage
2598            .get_send_intent_by_quote_id(&quote_id.to_string())
2599            .await
2600            .expect("lookup retried intent")
2601            .expect("retried intent exists");
2602        assert_eq!(retried.intent_id, initial.intent_id);
2603        assert!(matches!(
2604            retried.state,
2605            crate::send::payment_intent::record::SendIntentState::Pending { .. }
2606        ));
2607    }
2608
2609    #[tokio::test]
2610    async fn test_check_outgoing_payment_unknown_quote_reports_zero() {
2611        // A quote id with no active intent and no finalized tombstone must
2612        // return MeltQuoteState::Unknown with total_spent = 0 (existing
2613        // behaviour; pinned here for defence-in-depth).
2614        let backend = build_test_instance(5).await;
2615        let quote_id = QuoteId::UUID(Uuid::new_v4());
2616        let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2617
2618        let response = backend
2619            .check_outgoing_payment(&payment_identifier)
2620            .await
2621            .expect("check_outgoing_payment for unknown quote");
2622
2623        assert_eq!(response.status, MeltQuoteState::Unknown);
2624        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2625        assert_eq!(response.payment_proof, None);
2626    }
2627
2628    // ------------------------------------------------------------------
2629    // Chain-sync resilience tests
2630    // ------------------------------------------------------------------
2631
2632    #[test]
2633    fn test_is_transient_classifies_network_errors() {
2634        // Esplora errors are always classified as transient: the sync
2635        // loop should retry them on the next tick, and this classification
2636        // drives the log severity in the supervisor.
2637        let esplora_err = Error::Esplora(
2638            "HttpResponse { status: 525, message: \"error code: 525\" }".to_string(),
2639        );
2640        assert!(esplora_err.is_transient());
2641
2642        let esplora_404 = Error::Esplora(
2643            "HttpResponse { status: 404, message: \"Block not found\" }".to_string(),
2644        );
2645        assert!(esplora_404.is_transient());
2646
2647        // Local wallet/state errors are not transient: they indicate a
2648        // real defect that retrying will not resolve.
2649        let wallet_err = Error::Wallet("invalid checkpoint".to_string());
2650        assert!(!wallet_err.is_transient());
2651
2652        let vout_err = Error::VoutNotFound;
2653        assert!(!vout_err.is_transient());
2654
2655        // Timed-out I/O is transient.
2656        let io_err = Error::Io(std::io::Error::new(
2657            std::io::ErrorKind::TimedOut,
2658            "network timeout",
2659        ));
2660        assert!(io_err.is_transient());
2661
2662        // An arbitrary I/O error kind is not.
2663        let io_other = Error::Io(std::io::Error::new(
2664            std::io::ErrorKind::InvalidData,
2665            "bad data",
2666        ));
2667        assert!(!io_other.is_transient());
2668    }
2669
2670    #[tokio::test]
2671    async fn test_supervisor_restarts_failing_task_with_backoff() {
2672        // The supervisor must keep calling the supplied future as long
2673        // as it returns Err, until the cancel token is triggered.
2674        let cancel = CancellationToken::new();
2675        let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
2676
2677        let counter_clone = Arc::clone(&counter);
2678        let cancel_inner = cancel.clone();
2679        let supervisor = tokio::spawn(async move {
2680            super::supervise("test", cancel_inner, move |_c| {
2681                let c = Arc::clone(&counter_clone);
2682                async move {
2683                    c.fetch_add(1, Ordering::Relaxed);
2684                    Err::<(), Error>(Error::Esplora("boom".to_string()))
2685                }
2686            })
2687            .await;
2688        });
2689
2690        // Let a few restart cycles happen (initial backoff is 1s).
2691        tokio::time::sleep(Duration::from_millis(2_500)).await;
2692        cancel.cancel();
2693
2694        tokio::time::timeout(Duration::from_secs(5), supervisor)
2695            .await
2696            .expect("supervisor did not exit after cancel")
2697            .expect("supervisor task panicked");
2698
2699        let n = counter.load(Ordering::Relaxed);
2700        assert!(
2701            n >= 2,
2702            "supervisor should have restarted the task at least twice, got {n}"
2703        );
2704    }
2705
2706    #[tokio::test]
2707    async fn test_supervisor_exits_on_ok() {
2708        // Ok(()) from the task is treated as clean shutdown; the
2709        // supervisor exits immediately without restart.
2710        let cancel = CancellationToken::new();
2711        let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
2712
2713        let counter_clone = Arc::clone(&counter);
2714        let cancel_inner = cancel.clone();
2715        let supervisor = tokio::spawn(async move {
2716            super::supervise("test", cancel_inner, move |_c| {
2717                let c = Arc::clone(&counter_clone);
2718                async move {
2719                    c.fetch_add(1, Ordering::Relaxed);
2720                    Ok::<(), Error>(())
2721                }
2722            })
2723            .await;
2724        });
2725
2726        tokio::time::timeout(Duration::from_secs(5), supervisor)
2727            .await
2728            .expect("supervisor did not exit after Ok(())")
2729            .expect("supervisor task panicked");
2730
2731        assert_eq!(
2732            counter.load(Ordering::Relaxed),
2733            1,
2734            "supervisor must not restart a task that returned Ok(())"
2735        );
2736    }
2737
2738    #[tokio::test]
2739    async fn test_supervisor_cancel_during_backoff() {
2740        // Cancelling during the backoff sleep must exit promptly rather
2741        // than waiting for the sleep to expire.
2742        let cancel = CancellationToken::new();
2743        let cancel_inner = cancel.clone();
2744        let supervisor = tokio::spawn(async move {
2745            super::supervise("test", cancel_inner, move |_c| async move {
2746                // Fail immediately so we enter the backoff sleep.
2747                Err::<(), Error>(Error::Esplora("boom".to_string()))
2748            })
2749            .await;
2750        });
2751
2752        // Give the supervisor a moment to enter its first backoff.
2753        tokio::time::sleep(Duration::from_millis(200)).await;
2754        let cancel_at = std::time::Instant::now();
2755        cancel.cancel();
2756
2757        tokio::time::timeout(Duration::from_secs(2), supervisor)
2758            .await
2759            .expect("supervisor did not exit promptly after cancel")
2760            .expect("supervisor task panicked");
2761
2762        let elapsed = cancel_at.elapsed();
2763        assert!(
2764            elapsed < Duration::from_millis(500),
2765            "supervisor took {elapsed:?} to exit after cancel; expected < 500ms"
2766        );
2767    }
2768
2769    #[tokio::test]
2770    async fn test_sync_wallet_survives_unreachable_esplora() {
2771        // sync_wallet must not return Err when the Esplora endpoint is
2772        // unreachable — it should warn and continue. We prove this by
2773        // starting the backend (which spawns the sync task against a
2774        // bogus URL) and letting it run for long enough to tick at least
2775        // twice, then stop cleanly.
2776        let backend = build_test_instance(5).await;
2777        backend.start().await.expect("start");
2778
2779        // Sync interval is 60s per build_test_instance, so this test
2780        // only verifies the first synchronous tick path: the task must
2781        // stay alive and the supervisor must not log a "task failed"
2782        // line for a transient network error.
2783        tokio::time::sleep(Duration::from_millis(500)).await;
2784
2785        // The sync JoinHandle must still be running, not completed.
2786        {
2787            let tasks = backend.tasks.lock().await;
2788            let bg = tasks.as_ref().expect("tasks running");
2789            assert!(
2790                !bg.sync.is_finished(),
2791                "sync task must not exit on transient Esplora errors"
2792            );
2793        }
2794
2795        backend.stop().await.expect("stop");
2796    }
2797
2798    #[cfg(feature = "electrum")]
2799    #[tokio::test]
2800    async fn test_sync_wallet_survives_unreachable_electrum() {
2801        let chain_source = ChainSource::Electrum(ElectrumConfig {
2802            url: "tcp://127.0.0.1:1".to_string(),
2803            batch_size: 5,
2804        });
2805        let (backend, _tmp) = build_test_instance_with_chain_source(5, None, 60, chain_source)
2806            .await
2807            .expect("build Electrum test instance");
2808
2809        backend.start().await.expect("start");
2810        tokio::time::sleep(Duration::from_millis(500)).await;
2811
2812        {
2813            let tasks = backend.tasks.lock().await;
2814            let background = tasks.as_ref().expect("tasks running");
2815            assert!(
2816                !background.sync.is_finished(),
2817                "sync task must not exit on transient Electrum errors"
2818            );
2819        }
2820
2821        backend.stop().await.expect("stop");
2822    }
2823}