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        // A replay must preserve an existing payment even when current wallet
697        // funds, fees, or validation rules would reject a new attempt. Only a
698        // durably failed or unknown payment may proceed through dispatch again.
699        let payment_lookup_id = PaymentIdentifier::QuoteId(quote_id.clone());
700        let pay_state = self.check_outgoing_payment(&payment_lookup_id).await?;
701        match pay_state.status {
702            MeltQuoteState::Paid | MeltQuoteState::Pending => {
703                let total_spent = pay_state
704                    .total_spent
705                    .convert_to(unit)
706                    .map_err(Error::AmountConversion)?;
707                return Ok(MakePaymentResponse {
708                    total_spent,
709                    ..pay_state
710                });
711            }
712            MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => {}
713        }
714
715        if let Err(err) = Self::ensure_supported_payment_unit(unit) {
716            return Ok(Self::outgoing_payment_failure_response(
717                unit, &quote_id, err,
718            ));
719        }
720
721        let amount_sat = match Self::payment_amount_to_sat(unit, &amount) {
722            Ok(amount_sat) => amount_sat,
723            Err(err) => {
724                return Ok(Self::outgoing_payment_failure_response(
725                    unit, &quote_id, err,
726                ));
727            }
728        };
729        if let Err(err) = self.validate_send_amount(&address, amount_sat) {
730            return Ok(Self::outgoing_payment_failure_response(
731                unit, &quote_id, err,
732            ));
733        }
734
735        let max_fee_sat = match onchain_options.max_fee_amount {
736            Some(max_fee) => match Self::fee_limit_to_sat(unit, &max_fee) {
737                Ok(max_fee_sat) => max_fee_sat,
738                Err(err) => {
739                    return Ok(Self::outgoing_payment_failure_response(
740                        unit, &quote_id, err,
741                    ));
742                }
743            },
744            None => 1_000,
745        };
746        // Resolve the wallet-selected `fee_index` back to a configured tier.
747        // Older callers that omit `fee_index` continue to default to
748        // Immediate.
749        let tier = match self
750            .batch_config
751            .tier_for_fee_index(onchain_options.fee_index)
752            .map_err(Error::UnknownFeeIndex)
753        {
754            Ok(tier) => tier,
755            Err(err) => {
756                return Ok(Self::outgoing_payment_failure_response(
757                    unit, &quote_id, err,
758                ));
759            }
760        };
761        let metadata = PaymentMetadata::from_optional_json(onchain_options.metadata.as_deref());
762        let fee_estimate = match self
763            .estimate_onchain_fee_reserve(&address, amount_sat, tier)
764            .await
765        {
766            Ok(fee_estimate) => fee_estimate,
767            Err(err) => {
768                return Ok(Self::outgoing_payment_failure_response(
769                    unit, &quote_id, err,
770                ));
771            }
772        };
773        if fee_estimate.raw_fee_sat > max_fee_sat {
774            let err = Error::EstimatedFeeTooHigh {
775                estimated_fee: fee_estimate.raw_fee_sat,
776                max_fee: max_fee_sat,
777            };
778            return Ok(Self::outgoing_payment_failure_response(
779                unit, &quote_id, err,
780            ));
781        }
782
783        crate::send::payment_intent::SendIntent::new(
784            &self.storage,
785            quote_id.to_string(),
786            address,
787            amount_sat,
788            max_fee_sat,
789            tier,
790            metadata,
791        )
792        .await?;
793
794        if tier == PaymentTier::Immediate {
795            self.batch_notify.notify_one();
796        }
797
798        // The intent has been queued but no batch has been built yet, so the
799        // per-intent fee contribution is not yet knowable. Following the
800        // convention used by other backends (LND/LDK-Node/CLN return `0` for
801        // `Unknown`/`NotFound`), we return `0` as a sentinel meaning "actual
802        // spent amount is not yet known". Callers should wait for the
803        // terminal `Paid` event to read the authoritative `total_spent`.
804        Ok(MakePaymentResponse {
805            payment_lookup_id: PaymentIdentifier::QuoteId(quote_id),
806            payment_proof: None,
807            status: MeltQuoteState::Pending,
808            total_spent: Amount::new(0, unit.clone()),
809        })
810    }
811
812    async fn create_incoming_payment_request(
813        &self,
814        options: IncomingPaymentOptions,
815    ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
816        let onchain_options = match options {
817            IncomingPaymentOptions::Onchain(o) => o,
818            _ => return Err(cdk_common::payment::Error::UnsupportedPaymentOption),
819        };
820
821        let quote_id = onchain_options.quote_id;
822        let quote_id_string = quote_id.to_string();
823
824        let mut wallet_with_db = self.wallet_with_db.lock().await;
825
826        // An address already tracked for another quote (derived by another
827        // instance sharing this seed, or by a re-initialised local wallet)
828        // must not be handed out again; advance to a fresh address.
829        let address_str = 'reserve_address: {
830            for attempt in 1..=MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS {
831                let address = wallet_with_db
832                    .wallet
833                    .reveal_next_address(KeychainKind::External);
834                let candidate = address.address.to_string();
835
836                wallet_with_db.persist().map_err(|err| {
837                    tracing::warn!("Could not persist to bdk db: {}", err);
838
839                    Error::BdkPersist
840                })?;
841
842                if self
843                    .storage
844                    .track_receive_address(&candidate, &quote_id_string)
845                    .await?
846                {
847                    break 'reserve_address candidate;
848                }
849
850                tracing::debug!(
851                    quote_id = %quote_id,
852                    attempt,
853                    max_attempts = MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS,
854                    "Receive address is already reserved for another quote"
855                );
856            }
857
858            return Err(Error::ReceiveAddressReservationExhausted {
859                attempts: MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS,
860            }
861            .into());
862        };
863
864        Ok(CreateIncomingPaymentResponse {
865            request_lookup_id: PaymentIdentifier::QuoteId(quote_id),
866            request: address_str,
867            expiry: None,
868            extra_json: None,
869        })
870    }
871
872    async fn wait_payment_event(
873        &self,
874    ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
875        self.wait_invoice_is_active.store(true, Ordering::SeqCst);
876
877        let receiver = self.payment_sender.subscribe();
878        let stream = PaymentEventStream {
879            receiver: BroadcastStream::new(receiver),
880            cancel: Box::pin(self.wait_invoice_cancel_token.clone().cancelled_owned()),
881            is_active: Arc::clone(&self.wait_invoice_is_active),
882        };
883
884        Ok(Box::pin(stream))
885    }
886
887    async fn check_incoming_payment_status(
888        &self,
889        payment_identifier: &PaymentIdentifier,
890    ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
891        let PaymentIdentifier::QuoteId(quote_id) = payment_identifier else {
892            return Err(Error::UnsupportedOnchain.into());
893        };
894
895        let quote_id_str = quote_id.to_string();
896        let mut results = Vec::new();
897
898        // Only return finalized payments. Active intents (Detected state) are
899        // not yet confirmed and should not be reported to the mint for processing.
900        let finalized = self
901            .storage
902            .get_finalized_receive_intents_by_quote_id(&quote_id_str)
903            .await?;
904
905        for record in finalized {
906            results.push(WaitPaymentResponse {
907                payment_identifier: payment_identifier.clone(),
908                payment_amount: Amount::new(record.amount_sat, CurrencyUnit::Sat),
909                payment_id: record.outpoint,
910            });
911        }
912
913        Ok(results)
914    }
915
916    async fn check_outgoing_payment(
917        &self,
918        payment_identifier: &PaymentIdentifier,
919    ) -> Result<MakePaymentResponse, Self::Err> {
920        let quote_id = match payment_identifier {
921            PaymentIdentifier::QuoteId(id) => id.to_string(),
922            _ => return Err(Error::UnsupportedOnchain.into()),
923        };
924
925        // 1. Check active intents
926        if let Some(record) = self.storage.get_send_intent_by_quote_id(&quote_id).await? {
927            // `total_spent` is the actual amount spent (amount + fee) and is
928            // only reported once the payment has been made. Before the batch
929            // transaction has been built, the per-intent fee contribution is
930            // unknown, so we return `0` as a sentinel. This matches the
931            // convention used by other backends for non-terminal states.
932            let total_spent = match &record.state {
933                crate::send::payment_intent::record::SendIntentState::Pending { .. }
934                | crate::send::payment_intent::record::SendIntentState::Batched { .. } => {
935                    Amount::new(0, CurrencyUnit::Sat)
936                }
937                crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
938                    fee_contribution_sat,
939                    ..
940                } => Amount::new(record.amount_sat + fee_contribution_sat, CurrencyUnit::Sat),
941                crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
942                    Amount::new(0, CurrencyUnit::Sat)
943                }
944            };
945            let status = match record.state {
946                crate::send::payment_intent::record::SendIntentState::Pending { .. }
947                | crate::send::payment_intent::record::SendIntentState::Batched { .. }
948                | crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
949                    ..
950                } => MeltQuoteState::Pending,
951                crate::send::payment_intent::record::SendIntentState::Failed { .. } => {
952                    MeltQuoteState::Failed
953                }
954            };
955
956            return Ok(MakePaymentResponse {
957                payment_lookup_id: payment_identifier.clone(),
958                payment_proof: None,
959                status,
960                total_spent,
961            });
962        }
963
964        // 2. Check finalized tombstones
965        if let Some(record) = self
966            .storage
967            .get_finalized_intent_by_quote_id(&quote_id)
968            .await?
969        {
970            return Ok(MakePaymentResponse {
971                payment_lookup_id: payment_identifier.clone(),
972                payment_proof: Some(record.outpoint),
973                status: MeltQuoteState::Paid,
974                total_spent: Amount::new(record.total_spent_sat, CurrencyUnit::Sat),
975            });
976        }
977
978        Ok(MakePaymentResponse {
979            payment_lookup_id: payment_identifier.clone(),
980            payment_proof: None,
981            status: MeltQuoteState::Unknown,
982            total_spent: Amount::new(0, CurrencyUnit::Sat),
983        })
984    }
985
986    fn is_payment_event_stream_active(&self) -> bool {
987        self.wait_invoice_is_active.load(Ordering::SeqCst)
988    }
989
990    fn cancel_payment_event_stream(&self) {
991        self.wait_invoice_cancel_token.cancel();
992    }
993}
994
995#[cfg(test)]
996mod tests {
997    use std::fs;
998    use std::str::FromStr;
999
1000    use bdk_wallet::bitcoin::hashes::Hash as _;
1001    use bdk_wallet::bitcoin::{
1002        absolute, transaction, Network, OutPoint, Sequence, Transaction, TxIn, TxOut, Txid, Witness,
1003    };
1004    use bdk_wallet::keys::bip39::Mnemonic;
1005    use cdk_common::common::FeeReserve;
1006    use cdk_common::payment::{MintPayment, OnchainIncomingPaymentOptions};
1007    use futures::StreamExt;
1008
1009    use super::*;
1010    use crate::fee::apply_quote_fee_safety;
1011
1012    const TEST_MNEMONIC: &str =
1013        "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about";
1014    const OTHER_TEST_MNEMONIC: &str =
1015        "legal winner thank year wave sausage worth useful legal winner thank yellow";
1016
1017    #[tokio::test]
1018    async fn existing_wallet_preflight_requires_matching_persisted_wallet() {
1019        let (backend, tempdir) = build_test_instance_with_tempdir(5).await;
1020        drop(backend);
1021
1022        let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic");
1023        validate_existing_wallet(mnemonic, Network::Regtest, tempdir.path())
1024            .expect("matching wallet should pass preflight");
1025
1026        let other_mnemonic = Mnemonic::from_str(OTHER_TEST_MNEMONIC).expect("other mnemonic");
1027        assert!(matches!(
1028            validate_existing_wallet(other_mnemonic, Network::Regtest, tempdir.path()),
1029            Err(Error::Wallet(_))
1030        ));
1031        assert!(matches!(
1032            validate_existing_wallet(
1033                Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic"),
1034                Network::Signet,
1035                tempdir.path(),
1036            ),
1037            Err(Error::Wallet(_))
1038        ));
1039    }
1040
1041    #[test]
1042    fn existing_wallet_preflight_does_not_create_missing_or_empty_wallet() {
1043        let tempdir = tempfile::tempdir().expect("tempdir");
1044        let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("test mnemonic");
1045        let wallet_path = tempdir.path().join("bdk_wallet/bdk_wallet.sqlite");
1046        assert!(matches!(
1047            validate_existing_wallet(mnemonic.clone(), Network::Regtest, tempdir.path()),
1048            Err(Error::ExistingWalletMissing { .. })
1049        ));
1050        assert!(!wallet_path.exists());
1051
1052        fs::create_dir_all(wallet_path.parent().expect("wallet directory"))
1053            .expect("create wallet directory");
1054        drop(Connection::open(&wallet_path).expect("create empty sqlite file"));
1055        assert!(matches!(
1056            validate_existing_wallet(mnemonic, Network::Regtest, tempdir.path()),
1057            Err(Error::ExistingWalletNotInitialized { .. })
1058        ));
1059    }
1060
1061    /// Build a `CdkBdk` instance pointed at a bogus Esplora URL so the sync
1062    /// loop spins without needing a real backend. The ticks are short so
1063    /// shutdown tests run quickly.
1064    async fn build_test_instance(shutdown_timeout_secs: u64) -> CdkBdk {
1065        build_test_instance_with_tempdir(shutdown_timeout_secs)
1066            .await
1067            .0
1068    }
1069
1070    async fn build_test_instance_with_tempdir(
1071        shutdown_timeout_secs: u64,
1072    ) -> (CdkBdk, tempfile::TempDir) {
1073        build_test_instance_with_config(shutdown_timeout_secs, None, 60)
1074            .await
1075            .expect("build CdkBdk test instance")
1076    }
1077
1078    async fn build_test_instance_with_config(
1079        shutdown_timeout_secs: u64,
1080        batch_config: Option<BatchConfig>,
1081        sync_interval_secs: u64,
1082    ) -> Result<(CdkBdk, tempfile::TempDir), Error> {
1083        let chain_source = ChainSource::Esplora(EsploraConfig {
1084            url: "http://127.0.0.1:1".to_string(),
1085            parallel_requests: 1,
1086        });
1087
1088        build_test_instance_with_chain_source(
1089            shutdown_timeout_secs,
1090            batch_config,
1091            sync_interval_secs,
1092            chain_source,
1093        )
1094        .await
1095    }
1096
1097    async fn build_test_instance_with_chain_source(
1098        shutdown_timeout_secs: u64,
1099        batch_config: Option<BatchConfig>,
1100        sync_interval_secs: u64,
1101        chain_source: ChainSource,
1102    ) -> Result<(CdkBdk, tempfile::TempDir), Error> {
1103        let tmp = tempfile::tempdir().expect("tempdir");
1104        let mnemonic = Mnemonic::from_str(TEST_MNEMONIC).expect("mnemonic");
1105
1106        let kv = cdk_sqlite::mint::memory::empty()
1107            .await
1108            .expect("in-memory kv store");
1109
1110        let fee_reserve = FeeReserve {
1111            min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(),
1112            percent_fee_reserve: 0.02,
1113        };
1114
1115        let backend = CdkBdk::new(
1116            mnemonic,
1117            Network::Regtest,
1118            chain_source,
1119            tmp.path().to_string_lossy().into_owned(),
1120            fee_reserve,
1121            Arc::new(kv),
1122            batch_config,
1123            1,
1124            0,
1125            546,
1126            sync_interval_secs,
1127            Some(shutdown_timeout_secs),
1128            None,
1129        )?;
1130
1131        Ok((backend, tmp))
1132    }
1133
1134    /// Build a `CdkBdk` instance with its own local wallet directory but a
1135    /// shared KV store, like a second replica or a re-initialised node using
1136    /// the same seed.
1137    async fn build_test_instance_with_shared_kv(
1138        kv: Arc<cdk_sqlite::mint::MintSqliteDatabase>,
1139    ) -> (CdkBdk, tempfile::TempDir) {
1140        let tmp = tempfile::tempdir().expect("tempdir");
1141        let mnemonic = Mnemonic::from_str(
1142            "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about",
1143        )
1144        .expect("mnemonic");
1145        let chain_source = ChainSource::Esplora(EsploraConfig {
1146            url: "http://127.0.0.1:1".to_string(),
1147            parallel_requests: 1,
1148        });
1149        let fee_reserve = FeeReserve {
1150            min_fee_reserve: Amount::new(1, CurrencyUnit::Sat).into(),
1151            percent_fee_reserve: 0.02,
1152        };
1153
1154        let backend = CdkBdk::new(
1155            mnemonic,
1156            Network::Regtest,
1157            chain_source,
1158            tmp.path().to_string_lossy().into_owned(),
1159            fee_reserve,
1160            kv,
1161            None,
1162            1,
1163            0,
1164            546,
1165            60,
1166            Some(5),
1167            None,
1168        )
1169        .expect("build CdkBdk test instance");
1170
1171        (backend, tmp)
1172    }
1173
1174    #[tokio::test]
1175    async fn instances_sharing_seed_and_kv_never_share_a_receive_address() {
1176        let kv = Arc::new(
1177            cdk_sqlite::mint::memory::empty()
1178                .await
1179                .expect("in-memory kv store"),
1180        );
1181        let (first, _tmp_first) = build_test_instance_with_shared_kv(kv.clone()).await;
1182        let (second, _tmp_second) = build_test_instance_with_shared_kv(kv).await;
1183
1184        let first_request = first
1185            .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1186                OnchainIncomingPaymentOptions {
1187                    quote_id: cdk_common::QuoteId::new(),
1188                },
1189            ))
1190            .await
1191            .expect("first receive request");
1192        let second_request = second
1193            .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1194                OnchainIncomingPaymentOptions {
1195                    quote_id: cdk_common::QuoteId::new(),
1196                },
1197            ))
1198            .await
1199            .expect("second receive request");
1200
1201        assert_ne!(
1202            first_request.request, second_request.request,
1203            "each instance must hand out a distinct receive address"
1204        );
1205    }
1206
1207    #[tokio::test]
1208    async fn operator_deposit_address_is_not_associated_with_a_quote() {
1209        let kv = Arc::new(
1210            cdk_sqlite::mint::memory::empty()
1211                .await
1212                .expect("in-memory kv store"),
1213        );
1214        let (backend, _tmp) = build_test_instance_with_shared_kv(kv).await;
1215
1216        let operator_address = backend
1217            .create_operator_deposit_address()
1218            .await
1219            .expect("create operator deposit address");
1220        let quote_request = backend
1221            .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1222                OnchainIncomingPaymentOptions {
1223                    quote_id: cdk_common::QuoteId::new(),
1224                },
1225            ))
1226            .await
1227            .expect("create quote receive request");
1228
1229        assert_ne!(operator_address, quote_request.request);
1230        assert!(backend
1231            .storage
1232            .get_quote_id_by_receive_address(&operator_address)
1233            .await
1234            .expect("look up operator address")
1235            .is_none());
1236        assert!(!backend
1237            .storage
1238            .get_tracked_receive_addresses()
1239            .await
1240            .expect("list quote receive addresses")
1241            .contains(&operator_address));
1242    }
1243
1244    #[tokio::test]
1245    async fn receive_address_reservation_stops_after_attempt_limit() {
1246        let kv = Arc::new(
1247            cdk_sqlite::mint::memory::empty()
1248                .await
1249                .expect("in-memory kv store"),
1250        );
1251        let (first, _tmp_first) = build_test_instance_with_shared_kv(kv.clone()).await;
1252        let (second, _tmp_second) = build_test_instance_with_shared_kv(kv).await;
1253
1254        for _ in 0..MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS {
1255            first
1256                .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1257                    OnchainIncomingPaymentOptions {
1258                        quote_id: cdk_common::QuoteId::new(),
1259                    },
1260                ))
1261                .await
1262                .expect("reserve receive address");
1263        }
1264
1265        let err = second
1266            .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1267                OnchainIncomingPaymentOptions {
1268                    quote_id: cdk_common::QuoteId::new(),
1269                },
1270            ))
1271            .await
1272            .expect_err("reservation should stop after the attempt limit");
1273
1274        let cdk_common::payment::Error::Onchain(inner) = err else {
1275            panic!("expected onchain error");
1276        };
1277        assert!(matches!(
1278            inner.downcast_ref::<Error>(),
1279            Some(Error::ReceiveAddressReservationExhausted { attempts })
1280                if *attempts == MAX_RECEIVE_ADDRESS_RESERVATION_ATTEMPTS
1281        ));
1282    }
1283
1284    #[tokio::test]
1285    async fn wallet_info_lists_revealed_addresses_without_revealing_more() {
1286        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1287
1288        let initial_addresses = backend
1289            .wallet_addresses(0, 100)
1290            .await
1291            .expect("list initial addresses");
1292        assert_eq!(initial_addresses.total, 0);
1293
1294        backend
1295            .create_incoming_payment_request(IncomingPaymentOptions::Onchain(
1296                OnchainIncomingPaymentOptions {
1297                    quote_id: cdk_common::QuoteId::new(),
1298                },
1299            ))
1300            .await
1301            .expect("create on-chain request");
1302
1303        let addresses = backend
1304            .wallet_addresses(0, 100)
1305            .await
1306            .expect("list revealed addresses");
1307        assert_eq!(addresses.total, 1);
1308        assert_eq!(addresses.items.len(), 1);
1309        assert_eq!(addresses.items[0].keychain, WalletKeychain::External);
1310        assert_eq!(addresses.items[0].derivation_index, 0);
1311        assert!(!addresses.items[0].used);
1312        assert_eq!(addresses.items[0].balance_sat, 0);
1313
1314        let balance = backend.wallet_balance().await;
1315        assert_eq!(balance.total_sat, 0);
1316        assert_eq!(
1317            backend
1318                .wallet_transactions(0, 20)
1319                .await
1320                .expect("list transactions")
1321                .total,
1322            0
1323        );
1324
1325        let addresses_again = backend
1326            .wallet_addresses(0, 100)
1327            .await
1328            .expect("list revealed addresses again");
1329        assert_eq!(addresses_again.total, 1);
1330    }
1331
1332    #[tokio::test]
1333    async fn wallet_info_paginates_revealed_addresses_across_keychains() {
1334        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1335
1336        {
1337            let mut wallet_with_db = backend.wallet_with_db.lock().await;
1338            let _ = wallet_with_db
1339                .wallet
1340                .reveal_addresses_to(KeychainKind::External, 1)
1341                .count();
1342            let _ = wallet_with_db
1343                .wallet
1344                .reveal_addresses_to(KeychainKind::Internal, 1)
1345                .count();
1346            wallet_with_db
1347                .persist()
1348                .expect("persist revealed addresses");
1349        }
1350
1351        let page = backend
1352            .wallet_addresses(1, 2)
1353            .await
1354            .expect("list paginated addresses");
1355
1356        assert_eq!(page.total, 4);
1357        assert_eq!(page.items.len(), 2);
1358        assert_eq!(page.items[0].keychain, WalletKeychain::External);
1359        assert_eq!(page.items[0].derivation_index, 1);
1360        assert_eq!(page.items[1].keychain, WalletKeychain::Internal);
1361        assert_eq!(page.items[1].derivation_index, 0);
1362    }
1363
1364    async fn fund_backend_wallet_transactions(backend: &CdkBdk, amounts_sat: &[u64]) -> Vec<Txid> {
1365        let mut wallet_with_db = backend.wallet_with_db.lock().await;
1366        let funding_script = wallet_with_db
1367            .wallet
1368            .reveal_next_address(KeychainKind::External)
1369            .address
1370            .script_pubkey();
1371        let funding_transactions = amounts_sat
1372            .iter()
1373            .enumerate()
1374            .map(|(index, amount_sat)| Transaction {
1375                version: transaction::Version::TWO,
1376                lock_time: absolute::LockTime::ZERO,
1377                input: vec![TxIn {
1378                    previous_output: OutPoint::new(
1379                        Txid::all_zeros(),
1380                        u32::try_from(index).expect("test transaction index fits in u32"),
1381                    ),
1382                    script_sig: Default::default(),
1383                    sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1384                    witness: Witness::new(),
1385                }],
1386                output: vec![TxOut {
1387                    value: bdk_wallet::bitcoin::Amount::from_sat(*amount_sat),
1388                    script_pubkey: funding_script.clone(),
1389                }],
1390            })
1391            .collect::<Vec<_>>();
1392        let txids = funding_transactions
1393            .iter()
1394            .map(Transaction::compute_txid)
1395            .collect();
1396
1397        wallet_with_db
1398            .wallet
1399            .apply_unconfirmed_txs(funding_transactions.into_iter().map(|tx| (tx, 0)));
1400        wallet_with_db.persist().expect("persist funded wallet");
1401
1402        txids
1403    }
1404
1405    async fn fund_backend_wallet(backend: &CdkBdk, amount_sat: u64) {
1406        fund_backend_wallet_transactions(backend, &[amount_sat]).await;
1407    }
1408
1409    #[tokio::test]
1410    async fn wallet_info_reports_unconfirmed_funding() {
1411        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1412        fund_backend_wallet(&backend, 42_000).await;
1413
1414        let balance = backend.wallet_balance().await;
1415        assert_eq!(balance.untrusted_pending_sat, 42_000);
1416        assert_eq!(balance.total_sat, 42_000);
1417
1418        let transactions = backend
1419            .wallet_transactions(0, 20)
1420            .await
1421            .expect("list transactions");
1422        assert_eq!(transactions.total, 1);
1423        assert_eq!(transactions.items[0].received_sat, 42_000);
1424        assert_eq!(transactions.items[0].sent_sat, 0);
1425        assert_eq!(transactions.items[0].balance_delta_sat, 42_000);
1426        assert_eq!(transactions.items[0].confirmation_height, None);
1427        assert_eq!(transactions.items[0].first_seen, Some(0));
1428        assert_eq!(
1429            transactions.items[0].inputs,
1430            vec![WalletTransactionInput {
1431                txid: Txid::all_zeros().to_string(),
1432                vout: 0,
1433                amount_sat: None,
1434                address: None,
1435            }]
1436        );
1437
1438        let addresses = backend
1439            .wallet_addresses(0, 20)
1440            .await
1441            .expect("list addresses");
1442        assert_eq!(addresses.total, 1);
1443        assert!(addresses.items[0].used);
1444        assert_eq!(
1445            transactions.items[0].outputs,
1446            vec![WalletTransactionOutput {
1447                vout: 0,
1448                address: addresses.items[0].address.clone(),
1449                amount_sat: 42_000,
1450                quote_id: None,
1451            }]
1452        );
1453        assert_eq!(addresses.items[0].balance_sat, 42_000);
1454        assert_eq!(addresses.items[0].confirmed_balance_sat, 0);
1455
1456        let empty_page = backend
1457            .wallet_transactions(0, 0)
1458            .await
1459            .expect("list empty transaction page");
1460        assert_eq!(empty_page.total, 1);
1461        assert!(empty_page.items.is_empty());
1462    }
1463
1464    #[tokio::test]
1465    async fn wallet_info_pairs_unconfirmed_incoming_output_with_quote_id() {
1466        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1467        fund_backend_wallet(&backend, 42_000).await;
1468        let address = backend
1469            .wallet_addresses(0, 20)
1470            .await
1471            .expect("list addresses")
1472            .items[0]
1473            .address
1474            .clone();
1475        backend
1476            .storage
1477            .track_receive_address(&address, "mint-quote")
1478            .await
1479            .expect("track receive address");
1480
1481        let transactions = backend
1482            .wallet_transactions(0, 20)
1483            .await
1484            .expect("list transactions");
1485        assert_eq!(
1486            transactions.items[0].outputs,
1487            vec![WalletTransactionOutput {
1488                vout: 0,
1489                address,
1490                amount_sat: 42_000,
1491                quote_id: Some("mint-quote".to_string()),
1492            }]
1493        );
1494    }
1495
1496    #[tokio::test]
1497    async fn wallet_info_pairs_batched_outputs_with_quote_ids() {
1498        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1499        let funding_txids = fund_backend_wallet_transactions(&backend, &[20_000, 22_000]).await;
1500        let funding_address = backend
1501            .wallet_addresses(0, 20)
1502            .await
1503            .expect("list funding address")
1504            .items[0]
1505            .address
1506            .clone();
1507        let first_address = "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string();
1508        let second_address = "bcrt1q6rhpng9evdsfnn833a4f4vej0asu6dk5srld6x".to_string();
1509
1510        let mut wallet_with_db = backend.wallet_with_db.lock().await;
1511        let first_recipient_script = bdk_wallet::bitcoin::Address::from_str(&first_address)
1512            .expect("valid address")
1513            .require_network(Network::Regtest)
1514            .expect("regtest address")
1515            .script_pubkey();
1516        let second_recipient_script = bdk_wallet::bitcoin::Address::from_str(&second_address)
1517            .expect("valid address")
1518            .require_network(Network::Regtest)
1519            .expect("regtest address")
1520            .script_pubkey();
1521        let change_script = wallet_with_db
1522            .wallet
1523            .reveal_next_address(KeychainKind::Internal)
1524            .address
1525            .script_pubkey();
1526        let spending_transaction = Transaction {
1527            version: transaction::Version::TWO,
1528            lock_time: absolute::LockTime::ZERO,
1529            input: funding_txids
1530                .iter()
1531                .rev()
1532                .map(|txid| TxIn {
1533                    previous_output: OutPoint::new(*txid, 0),
1534                    script_sig: Default::default(),
1535                    sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1536                    witness: Witness::new(),
1537                })
1538                .collect(),
1539            output: vec![
1540                TxOut {
1541                    value: bdk_wallet::bitcoin::Amount::from_sat(20_000),
1542                    script_pubkey: first_recipient_script,
1543                },
1544                TxOut {
1545                    value: bdk_wallet::bitcoin::Amount::from_sat(10_000),
1546                    script_pubkey: second_recipient_script,
1547                },
1548                TxOut {
1549                    value: bdk_wallet::bitcoin::Amount::from_sat(11_900),
1550                    script_pubkey: change_script,
1551                },
1552            ],
1553        };
1554        let spending_txid = spending_transaction.compute_txid();
1555        wallet_with_db
1556            .wallet
1557            .apply_unconfirmed_txs([(spending_transaction, 1)]);
1558        wallet_with_db
1559            .persist()
1560            .expect("persist spending transaction");
1561        drop(wallet_with_db);
1562
1563        let batch_id = Uuid::new_v4();
1564        let intents = [
1565            (Uuid::new_v4(), "quote-first", &first_address, 20_000, 0),
1566            (Uuid::new_v4(), "quote-second", &second_address, 10_000, 1),
1567        ];
1568        for (intent_id, quote_id, address, amount_sat, vout) in &intents {
1569            backend
1570                .storage
1571                .create_send_intent_if_absent(
1572                    &crate::send::payment_intent::record::SendIntentRecord {
1573                        intent_id: *intent_id,
1574                        quote_id: quote_id.to_string(),
1575                        address: address.to_string(),
1576                        amount_sat: *amount_sat,
1577                        max_fee_amount_sat: 1_000,
1578                        tier: PaymentTier::Immediate,
1579                        metadata: PaymentMetadata::default(),
1580                        state: crate::send::payment_intent::record::SendIntentState::AwaitingConfirmation {
1581                            batch_id,
1582                            txid: spending_txid.to_string(),
1583                            outpoint: format!("{spending_txid}:{vout}"),
1584                            fee_contribution_sat: 50,
1585                            created_at: 0,
1586                        },
1587                    },
1588                )
1589                .await
1590                .expect("store send intent");
1591        }
1592
1593        let transactions = backend
1594            .wallet_transactions(0, 20)
1595            .await
1596            .expect("list transactions");
1597
1598        assert_eq!(transactions.total, 3);
1599        assert_eq!(
1600            transactions.items[0].inputs,
1601            vec![
1602                WalletTransactionInput {
1603                    txid: funding_txids[1].to_string(),
1604                    vout: 0,
1605                    amount_sat: Some(22_000),
1606                    address: Some(funding_address.clone()),
1607                },
1608                WalletTransactionInput {
1609                    txid: funding_txids[0].to_string(),
1610                    vout: 0,
1611                    amount_sat: Some(20_000),
1612                    address: Some(funding_address),
1613                },
1614            ]
1615        );
1616        assert_eq!(
1617            transactions.items[0].outputs,
1618            vec![
1619                WalletTransactionOutput {
1620                    vout: 0,
1621                    address: first_address.clone(),
1622                    amount_sat: 20_000,
1623                    quote_id: Some("quote-first".to_string()),
1624                },
1625                WalletTransactionOutput {
1626                    vout: 1,
1627                    address: second_address.clone(),
1628                    amount_sat: 10_000,
1629                    quote_id: Some("quote-second".to_string()),
1630                },
1631            ]
1632        );
1633        assert_eq!(transactions.items[0].sent_sat, 42_000);
1634        assert_eq!(transactions.items[0].received_sat, 11_900);
1635
1636        for (intent_id, quote_id, _, amount_sat, vout) in &intents {
1637            backend
1638                .storage
1639                .finalize_send_intent(
1640                    intent_id,
1641                    &FinalizedSendIntentRecord {
1642                        intent_id: *intent_id,
1643                        quote_id: quote_id.to_string(),
1644                        total_spent_sat: *amount_sat + 50,
1645                        outpoint: format!("{spending_txid}:{vout}"),
1646                        finalized_at: 0,
1647                    },
1648                )
1649                .await
1650                .expect("finalize send intent");
1651        }
1652
1653        let finalized_transactions = backend
1654            .wallet_transactions(0, 20)
1655            .await
1656            .expect("list transactions after intent finalization");
1657        assert_eq!(
1658            finalized_transactions.items[0]
1659                .outputs
1660                .iter()
1661                .map(|output| output.quote_id.as_deref())
1662                .collect::<Vec<_>>(),
1663            vec![Some("quote-first"), Some("quote-second")]
1664        );
1665    }
1666
1667    #[tokio::test]
1668    async fn wallet_info_reports_multiple_incoming_outputs_in_vout_order() {
1669        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1670        let mut wallet_with_db = backend.wallet_with_db.lock().await;
1671        let output_script = wallet_with_db
1672            .wallet
1673            .reveal_next_address(KeychainKind::External)
1674            .address
1675            .script_pubkey();
1676        let funding_transaction = Transaction {
1677            version: transaction::Version::TWO,
1678            lock_time: absolute::LockTime::ZERO,
1679            input: vec![TxIn {
1680                previous_output: OutPoint::new(Txid::all_zeros(), 0),
1681                script_sig: Default::default(),
1682                sequence: Sequence::ENABLE_RBF_NO_LOCKTIME,
1683                witness: Witness::new(),
1684            }],
1685            output: vec![
1686                TxOut {
1687                    value: bdk_wallet::bitcoin::Amount::from_sat(21_000),
1688                    script_pubkey: output_script.clone(),
1689                },
1690                TxOut {
1691                    value: bdk_wallet::bitcoin::Amount::from_sat(21_000),
1692                    script_pubkey: output_script,
1693                },
1694            ],
1695        };
1696        wallet_with_db
1697            .wallet
1698            .apply_unconfirmed_txs([(funding_transaction, 0)]);
1699        wallet_with_db
1700            .persist()
1701            .expect("persist funding transaction");
1702        drop(wallet_with_db);
1703
1704        let transactions = backend
1705            .wallet_transactions(0, 20)
1706            .await
1707            .expect("list transactions");
1708
1709        assert_eq!(transactions.total, 1);
1710        assert_eq!(transactions.items[0].outputs.len(), 2);
1711        assert_eq!(transactions.items[0].outputs[0].vout, 0);
1712        assert_eq!(transactions.items[0].outputs[0].amount_sat, 21_000);
1713        assert_eq!(transactions.items[0].outputs[1].vout, 1);
1714        assert_eq!(transactions.items[0].outputs[1].amount_sat, 21_000);
1715    }
1716
1717    #[tokio::test]
1718    async fn wallet_info_uses_txid_to_order_equal_chain_positions() {
1719        let (backend, _tmp) = build_test_instance_with_tempdir(1).await;
1720        let mut expected_txids =
1721            fund_backend_wallet_transactions(&backend, &[21_000, 42_000]).await;
1722        expected_txids.sort_by(|left, right| right.cmp(left));
1723
1724        let first_page = backend
1725            .wallet_transactions(0, 1)
1726            .await
1727            .expect("list first transaction page");
1728        let second_page = backend
1729            .wallet_transactions(1, 1)
1730            .await
1731            .expect("list second transaction page");
1732
1733        assert_eq!(first_page.total, 2);
1734        assert_eq!(second_page.total, 2);
1735        assert_eq!(first_page.items[0].txid, expected_txids[0].to_string());
1736        assert_eq!(second_page.items[0].txid, expected_txids[1].to_string());
1737    }
1738
1739    #[tokio::test]
1740    async fn test_new_rejects_zero_sync_interval() {
1741        match build_test_instance_with_config(5, None, 0).await {
1742            Err(Error::InvalidConfig(message)) => {
1743                assert!(message.contains("sync_interval_secs"));
1744            }
1745            Ok(_) => panic!("zero sync interval should be rejected"),
1746            Err(err) => panic!("expected invalid config error, got {err}"),
1747        }
1748    }
1749
1750    #[tokio::test]
1751    async fn test_new_rejects_zero_batch_poll_interval() {
1752        let batch_config = BatchConfig {
1753            poll_interval: Duration::ZERO,
1754            ..BatchConfig::default()
1755        };
1756
1757        match build_test_instance_with_config(5, Some(batch_config), 60).await {
1758            Err(Error::InvalidConfig(message)) => {
1759                assert!(message.contains("poll_interval"));
1760            }
1761            Ok(_) => panic!("zero batch poll interval should be rejected"),
1762            Err(err) => panic!("expected invalid config error, got {err}"),
1763        }
1764    }
1765
1766    #[tokio::test]
1767    async fn test_new_rejects_zero_target_block_time() {
1768        let batch_config = BatchConfig {
1769            target_block_time: Duration::ZERO,
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("target_block_time"));
1776            }
1777            Ok(_) => panic!("zero target block time should be rejected"),
1778            Err(err) => panic!("expected invalid config error, got {err}"),
1779        }
1780    }
1781
1782    #[tokio::test]
1783    async fn test_new_rejects_invalid_fallback_fee_rate() {
1784        let batch_config = BatchConfig {
1785            fee_estimation: FeeEstimationConfig {
1786                fallback_sat_per_vb: 0.0,
1787                ..FeeEstimationConfig::default()
1788            },
1789            ..BatchConfig::default()
1790        };
1791
1792        match build_test_instance_with_config(5, Some(batch_config), 60).await {
1793            Err(Error::InvalidConfig(message)) => {
1794                assert!(message.contains("fallback_sat_per_vb"));
1795            }
1796            Ok(_) => panic!("invalid fallback fee rate should be rejected"),
1797            Err(err) => panic!("expected invalid config error, got {err}"),
1798        }
1799    }
1800
1801    #[test]
1802    fn test_default_batch_deadlines_match_advertised_blocks() {
1803        let batch_config = BatchConfig::default();
1804
1805        assert_eq!(batch_config.target_block_time, Duration::from_secs(600));
1806        assert_eq!(batch_config.standard_deadline, Duration::from_secs(3600));
1807        assert_eq!(batch_config.economy_deadline, Duration::from_secs(86_400));
1808        assert_eq!(
1809            batch_config.max_intent_age,
1810            Some(Duration::from_secs(86_430))
1811        );
1812    }
1813
1814    #[tokio::test]
1815    async fn test_start_then_stop_exits_promptly() {
1816        let backend = build_test_instance(5).await;
1817
1818        let started = tokio::time::timeout(Duration::from_secs(10), backend.start())
1819            .await
1820            .expect("start timed out");
1821        started.expect("start should succeed");
1822
1823        let stopped = tokio::time::timeout(Duration::from_secs(10), backend.stop())
1824            .await
1825            .expect("stop timed out");
1826        stopped.expect("stop should succeed");
1827    }
1828
1829    #[tokio::test]
1830    async fn test_double_start_returns_already_started() {
1831        let backend = build_test_instance(5).await;
1832        backend.start().await.expect("first start");
1833
1834        let second = backend.start().await;
1835        assert!(second.is_err(), "second start should error");
1836
1837        backend.stop().await.expect("stop");
1838    }
1839
1840    #[tokio::test]
1841    async fn test_stop_without_start_is_ok() {
1842        let backend = build_test_instance(5).await;
1843        backend.stop().await.expect("stop on never-started is ok");
1844        backend.stop().await.expect("double stop is ok");
1845    }
1846
1847    #[tokio::test]
1848    async fn test_restart_after_stop() {
1849        let backend = build_test_instance(5).await;
1850        backend.start().await.expect("first start");
1851        backend.stop().await.expect("first stop");
1852        backend.start().await.expect("second start");
1853        backend.stop().await.expect("second stop");
1854    }
1855
1856    #[tokio::test]
1857    async fn test_wait_payment_event_tracks_active_state_and_cancels() {
1858        let backend = build_test_instance(5).await;
1859        assert!(!backend.is_payment_event_stream_active());
1860
1861        let mut stream = backend
1862            .wait_payment_event()
1863            .await
1864            .expect("payment event stream");
1865        assert!(backend.is_payment_event_stream_active());
1866
1867        backend.cancel_payment_event_stream();
1868
1869        let next = tokio::time::timeout(Duration::from_secs(2), stream.next())
1870            .await
1871            .expect("stream should observe cancellation promptly");
1872        assert!(next.is_none());
1873        assert!(!backend.is_payment_event_stream_active());
1874    }
1875
1876    #[test]
1877    fn test_quote_fee_safety_adds_multiplier_and_fixed_margin() {
1878        let config = FeeEstimationConfig {
1879            quote_safety_multiplier: 1.25,
1880            quote_fixed_safety_sat: 500,
1881            ..FeeEstimationConfig::default()
1882        };
1883
1884        assert_eq!(apply_quote_fee_safety(1_000, &config), 1_750);
1885    }
1886
1887    #[tokio::test]
1888    async fn test_fee_rate_cache_falls_back_on_error() {
1889        // With an unreachable Esplora URL, estimate_fee_rate_sat_per_vb
1890        // returns an error. The quote path falls back to the configured
1891        // default. We exercise the fallback by invoking get_payment_quote
1892        // with a tier hint and observing that it returns a non-zero fee.
1893        let backend = build_test_instance(5).await;
1894
1895        let tier_err = backend
1896            .estimate_fee_rate_sat_per_vb(PaymentTier::Immediate)
1897            .await;
1898        assert!(
1899            tier_err.is_err(),
1900            "fee rate estimation should fail against bogus Esplora URL"
1901        );
1902    }
1903
1904    #[tokio::test]
1905    async fn test_get_payment_quote_does_not_stage_wallet_changes() {
1906        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1907        fund_backend_wallet(&backend, 100_000).await;
1908        let (_quote_id, options) = onchain_options_for(10_000);
1909
1910        backend
1911            .get_payment_quote(&CurrencyUnit::Sat, options)
1912            .await
1913            .expect("quote should succeed with fallback fee rate");
1914
1915        let wallet_with_db = backend.wallet_with_db.lock().await;
1916        assert!(
1917            wallet_with_db.wallet.staged().is_none(),
1918            "quote estimation must not mutate or stage BDK wallet state"
1919        );
1920    }
1921
1922    #[tokio::test]
1923    async fn test_default_fee_options_emit_immediate_only() {
1924        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
1925        fund_backend_wallet(&backend, 100_000).await;
1926        let (_quote_id, options) = onchain_options_for(10_000);
1927
1928        let quote = backend
1929            .get_payment_quote(&CurrencyUnit::Sat, options)
1930            .await
1931            .expect("quote should succeed");
1932
1933        let fee_options = quote.fee_options.expect("fee options");
1934        assert_eq!(fee_options.len(), 1);
1935        assert_eq!(fee_options[0].fee_index, 0);
1936        assert_eq!(fee_options[0].estimated_blocks, 1);
1937    }
1938
1939    #[tokio::test]
1940    async fn test_configured_fee_options_emit_indexes_in_order() {
1941        let batch_config = BatchConfig {
1942            fee_options: vec![
1943                PaymentTier::Immediate,
1944                PaymentTier::Standard,
1945                PaymentTier::Economy,
1946            ],
1947            ..BatchConfig::default()
1948        };
1949        let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1950            .await
1951            .expect("build CdkBdk test instance");
1952        fund_backend_wallet(&backend, 100_000).await;
1953        let (_quote_id, options) = onchain_options_for(10_000);
1954
1955        let quote = backend
1956            .get_payment_quote(&CurrencyUnit::Sat, options)
1957            .await
1958            .expect("quote should succeed");
1959
1960        let fee_options = quote.fee_options.expect("fee options");
1961        let indexes: Vec<u32> = fee_options.iter().map(|option| option.fee_index).collect();
1962        let estimated_blocks: Vec<u32> = fee_options
1963            .iter()
1964            .map(|option| option.estimated_blocks)
1965            .collect();
1966
1967        assert_eq!(indexes, vec![0, 1, 2]);
1968        assert_eq!(estimated_blocks, vec![1, 6, 144]);
1969    }
1970
1971    #[tokio::test]
1972    async fn test_configured_fee_index_resolves_by_position() {
1973        let batch_config = BatchConfig {
1974            fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
1975            ..BatchConfig::default()
1976        };
1977        let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
1978            .await
1979            .expect("build CdkBdk test instance");
1980        fund_backend_wallet(&backend, 100_000).await;
1981        let (quote_id, mut options) = onchain_options_for(10_000);
1982        let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
1983            panic!("expected onchain options");
1984        };
1985        onchain.fee_index = Some(1);
1986        onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
1987
1988        backend
1989            .make_payment(&CurrencyUnit::Sat, options)
1990            .await
1991            .expect("make_payment should enqueue the intent");
1992
1993        let intent = backend
1994            .storage
1995            .get_send_intent_by_quote_id(&quote_id.to_string())
1996            .await
1997            .expect("lookup send intent by quote id")
1998            .expect("send intent should be persisted");
1999
2000        assert_eq!(intent.tier, PaymentTier::Economy);
2001    }
2002
2003    #[tokio::test]
2004    async fn test_make_payment_returns_failed_for_unknown_fee_index() {
2005        let backend = build_test_instance(5).await;
2006        let (quote_id, mut options) = onchain_options_for(10_000);
2007        let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
2008            panic!("expected onchain options");
2009        };
2010        onchain.fee_index = Some(99);
2011
2012        let response = backend
2013            .make_payment(&CurrencyUnit::Sat, options)
2014            .await
2015            .expect("definitive pre-dispatch rejection should return a payment response");
2016
2017        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2018        assert!(
2019            backend
2020                .storage
2021                .get_send_intent_by_quote_id(&quote_id.to_string())
2022                .await
2023                .expect("lookup send intent by quote id")
2024                .is_none(),
2025            "unknown fee index rejection must not leave a pending send intent behind"
2026        );
2027    }
2028
2029    #[tokio::test]
2030    async fn test_make_payment_omitted_fee_index_defaults_to_immediate() {
2031        let batch_config = BatchConfig {
2032            fee_options: vec![PaymentTier::Immediate, PaymentTier::Economy],
2033            ..BatchConfig::default()
2034        };
2035        let (backend, _tmp) = build_test_instance_with_config(5, Some(batch_config), 60)
2036            .await
2037            .expect("build CdkBdk test instance");
2038        fund_backend_wallet(&backend, 100_000).await;
2039        let (quote_id, options) = onchain_options_for(10_000);
2040
2041        backend
2042            .make_payment(&CurrencyUnit::Sat, options)
2043            .await
2044            .expect("make_payment should enqueue the intent");
2045
2046        let intent = backend
2047            .storage
2048            .get_send_intent_by_quote_id(&quote_id.to_string())
2049            .await
2050            .expect("lookup send intent by quote id")
2051            .expect("send intent should be persisted");
2052
2053        assert_eq!(intent.tier, PaymentTier::Immediate);
2054    }
2055
2056    #[tokio::test]
2057    async fn test_new_rejects_invalid_fee_option_lists() {
2058        for fee_options in [
2059            Vec::new(),
2060            vec![PaymentTier::Immediate, PaymentTier::Immediate],
2061            vec![
2062                PaymentTier::Immediate,
2063                PaymentTier::Standard,
2064                PaymentTier::Economy,
2065                PaymentTier::Immediate,
2066            ],
2067        ] {
2068            let batch_config = BatchConfig {
2069                fee_options,
2070                ..BatchConfig::default()
2071            };
2072            match build_test_instance_with_config(5, Some(batch_config), 60).await {
2073                Err(Error::InvalidConfig(message)) => {
2074                    assert!(message.contains("fee_options"));
2075                }
2076                Ok(_) => panic!("invalid fee options should be rejected"),
2077                Err(err) => panic!("expected invalid config error, got {err}"),
2078            }
2079        }
2080    }
2081
2082    #[tokio::test]
2083    async fn test_get_payment_quote_rejects_empty_wallet() {
2084        let backend = build_test_instance(5).await;
2085        let (_quote_id, options) = onchain_options_for(10_000);
2086
2087        let err = backend
2088            .get_payment_quote(&CurrencyUnit::Sat, options)
2089            .await
2090            .expect_err("empty wallet should not receive an onchain quote");
2091
2092        let cdk_common::payment::Error::Onchain(inner) = err else {
2093            panic!("expected onchain error");
2094        };
2095
2096        let backend_err = inner
2097            .downcast_ref::<Error>()
2098            .expect("expected cdk-bdk backend error");
2099        assert!(matches!(backend_err, Error::NoSpendableUtxos));
2100    }
2101
2102    #[tokio::test]
2103    async fn test_make_payment_returns_failed_when_current_fee_exceeds_max_fee() {
2104        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2105        fund_backend_wallet(&backend, 100_000).await;
2106        let (quote_id, mut options) = onchain_options_for(10_000);
2107        let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
2108            panic!("expected onchain options");
2109        };
2110        onchain.max_fee_amount = Some(Amount::new(1, CurrencyUnit::Sat));
2111
2112        let response = backend
2113            .make_payment(&CurrencyUnit::Sat, options)
2114            .await
2115            .expect("definitive pre-dispatch rejection should return a payment response");
2116
2117        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2118
2119        assert!(
2120            backend
2121                .storage
2122                .get_send_intent_by_quote_id(&quote_id.to_string())
2123                .await
2124                .expect("lookup send intent by quote id")
2125                .is_none(),
2126            "fee recheck rejection must not leave a pending send intent behind"
2127        );
2128    }
2129
2130    #[tokio::test]
2131    async fn test_get_settings_reports_min_send_amount() {
2132        let backend = build_test_instance(5).await;
2133
2134        let settings = backend.get_settings().await.expect("settings");
2135        let onchain = settings.onchain.expect("onchain settings");
2136
2137        assert_eq!(onchain.min_receive_amount_sat, 0);
2138        assert_eq!(onchain.min_send_amount_sat, 546);
2139    }
2140
2141    // ------------------------------------------------------------------
2142    // Regression tests for Finding 5: total_spent is only authoritative
2143    // after the payment has been made. While the intent is queued but not
2144    // yet broadcast, the per-intent fee is unknown, so `total_spent` is
2145    // reported as 0 (sentinel), matching the LND/LDK/CLN convention for
2146    // non-terminal responses.
2147    // ------------------------------------------------------------------
2148
2149    use cdk_common::payment::OnchainOutgoingPaymentOptions;
2150    use cdk_common::QuoteId;
2151    use uuid::Uuid;
2152
2153    /// Build an onchain outgoing payment option with a fresh quote id.
2154    fn onchain_options_for(amount_sat: u64) -> (QuoteId, OutgoingPaymentOptions) {
2155        let quote_id = QuoteId::UUID(Uuid::new_v4());
2156        (
2157            quote_id.clone(),
2158            onchain_options_for_quote(quote_id, amount_sat),
2159        )
2160    }
2161
2162    fn onchain_options_for_quote(quote_id: QuoteId, amount_sat: u64) -> OutgoingPaymentOptions {
2163        OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
2164            address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2165            amount: Amount::new(amount_sat, CurrencyUnit::Sat),
2166            max_fee_amount: Some(Amount::new(1_000, CurrencyUnit::Sat)),
2167            quote_id,
2168            fee_index: None,
2169            metadata: None,
2170        }))
2171    }
2172
2173    fn onchain_options_for_msat(
2174        quote_id: QuoteId,
2175        amount_msat: u64,
2176        max_fee_msat: u64,
2177    ) -> OutgoingPaymentOptions {
2178        OutgoingPaymentOptions::Onchain(Box::new(OnchainOutgoingPaymentOptions {
2179            address: "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2180            amount: Amount::new(amount_msat, CurrencyUnit::Msat),
2181            max_fee_amount: Some(Amount::new(max_fee_msat, CurrencyUnit::Msat)),
2182            quote_id,
2183            fee_index: None,
2184            metadata: None,
2185        }))
2186    }
2187
2188    fn assert_authoritative_failure_response(
2189        response: MakePaymentResponse,
2190        quote_id: QuoteId,
2191        unit: CurrencyUnit,
2192    ) {
2193        assert_eq!(
2194            response.payment_lookup_id,
2195            PaymentIdentifier::QuoteId(quote_id)
2196        );
2197        assert_eq!(response.status, MeltQuoteState::Failed);
2198        assert_eq!(response.total_spent, Amount::new(0, unit));
2199        assert!(response.payment_proof.is_none());
2200    }
2201
2202    #[tokio::test]
2203    async fn test_get_payment_quote_converts_fee_options_to_msat() {
2204        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2205        fund_backend_wallet(&backend, 100_000).await;
2206        let quote_id = QuoteId::UUID(Uuid::new_v4());
2207        let options = onchain_options_for_msat(quote_id, 10_000_000, 10_000_000);
2208
2209        let quote = backend
2210            .get_payment_quote(&CurrencyUnit::Msat, options)
2211            .await
2212            .expect("msat quote should succeed");
2213
2214        assert_eq!(quote.amount, Amount::new(10_000_000, CurrencyUnit::Msat));
2215        assert_eq!(quote.fee.unit(), &CurrencyUnit::Msat);
2216        assert_eq!(quote.fee.value() % MSAT_IN_SAT, 0);
2217
2218        let fee_options = quote.fee_options.expect("fee options");
2219        assert!(fee_options
2220            .iter()
2221            .all(|option| u64::from(option.fee_reserve) % MSAT_IN_SAT == 0));
2222        assert_eq!(
2223            quote.fee.value(),
2224            fee_options
2225                .iter()
2226                .map(|option| u64::from(option.fee_reserve))
2227                .min()
2228                .expect("non-empty fee options")
2229        );
2230    }
2231
2232    #[tokio::test]
2233    async fn test_make_payment_converts_msat_amount_and_fee_to_sat() {
2234        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2235        fund_backend_wallet(&backend, 100_000).await;
2236        let quote_id = QuoteId::UUID(Uuid::new_v4());
2237        let options = onchain_options_for_msat(quote_id.clone(), 10_000_000, 10_000_000);
2238
2239        let response = backend
2240            .make_payment(&CurrencyUnit::Msat, options)
2241            .await
2242            .expect("msat payment should enqueue a sat-native intent");
2243
2244        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Msat));
2245        let intent = backend
2246            .storage
2247            .get_send_intent_by_quote_id(&quote_id.to_string())
2248            .await
2249            .expect("lookup send intent")
2250            .expect("send intent should be persisted");
2251        assert_eq!(intent.amount_sat, 10_000);
2252        assert_eq!(intent.max_fee_amount_sat, 10_000);
2253    }
2254
2255    #[tokio::test]
2256    async fn test_make_payment_returns_failed_for_fractional_satoshi_amount() {
2257        let backend = build_test_instance(5).await;
2258        let quote_id = QuoteId::UUID(Uuid::new_v4());
2259        let options = onchain_options_for_msat(quote_id.clone(), 10_000_001, 10_000_000);
2260
2261        let response = backend
2262            .make_payment(&CurrencyUnit::Msat, options)
2263            .await
2264            .expect("definitive pre-dispatch rejection should return a payment response");
2265
2266        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Msat);
2267        assert!(backend
2268            .storage
2269            .get_send_intent_by_quote_id(&quote_id.to_string())
2270            .await
2271            .expect("lookup send intent")
2272            .is_none());
2273    }
2274
2275    #[tokio::test]
2276    async fn test_make_payment_returns_failed_for_mismatched_fee_unit() {
2277        let backend = build_test_instance(5).await;
2278        let quote_id = QuoteId::UUID(Uuid::new_v4());
2279        let mut options = onchain_options_for_msat(quote_id.clone(), 10_000_000, 10_000_000);
2280        let OutgoingPaymentOptions::Onchain(onchain) = &mut options else {
2281            panic!("expected onchain options");
2282        };
2283        onchain.max_fee_amount = Some(Amount::new(10_000, CurrencyUnit::Sat));
2284
2285        let response = backend
2286            .make_payment(&CurrencyUnit::Msat, options)
2287            .await
2288            .expect("definitive pre-dispatch rejection should return a payment response");
2289
2290        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Msat);
2291        assert!(backend
2292            .storage
2293            .get_send_intent_by_quote_id(&quote_id.to_string())
2294            .await
2295            .expect("lookup send intent")
2296            .is_none());
2297    }
2298
2299    #[tokio::test]
2300    async fn test_make_payment_pending_total_spent_is_zero() {
2301        // make_payment queues the intent before a batch has been built, so
2302        // the per-intent fee is unknown. total_spent MUST be 0, not the
2303        // user-requested amount (which would imply no fee).
2304        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2305        fund_backend_wallet(&backend, 100_000).await;
2306        let (quote_id, options) = onchain_options_for(10_000);
2307
2308        let response = backend
2309            .make_payment(&CurrencyUnit::Sat, options)
2310            .await
2311            .expect("make_payment should enqueue the intent");
2312
2313        assert_eq!(response.status, MeltQuoteState::Pending);
2314        assert_eq!(
2315            response.payment_lookup_id,
2316            PaymentIdentifier::QuoteId(quote_id)
2317        );
2318        assert_eq!(
2319            response.total_spent,
2320            Amount::new(0, CurrencyUnit::Sat),
2321            "Pending onchain response MUST use 0 sentinel; the real \
2322             total_spent is only known after the batch transaction is built"
2323        );
2324    }
2325
2326    #[tokio::test]
2327    async fn test_get_payment_quote_rejects_dust_output() {
2328        let backend = build_test_instance(5).await;
2329        let (_quote_id, options) = onchain_options_for(1);
2330
2331        let err = backend
2332            .get_payment_quote(&CurrencyUnit::Sat, options)
2333            .await
2334            .expect_err("dust output should be rejected at quote time");
2335
2336        let cdk_common::payment::Error::Onchain(inner) = err else {
2337            panic!("expected onchain error");
2338        };
2339
2340        let backend_err = inner
2341            .downcast_ref::<Error>()
2342            .expect("expected cdk-bdk backend error");
2343        assert!(matches!(backend_err, Error::DustOutput { .. }));
2344    }
2345
2346    #[tokio::test]
2347    async fn test_make_payment_returns_failed_for_dust_without_persisting_intent() {
2348        let backend = build_test_instance(5).await;
2349        let (quote_id, options) = onchain_options_for(1);
2350
2351        let response = backend
2352            .make_payment(&CurrencyUnit::Sat, options)
2353            .await
2354            .expect("definitive pre-dispatch rejection should return a payment response");
2355
2356        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2357        assert!(
2358            backend
2359                .storage
2360                .get_send_intent_by_quote_id(&quote_id.to_string())
2361                .await
2362                .expect("lookup send intent by quote id")
2363                .is_none(),
2364            "dust rejection must not leave a pending send intent behind"
2365        );
2366    }
2367
2368    #[tokio::test]
2369    async fn test_get_payment_quote_rejects_amount_below_minimum_send() {
2370        let backend = build_test_instance(5).await;
2371        let (_quote_id, options) = onchain_options_for(545);
2372
2373        let err = backend
2374            .get_payment_quote(&CurrencyUnit::Sat, options)
2375            .await
2376            .expect_err("amount below configured minimum should be rejected at quote time");
2377
2378        let cdk_common::payment::Error::Onchain(inner) = err else {
2379            panic!("expected onchain error");
2380        };
2381
2382        let backend_err = inner
2383            .downcast_ref::<Error>()
2384            .expect("expected cdk-bdk backend error");
2385        assert!(matches!(
2386            backend_err,
2387            Error::AmountBelowMinimumSend {
2388                amount: 545,
2389                min: 546
2390            }
2391        ));
2392    }
2393
2394    #[tokio::test]
2395    async fn test_make_payment_returns_failed_below_minimum_without_persisting_intent() {
2396        let backend = build_test_instance(5).await;
2397        let (quote_id, options) = onchain_options_for(545);
2398
2399        let response = backend
2400            .make_payment(&CurrencyUnit::Sat, options)
2401            .await
2402            .expect("definitive pre-dispatch rejection should return a payment response");
2403
2404        assert_authoritative_failure_response(response, quote_id.clone(), CurrencyUnit::Sat);
2405        assert!(
2406            backend
2407                .storage
2408                .get_send_intent_by_quote_id(&quote_id.to_string())
2409                .await
2410                .expect("lookup send intent by quote id")
2411                .is_none(),
2412            "minimum-send rejection must not leave a pending send intent behind"
2413        );
2414    }
2415
2416    #[tokio::test]
2417    async fn test_check_outgoing_payment_pending_intent_reports_zero_total_spent() {
2418        // An intent freshly created via make_payment is in state Pending.
2419        // check_outgoing_payment must report total_spent = 0 because the
2420        // fee contribution is not yet knowable.
2421        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2422        fund_backend_wallet(&backend, 100_000).await;
2423        let (quote_id, options) = onchain_options_for(12_345);
2424
2425        backend
2426            .make_payment(&CurrencyUnit::Sat, options)
2427            .await
2428            .expect("make_payment should enqueue the intent");
2429
2430        let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2431        let response = backend
2432            .check_outgoing_payment(&payment_identifier)
2433            .await
2434            .expect("check_outgoing_payment for Pending intent");
2435
2436        assert_eq!(response.status, MeltQuoteState::Pending);
2437        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2438        assert_eq!(response.payment_proof, None);
2439    }
2440
2441    #[tokio::test]
2442    async fn test_check_outgoing_payment_batched_intent_reports_zero_total_spent() {
2443        // Driving an intent through Pending → Batched (fee still unknown at
2444        // the per-intent level until the batch transaction is built) must
2445        // still report total_spent = 0.
2446        use crate::send::payment_intent::SendIntent;
2447        use crate::types::{PaymentMetadata, PaymentTier};
2448
2449        let backend = build_test_instance(5).await;
2450        let quote_id = QuoteId::UUID(Uuid::new_v4());
2451
2452        let pending = SendIntent::new(
2453            &backend.storage,
2454            quote_id.to_string(),
2455            "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2456            20_000,
2457            1_000,
2458            PaymentTier::Standard,
2459            PaymentMetadata::default(),
2460        )
2461        .await
2462        .expect("create Pending send intent");
2463
2464        pending
2465            .assign_to_batch(&backend.storage, Uuid::new_v4())
2466            .await
2467            .expect("transition Pending → Batched");
2468
2469        let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2470        let response = backend
2471            .check_outgoing_payment(&payment_identifier)
2472            .await
2473            .expect("check_outgoing_payment for Batched intent");
2474
2475        assert_eq!(response.status, MeltQuoteState::Pending);
2476        assert_eq!(
2477            response.total_spent,
2478            Amount::new(0, CurrencyUnit::Sat),
2479            "Batched intents report total_spent = 0 until the batch \
2480             transaction is built and the per-intent fee is fixed"
2481        );
2482    }
2483
2484    #[tokio::test]
2485    async fn test_check_outgoing_payment_awaiting_confirmation_includes_fee() {
2486        // Once an intent reaches AwaitingConfirmation, the per-intent fee
2487        // contribution is persisted on the intent record. check_outgoing_payment
2488        // must now report total_spent = amount + fee_contribution_sat so that
2489        // downstream consumers (e.g. recovery / subscribers) see the
2490        // authoritative figure even though the payment is still unconfirmed.
2491        use crate::send::payment_intent::SendIntent;
2492        use crate::types::{PaymentMetadata, PaymentTier};
2493
2494        let backend = build_test_instance(5).await;
2495        let quote_id = QuoteId::UUID(Uuid::new_v4());
2496
2497        let pending = SendIntent::new(
2498            &backend.storage,
2499            quote_id.to_string(),
2500            "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2501            30_000,
2502            2_000,
2503            PaymentTier::Immediate,
2504            PaymentMetadata::default(),
2505        )
2506        .await
2507        .expect("create Pending send intent");
2508
2509        let batched = pending
2510            .assign_to_batch(&backend.storage, Uuid::new_v4())
2511            .await
2512            .expect("transition Pending → Batched");
2513
2514        let fee_contrib = 512_u64;
2515        batched
2516            .mark_broadcast(
2517                &backend.storage,
2518                "deadbeef".to_string(),
2519                "deadbeef:0".to_string(),
2520                fee_contrib,
2521            )
2522            .await
2523            .expect("transition Batched → AwaitingConfirmation");
2524
2525        let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2526        let response = backend
2527            .check_outgoing_payment(&payment_identifier)
2528            .await
2529            .expect("check_outgoing_payment for AwaitingConfirmation intent");
2530
2531        assert_eq!(response.status, MeltQuoteState::Pending);
2532        assert_eq!(
2533            response.total_spent,
2534            Amount::new(30_000 + fee_contrib, CurrencyUnit::Sat),
2535            "AwaitingConfirmation intents know the per-intent fee \
2536             contribution and must report amount + fee"
2537        );
2538    }
2539
2540    #[tokio::test]
2541    async fn test_check_outgoing_payment_failed_intent_reports_failed() {
2542        use crate::send::payment_intent::SendIntent;
2543        use crate::types::{PaymentMetadata, PaymentTier};
2544
2545        let backend = build_test_instance(5).await;
2546        let quote_id = QuoteId::UUID(Uuid::new_v4());
2547
2548        let pending = SendIntent::new(
2549            &backend.storage,
2550            quote_id.to_string(),
2551            "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2552            30_000,
2553            2_000,
2554            PaymentTier::Immediate,
2555            PaymentMetadata::default(),
2556        )
2557        .await
2558        .expect("create Pending send intent");
2559
2560        pending
2561            .fail(&backend.storage, "fee too high".to_string())
2562            .await
2563            .expect("transition Pending to Failed");
2564
2565        let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2566        let response = backend
2567            .check_outgoing_payment(&payment_identifier)
2568            .await
2569            .expect("check_outgoing_payment for Failed intent");
2570
2571        assert_eq!(response.status, MeltQuoteState::Failed);
2572        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2573        assert_eq!(response.payment_proof, None);
2574    }
2575
2576    #[tokio::test]
2577    async fn test_make_payment_replay_preserves_durable_state_without_spendable_utxos() {
2578        use crate::send::payment_intent::SendIntent;
2579
2580        async fn assert_replay(
2581            backend: &CdkBdk,
2582            quote_id: &QuoteId,
2583            status: MeltQuoteState,
2584            spent_sat: u64,
2585            proof: Option<&str>,
2586        ) {
2587            for unit in [CurrencyUnit::Sat, CurrencyUnit::Msat] {
2588                let options = match unit {
2589                    CurrencyUnit::Sat => onchain_options_for_quote(quote_id.clone(), 10_000),
2590                    _ => onchain_options_for_msat(quote_id.clone(), 10_000_000, 1_000_000),
2591                };
2592                let response = backend
2593                    .make_payment(&unit, options)
2594                    .await
2595                    .expect("replay should return durable payment state");
2596
2597                assert_eq!(response.status, status);
2598                assert_eq!(
2599                    response.payment_lookup_id,
2600                    PaymentIdentifier::QuoteId(quote_id.clone())
2601                );
2602                assert_eq!(response.payment_proof.as_deref(), proof);
2603                assert_eq!(
2604                    response.total_spent,
2605                    Amount::new(spent_sat, CurrencyUnit::Sat)
2606                        .convert_to(&unit)
2607                        .expect("convert expected spent amount")
2608                );
2609            }
2610        }
2611
2612        // Persist the payment lifecycle directly, leaving the wallet empty so
2613        // any fresh fee estimate would reject the otherwise identical replay.
2614        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2615        let quote_id = QuoteId::UUID(Uuid::new_v4());
2616        let pending = SendIntent::new(
2617            &backend.storage,
2618            quote_id.to_string(),
2619            "bcrt1qw508d6qejxtdg4y5r3zarvary0c5xw7kygt080".to_string(),
2620            10_000,
2621            1_000,
2622            PaymentTier::Immediate,
2623            PaymentMetadata::default(),
2624        )
2625        .await
2626        .expect("create original intent");
2627        assert_replay(&backend, &quote_id, MeltQuoteState::Pending, 0, None).await;
2628
2629        let batched = pending
2630            .assign_to_batch(&backend.storage, Uuid::new_v4())
2631            .await
2632            .expect("batch original intent");
2633        assert_replay(&backend, &quote_id, MeltQuoteState::Pending, 0, None).await;
2634
2635        let broadcast = batched
2636            .mark_broadcast(
2637                &backend.storage,
2638                "deadbeef".to_string(),
2639                "deadbeef:0".to_string(),
2640                250,
2641            )
2642            .await
2643            .expect("broadcast original intent");
2644        assert_replay(&backend, &quote_id, MeltQuoteState::Pending, 10_250, None).await;
2645
2646        broadcast
2647            .finalize(&backend.storage)
2648            .await
2649            .expect("finalize original intent");
2650        assert_replay(
2651            &backend,
2652            &quote_id,
2653            MeltQuoteState::Paid,
2654            10_250,
2655            Some("deadbeef:0"),
2656        )
2657        .await;
2658    }
2659
2660    #[tokio::test]
2661    async fn test_make_payment_can_retry_failed_intent_with_same_quote_id() {
2662        let (backend, _tmp) = build_test_instance_with_tempdir(5).await;
2663        fund_backend_wallet(&backend, 100_000).await;
2664        let (quote_id, options) = onchain_options_for(30_000);
2665
2666        backend
2667            .make_payment(&CurrencyUnit::Sat, options)
2668            .await
2669            .expect("initial make_payment should enqueue intent");
2670
2671        let initial = backend
2672            .storage
2673            .get_send_intent_by_quote_id(&quote_id.to_string())
2674            .await
2675            .expect("lookup initial intent")
2676            .expect("initial intent exists");
2677
2678        backend
2679            .storage
2680            .update_send_intent(
2681                &initial.intent_id,
2682                &crate::send::payment_intent::record::SendIntentState::Failed {
2683                    reason: "pre-sign failure".to_string(),
2684                    created_at: 1_700_000_000,
2685                    failed_at: 1_700_000_100,
2686                },
2687            )
2688            .await
2689            .expect("mark failed");
2690
2691        let retry_options = onchain_options_for_quote(quote_id.clone(), 30_000);
2692        let response = backend
2693            .make_payment(&CurrencyUnit::Sat, retry_options)
2694            .await
2695            .expect("retry with same quote id should requeue failed intent");
2696
2697        assert_eq!(response.status, MeltQuoteState::Pending);
2698
2699        let retried = backend
2700            .storage
2701            .get_send_intent_by_quote_id(&quote_id.to_string())
2702            .await
2703            .expect("lookup retried intent")
2704            .expect("retried intent exists");
2705        assert_eq!(retried.intent_id, initial.intent_id);
2706        assert!(matches!(
2707            retried.state,
2708            crate::send::payment_intent::record::SendIntentState::Pending { .. }
2709        ));
2710    }
2711
2712    #[tokio::test]
2713    async fn test_check_outgoing_payment_unknown_quote_reports_zero() {
2714        // A quote id with no active intent and no finalized tombstone must
2715        // return MeltQuoteState::Unknown with total_spent = 0 (existing
2716        // behaviour; pinned here for defence-in-depth).
2717        let backend = build_test_instance(5).await;
2718        let quote_id = QuoteId::UUID(Uuid::new_v4());
2719        let payment_identifier = PaymentIdentifier::QuoteId(quote_id);
2720
2721        let response = backend
2722            .check_outgoing_payment(&payment_identifier)
2723            .await
2724            .expect("check_outgoing_payment for unknown quote");
2725
2726        assert_eq!(response.status, MeltQuoteState::Unknown);
2727        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
2728        assert_eq!(response.payment_proof, None);
2729    }
2730
2731    // ------------------------------------------------------------------
2732    // Chain-sync resilience tests
2733    // ------------------------------------------------------------------
2734
2735    #[test]
2736    fn test_is_transient_classifies_network_errors() {
2737        // Esplora errors are always classified as transient: the sync
2738        // loop should retry them on the next tick, and this classification
2739        // drives the log severity in the supervisor.
2740        let esplora_err = Error::Esplora(
2741            "HttpResponse { status: 525, message: \"error code: 525\" }".to_string(),
2742        );
2743        assert!(esplora_err.is_transient());
2744
2745        let esplora_404 = Error::Esplora(
2746            "HttpResponse { status: 404, message: \"Block not found\" }".to_string(),
2747        );
2748        assert!(esplora_404.is_transient());
2749
2750        // Local wallet/state errors are not transient: they indicate a
2751        // real defect that retrying will not resolve.
2752        let wallet_err = Error::Wallet("invalid checkpoint".to_string());
2753        assert!(!wallet_err.is_transient());
2754
2755        let vout_err = Error::VoutNotFound;
2756        assert!(!vout_err.is_transient());
2757
2758        // Timed-out I/O is transient.
2759        let io_err = Error::Io(std::io::Error::new(
2760            std::io::ErrorKind::TimedOut,
2761            "network timeout",
2762        ));
2763        assert!(io_err.is_transient());
2764
2765        // An arbitrary I/O error kind is not.
2766        let io_other = Error::Io(std::io::Error::new(
2767            std::io::ErrorKind::InvalidData,
2768            "bad data",
2769        ));
2770        assert!(!io_other.is_transient());
2771    }
2772
2773    #[tokio::test]
2774    async fn test_supervisor_restarts_failing_task_with_backoff() {
2775        // The supervisor must keep calling the supplied future as long
2776        // as it returns Err, until the cancel token is triggered.
2777        let cancel = CancellationToken::new();
2778        let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
2779
2780        let counter_clone = Arc::clone(&counter);
2781        let cancel_inner = cancel.clone();
2782        let supervisor = tokio::spawn(async move {
2783            super::supervise("test", cancel_inner, move |_c| {
2784                let c = Arc::clone(&counter_clone);
2785                async move {
2786                    c.fetch_add(1, Ordering::Relaxed);
2787                    Err::<(), Error>(Error::Esplora("boom".to_string()))
2788                }
2789            })
2790            .await;
2791        });
2792
2793        // Let a few restart cycles happen (initial backoff is 1s).
2794        tokio::time::sleep(Duration::from_millis(2_500)).await;
2795        cancel.cancel();
2796
2797        tokio::time::timeout(Duration::from_secs(5), supervisor)
2798            .await
2799            .expect("supervisor did not exit after cancel")
2800            .expect("supervisor task panicked");
2801
2802        let n = counter.load(Ordering::Relaxed);
2803        assert!(
2804            n >= 2,
2805            "supervisor should have restarted the task at least twice, got {n}"
2806        );
2807    }
2808
2809    #[tokio::test]
2810    async fn test_supervisor_exits_on_ok() {
2811        // Ok(()) from the task is treated as clean shutdown; the
2812        // supervisor exits immediately without restart.
2813        let cancel = CancellationToken::new();
2814        let counter = Arc::new(std::sync::atomic::AtomicU32::new(0));
2815
2816        let counter_clone = Arc::clone(&counter);
2817        let cancel_inner = cancel.clone();
2818        let supervisor = tokio::spawn(async move {
2819            super::supervise("test", cancel_inner, move |_c| {
2820                let c = Arc::clone(&counter_clone);
2821                async move {
2822                    c.fetch_add(1, Ordering::Relaxed);
2823                    Ok::<(), Error>(())
2824                }
2825            })
2826            .await;
2827        });
2828
2829        tokio::time::timeout(Duration::from_secs(5), supervisor)
2830            .await
2831            .expect("supervisor did not exit after Ok(())")
2832            .expect("supervisor task panicked");
2833
2834        assert_eq!(
2835            counter.load(Ordering::Relaxed),
2836            1,
2837            "supervisor must not restart a task that returned Ok(())"
2838        );
2839    }
2840
2841    #[tokio::test]
2842    async fn test_supervisor_cancel_during_backoff() {
2843        // Cancelling during the backoff sleep must exit promptly rather
2844        // than waiting for the sleep to expire.
2845        let cancel = CancellationToken::new();
2846        let cancel_inner = cancel.clone();
2847        let supervisor = tokio::spawn(async move {
2848            super::supervise("test", cancel_inner, move |_c| async move {
2849                // Fail immediately so we enter the backoff sleep.
2850                Err::<(), Error>(Error::Esplora("boom".to_string()))
2851            })
2852            .await;
2853        });
2854
2855        // Give the supervisor a moment to enter its first backoff.
2856        tokio::time::sleep(Duration::from_millis(200)).await;
2857        let cancel_at = std::time::Instant::now();
2858        cancel.cancel();
2859
2860        tokio::time::timeout(Duration::from_secs(2), supervisor)
2861            .await
2862            .expect("supervisor did not exit promptly after cancel")
2863            .expect("supervisor task panicked");
2864
2865        let elapsed = cancel_at.elapsed();
2866        assert!(
2867            elapsed < Duration::from_millis(500),
2868            "supervisor took {elapsed:?} to exit after cancel; expected < 500ms"
2869        );
2870    }
2871
2872    #[tokio::test]
2873    async fn test_sync_wallet_survives_unreachable_esplora() {
2874        // sync_wallet must not return Err when the Esplora endpoint is
2875        // unreachable — it should warn and continue. We prove this by
2876        // starting the backend (which spawns the sync task against a
2877        // bogus URL) and letting it run for long enough to tick at least
2878        // twice, then stop cleanly.
2879        let backend = build_test_instance(5).await;
2880        backend.start().await.expect("start");
2881
2882        // Sync interval is 60s per build_test_instance, so this test
2883        // only verifies the first synchronous tick path: the task must
2884        // stay alive and the supervisor must not log a "task failed"
2885        // line for a transient network error.
2886        tokio::time::sleep(Duration::from_millis(500)).await;
2887
2888        // The sync JoinHandle must still be running, not completed.
2889        {
2890            let tasks = backend.tasks.lock().await;
2891            let bg = tasks.as_ref().expect("tasks running");
2892            assert!(
2893                !bg.sync.is_finished(),
2894                "sync task must not exit on transient Esplora errors"
2895            );
2896        }
2897
2898        backend.stop().await.expect("stop");
2899    }
2900
2901    #[cfg(feature = "electrum")]
2902    #[tokio::test]
2903    async fn test_sync_wallet_survives_unreachable_electrum() {
2904        let chain_source = ChainSource::Electrum(ElectrumConfig {
2905            url: "tcp://127.0.0.1:1".to_string(),
2906            batch_size: 5,
2907        });
2908        let (backend, _tmp) = build_test_instance_with_chain_source(5, None, 60, chain_source)
2909            .await
2910            .expect("build Electrum test instance");
2911
2912        backend.start().await.expect("start");
2913        tokio::time::sleep(Duration::from_millis(500)).await;
2914
2915        {
2916            let tasks = backend.tasks.lock().await;
2917            let background = tasks.as_ref().expect("tasks running");
2918            assert!(
2919                !background.sync.is_finished(),
2920                "sync task must not exit on transient Electrum errors"
2921            );
2922        }
2923
2924        backend.stop().await.expect("stop");
2925    }
2926}