Skip to main content

cdk_ldk_node/
lib.rs

1//! CDK lightning backend for ldk-node
2
3#![doc = include_str!("../README.md")]
4
5use std::fmt;
6use std::net::SocketAddr;
7use std::pin::Pin;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11use async_trait::async_trait;
12use bip39::Mnemonic;
13use cdk_common::common::FeeReserve;
14use cdk_common::database::DynKVStore;
15use cdk_common::payment::{self, *};
16use cdk_common::redact::url_for_logs;
17use cdk_common::util::{hex, unix_time};
18use cdk_common::{Amount, CurrencyUnit, MeltOptions, MeltQuoteState, QuoteId};
19use futures::{Stream, StreamExt};
20use ldk_node::bitcoin::hashes::Hash;
21use ldk_node::bitcoin::Network;
22use ldk_node::lightning::ln::channelmanager::PaymentId;
23use ldk_node::lightning::ln::msgs::SocketAddress;
24use ldk_node::lightning::routing::router::RouteParametersConfig;
25use ldk_node::lightning_invoice::{Bolt11InvoiceDescription, Description};
26use ldk_node::lightning_types::payment::PaymentHash;
27use ldk_node::logger::{LogLevel, LogWriter};
28use ldk_node::payment::{PaymentDetails, PaymentDirection, PaymentKind, PaymentStatus};
29use ldk_node::{Builder, Event, Node};
30use tokio_stream::wrappers::BroadcastStream;
31use tokio_util::sync::CancellationToken;
32use tracing::instrument;
33
34use crate::error::Error;
35use crate::log::StdoutLogWriter;
36
37mod error;
38mod log;
39mod web;
40
41/// Primary KV namespace for the ldk-node backend's durable bookkeeping
42const LDK_KV_PRIMARY_NAMESPACE: &str = "cdk_ldk_node_lightning_backend";
43/// Secondary KV namespace holding the bolt12 melt quote id -> payment id
44/// mapping used to resolve `PaymentIdentifier::QuoteId` lookups
45const LDK_KV_BOLT12_OUTGOING_SECONDARY_NAMESPACE: &str = "bolt12_outgoing_payments";
46
47/// Result of looking up the payment id recorded for a bolt12 melt quote
48#[derive(Debug, Clone, Copy, PartialEq, Eq)]
49enum Bolt12QuotePaymentIdLookup {
50    /// A payment id was recorded: the payment was dispatched and is tracked
51    Found(PaymentId),
52    /// The dispatch sentinel is present: `send` was started but no payment id
53    /// was recorded (crash during dispatch, or dispatch errored before the
54    /// sentinel could be cleaned up). The payment state is indeterminate.
55    Dispatching,
56    /// No record exists: the payment was never dispatched
57    Missing,
58    /// A record exists but cannot be parsed
59    Malformed,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63enum Bolt12QuotePaymentIdResolution {
64    PaymentId(PaymentId),
65    Status(MeltQuoteState),
66}
67
68impl Bolt12QuotePaymentIdLookup {
69    fn resolve(self) -> Bolt12QuotePaymentIdResolution {
70        match self {
71            Self::Found(payment_id) => Bolt12QuotePaymentIdResolution::PaymentId(payment_id),
72            // Dispatch was attempted but no payment id was recorded. Pending
73            // prevents the live melt saga from compensating an indeterminate
74            // payment after a dispatch-ambiguous error.
75            Self::Dispatching => Bolt12QuotePaymentIdResolution::Status(MeltQuoteState::Pending),
76            // Without the pre-dispatch sentinel, the payment was never sent.
77            Self::Missing => Bolt12QuotePaymentIdResolution::Status(MeltQuoteState::Unpaid),
78            // Corrupt bookkeeping cannot establish any payment state.
79            Self::Malformed => Bolt12QuotePaymentIdResolution::Status(MeltQuoteState::Unknown),
80        }
81    }
82}
83
84/// Whether an LDK BOLT12 send error can occur after dispatch was accepted.
85///
86/// In `ldk-node` 0.7, [`ldk_node::NodeError::PersistenceFailed`] can be returned
87/// while persisting the payment record after `ChannelManager::pay_for_offer`
88/// accepted the payment. All other errors returned by the BOLT12 send methods
89/// occur before dispatch or after `pay_for_offer` rejected the attempt.
90fn bolt12_send_error_has_ambiguous_dispatch(err: &ldk_node::NodeError) -> bool {
91    matches!(err, ldk_node::NodeError::PersistenceFailed)
92}
93
94/// CDK Lightning backend using LDK Node
95///
96/// Provides Lightning Network functionality for CDK with support for Cashu operations.
97/// Handles payment creation, processing, and event management using the Lightning Development Kit.
98#[derive(Clone)]
99pub struct CdkLdkNode {
100    inner: Arc<Node>,
101    fee_reserve: FeeReserve,
102    kv_store: DynKVStore,
103    wait_invoice_cancel_token: CancellationToken,
104    wait_invoice_is_active: Arc<AtomicBool>,
105    sender: tokio::sync::broadcast::Sender<WaitPaymentResponse>,
106    receiver: Arc<tokio::sync::broadcast::Receiver<WaitPaymentResponse>>,
107    events_cancel_token: CancellationToken,
108    web_addr: Option<SocketAddr>,
109}
110
111impl fmt::Debug for CdkLdkNode {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        f.debug_struct("CdkLdkNode")
114            .field("fee_reserve", &self.fee_reserve)
115            .field("web_addr", &self.web_addr)
116            .finish_non_exhaustive()
117    }
118}
119
120/// Configuration for connecting to Bitcoin RPC
121///
122/// Contains the necessary connection parameters for Bitcoin Core RPC interface.
123#[derive(Clone)]
124pub struct BitcoinRpcConfig {
125    /// Bitcoin RPC server hostname or IP address
126    pub host: String,
127    /// Bitcoin RPC server port number
128    pub port: u16,
129    /// Username for Bitcoin RPC authentication
130    pub user: String,
131    /// Password for Bitcoin RPC authentication
132    pub password: String,
133}
134
135impl fmt::Debug for BitcoinRpcConfig {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        f.debug_struct("BitcoinRpcConfig")
138            .field("host", &self.host)
139            .field("port", &self.port)
140            .field("user", &self.user)
141            .field("password", &"[REDACTED]")
142            .finish()
143    }
144}
145
146/// Source of blockchain data for the Lightning node
147///
148/// Specifies how the node should connect to the Bitcoin network to retrieve
149/// blockchain information and broadcast transactions.
150#[derive(Clone)]
151pub enum ChainSource {
152    /// Use an Esplora server for blockchain data
153    ///
154    /// Contains the URL of the Esplora server endpoint
155    Esplora(String),
156    /// Use an Electrum server for blockchain data
157    ///
158    /// Contains the URL of the Electrum server endpoint
159    Electrum(String),
160    /// Use Bitcoin Core RPC for blockchain data
161    ///
162    /// Contains the configuration for connecting to Bitcoin Core
163    BitcoinRpc(BitcoinRpcConfig),
164}
165
166impl fmt::Debug for ChainSource {
167    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
168        match self {
169            Self::Esplora(url) => f.debug_tuple("Esplora").field(&url_for_logs(url)).finish(),
170            Self::Electrum(url) => f.debug_tuple("Electrum").field(&url_for_logs(url)).finish(),
171            Self::BitcoinRpc(config) => f.debug_tuple("BitcoinRpc").field(config).finish(),
172        }
173    }
174}
175
176/// Source of Lightning network gossip data
177///
178/// Specifies how the node should learn about the Lightning Network topology
179/// and routing information.
180#[derive(Clone)]
181pub enum GossipSource {
182    /// Learn gossip through peer-to-peer connections
183    ///
184    /// The node will connect to other Lightning nodes and exchange gossip data directly
185    P2P,
186    /// Use Rapid Gossip Sync for efficient gossip updates
187    ///
188    /// Contains the URL of the RGS server for compressed gossip data
189    RapidGossipSync(String),
190}
191
192impl fmt::Debug for GossipSource {
193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
194        match self {
195            Self::P2P => f.write_str("P2P"),
196            Self::RapidGossipSync(url) => f
197                .debug_tuple("RapidGossipSync")
198                .field(&url_for_logs(url))
199                .finish(),
200        }
201    }
202}
203/// A builder for an [`CdkLdkNode`] instance.
204pub struct CdkLdkNodeBuilder {
205    network: Network,
206    chain_source: ChainSource,
207    gossip_source: GossipSource,
208    log_dir_path: Option<String>,
209    storage_dir_path: String,
210    fee_reserve: FeeReserve,
211    kv_store: DynKVStore,
212    listening_addresses: Vec<SocketAddress>,
213    seed: Option<Mnemonic>,
214    announcement_addresses: Option<Vec<SocketAddress>>,
215}
216
217impl std::fmt::Debug for CdkLdkNodeBuilder {
218    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219        f.debug_struct("CdkLdkNodeBuilder")
220            .field("network", &self.network)
221            .field("chain_source", &self.chain_source)
222            .field("gossip_source", &self.gossip_source)
223            .field("log_dir_path", &self.log_dir_path)
224            .field("storage_dir_path", &self.storage_dir_path)
225            .field("fee_reserve", &self.fee_reserve)
226            .field("listening_addresses", &self.listening_addresses)
227            .field("announcement_addresses", &self.announcement_addresses)
228            .finish_non_exhaustive()
229    }
230}
231
232impl CdkLdkNodeBuilder {
233    /// Creates a new builder instance.
234    pub fn new(
235        network: Network,
236        chain_source: ChainSource,
237        gossip_source: GossipSource,
238        storage_dir_path: String,
239        fee_reserve: FeeReserve,
240        listening_addresses: Vec<SocketAddress>,
241        kv_store: DynKVStore,
242    ) -> Self {
243        Self {
244            network,
245            chain_source,
246            gossip_source,
247            storage_dir_path,
248            fee_reserve,
249            kv_store,
250            listening_addresses,
251            seed: None,
252            announcement_addresses: None,
253            log_dir_path: None,
254        }
255    }
256
257    /// Configures the [`CdkLdkNode`] to use the Mnemonic for entropy source configuration
258    pub fn with_seed(mut self, seed: Mnemonic) -> Self {
259        self.seed = Some(seed);
260        self
261    }
262    /// Configures the [`CdkLdkNode`] to use announce this address to the lightning network
263    pub fn with_announcement_address(mut self, announcement_addresses: Vec<SocketAddress>) -> Self {
264        self.announcement_addresses = Some(announcement_addresses);
265        self
266    }
267    /// Configures the [`CdkLdkNode`] to use announce this address to the lightning network
268    pub fn with_log_dir_path(mut self, log_dir_path: String) -> Self {
269        self.log_dir_path = Some(log_dir_path);
270        self
271    }
272
273    /// Builds the [`CdkLdkNode`] instance
274    ///
275    /// # Errors
276    /// Returns an error if the LDK node builder fails to create the node
277    pub fn build(self) -> Result<CdkLdkNode, Error> {
278        let mut ldk = Builder::new();
279        ldk.set_network(self.network);
280        tracing::info!("Storage dir of node is {}", self.storage_dir_path);
281        ldk.set_storage_dir_path(self.storage_dir_path);
282
283        match self.chain_source {
284            ChainSource::Esplora(esplora_url) => {
285                ldk.set_chain_source_esplora(esplora_url, None);
286            }
287            ChainSource::Electrum(electrum_url) => {
288                ldk.set_chain_source_electrum(electrum_url, None);
289            }
290            ChainSource::BitcoinRpc(BitcoinRpcConfig {
291                host,
292                port,
293                user,
294                password,
295            }) => {
296                ldk.set_chain_source_bitcoind_rpc(host, port, user, password);
297            }
298        }
299
300        match self.gossip_source {
301            GossipSource::P2P => {
302                ldk.set_gossip_source_p2p();
303            }
304            GossipSource::RapidGossipSync(rgs_url) => {
305                ldk.set_gossip_source_rgs(rgs_url);
306            }
307        }
308
309        ldk.set_listening_addresses(self.listening_addresses)?;
310        if self.log_dir_path.is_some() {
311            ldk.set_filesystem_logger(self.log_dir_path, Some(LogLevel::Info));
312        } else {
313            ldk.set_custom_logger(Arc::new(StdoutLogWriter));
314        }
315
316        ldk.set_node_alias("cdk-ldk-node".to_string())?;
317        // set the seed as bip39 entropy mnemonic
318        if let Some(seed) = self.seed {
319            ldk.set_entropy_bip39_mnemonic(seed, None);
320        }
321        // set the announcement addresses
322        if let Some(announcement_addresses) = self.announcement_addresses {
323            ldk.set_announcement_addresses(announcement_addresses)?;
324        }
325
326        let node = ldk.build()?;
327
328        tracing::info!("Creating tokio channel for payment notifications");
329        let (sender, receiver) = tokio::sync::broadcast::channel(8);
330
331        let id = node.node_id();
332
333        let adr = node.announcement_addresses();
334
335        tracing::info!(
336            "Created node {} with address {:?} on network {}",
337            id,
338            adr,
339            self.network
340        );
341
342        Ok(CdkLdkNode {
343            inner: node.into(),
344            fee_reserve: self.fee_reserve,
345            kv_store: self.kv_store,
346            wait_invoice_cancel_token: CancellationToken::new(),
347            wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
348            sender,
349            receiver: Arc::new(receiver),
350            events_cancel_token: CancellationToken::new(),
351            web_addr: None,
352        })
353    }
354}
355
356impl CdkLdkNode {
357    /// Set the web server address for the LDK node management interface
358    ///
359    /// # Arguments
360    /// * `addr` - Socket address for the web server. If None, no web server will be started.
361    pub fn set_web_addr(&mut self, addr: Option<SocketAddr>) {
362        self.web_addr = addr;
363    }
364
365    /// Get a default web server address using an unused port
366    ///
367    /// Returns a SocketAddr with localhost and port 0, which will cause
368    /// the system to automatically assign an available port
369    pub fn default_web_addr() -> SocketAddr {
370        SocketAddr::from(([127, 0, 0, 1], 8091))
371    }
372
373    /// Best-effort removal of the pre-dispatch sentinel after a send that did
374    /// not dispatch. If removal fails the sentinel remains and the payment
375    /// resolves as `Pending`, keeping the melt proofs reserved.
376    async fn cleanup_bolt12_dispatch_sentinel(&self, quote_id: &QuoteId) {
377        if let Err(err) = delete_bolt12_quote_payment_id(&self.kv_store, quote_id).await {
378            tracing::warn!(
379                "Could not remove BOLT12 dispatch sentinel for quote {quote_id}: {err}. \
380                 The payment will remain Pending."
381            );
382        }
383    }
384
385    fn make_payment_response_from_details(
386        unit: &CurrencyUnit,
387        payment_lookup_id: PaymentIdentifier,
388        payment_details: &PaymentDetails,
389    ) -> Result<MakePaymentResponse, payment::Error> {
390        let status = match payment_details.status {
391            PaymentStatus::Pending => MeltQuoteState::Pending,
392            PaymentStatus::Succeeded => MeltQuoteState::Paid,
393            PaymentStatus::Failed => MeltQuoteState::Failed,
394        };
395
396        let payment_proof = match &payment_details.kind {
397            PaymentKind::Bolt11 { preimage, .. } => preimage.map(|p| p.to_string()),
398            PaymentKind::Bolt12Offer { preimage, .. } => preimage.map(|p| p.to_string()),
399            _ => return Err(Error::UnexpectedPaymentKind.into()),
400        };
401
402        let total_spent = if status == MeltQuoteState::Paid {
403            let total_spent = payment_details
404                .amount_msat
405                .ok_or(Error::CouldNotGetAmountSpent)?
406                + payment_details.fee_paid_msat.unwrap_or_default();
407            Amount::new(total_spent, CurrencyUnit::Msat).convert_to(unit)?
408        } else {
409            Amount::new(0, unit.clone())
410        };
411
412        Ok(MakePaymentResponse {
413            payment_lookup_id,
414            payment_proof,
415            status,
416            total_spent,
417        })
418    }
419
420    fn select_bolt11_payment_details(
421        payment_details: impl IntoIterator<Item = PaymentDetails>,
422    ) -> Option<PaymentDetails> {
423        payment_details.into_iter().min_by_key(|details| {
424            let status_order = match details.status {
425                PaymentStatus::Succeeded => 0_u8,
426                PaymentStatus::Pending => 1,
427                PaymentStatus::Failed => 2,
428            };
429
430            (
431                status_order,
432                std::cmp::Reverse(details.latest_update_timestamp),
433            )
434        })
435    }
436
437    /// Start the CDK LDK Node
438    ///
439    /// Starts the underlying LDK node and begins event processing.
440    /// Sets up event handlers to listen for Lightning events like payment received.
441    ///
442    /// # Returns
443    /// Returns `Ok(())` on successful start, error otherwise
444    ///
445    /// # Errors
446    /// Returns an error if the LDK node fails to start or event handling setup fails
447    pub fn start_ldk_node(&self) -> Result<(), Error> {
448        tracing::info!("Starting cdk-ldk node");
449        self.inner.start()?;
450        let node_config = self.inner.config();
451
452        tracing::info!("Starting node with network {}", node_config.network);
453
454        tracing::info!("Node status: {:?}", self.inner.status());
455
456        self.handle_events()?;
457
458        Ok(())
459    }
460
461    /// Start the web server for the LDK node management interface
462    ///
463    /// Starts a web server that provides a user interface for managing the LDK node.
464    /// The web interface allows users to view balances, manage channels, create invoices,
465    /// and send payments.
466    ///
467    /// # Arguments
468    /// * `web_addr` - The socket address to bind the web server to
469    ///
470    /// # Returns
471    /// Returns `Ok(())` on successful start, error otherwise
472    ///
473    /// # Errors
474    /// Returns an error if the web server fails to start
475    pub fn start_web_server(&self, web_addr: SocketAddr) -> Result<(), Error> {
476        let web_server = crate::web::WebServer::new(Arc::new(self.clone()));
477
478        tokio::spawn(async move {
479            if let Err(e) = web_server.serve(web_addr).await {
480                tracing::error!("Web server error: {}", e);
481            }
482        });
483
484        Ok(())
485    }
486
487    /// Stop the CDK LDK Node
488    ///
489    /// Gracefully stops the node by cancelling all active tasks and event handlers.
490    /// This includes:
491    /// - Cancelling the event handler task
492    /// - Cancelling any active wait_invoice streams
493    /// - Stopping the underlying LDK node
494    ///
495    /// # Returns
496    /// Returns `Ok(())` on successful shutdown, error otherwise
497    ///
498    /// # Errors
499    /// Returns an error if the underlying LDK node fails to stop
500    pub fn stop_ldk_node(&self) -> Result<(), Error> {
501        tracing::info!("Stopping CdkLdkNode");
502        // Cancel all tokio tasks
503        tracing::info!("Cancelling event handler");
504        self.events_cancel_token.cancel();
505
506        // Cancel any payment event streams
507        if self.is_payment_event_stream_active() {
508            tracing::info!("Cancelling payment event stream");
509            self.wait_invoice_cancel_token.cancel();
510        }
511
512        // Stop the LDK node
513        tracing::info!("Stopping LDK node");
514        self.inner.stop()?;
515        tracing::info!("CdkLdkNode stopped successfully");
516        Ok(())
517    }
518
519    /// Handle payment received event
520    async fn handle_payment_received(
521        node: &Arc<Node>,
522        sender: &tokio::sync::broadcast::Sender<WaitPaymentResponse>,
523        payment_id: Option<PaymentId>,
524        payment_hash: PaymentHash,
525        amount_msat: u64,
526    ) {
527        tracing::info!(
528            "Received payment for hash={} of amount={} msat",
529            payment_hash,
530            amount_msat
531        );
532
533        let payment_id = match payment_id {
534            Some(id) => id,
535            None => {
536                tracing::warn!("Received payment without payment_id");
537                return;
538            }
539        };
540
541        let payment_id_hex = hex::encode(payment_id.0);
542
543        if amount_msat == 0 {
544            tracing::warn!("Payment of no amount");
545            return;
546        }
547
548        tracing::info!(
549            "Processing payment notification: id={}, amount={} msats",
550            payment_id_hex,
551            amount_msat
552        );
553
554        let payment_details = match node.payment(&payment_id) {
555            Some(details) => details,
556            None => {
557                tracing::error!("Could not find payment details for id={}", payment_id_hex);
558                return;
559            }
560        };
561
562        let (payment_identifier, payment_id) = match payment_details.kind {
563            PaymentKind::Bolt11 { hash, .. } => {
564                (PaymentIdentifier::PaymentHash(hash.0), hash.to_string())
565            }
566            PaymentKind::Bolt12Offer { hash, offer_id, .. } => match hash {
567                Some(h) => (
568                    PaymentIdentifier::OfferId(offer_id.to_string()),
569                    h.to_string(),
570                ),
571                None => {
572                    tracing::error!("Bolt12 payment missing hash");
573                    return;
574                }
575            },
576            k => {
577                tracing::warn!("Received payment of kind {:?} which is not supported", k);
578                return;
579            }
580        };
581
582        let wait_payment_response = WaitPaymentResponse {
583            payment_identifier,
584            payment_amount: Amount::new(amount_msat, CurrencyUnit::Msat),
585            payment_id,
586        };
587
588        match sender.send(wait_payment_response) {
589            Ok(_) => tracing::info!("Successfully sent payment notification to stream"),
590            Err(err) => tracing::error!(
591                "Could not send payment received notification on channel: {}",
592                err
593            ),
594        }
595    }
596
597    /// Set up event handling for the node
598    pub fn handle_events(&self) -> Result<(), Error> {
599        let node = self.inner.clone();
600        let sender = self.sender.clone();
601        let cancel_token = self.events_cancel_token.clone();
602
603        tracing::info!("Starting event handler task");
604
605        tokio::spawn(async move {
606            tracing::info!("Event handler loop started");
607            loop {
608                tokio::select! {
609                    _ = cancel_token.cancelled() => {
610                        tracing::info!("Event handler cancelled");
611                        break;
612                    }
613                    event = node.next_event_async() => {
614                        match event {
615                            Event::PaymentReceived {
616                                payment_id,
617                                payment_hash,
618                                amount_msat,
619                                custom_records: _
620                            } => {
621                                Self::handle_payment_received(
622                                    &node,
623                                    &sender,
624                                    payment_id,
625                                    payment_hash,
626                                    amount_msat
627                                ).await;
628                            }
629                            Event::PaymentFailed {
630                                payment_id,
631                                payment_hash,
632                                reason,
633                            } => {
634                                tracing::error!(
635                                    payment_id = ?payment_id,
636                                    payment_hash = ?payment_hash,
637                                    reason = ?reason,
638                                    "LDK node payment failed"
639                                );
640                            }
641                            event => {
642                                tracing::debug!("Received other ldk node event: {:?}", event);
643                            }
644                        }
645
646                        if let Err(err) = node.event_handled() {
647                            tracing::error!("Error handling node event: {}", err);
648                        } else {
649                            tracing::debug!("Successfully handled node event");
650                        }
651                    }
652                }
653            }
654            tracing::info!("Event handler loop terminated");
655        });
656
657        tracing::info!("Event handler task spawned");
658        Ok(())
659    }
660
661    /// Get Node used
662    pub fn node(&self) -> Arc<Node> {
663        Arc::clone(&self.inner)
664    }
665}
666
667/// Mint payment trait
668#[async_trait]
669impl MintPayment for CdkLdkNode {
670    type Err = payment::Error;
671
672    /// Start the payment processor
673    /// Starts the LDK node and begins event processing
674    async fn start(&self) -> Result<(), Self::Err> {
675        self.start_ldk_node().map_err(|e| {
676            tracing::error!("Failed to start CdkLdkNode: {}", e);
677            e
678        })?;
679
680        tracing::info!("CdkLdkNode payment processor started successfully");
681
682        // Start web server if configured
683        if let Some(web_addr) = self.web_addr {
684            tracing::info!("Starting LDK Node web interface on {}", web_addr);
685            self.start_web_server(web_addr).map_err(|e| {
686                tracing::error!("Failed to start web server: {}", e);
687                e
688            })?;
689        } else {
690            tracing::info!("No web server address configured, skipping web interface");
691        }
692
693        Ok(())
694    }
695
696    /// Stop the payment processor
697    /// Gracefully stops the LDK node and cancels all background tasks
698    async fn stop(&self) -> Result<(), Self::Err> {
699        self.stop_ldk_node().map_err(|e| {
700            tracing::error!("Failed to stop CdkLdkNode: {}", e);
701            e.into()
702        })
703    }
704
705    /// Base Settings
706    async fn get_settings(&self) -> Result<SettingsResponse, Self::Err> {
707        let settings = SettingsResponse {
708            unit: CurrencyUnit::Msat.to_string(),
709            bolt11: Some(payment::Bolt11Settings {
710                mpp: false,
711                amountless: true,
712                invoice_description: true,
713            }),
714            bolt12: Some(payment::Bolt12Settings { amountless: true }),
715            onchain: None,
716            custom: std::collections::HashMap::new(),
717        };
718        Ok(settings)
719    }
720
721    /// Create a new invoice
722    #[instrument(skip(self))]
723    async fn create_incoming_payment_request(
724        &self,
725        options: IncomingPaymentOptions,
726    ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
727        match options {
728            IncomingPaymentOptions::Bolt11(bolt11_options) => {
729                let amount_msat: Amount = bolt11_options
730                    .amount
731                    .convert_to(&CurrencyUnit::Msat)?
732                    .into();
733                let description = bolt11_options.description.unwrap_or_default();
734                let time = match bolt11_options.unix_expiry {
735                    Some(t) => t
736                        .checked_sub(unix_time())
737                        .ok_or(payment::Error::InvalidExpiry)?,
738                    None => 36000,
739                };
740
741                let description = Bolt11InvoiceDescription::Direct(
742                    Description::new(description).map_err(|_| Error::InvalidDescription)?,
743                );
744
745                let payment = self
746                    .inner
747                    .bolt11_payment()
748                    .receive(amount_msat.into(), &description, time as u32)
749                    .map_err(Error::LdkNode)?;
750
751                let payment_hash = payment.payment_hash().to_string();
752                let payment_identifier = PaymentIdentifier::PaymentHash(
753                    hex::decode(&payment_hash)?
754                        .try_into()
755                        .map_err(|_| Error::InvalidPaymentHashLength)?,
756                );
757
758                Ok(CreateIncomingPaymentResponse {
759                    request_lookup_id: payment_identifier,
760                    request: payment.to_string(),
761                    expiry: Some(unix_time() + time),
762                    extra_json: None,
763                })
764            }
765            IncomingPaymentOptions::Bolt12(bolt12_options) => {
766                let Bolt12IncomingPaymentOptions {
767                    description,
768                    amount,
769                    unix_expiry,
770                } = *bolt12_options;
771
772                let time = unix_expiry
773                    .map(|t| {
774                        t.checked_sub(unix_time())
775                            .ok_or(payment::Error::InvalidExpiry)
776                            .map(|t| t as u32)
777                    })
778                    .transpose()?;
779
780                let offer = match amount {
781                    Some(amount) => {
782                        let amount_msat: Amount = amount.convert_to(&CurrencyUnit::Msat)?.into();
783
784                        self.inner
785                            .bolt12_payment()
786                            .receive(
787                                amount_msat.into(),
788                                &description.unwrap_or("".to_string()),
789                                time,
790                                None,
791                            )
792                            .map_err(Error::LdkNode)?
793                    }
794                    None => self
795                        .inner
796                        .bolt12_payment()
797                        .receive_variable_amount(&description.unwrap_or("".to_string()), time)
798                        .map_err(Error::LdkNode)?,
799                };
800                let payment_identifier = PaymentIdentifier::OfferId(offer.id().to_string());
801
802                Ok(CreateIncomingPaymentResponse {
803                    request_lookup_id: payment_identifier,
804                    request: offer.to_string(),
805                    expiry: unix_expiry,
806                    extra_json: None,
807                })
808            }
809            IncomingPaymentOptions::Custom(_) | IncomingPaymentOptions::Onchain(_) => {
810                Err(cdk_common::payment::Error::UnsupportedPaymentOption)
811            }
812        }
813    }
814
815    /// Get payment quote
816    /// Used to get fee and amount required for a payment request
817    #[instrument(skip_all)]
818    async fn get_payment_quote(
819        &self,
820        unit: &CurrencyUnit,
821        options: OutgoingPaymentOptions,
822    ) -> Result<PaymentQuoteResponse, Self::Err> {
823        match options {
824            cdk_common::payment::OutgoingPaymentOptions::Custom(_) => {
825                Err(cdk_common::payment::Error::UnsupportedPaymentOption)
826            }
827            OutgoingPaymentOptions::Bolt11(bolt11_options) => {
828                let bolt11 = bolt11_options.bolt11;
829
830                let amount_msat = match bolt11_options.melt_options {
831                    Some(MeltOptions::Amountless { amountless }) => {
832                        let amount_msat = amountless.amount_msat;
833
834                        if let Some(invoice_amount) = bolt11.amount_milli_satoshis() {
835                            if invoice_amount != u64::from(amount_msat) {
836                                return Err(payment::Error::AmountMismatch);
837                            }
838                        }
839
840                        amount_msat
841                    }
842                    Some(MeltOptions::Mpp { mpp }) => mpp.amount,
843                    None => bolt11
844                        .amount_milli_satoshis()
845                        .ok_or(Error::UnknownInvoiceAmount)?
846                        .into(),
847                };
848
849                let amount =
850                    Amount::new(amount_msat.into(), CurrencyUnit::Msat).convert_to(unit)?;
851
852                let relative_fee_reserve =
853                    (self.fee_reserve.percent_fee_reserve * amount.value() as f32) as u64;
854
855                let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into();
856
857                let fee = match relative_fee_reserve > absolute_fee_reserve {
858                    true => relative_fee_reserve,
859                    false => absolute_fee_reserve,
860                };
861
862                let payment_hash = bolt11.payment_hash().to_string();
863                let payment_hash_bytes = hex::decode(&payment_hash)?
864                    .try_into()
865                    .map_err(|_| Error::InvalidPaymentHashLength)?;
866
867                Ok(PaymentQuoteResponse {
868                    request_lookup_id: Some(PaymentIdentifier::PaymentHash(payment_hash_bytes)),
869                    amount,
870                    fee: Amount::new(fee, unit.clone()),
871                    state: MeltQuoteState::Unpaid,
872                    extra_json: None,
873                    estimated_blocks: None,
874                    fee_options: None,
875                })
876            }
877            OutgoingPaymentOptions::Bolt12(bolt12_options) => {
878                let offer = bolt12_options.offer;
879
880                let amount_msat = match bolt12_options.melt_options {
881                    Some(melt_options) => melt_options.amount_msat(),
882                    None => {
883                        let amount = offer.amount().ok_or(payment::Error::AmountMismatch)?;
884
885                        match amount {
886                            ldk_node::lightning::offers::offer::Amount::Bitcoin {
887                                amount_msats,
888                            } => amount_msats.into(),
889                            _ => return Err(payment::Error::AmountMismatch),
890                        }
891                    }
892                };
893                let amount =
894                    Amount::new(amount_msat.into(), CurrencyUnit::Msat).convert_to(unit)?;
895
896                let relative_fee_reserve =
897                    (self.fee_reserve.percent_fee_reserve * amount.value() as f32) as u64;
898
899                let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into();
900
901                let fee = match relative_fee_reserve > absolute_fee_reserve {
902                    true => relative_fee_reserve,
903                    false => absolute_fee_reserve,
904                };
905
906                Ok(PaymentQuoteResponse {
907                    request_lookup_id: Some(PaymentIdentifier::QuoteId(
908                        bolt12_options.quote_id.clone(),
909                    )),
910                    amount,
911                    fee: Amount::new(fee, unit.clone()),
912                    state: MeltQuoteState::Unpaid,
913                    extra_json: None,
914                    estimated_blocks: None,
915                    fee_options: None,
916                })
917            }
918            OutgoingPaymentOptions::Onchain(_) => {
919                Err(cdk_common::payment::Error::UnsupportedPaymentOption)
920            }
921        }
922    }
923
924    /// Pay request
925    #[instrument(skip(self, options))]
926    async fn make_payment(
927        &self,
928        unit: &CurrencyUnit,
929        options: OutgoingPaymentOptions,
930    ) -> Result<MakePaymentResponse, Self::Err> {
931        match options {
932            cdk_common::payment::OutgoingPaymentOptions::Custom(_) => {
933                Err(cdk_common::payment::Error::UnsupportedPaymentOption)
934            }
935            OutgoingPaymentOptions::Bolt11(bolt11_options) => {
936                let bolt11 = bolt11_options.bolt11;
937
938                let send_params = match bolt11_options
939                    .max_fee_amount
940                    .map(|f| {
941                        f.convert_to(&CurrencyUnit::Msat)
942                            .map(|amount_msat| RouteParametersConfig {
943                                max_total_routing_fee_msat: Some(amount_msat.value()),
944                                ..Default::default()
945                            })
946                    })
947                    .transpose()
948                {
949                    Ok(params) => params,
950                    Err(err) => {
951                        tracing::error!("Failed to convert fee amount: {}", err);
952                        return Err(payment::Error::Custom(format!("Invalid fee amount: {err}")));
953                    }
954                };
955
956                let payment_id = match bolt11_options.melt_options {
957                    Some(MeltOptions::Amountless { amountless }) => {
958                        if let Some(invoice_amount) = bolt11.amount_milli_satoshis() {
959                            if invoice_amount != u64::from(amountless.amount_msat) {
960                                return Err(payment::Error::AmountMismatch);
961                            }
962                        }
963
964                        self.inner
965                            .bolt11_payment()
966                            .send_using_amount(&bolt11, amountless.amount_msat.into(), send_params)
967                            .map_err(|err| {
968                                tracing::error!("Could not send send amountless bolt11: {}", err);
969                                Error::CouldNotSendBolt11WithoutAmount
970                            })?
971                    }
972                    None => self
973                        .inner
974                        .bolt11_payment()
975                        .send(&bolt11, send_params)
976                        .map_err(|err| {
977                            tracing::error!("Could not send bolt11 {}", err);
978                            Error::CouldNotSendBolt11
979                        })?,
980                    _ => return Err(payment::Error::UnsupportedPaymentOption),
981                };
982
983                // Check payment status for up to 10 seconds
984                let start = std::time::Instant::now();
985                let timeout = std::time::Duration::from_secs(10);
986
987                let payment_details = loop {
988                    let details = self
989                        .inner
990                        .payment(&payment_id)
991                        .ok_or(Error::PaymentNotFound)?;
992
993                    match details.status {
994                        PaymentStatus::Succeeded => break details,
995                        PaymentStatus::Failed => {
996                            tracing::error!("Failed to pay bolt11 payment.");
997                            break details;
998                        }
999                        PaymentStatus::Pending => {
1000                            if start.elapsed() > timeout {
1001                                tracing::warn!(
1002                                    "Paying bolt11 exceeded timeout 10 seconds no longer waitning."
1003                                );
1004                                break details;
1005                            }
1006                            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1007                            continue;
1008                        }
1009                    }
1010                };
1011
1012                Self::make_payment_response_from_details(
1013                    unit,
1014                    PaymentIdentifier::PaymentHash(bolt11.payment_hash().to_byte_array()),
1015                    &payment_details,
1016                )
1017            }
1018            OutgoingPaymentOptions::Bolt12(bolt12_options) => {
1019                let offer = bolt12_options.offer;
1020                let quote_id = bolt12_options.quote_id.clone();
1021                let quote_payment_identifier = PaymentIdentifier::QuoteId(quote_id.clone());
1022
1023                let send_params = match bolt12_options
1024                    .max_fee_amount
1025                    .map(|f| {
1026                        f.convert_to(&CurrencyUnit::Msat)
1027                            .map(|amount_msat| RouteParametersConfig {
1028                                max_total_routing_fee_msat: Some(amount_msat.value()),
1029                                ..Default::default()
1030                            })
1031                    })
1032                    .transpose()
1033                {
1034                    Ok(params) => params,
1035                    Err(err) => {
1036                        tracing::error!("Failed to convert fee amount: {}", err);
1037                        return Err(payment::Error::Custom(format!("Invalid fee amount: {err}")));
1038                    }
1039                };
1040
1041                // Write the pre-dispatch sentinel before attempting the send so
1042                // a crash during dispatch is distinguishable from "never
1043                // dispatched" (mirrors cdk-cln's pre-dispatch bolt12 quote
1044                // mapping). The payment must not be attempted unless this
1045                // marker is durable.
1046                write_bolt12_quote_payment_id(&self.kv_store, &quote_id, None).await?;
1047
1048                let payment_id = match bolt12_options.melt_options {
1049                    Some(MeltOptions::Amountless { amountless }) => {
1050                        self.inner.bolt12_payment().send_using_amount(
1051                            &offer,
1052                            amountless.amount_msat.into(),
1053                            None,
1054                            None,
1055                            send_params,
1056                        )
1057                    }
1058                    None => self
1059                        .inner
1060                        .bolt12_payment()
1061                        .send(&offer, None, None, send_params),
1062                    _ => {
1063                        self.cleanup_bolt12_dispatch_sentinel(&quote_id).await;
1064                        return Err(payment::Error::UnsupportedPaymentOption);
1065                    }
1066                };
1067
1068                let payment_id = match payment_id {
1069                    Ok(payment_id) => payment_id,
1070                    Err(err) => {
1071                        match bolt12_send_error_has_ambiguous_dispatch(&err) {
1072                            true => {
1073                                tracing::warn!(
1074                                    quote_id = %quote_id,
1075                                    "LDK payment persistence failed after BOLT12 send; retaining \
1076                                     the dispatch sentinel because the payment may have been dispatched"
1077                                );
1078                            }
1079                            false => {
1080                                self.cleanup_bolt12_dispatch_sentinel(&quote_id).await;
1081                            }
1082                        }
1083                        return Err(Error::LdkNode(err).into());
1084                    }
1085                };
1086
1087                // Record the payment id so QuoteId lookups resolve to the
1088                // dispatched payment. Best-effort: if this write fails the
1089                // sentinel remains and the payment resolves as Pending, keeping
1090                // the melt proofs reserved.
1091                if let Err(err) =
1092                    write_bolt12_quote_payment_id(&self.kv_store, &quote_id, Some(&payment_id))
1093                        .await
1094                {
1095                    tracing::error!(
1096                        "Could not record BOLT12 payment id for quote {quote_id}: {err}. \
1097                         The payment will remain Pending until manual intervention."
1098                    );
1099                }
1100
1101                // Check payment status for up to 10 seconds
1102                let start = std::time::Instant::now();
1103                let timeout = std::time::Duration::from_secs(10);
1104
1105                let payment_details = loop {
1106                    let details = self
1107                        .inner
1108                        .payment(&payment_id)
1109                        .ok_or(Error::PaymentNotFound)?;
1110
1111                    match details.status {
1112                        PaymentStatus::Succeeded => break details,
1113                        PaymentStatus::Failed => {
1114                            tracing::error!(
1115                                payment_id = %payment_id,
1116                                amount_msat = ?details.amount_msat,
1117                                fee_paid_msat = ?details.fee_paid_msat,
1118                                payment_kind = ?details.kind,
1119                                "Bolt12 payment failed"
1120                            );
1121                            break details;
1122                        }
1123                        PaymentStatus::Pending => {
1124                            if start.elapsed() > timeout {
1125                                tracing::warn!(
1126                                    "Payment has been being for 10 seconds. No longer waiting"
1127                                );
1128                                break details;
1129                            }
1130                            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1131                            continue;
1132                        }
1133                    }
1134                };
1135
1136                Self::make_payment_response_from_details(
1137                    unit,
1138                    quote_payment_identifier,
1139                    &payment_details,
1140                )
1141            }
1142            OutgoingPaymentOptions::Onchain(_) => {
1143                Err(cdk_common::payment::Error::UnsupportedPaymentOption)
1144            }
1145        }
1146    }
1147
1148    /// Listen for invoices to be paid to the mint
1149    /// Returns a stream of request_lookup_id once invoices are paid
1150    #[instrument(skip(self))]
1151    async fn wait_payment_event(
1152        &self,
1153    ) -> Result<Pin<Box<dyn Stream<Item = cdk_common::payment::Event> + Send>>, Self::Err> {
1154        tracing::info!("Starting stream for invoices - wait_any_incoming_payment called");
1155
1156        // Set active flag to indicate stream is active
1157        self.wait_invoice_is_active.store(true, Ordering::SeqCst);
1158        tracing::debug!("wait_invoice_is_active set to true");
1159
1160        let receiver = self.receiver.clone();
1161
1162        tracing::info!("Receiver obtained successfully, creating response stream");
1163
1164        // Transform the String stream into a WaitPaymentResponse stream
1165        let response_stream = BroadcastStream::new(receiver.resubscribe());
1166
1167        // Map the stream to handle BroadcastStreamRecvError and wrap in Event
1168        let response_stream = response_stream.filter_map(|result| async move {
1169            match result {
1170                Ok(payment) => Some(cdk_common::payment::Event::PaymentReceived(payment)),
1171                Err(err) => {
1172                    tracing::warn!("Error in broadcast stream: {}", err);
1173                    None
1174                }
1175            }
1176        });
1177
1178        // Create a combined stream that also handles cancellation
1179        let cancel_token = self.wait_invoice_cancel_token.clone();
1180        let is_active = self.wait_invoice_is_active.clone();
1181
1182        let stream = Box::pin(response_stream);
1183
1184        // Set up a task to clean up when the stream is dropped
1185        tokio::spawn(async move {
1186            cancel_token.cancelled().await;
1187            tracing::info!("wait_invoice stream cancelled");
1188            is_active.store(false, Ordering::SeqCst);
1189        });
1190
1191        tracing::info!("wait_any_incoming_payment returning stream");
1192        Ok(stream)
1193    }
1194
1195    /// Is payment event stream active
1196    fn is_payment_event_stream_active(&self) -> bool {
1197        self.wait_invoice_is_active.load(Ordering::SeqCst)
1198    }
1199
1200    /// Cancel payment event stream
1201    fn cancel_payment_event_stream(&self) {
1202        self.wait_invoice_cancel_token.cancel()
1203    }
1204
1205    /// Check the status of an incoming payment
1206    async fn check_incoming_payment_status(
1207        &self,
1208        payment_identifier: &PaymentIdentifier,
1209    ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
1210        // Bolt12 offers are identified by offer id and can be paid more than
1211        // once, so collect every settled inbound payment for the offer.
1212        if let PaymentIdentifier::OfferId(offer_id) = payment_identifier {
1213            let payments = self.inner.list_payments_with_filter(|p| {
1214                p.direction == PaymentDirection::Inbound
1215                    && p.status == PaymentStatus::Succeeded
1216                    && matches!(
1217                        &p.kind,
1218                        PaymentKind::Bolt12Offer { offer_id: oid, .. } if oid.to_string() == *offer_id
1219                    )
1220            });
1221
1222            return Ok(payments
1223                .into_iter()
1224                .filter_map(|p| {
1225                    let payment_id = match &p.kind {
1226                        PaymentKind::Bolt12Offer {
1227                            hash: Some(hash), ..
1228                        } => hash.to_string(),
1229                        _ => {
1230                            tracing::warn!("Bolt12 payment for offer {} missing hash", offer_id);
1231                            return None;
1232                        }
1233                    };
1234
1235                    Some(WaitPaymentResponse {
1236                        payment_identifier: payment_identifier.clone(),
1237                        payment_amount: Amount::new(p.amount_msat?, CurrencyUnit::Msat),
1238                        payment_id,
1239                    })
1240                })
1241                .collect());
1242        }
1243
1244        let payment_id_str = match payment_identifier {
1245            PaymentIdentifier::PaymentHash(hash) => hex::encode(hash),
1246            PaymentIdentifier::CustomId(id) => id.clone(),
1247            _ => return Err(Error::UnsupportedPaymentIdentifierType.into()),
1248        };
1249
1250        let payment_id = PaymentId(
1251            hex::decode(&payment_id_str)?
1252                .try_into()
1253                .map_err(|_| Error::InvalidPaymentIdLength)?,
1254        );
1255
1256        let payment_details = self
1257            .inner
1258            .payment(&payment_id)
1259            .ok_or(Error::PaymentNotFound)?;
1260
1261        if payment_details.direction == PaymentDirection::Outbound {
1262            return Err(Error::InvalidPaymentDirection.into());
1263        }
1264
1265        let amount = if payment_details.status == PaymentStatus::Succeeded {
1266            payment_details
1267                .amount_msat
1268                .ok_or(Error::CouldNotGetPaymentAmount)?
1269        } else {
1270            return Ok(vec![]);
1271        };
1272
1273        let response = WaitPaymentResponse {
1274            payment_identifier: payment_identifier.clone(),
1275            payment_amount: Amount::new(amount, CurrencyUnit::Msat),
1276            payment_id: payment_id_str,
1277        };
1278
1279        Ok(vec![response])
1280    }
1281
1282    /// Check the status of an outgoing payment
1283    async fn check_outgoing_payment(
1284        &self,
1285        request_lookup_id: &PaymentIdentifier,
1286    ) -> Result<MakePaymentResponse, Self::Err> {
1287        let payment_details = match request_lookup_id {
1288            PaymentIdentifier::PaymentHash(id_hash) => {
1289                Self::select_bolt11_payment_details(self.inner.list_payments_with_filter(|p| {
1290                    p.direction == PaymentDirection::Outbound
1291                        && matches!(&p.kind, PaymentKind::Bolt11 { hash, .. } if &hash.0 == id_hash)
1292                }))
1293            }
1294            PaymentIdentifier::PaymentId(id) => self.inner.payment(&PaymentId(*id)),
1295            PaymentIdentifier::QuoteId(quote_id) => {
1296                match read_bolt12_quote_payment_id(&self.kv_store, quote_id)
1297                    .await?
1298                    .resolve()
1299                {
1300                    Bolt12QuotePaymentIdResolution::PaymentId(payment_id) => {
1301                        self.inner.payment(&payment_id)
1302                    }
1303                    Bolt12QuotePaymentIdResolution::Status(status) => {
1304                        return Ok(MakePaymentResponse {
1305                            payment_lookup_id: request_lookup_id.clone(),
1306                            payment_proof: None,
1307                            status,
1308                            total_spent: Amount::new(0, CurrencyUnit::Msat),
1309                        });
1310                    }
1311                }
1312            }
1313            _ => {
1314                return Ok(MakePaymentResponse {
1315                    payment_lookup_id: request_lookup_id.clone(),
1316                    payment_proof: None,
1317                    status: MeltQuoteState::Unknown,
1318                    total_spent: Amount::new(0, CurrencyUnit::Msat),
1319                });
1320            }
1321        }
1322        .ok_or(Error::PaymentNotFound)?;
1323
1324        if payment_details.direction != PaymentDirection::Outbound {
1325            return Err(Error::InvalidPaymentDirection.into());
1326        }
1327
1328        Self::make_payment_response_from_details(
1329            &CurrencyUnit::Msat,
1330            request_lookup_id.clone(),
1331            &payment_details,
1332        )
1333    }
1334}
1335
1336impl Drop for CdkLdkNode {
1337    fn drop(&mut self) {
1338        tracing::info!("Drop called on CdkLdkNode");
1339        self.wait_invoice_cancel_token.cancel();
1340        tracing::debug!("Cancelled wait_invoice token in drop");
1341    }
1342}
1343
1344/// KV key for the bolt12 melt quote id -> payment id mapping
1345fn bolt12_quote_payment_id_key(quote_id: &QuoteId) -> Result<String, Error> {
1346    match quote_id {
1347        QuoteId::UUID(uuid) => Ok(uuid.to_string()),
1348        QuoteId::BASE64(_) => Err(Error::InvalidQuoteId),
1349    }
1350}
1351
1352/// Records the bolt12 melt quote id -> payment id mapping.
1353///
1354/// `payment_id` of `None` writes the pre-dispatch sentinel: it marks that
1355/// `send` is about to be attempted so a crash during dispatch is
1356/// distinguishable from "never dispatched" (mirrors cdk-cln's pre-dispatch
1357/// bolt12 quote mapping).
1358async fn write_bolt12_quote_payment_id(
1359    kv_store: &DynKVStore,
1360    quote_id: &QuoteId,
1361    payment_id: Option<&PaymentId>,
1362) -> Result<(), Error> {
1363    let key = bolt12_quote_payment_id_key(quote_id)?;
1364    let value = payment_id.map(|id| hex::encode(id.0)).unwrap_or_default();
1365    let mut tx = kv_store
1366        .begin_transaction()
1367        .await
1368        .map_err(|e| Error::Database(e.to_string()))?;
1369
1370    tx.kv_write(
1371        LDK_KV_PRIMARY_NAMESPACE,
1372        LDK_KV_BOLT12_OUTGOING_SECONDARY_NAMESPACE,
1373        &key,
1374        value.as_bytes(),
1375    )
1376    .await
1377    .map_err(|e| Error::Database(e.to_string()))?;
1378    tx.commit()
1379        .await
1380        .map_err(|e| Error::Database(e.to_string()))?;
1381
1382    Ok(())
1383}
1384
1385/// Reads the bolt12 melt quote id -> payment id mapping
1386async fn read_bolt12_quote_payment_id(
1387    kv_store: &DynKVStore,
1388    quote_id: &QuoteId,
1389) -> Result<Bolt12QuotePaymentIdLookup, Error> {
1390    let key = bolt12_quote_payment_id_key(quote_id)?;
1391    let Some(stored) = kv_store
1392        .kv_read(
1393            LDK_KV_PRIMARY_NAMESPACE,
1394            LDK_KV_BOLT12_OUTGOING_SECONDARY_NAMESPACE,
1395            &key,
1396        )
1397        .await
1398        .map_err(|e| Error::Database(e.to_string()))?
1399    else {
1400        return Ok(Bolt12QuotePaymentIdLookup::Missing);
1401    };
1402
1403    if stored.is_empty() {
1404        return Ok(Bolt12QuotePaymentIdLookup::Dispatching);
1405    }
1406
1407    let payment_id_hex = match String::from_utf8(stored) {
1408        Ok(payment_id_hex) => payment_id_hex,
1409        Err(err) => {
1410            tracing::warn!(
1411                "LDK: invalid UTF-8 in BOLT12 payment id mapping for quote {quote_id}: {err}"
1412            );
1413            return Ok(Bolt12QuotePaymentIdLookup::Malformed);
1414        }
1415    };
1416
1417    let payment_id_bytes = match hex::decode(&payment_id_hex) {
1418        Ok(bytes) => bytes,
1419        Err(err) => {
1420            tracing::warn!(
1421                "LDK: invalid hex in BOLT12 payment id mapping for quote {quote_id}: {err}"
1422            );
1423            return Ok(Bolt12QuotePaymentIdLookup::Malformed);
1424        }
1425    };
1426
1427    let payment_id: [u8; 32] = match payment_id_bytes.try_into() {
1428        Ok(payment_id) => payment_id,
1429        Err(_) => {
1430            tracing::warn!("LDK: invalid payment id length in BOLT12 mapping for quote {quote_id}");
1431            return Ok(Bolt12QuotePaymentIdLookup::Malformed);
1432        }
1433    };
1434
1435    Ok(Bolt12QuotePaymentIdLookup::Found(PaymentId(payment_id)))
1436}
1437
1438/// Removes the bolt12 melt quote id -> payment id mapping
1439async fn delete_bolt12_quote_payment_id(
1440    kv_store: &DynKVStore,
1441    quote_id: &QuoteId,
1442) -> Result<(), Error> {
1443    let key = bolt12_quote_payment_id_key(quote_id)?;
1444    let mut tx = kv_store
1445        .begin_transaction()
1446        .await
1447        .map_err(|e| Error::Database(e.to_string()))?;
1448
1449    tx.kv_remove(
1450        LDK_KV_PRIMARY_NAMESPACE,
1451        LDK_KV_BOLT12_OUTGOING_SECONDARY_NAMESPACE,
1452        &key,
1453    )
1454    .await
1455    .map_err(|e| Error::Database(e.to_string()))?;
1456    tx.commit()
1457        .await
1458        .map_err(|e| Error::Database(e.to_string()))?;
1459
1460    Ok(())
1461}
1462
1463#[cfg(test)]
1464mod tests {
1465    use super::*;
1466
1467    #[test]
1468    fn bitcoin_rpc_debug_redacts_password() {
1469        let source = ChainSource::BitcoinRpc(BitcoinRpcConfig {
1470            host: "127.0.0.1".to_string(),
1471            port: 8332,
1472            user: "rpc-user".to_string(),
1473            password: "rpc-password-secret".to_string(),
1474        });
1475
1476        let debug = format!("{source:?}");
1477
1478        assert!(debug.contains("127.0.0.1"));
1479        assert!(debug.contains("rpc-user"));
1480        assert!(debug.contains("[REDACTED]"));
1481        assert!(!debug.contains("rpc-password-secret"));
1482    }
1483
1484    #[test]
1485    fn chain_source_debug_redacts_url_credentials() {
1486        for source in [
1487            ChainSource::Esplora("https://esplora-user:esplora-secret@example.com/api".to_string()),
1488            ChainSource::Electrum(
1489                "ssl://electrum-user:electrum-secret@example.com:50002".to_string(),
1490            ),
1491        ] {
1492            let debug = format!("{source:?}");
1493
1494            assert!(debug.contains("example.com"));
1495            assert!(!debug.contains("-user"));
1496            assert!(!debug.contains("-secret"));
1497        }
1498    }
1499
1500    #[test]
1501    fn gossip_source_debug_redacts_url_credentials() {
1502        let source = GossipSource::RapidGossipSync(
1503            "https://rgs-user:rgs-secret@example.com/snapshot".to_string(),
1504        );
1505
1506        let debug = format!("{source:?}");
1507
1508        assert!(debug.contains("https://example.com/snapshot"));
1509        assert!(!debug.contains("rgs-user"));
1510        assert!(!debug.contains("rgs-secret"));
1511    }
1512
1513    fn test_payment_details(status: PaymentStatus, amount_msat: Option<u64>) -> PaymentDetails {
1514        PaymentDetails {
1515            id: PaymentId([2; 32]),
1516            kind: PaymentKind::Bolt11 {
1517                hash: PaymentHash([1; 32]),
1518                preimage: None,
1519                secret: None,
1520            },
1521            amount_msat,
1522            fee_paid_msat: None,
1523            direction: PaymentDirection::Outbound,
1524            status,
1525            latest_update_timestamp: 0,
1526        }
1527    }
1528
1529    fn test_payment_details_with_id(
1530        id: [u8; 32],
1531        status: PaymentStatus,
1532        latest_update_timestamp: u64,
1533    ) -> PaymentDetails {
1534        PaymentDetails {
1535            id: PaymentId(id),
1536            latest_update_timestamp,
1537            ..test_payment_details(status, None)
1538        }
1539    }
1540
1541    #[test]
1542    fn failed_payment_response_does_not_require_amount() {
1543        let details = test_payment_details(PaymentStatus::Failed, None);
1544
1545        let response = CdkLdkNode::make_payment_response_from_details(
1546            &CurrencyUnit::Msat,
1547            PaymentIdentifier::PaymentId([2; 32]),
1548            &details,
1549        )
1550        .expect("failed payment details should map without amount");
1551
1552        assert_eq!(response.status, MeltQuoteState::Failed);
1553        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Msat));
1554    }
1555
1556    #[test]
1557    fn pending_payment_response_does_not_require_amount() {
1558        let details = test_payment_details(PaymentStatus::Pending, None);
1559
1560        let response = CdkLdkNode::make_payment_response_from_details(
1561            &CurrencyUnit::Msat,
1562            PaymentIdentifier::PaymentId([2; 32]),
1563            &details,
1564        )
1565        .expect("pending payment details should map without amount");
1566
1567        assert_eq!(response.status, MeltQuoteState::Pending);
1568        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Msat));
1569    }
1570
1571    #[test]
1572    fn paid_payment_response_requires_amount() {
1573        let details = test_payment_details(PaymentStatus::Succeeded, None);
1574
1575        let err = CdkLdkNode::make_payment_response_from_details(
1576            &CurrencyUnit::Msat,
1577            PaymentIdentifier::PaymentId([2; 32]),
1578            &details,
1579        )
1580        .expect_err("paid payment details without amount should fail");
1581
1582        assert!(matches!(err, payment::Error::Backend(_)));
1583    }
1584
1585    #[test]
1586    fn bolt11_payment_selection_prefers_pending_over_failed() {
1587        let failed = test_payment_details_with_id([1; 32], PaymentStatus::Failed, 2);
1588        let pending = test_payment_details_with_id([2; 32], PaymentStatus::Pending, 1);
1589
1590        let selected = CdkLdkNode::select_bolt11_payment_details([failed, pending])
1591            .expect("payment details should be selected");
1592
1593        assert_eq!(selected.id, PaymentId([2; 32]));
1594        assert_eq!(selected.status, PaymentStatus::Pending);
1595    }
1596
1597    #[test]
1598    fn bolt11_payment_selection_prefers_succeeded_over_pending() {
1599        let pending = test_payment_details_with_id([1; 32], PaymentStatus::Pending, 2);
1600        let succeeded = PaymentDetails {
1601            amount_msat: Some(1000),
1602            ..test_payment_details_with_id([2; 32], PaymentStatus::Succeeded, 1)
1603        };
1604
1605        let selected = CdkLdkNode::select_bolt11_payment_details([pending, succeeded])
1606            .expect("payment details should be selected");
1607
1608        assert_eq!(selected.id, PaymentId([2; 32]));
1609        assert_eq!(selected.status, PaymentStatus::Succeeded);
1610    }
1611
1612    #[test]
1613    fn bolt11_payment_selection_uses_latest_failed_when_all_failed() {
1614        let older_failed = test_payment_details_with_id([1; 32], PaymentStatus::Failed, 1);
1615        let newer_failed = test_payment_details_with_id([2; 32], PaymentStatus::Failed, 2);
1616
1617        let selected = CdkLdkNode::select_bolt11_payment_details([older_failed, newer_failed])
1618            .expect("payment details should be selected");
1619
1620        assert_eq!(selected.id, PaymentId([2; 32]));
1621        assert_eq!(selected.status, PaymentStatus::Failed);
1622    }
1623
1624    #[test]
1625    fn bolt12_persistence_failure_has_ambiguous_dispatch() {
1626        assert!(bolt12_send_error_has_ambiguous_dispatch(
1627            &ldk_node::NodeError::PersistenceFailed
1628        ));
1629
1630        for not_dispatched in [
1631            ldk_node::NodeError::NotRunning,
1632            ldk_node::NodeError::UnsupportedCurrency,
1633            ldk_node::NodeError::InvalidOffer,
1634            ldk_node::NodeError::InvalidAmount,
1635            ldk_node::NodeError::DuplicatePayment,
1636            ldk_node::NodeError::InvoiceRequestCreationFailed,
1637            ldk_node::NodeError::PaymentSendingFailed,
1638        ] {
1639            assert!(
1640                !bolt12_send_error_has_ambiguous_dispatch(&not_dispatched),
1641                "{not_dispatched} must be treated as not dispatched"
1642            );
1643        }
1644    }
1645
1646    #[test]
1647    fn bolt12_quote_payment_id_lookup_resolution_is_safe() {
1648        assert_eq!(
1649            Bolt12QuotePaymentIdLookup::Dispatching.resolve(),
1650            Bolt12QuotePaymentIdResolution::Status(MeltQuoteState::Pending),
1651            "an indeterminate dispatch must keep melt proofs reserved"
1652        );
1653        assert_eq!(
1654            Bolt12QuotePaymentIdLookup::Missing.resolve(),
1655            Bolt12QuotePaymentIdResolution::Status(MeltQuoteState::Unpaid),
1656            "a missing sentinel means the payment was never dispatched"
1657        );
1658        assert_eq!(
1659            Bolt12QuotePaymentIdLookup::Malformed.resolve(),
1660            Bolt12QuotePaymentIdResolution::Status(MeltQuoteState::Unknown),
1661            "corrupt bookkeeping must remain indeterminate"
1662        );
1663    }
1664
1665    async fn test_kv_store() -> DynKVStore {
1666        std::sync::Arc::new(cdk_sqlite::mint::memory::empty().await.unwrap())
1667    }
1668
1669    /// The mapping must resolve Missing before any dispatch, Found after the
1670    /// payment id is recorded, and Dispatching (indeterminate) while only the
1671    /// pre-dispatch sentinel exists.
1672    #[tokio::test]
1673    async fn bolt12_quote_payment_id_mapping_lifecycle() {
1674        let kv_store = test_kv_store().await;
1675        let quote_id = QuoteId::new();
1676
1677        assert_eq!(
1678            read_bolt12_quote_payment_id(&kv_store, &quote_id)
1679                .await
1680                .unwrap(),
1681            Bolt12QuotePaymentIdLookup::Missing,
1682            "no record must resolve as never dispatched"
1683        );
1684
1685        // Pre-dispatch sentinel
1686        write_bolt12_quote_payment_id(&kv_store, &quote_id, None)
1687            .await
1688            .unwrap();
1689        assert_eq!(
1690            read_bolt12_quote_payment_id(&kv_store, &quote_id)
1691                .await
1692                .unwrap(),
1693            Bolt12QuotePaymentIdLookup::Dispatching,
1694            "sentinel must resolve as indeterminate, never terminal"
1695        );
1696
1697        // Record the payment id
1698        let payment_id = PaymentId([7; 32]);
1699        write_bolt12_quote_payment_id(&kv_store, &quote_id, Some(&payment_id))
1700            .await
1701            .unwrap();
1702        assert_eq!(
1703            read_bolt12_quote_payment_id(&kv_store, &quote_id)
1704                .await
1705                .unwrap(),
1706            Bolt12QuotePaymentIdLookup::Found(payment_id)
1707        );
1708
1709        // Removal returns to Missing (failed dispatch cleanup)
1710        delete_bolt12_quote_payment_id(&kv_store, &quote_id)
1711            .await
1712            .unwrap();
1713        assert_eq!(
1714            read_bolt12_quote_payment_id(&kv_store, &quote_id)
1715                .await
1716                .unwrap(),
1717            Bolt12QuotePaymentIdLookup::Missing
1718        );
1719    }
1720
1721    /// A corrupted mapping must resolve as indeterminate (`Malformed`), never
1722    /// as a terminal state that could trigger compensation.
1723    #[tokio::test]
1724    async fn bolt12_quote_payment_id_mapping_malformed_is_indeterminate() {
1725        let kv_store = test_kv_store().await;
1726        let quote_id = QuoteId::new();
1727        let key = bolt12_quote_payment_id_key(&quote_id).unwrap();
1728
1729        for corrupt in ["not-hex", "0102", "zz"] {
1730            let mut tx = kv_store.begin_transaction().await.unwrap();
1731            tx.kv_write(
1732                LDK_KV_PRIMARY_NAMESPACE,
1733                LDK_KV_BOLT12_OUTGOING_SECONDARY_NAMESPACE,
1734                &key,
1735                corrupt.as_bytes(),
1736            )
1737            .await
1738            .unwrap();
1739            tx.commit().await.unwrap();
1740
1741            assert_eq!(
1742                read_bolt12_quote_payment_id(&kv_store, &quote_id)
1743                    .await
1744                    .unwrap(),
1745                Bolt12QuotePaymentIdLookup::Malformed,
1746                "corrupt value {corrupt} must be indeterminate"
1747            );
1748        }
1749    }
1750}