Skip to main content

cdk_lnd/
lib.rs

1//! CDK lightning backend for LND
2
3// Copyright (c) 2023 Steffen (MIT)
4
5#![doc = include_str!("../README.md")]
6
7use std::cmp::max;
8use std::path::PathBuf;
9use std::pin::Pin;
10use std::str::FromStr;
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::Arc;
13
14use anyhow::anyhow;
15use async_trait::async_trait;
16use cdk_common::amount::{Amount, MSAT_IN_SAT};
17use cdk_common::bitcoin::hashes::Hash;
18use cdk_common::common::FeeReserve;
19use cdk_common::database::DynKVStore;
20use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState};
21use cdk_common::payment::{
22    self, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse,
23    MintPayment, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, SettingsResponse,
24    WaitPaymentResponse,
25};
26use cdk_common::util::{hex, unix_time};
27use cdk_common::Bolt11Invoice;
28use error::Error;
29use futures::{Stream, StreamExt};
30use lnrpc::fee_limit::Limit;
31use lnrpc::payment::PaymentStatus;
32use lnrpc::{FeeLimit, Hop, MppRecord};
33use tokio_util::sync::CancellationToken;
34use tracing::instrument;
35
36mod client;
37pub mod error;
38
39mod proto;
40pub(crate) use proto::{lnrpc, routerrpc};
41
42use crate::lnrpc::invoice::InvoiceState;
43
44/// LND KV Store constants
45const LND_KV_PRIMARY_NAMESPACE: &str = "cdk_lnd_lightning_backend";
46const LND_KV_SECONDARY_NAMESPACE: &str = "payment_indices";
47const LAST_ADD_INDEX_KV_KEY: &str = "last_add_index";
48const LAST_SETTLE_INDEX_KV_KEY: &str = "last_settle_index";
49
50/// Lnd mint backend
51#[derive(Clone)]
52pub struct Lnd {
53    _address: String,
54    _cert_file: PathBuf,
55    _macaroon_file: PathBuf,
56    lnd_client: client::Client,
57    fee_reserve: FeeReserve,
58    kv_store: DynKVStore,
59    wait_invoice_cancel_token: CancellationToken,
60    wait_invoice_is_active: Arc<AtomicBool>,
61    settings: SettingsResponse,
62    unit: CurrencyUnit,
63}
64
65impl std::fmt::Debug for Lnd {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("Lnd")
68            .field("fee_reserve", &self.fee_reserve)
69            .finish_non_exhaustive()
70    }
71}
72
73impl Lnd {
74    /// Maximum number of attempts at a partial payment
75    pub const MAX_ROUTE_RETRIES: usize = 50;
76
77    /// Create new [`Lnd`]
78    pub async fn new(
79        address: String,
80        cert_file: PathBuf,
81        macaroon_file: PathBuf,
82        fee_reserve: FeeReserve,
83        kv_store: DynKVStore,
84    ) -> Result<Self, Error> {
85        // Validate address is not empty
86        if address.is_empty() {
87            return Err(Error::InvalidConfig("LND address cannot be empty".into()));
88        }
89
90        // Validate cert_file exists and is not empty
91        if !cert_file.exists() || cert_file.metadata().map(|m| m.len() == 0).unwrap_or(true) {
92            return Err(Error::InvalidConfig(format!(
93                "LND certificate file not found or empty: {cert_file:?}"
94            )));
95        }
96
97        // Validate macaroon_file exists and is not empty
98        if !macaroon_file.exists()
99            || macaroon_file
100                .metadata()
101                .map(|m| m.len() == 0)
102                .unwrap_or(true)
103        {
104            return Err(Error::InvalidConfig(format!(
105                "LND macaroon file not found or empty: {macaroon_file:?}"
106            )));
107        }
108
109        let lnd_client = client::connect(&address, &cert_file, &macaroon_file)
110            .await
111            .map_err(|err| {
112                tracing::error!("Connection error: {}", err.to_string());
113                Error::Connection
114            })?;
115
116        let unit = CurrencyUnit::Msat;
117        Ok(Self {
118            _address: address,
119            _cert_file: cert_file,
120            _macaroon_file: macaroon_file,
121            lnd_client,
122            fee_reserve,
123            kv_store,
124            wait_invoice_cancel_token: CancellationToken::new(),
125            wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
126            settings: SettingsResponse {
127                unit: unit.to_string(),
128                bolt11: Some(payment::Bolt11Settings {
129                    mpp: true,
130                    amountless: true,
131                    invoice_description: true,
132                }),
133                bolt12: None,
134                onchain: None,
135                custom: std::collections::HashMap::new(),
136            },
137            unit,
138        })
139    }
140
141    /// Get last add and settle indices from KV store
142    #[instrument(skip_all)]
143    async fn get_last_indices(&self) -> Result<(Option<u64>, Option<u64>), Error> {
144        let add_index = if let Some(stored_index) = self
145            .kv_store
146            .kv_read(
147                LND_KV_PRIMARY_NAMESPACE,
148                LND_KV_SECONDARY_NAMESPACE,
149                LAST_ADD_INDEX_KV_KEY,
150            )
151            .await
152            .map_err(|e| Error::Database(e.to_string()))?
153        {
154            if let Ok(index_str) = std::str::from_utf8(stored_index.as_slice()) {
155                index_str.parse::<u64>().ok()
156            } else {
157                None
158            }
159        } else {
160            None
161        };
162
163        let settle_index = if let Some(stored_index) = self
164            .kv_store
165            .kv_read(
166                LND_KV_PRIMARY_NAMESPACE,
167                LND_KV_SECONDARY_NAMESPACE,
168                LAST_SETTLE_INDEX_KV_KEY,
169            )
170            .await
171            .map_err(|e| Error::Database(e.to_string()))?
172        {
173            if let Ok(index_str) = std::str::from_utf8(stored_index.as_slice()) {
174                index_str.parse::<u64>().ok()
175            } else {
176                None
177            }
178        } else {
179            None
180        };
181
182        tracing::debug!(
183            "LND: Retrieved last indices from KV store - add_index: {:?}, settle_index: {:?}",
184            add_index,
185            settle_index
186        );
187        Ok((add_index, settle_index))
188    }
189}
190
191fn lnrpc_payment_total_spent(payment: &lnrpc::Payment) -> Result<Amount<CurrencyUnit>, Error> {
192    let total_msat = payment
193        .value_msat
194        .checked_add(payment.fee_msat)
195        .ok_or(Error::AmountOverflow)?;
196    let total_msat = u64::try_from(total_msat).map_err(|_| Error::AmountOverflow)?;
197
198    Ok(Amount::new(total_msat, CurrencyUnit::Msat))
199}
200
201fn msat_total_spent_for_unit(
202    total_msat: u64,
203    unit: &CurrencyUnit,
204) -> Result<Amount<CurrencyUnit>, Error> {
205    match unit {
206        CurrencyUnit::Msat => Ok(Amount::new(total_msat, CurrencyUnit::Msat)),
207        CurrencyUnit::Sat => Ok(Amount::new(
208            total_msat.div_ceil(MSAT_IN_SAT),
209            CurrencyUnit::Sat,
210        )),
211        _ => Amount::new(total_msat, CurrencyUnit::Msat)
212            .convert_to(unit)
213            .map_err(Error::from),
214    }
215}
216
217/// Build an authoritative terminal-failure response for a payment that was
218/// rejected before dispatch.
219///
220/// The mint treats an `Ok` response with `MeltQuoteState::Failed` as
221/// authoritative (it may compensate the melt), unlike an `Err`, whose dispatch
222/// phase is unknown and which is therefore kept indeterminate. Pre-dispatch
223/// rejections must be returned as this response so the melt can be rolled back
224/// instead of parked pending.
225///
226/// Conversely, errors that straddle the dispatch boundary — a gRPC `Status`
227/// error from `send_*` (`Error::LndError`), or a stream that drops after
228/// dispatch began (`Error::AmbiguousDispatch`) — must stay `Err` so the melt
229/// stays indeterminate. Do not convert those to this response.
230fn outgoing_payment_failure_response(
231    unit: &CurrencyUnit,
232    payment_lookup_id: PaymentIdentifier,
233) -> MakePaymentResponse {
234    MakePaymentResponse {
235        payment_lookup_id,
236        payment_proof: None,
237        status: MeltQuoteState::Failed,
238        total_spent: Amount::new(0, unit.clone()),
239    }
240}
241
242/// Preserve an existing payment, or reject an expired invoice before dispatch.
243fn bolt11_pre_dispatch_response(
244    unit: &CurrencyUnit,
245    bolt11: &Bolt11Invoice,
246    pay_state: MakePaymentResponse,
247) -> Result<Option<MakePaymentResponse>, payment::Error> {
248    let payment_lookup_id = PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref());
249    Ok(match pay_state.status {
250        MeltQuoteState::Paid | MeltQuoteState::Pending => Some(MakePaymentResponse {
251            payment_lookup_id,
252            total_spent: match (pay_state.total_spent.unit(), unit) {
253                (CurrencyUnit::Msat, CurrencyUnit::Sat) => Amount::new(
254                    pay_state.total_spent.value().div_ceil(MSAT_IN_SAT),
255                    CurrencyUnit::Sat,
256                ),
257                _ => pay_state.total_spent.convert_to(unit)?,
258            },
259            ..pay_state
260        }),
261        MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => {
262            // LND rejects expired invoices before recording a payment, so a
263            // later lookup cannot resolve that rejection. Return an authoritative
264            // failure locally while we know no dispatch has been attempted.
265            bolt11
266                .is_expired()
267                .then(|| outgoing_payment_failure_response(unit, payment_lookup_id))
268        }
269    })
270}
271
272#[async_trait]
273impl MintPayment for Lnd {
274    type Err = payment::Error;
275
276    #[instrument(skip_all)]
277    async fn get_settings(&self) -> Result<SettingsResponse, Self::Err> {
278        Ok(self.settings.clone())
279    }
280
281    #[instrument(skip_all)]
282    fn is_payment_event_stream_active(&self) -> bool {
283        self.wait_invoice_is_active.load(Ordering::SeqCst)
284    }
285
286    #[instrument(skip_all)]
287    fn cancel_payment_event_stream(&self) {
288        self.wait_invoice_cancel_token.cancel()
289    }
290
291    #[instrument(skip_all)]
292    async fn wait_payment_event(
293        &self,
294    ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
295        let mut lnd_client = self.lnd_client.clone();
296
297        // Get last indices from KV store
298        let (last_add_index, last_settle_index) =
299            self.get_last_indices().await.unwrap_or((None, None));
300
301        let stream_req = lnrpc::InvoiceSubscription {
302            add_index: last_add_index.unwrap_or(0),
303            settle_index: last_settle_index.unwrap_or(0),
304        };
305
306        tracing::debug!(
307            "LND: Starting invoice subscription with add_index: {}, settle_index: {}",
308            stream_req.add_index,
309            stream_req.settle_index
310        );
311
312        let stream = lnd_client
313            .lightning()
314            .subscribe_invoices(stream_req)
315            .await
316            .map_err(|_err| {
317                tracing::error!("Could not subscribe to invoice");
318                Error::Connection
319            })?
320            .into_inner();
321
322        let cancel_token = self.wait_invoice_cancel_token.clone();
323        let kv_store = self.kv_store.clone();
324
325        let event_stream = futures::stream::unfold(
326            (
327                stream,
328                cancel_token,
329                Arc::clone(&self.wait_invoice_is_active),
330                kv_store,
331                last_add_index.unwrap_or(0),
332                last_settle_index.unwrap_or(0),
333            ),
334            |(
335                mut stream,
336                cancel_token,
337                is_active,
338                kv_store,
339                mut current_add_index,
340                mut current_settle_index,
341            )| async move {
342                is_active.store(true, Ordering::SeqCst);
343
344                loop {
345                    tokio::select! {
346                        _ = cancel_token.cancelled() => {
347                            // Stream is cancelled
348                            is_active.store(false, Ordering::SeqCst);
349                            tracing::info!("Waiting for lnd invoice ending");
350                            return None;
351                        }
352                        msg = stream.message() => {
353                            match msg {
354                                Ok(Some(msg)) => {
355                                    // Update indices based on the message
356                                    current_add_index = current_add_index.max(msg.add_index);
357                                    current_settle_index = current_settle_index.max(msg.settle_index);
358
359                                    // Store the updated indices in KV store regardless of settlement status
360                                    let add_index_str = current_add_index.to_string();
361                                    let settle_index_str = current_settle_index.to_string();
362
363                                    if let Ok(mut tx) = kv_store.begin_transaction().await {
364                                        let mut has_error = false;
365
366                                        if let Err(e) = tx.kv_write(LND_KV_PRIMARY_NAMESPACE, LND_KV_SECONDARY_NAMESPACE, LAST_ADD_INDEX_KV_KEY, add_index_str.as_bytes()).await {
367                                            tracing::warn!("LND: Failed to write add_index {} to KV store: {}", current_add_index, e);
368                                            has_error = true;
369                                        }
370
371                                        if let Err(e) = tx.kv_write(LND_KV_PRIMARY_NAMESPACE, LND_KV_SECONDARY_NAMESPACE, LAST_SETTLE_INDEX_KV_KEY, settle_index_str.as_bytes()).await {
372                                            tracing::warn!("LND: Failed to write settle_index {} to KV store: {}", current_settle_index, e);
373                                            has_error = true;
374                                        }
375
376                                        if !has_error {
377                                            if let Err(e) = tx.commit().await {
378                                                tracing::warn!("LND: Failed to commit indices to KV store: {}", e);
379                                            } else {
380                                                tracing::debug!("LND: Stored updated indices - add_index: {}, settle_index: {}", current_add_index, current_settle_index);
381                                            }
382                                        }
383                                    } else {
384                                        tracing::warn!("LND: Failed to begin KV transaction for storing indices");
385                                    }
386
387                                    // Only emit event for settled invoices
388                                    if msg.state() == InvoiceState::Settled {
389                                        let hash_slice: Result<[u8;32], _> = msg.r_hash.try_into();
390
391                                        if let Ok(hash_slice) = hash_slice {
392                                            let hash = hex::encode(hash_slice);
393
394                                            tracing::info!("LND: Payment for {} with amount {} msat", hash,  msg.amt_paid_msat);
395
396                                            let wait_response = WaitPaymentResponse {
397                                                payment_identifier: PaymentIdentifier::PaymentHash(hash_slice),
398                                                payment_amount: Amount::new(msg.amt_paid_msat as u64, CurrencyUnit::Msat),
399                                                payment_id: hash,
400                                            };
401                                            let event = Event::PaymentReceived(wait_response);
402                                            return Some((event, (stream, cancel_token, is_active, kv_store, current_add_index, current_settle_index)));
403                                        } else {
404                                            // Invalid hash, skip this message but continue streaming
405                                            tracing::error!("LND returned invalid payment hash");
406                                            // Continue the loop without yielding
407                                            continue;
408                                        }
409                                    } else {
410                                        // Not a settled invoice, continue but don't emit event
411                                        tracing::debug!("LND: Received non-settled invoice, continuing to wait for settled invoices");
412                                        // Continue the loop without yielding
413                                        continue;
414                                    }
415                                }
416                                Ok(None) => {
417                                    is_active.store(false, Ordering::SeqCst);
418                                    tracing::info!("LND invoice stream ended.");
419                                    return None;
420                                }
421                                Err(err) => {
422                                    is_active.store(false, Ordering::SeqCst);
423                                    tracing::warn!("Encountered error in LND invoice stream. Stream ending");
424                                    tracing::error!("{:?}", err);
425                                    return None;
426                                }
427                            }
428                        }
429                    }
430                }
431            },
432        );
433
434        Ok(Box::pin(event_stream))
435    }
436
437    #[instrument(skip_all)]
438    async fn get_payment_quote(
439        &self,
440        unit: &CurrencyUnit,
441        options: OutgoingPaymentOptions,
442    ) -> Result<PaymentQuoteResponse, Self::Err> {
443        match options {
444            OutgoingPaymentOptions::Bolt11(bolt11_options) => {
445                let amount_msat = match bolt11_options.melt_options {
446                    Some(MeltOptions::Amountless { amountless }) => {
447                        let amount_msat = amountless.amount_msat;
448
449                        if let Some(invoice_amount) = bolt11_options.bolt11.amount_milli_satoshis()
450                        {
451                            if invoice_amount != u64::from(amount_msat) {
452                                return Err(payment::Error::AmountMismatch);
453                            }
454                        }
455
456                        amount_msat
457                    }
458                    Some(MeltOptions::Mpp { mpp }) => mpp.amount,
459                    None => bolt11_options
460                        .bolt11
461                        .amount_milli_satoshis()
462                        .ok_or(Error::UnknownInvoiceAmount)?
463                        .into(),
464                };
465
466                let amount =
467                    Amount::new(amount_msat.into(), CurrencyUnit::Msat).convert_to(unit)?;
468
469                let relative_fee_reserve =
470                    (self.fee_reserve.percent_fee_reserve * amount.value() as f32) as u64;
471
472                let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into();
473
474                let fee = max(relative_fee_reserve, absolute_fee_reserve);
475
476                Ok(PaymentQuoteResponse {
477                    request_lookup_id: Some(PaymentIdentifier::PaymentHash(
478                        *bolt11_options.bolt11.payment_hash().as_ref(),
479                    )),
480                    amount,
481                    fee: Amount::new(fee, unit.clone()),
482                    state: MeltQuoteState::Unpaid,
483                    extra_json: None,
484                    estimated_blocks: None,
485                    fee_options: None,
486                })
487            }
488            OutgoingPaymentOptions::Bolt12(_) => {
489                Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND")))
490            }
491            OutgoingPaymentOptions::Custom(_) | OutgoingPaymentOptions::Onchain(_) => {
492                Err(payment::Error::UnsupportedPaymentOption)
493            }
494        }
495    }
496
497    #[instrument(skip_all)]
498    async fn make_payment(
499        &self,
500        unit: &CurrencyUnit,
501        options: OutgoingPaymentOptions,
502    ) -> Result<MakePaymentResponse, Self::Err> {
503        match options {
504            OutgoingPaymentOptions::Bolt11(bolt11_options) => {
505                let bolt11 = bolt11_options.bolt11;
506                let payment_lookup_id =
507                    PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref());
508
509                // A prior lookup is authoritative evidence, not an error:
510                // report the already-recorded outcome so the mint reconciles
511                // against durable state instead of treating the duplicate melt
512                // as an ambiguous dispatch failure.
513                let pay_state = self.check_outgoing_payment(&payment_lookup_id).await?;
514
515                if let Some(response) = bolt11_pre_dispatch_response(unit, &bolt11, pay_state)? {
516                    return Ok(response);
517                }
518
519                // Detect partial payments
520                match bolt11_options.melt_options {
521                    Some(MeltOptions::Mpp { mpp }) => {
522                        let amount_msat: u64 = match bolt11.amount_milli_satoshis() {
523                            Some(amount_msat) => amount_msat,
524                            None => {
525                                // Invoice carries no amount; a local parse
526                                // failure before any dispatch.
527                                tracing::warn!(
528                                    payment_lookup_id = %payment_lookup_id,
529                                    "LND MPP payment rejected before dispatch: invoice has no amount",
530                                );
531                                return Ok(outgoing_payment_failure_response(
532                                    unit,
533                                    payment_lookup_id,
534                                ));
535                            }
536                        };
537                        {
538                            let partial_amount_msat = mpp.amount;
539                            let invoice = bolt11;
540                            let max_fee: Option<Amount<CurrencyUnit>> =
541                                bolt11_options.max_fee_amount.clone();
542
543                            // Extract information from invoice
544                            let pub_key = invoice.get_payee_pub_key();
545                            let payer_addr = invoice.payment_secret().0.to_vec();
546                            let payment_hash = invoice.payment_hash();
547
548                            let mut lnd_client = self.lnd_client.clone();
549
550                            for attempt in 0..Self::MAX_ROUTE_RETRIES {
551                                // Create a request for the routes
552                                let route_req = lnrpc::QueryRoutesRequest {
553                                    pub_key: hex::encode(pub_key.serialize()),
554                                    amt_msat: u64::from(partial_amount_msat) as i64,
555                                    fee_limit: max_fee
556                                        .clone()
557                                        .map(|f| {
558                                            let fee_msat = f.to_msat()?;
559                                            let limit = Limit::FixedMsat(fee_msat as i64);
560                                            Ok::<_, Error>(FeeLimit { limit: Some(limit) })
561                                        })
562                                        .transpose()?,
563                                    use_mission_control: true,
564                                    ..Default::default()
565                                };
566
567                                // Query the routes
568                                let mut routes_response = lnd_client
569                                    .lightning()
570                                    .query_routes(route_req)
571                                    .await
572                                    .inspect_err(|err| {
573                                        tracing::warn!(
574                                            payment_lookup_id = %payment_lookup_id,
575                                            attempt = attempt + 1,
576                                            rpc_code = %err.code(),
577                                            error = %err.message(),
578                                            "LND MPP route query failed",
579                                        );
580                                    })
581                                    .map_err(Error::LndError)?
582                                    .into_inner();
583
584                                // Get first route and update its MPP record. An
585                                // empty route set means LND found no path; the
586                                // payment was never dispatched.
587                                let route = match routes_response.routes.first_mut() {
588                                    Some(route) => route,
589                                    None => {
590                                        tracing::warn!(
591                                            payment_lookup_id = %payment_lookup_id,
592                                            attempt = attempt + 1,
593                                            "LND MPP route query returned no routes",
594                                        );
595                                        return Ok(outgoing_payment_failure_response(
596                                            unit,
597                                            payment_lookup_id,
598                                        ));
599                                    }
600                                };
601
602                                // attempt it and check the result
603                                let last_hop: &mut Hop = match route.hops.last_mut() {
604                                    Some(last_hop) => last_hop,
605                                    None => {
606                                        tracing::warn!(
607                                            payment_lookup_id = %payment_lookup_id,
608                                            attempt = attempt + 1,
609                                            "LND MPP route has no hops",
610                                        );
611                                        return Ok(outgoing_payment_failure_response(
612                                            unit,
613                                            payment_lookup_id,
614                                        ));
615                                    }
616                                };
617                                let mpp_record = MppRecord {
618                                    payment_addr: payer_addr.clone(),
619                                    total_amt_msat: amount_msat as i64,
620                                };
621                                last_hop.mpp_record = Some(mpp_record);
622
623                                let payment_response = lnd_client
624                                    .router()
625                                    .send_to_route_v2(routerrpc::SendToRouteRequest {
626                                        payment_hash: payment_hash.to_byte_array().to_vec(),
627                                        route: Some(route.clone()),
628                                        ..Default::default()
629                                    })
630                                    .await
631                                    .inspect_err(|err| {
632                                        tracing::warn!(
633                                            payment_lookup_id = %payment_lookup_id,
634                                            attempt = attempt + 1,
635                                            rpc_code = %err.code(),
636                                            error = %err.message(),
637                                            "LND MPP dispatch RPC failed; payment outcome requires verification",
638                                        );
639                                    })
640                                    .map_err(Error::LndError)?
641                                    .into_inner();
642
643                                if let Some(failure) = payment_response.failure {
644                                    if failure.code == 15 {
645                                        tracing::debug!(
646                                            payment_lookup_id = %payment_lookup_id,
647                                            attempt = attempt + 1,
648                                            failure_code = failure.code,
649                                            failure_reason = failure.code().as_str_name(),
650                                            failure_source_index = failure.failure_source_index,
651                                            "LND MPP route failed; querying another route",
652                                        );
653                                        continue;
654                                    }
655                                    tracing::warn!(
656                                        payment_lookup_id = %payment_lookup_id,
657                                        attempt = attempt + 1,
658                                        failure_code = failure.code,
659                                        failure_reason = failure.code().as_str_name(),
660                                        failure_source_index = failure.failure_source_index,
661                                        "LND MPP attempt returned a failure",
662                                    );
663                                }
664
665                                // Get status and maybe the preimage
666                                let (status, payment_preimage) = match payment_response.status {
667                                    0 => (MeltQuoteState::Pending, None),
668                                    1 => (
669                                        MeltQuoteState::Paid,
670                                        Some(hex::encode(payment_response.preimage)),
671                                    ),
672                                    2 => (MeltQuoteState::Unpaid, None),
673                                    _ => (MeltQuoteState::Unknown, None),
674                                };
675
676                                // Get the actual amount paid in msats
677                                let total_amt_msat: u64 = payment_response
678                                    .route
679                                    .map_or(0, |route| route.total_amt_msat as u64);
680
681                                return Ok(MakePaymentResponse {
682                                    payment_lookup_id: PaymentIdentifier::PaymentHash(
683                                        payment_hash.to_byte_array(),
684                                    ),
685                                    payment_proof: payment_preimage,
686                                    status,
687                                    total_spent: msat_total_spent_for_unit(total_amt_msat, unit)?,
688                                });
689                            }
690
691                            // "We have exhausted all tactical options" -- STEM, Upgrade (2018)
692                            // All route attempts returned retryable failures.
693                            tracing::warn!(
694                                payment_lookup_id = %payment_lookup_id,
695                                attempts = Self::MAX_ROUTE_RETRIES,
696                                "LND MPP payment exhausted route retries",
697                            );
698                            Ok(outgoing_payment_failure_response(unit, payment_lookup_id))
699                        }
700                    }
701                    _ => {
702                        let mut lnd_client = self.lnd_client.clone();
703
704                        let max_fee: Option<Amount<CurrencyUnit>> = bolt11_options.max_fee_amount;
705
706                        let amount_msat = match bolt11_options.melt_options {
707                            Some(MeltOptions::Amountless { amountless }) => {
708                                let amount_msat = amountless.amount_msat;
709
710                                if let Some(invoice_amount) = bolt11.amount_milli_satoshis() {
711                                    if invoice_amount != u64::from(amount_msat) {
712                                        // Invoice/request amount disagreement is
713                                        // a local validation failure, before any
714                                        // dispatch to LND.
715                                        tracing::warn!(
716                                            payment_lookup_id = %payment_lookup_id,
717                                            invoice_amount_msat = invoice_amount,
718                                            requested_amount_msat = u64::from(amount_msat),
719                                            "LND payment rejected before dispatch: invoice and requested amounts differ",
720                                        );
721                                        return Ok(outgoing_payment_failure_response(
722                                            unit,
723                                            payment_lookup_id,
724                                        ));
725                                    }
726                                }
727
728                                u64::from(amount_msat)
729                            }
730                            Some(MeltOptions::Mpp { mpp }) => u64::from(mpp.amount),
731                            None => 0,
732                        };
733
734                        let fee_limit_msat = match max_fee {
735                            Some(fee) => fee.convert_to(&CurrencyUnit::Msat)?.value() as i64,
736                            None => 0,
737                        };
738
739                        let pay_req = routerrpc::SendPaymentRequest {
740                            payment_request: bolt11.to_string(),
741                            fee_limit_msat,
742                            amt_msat: amount_msat as i64,
743                            ..Default::default()
744                        };
745
746                        let mut payment_stream = lnd_client
747                            .router()
748                            .send_payment_v2(pay_req)
749                            .await
750                            .map_err(|err| {
751                                tracing::warn!(
752                                    payment_lookup_id = %payment_lookup_id,
753                                    rpc_code = %err.code(),
754                                    error = %err.message(),
755                                    "LND payment dispatch RPC failed; payment outcome requires verification",
756                                );
757                                // A gRPC error here may arrive after LND accepted
758                                // the payment; the dispatch outcome is unknown.
759                                Error::AmbiguousDispatch
760                            })?
761                            .into_inner();
762
763                        while let Some(update) = payment_stream.message().await.map_err(|err| {
764                            tracing::warn!(
765                                payment_lookup_id = %payment_lookup_id,
766                                rpc_code = %err.code(),
767                                error = %err.message(),
768                                "LND payment stream failed after dispatch; payment may still settle",
769                            );
770                            // The stream dropped after dispatch began; the payment
771                            // may still settle.
772                            Error::AmbiguousDispatch
773                        })? {
774                            let status = update.status();
775
776                            let response_status = match status {
777                                PaymentStatus::InFlight | PaymentStatus::Initiated => {
778                                    continue;
779                                }
780                                PaymentStatus::Succeeded => MeltQuoteState::Paid,
781                                PaymentStatus::Failed => {
782                                    tracing::warn!(
783                                        payment_lookup_id = %payment_lookup_id,
784                                        failure_code = update.failure_reason,
785                                        failure_reason = update.failure_reason().as_str_name(),
786                                        "LND outgoing payment failed",
787                                    );
788                                    MeltQuoteState::Failed
789                                }
790                                #[allow(deprecated)]
791                                PaymentStatus::Unknown => MeltQuoteState::Unknown,
792                            };
793
794                            let total_msat = update
795                                .value_msat
796                                .checked_add(update.fee_msat)
797                                .ok_or(Error::AmountOverflow)?;
798
799                            let payment_preimage = if update.payment_preimage.is_empty() {
800                                None
801                            } else {
802                                Some(update.payment_preimage)
803                            };
804
805                            let payment_identifier =
806                                PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref());
807
808                            return Ok(MakePaymentResponse {
809                                payment_lookup_id: payment_identifier,
810                                payment_proof: payment_preimage,
811                                status: response_status,
812                                total_spent: msat_total_spent_for_unit(total_msat as u64, unit)?,
813                            });
814                        }
815
816                        tracing::warn!(
817                            payment_lookup_id = %payment_lookup_id,
818                            "LND payment stream ended without a terminal result; payment outcome remains unknown",
819                        );
820                        Err(Error::UnknownPaymentStatus.into())
821                    }
822                }
823            }
824            OutgoingPaymentOptions::Bolt12(_) => {
825                Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND")))
826            }
827            OutgoingPaymentOptions::Custom(_) | OutgoingPaymentOptions::Onchain(_) => {
828                Err(payment::Error::UnsupportedPaymentOption)
829            }
830        }
831    }
832
833    #[instrument(skip(self, options))]
834    async fn create_incoming_payment_request(
835        &self,
836        options: IncomingPaymentOptions,
837    ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
838        match options {
839            IncomingPaymentOptions::Bolt11(bolt11_options) => {
840                let description = bolt11_options.description.unwrap_or_default();
841                let amount = bolt11_options.amount;
842                let unix_expiry = bolt11_options.unix_expiry;
843
844                let amount_msat: Amount = amount.convert_to(&CurrencyUnit::Msat)?.into();
845
846                let invoice_request = lnrpc::Invoice {
847                    value_msat: u64::from(amount_msat) as i64,
848                    memo: description,
849                    expiry: unix_expiry
850                        .map(|t| {
851                            t.checked_sub(unix_time())
852                                .ok_or(payment::Error::InvalidExpiry)
853                        })
854                        .transpose()?
855                        .unwrap_or_default() as i64,
856                    ..Default::default()
857                };
858
859                let mut lnd_client = self.lnd_client.clone();
860
861                let invoice = lnd_client
862                    .lightning()
863                    .add_invoice(tonic::Request::new(invoice_request))
864                    .await
865                    .map_err(|e| payment::Error::Anyhow(anyhow!(e)))?
866                    .into_inner();
867
868                let bolt11 = Bolt11Invoice::from_str(&invoice.payment_request)?;
869
870                let payment_identifier =
871                    PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref());
872
873                let expiry = bolt11.expires_at().map(|t| t.as_secs());
874
875                Ok(CreateIncomingPaymentResponse {
876                    request_lookup_id: payment_identifier,
877                    request: bolt11.to_string(),
878                    expiry,
879                    extra_json: None,
880                })
881            }
882            IncomingPaymentOptions::Bolt12(_) => {
883                Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND")))
884            }
885            IncomingPaymentOptions::Custom(_) | IncomingPaymentOptions::Onchain(_) => {
886                Err(payment::Error::UnsupportedPaymentOption)
887            }
888        }
889    }
890
891    #[instrument(skip(self))]
892    async fn check_incoming_payment_status(
893        &self,
894        payment_identifier: &PaymentIdentifier,
895    ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
896        let mut lnd_client = self.lnd_client.clone();
897
898        let invoice_request = lnrpc::PaymentHash {
899            r_hash: hex::decode(payment_identifier.to_string())?,
900            ..Default::default()
901        };
902
903        let invoice = lnd_client
904            .lightning()
905            .lookup_invoice(tonic::Request::new(invoice_request))
906            .await
907            .map_err(|e| payment::Error::Anyhow(anyhow!(e)))?
908            .into_inner();
909
910        if invoice.state() == InvoiceState::Settled {
911            Ok(vec![WaitPaymentResponse {
912                payment_identifier: payment_identifier.clone(),
913                payment_amount: Amount::new(invoice.amt_paid_msat as u64, CurrencyUnit::Msat),
914                payment_id: hex::encode(invoice.r_hash),
915            }])
916        } else {
917            Ok(vec![])
918        }
919    }
920
921    #[instrument(skip(self))]
922    async fn check_outgoing_payment(
923        &self,
924        payment_identifier: &PaymentIdentifier,
925    ) -> Result<MakePaymentResponse, Self::Err> {
926        let mut lnd_client = self.lnd_client.clone();
927
928        let payment_hash = &payment_identifier.to_string();
929
930        let track_request = routerrpc::TrackPaymentRequest {
931            payment_hash: hex::decode(payment_hash).map_err(|_| Error::InvalidHash)?,
932            no_inflight_updates: true,
933        };
934
935        let payment_response = lnd_client.router().track_payment_v2(track_request).await;
936
937        let mut payment_stream = match payment_response {
938            Ok(stream) => stream.into_inner(),
939            Err(err) => {
940                let err_code = err.code();
941                if err_code == tonic::Code::NotFound {
942                    tracing::debug!(
943                        payment_lookup_id = %payment_identifier,
944                        "LND does not know this outgoing payment; reporting Unknown because absence is not authoritative proof of permanent failure",
945                    );
946                    return Ok(MakePaymentResponse {
947                        payment_lookup_id: payment_identifier.clone(),
948                        payment_proof: None,
949                        status: MeltQuoteState::Unknown,
950                        total_spent: Amount::new(0, self.unit.clone()),
951                    });
952                } else {
953                    tracing::warn!(
954                        payment_lookup_id = %payment_identifier,
955                        rpc_code = %err_code,
956                        error = %err.message(),
957                        "LND outgoing payment status RPC failed; payment outcome remains unknown",
958                    );
959                    return Err(payment::Error::UnknownPaymentState);
960                }
961            }
962        };
963
964        while let Some(update_result) = payment_stream.next().await {
965            match update_result {
966                Ok(update) => {
967                    let status = update.status();
968
969                    let response = match status {
970                        #[allow(deprecated)]
971                        PaymentStatus::Unknown => MakePaymentResponse {
972                            payment_lookup_id: payment_identifier.clone(),
973                            payment_proof: Some(update.payment_preimage),
974                            status: MeltQuoteState::Unknown,
975                            total_spent: Amount::new(0, self.unit.clone()),
976                        },
977                        PaymentStatus::InFlight | PaymentStatus::Initiated => {
978                            // Continue waiting for the next update
979                            continue;
980                        }
981                        PaymentStatus::Succeeded => {
982                            let total_spent = lnrpc_payment_total_spent(&update)?;
983
984                            MakePaymentResponse {
985                                payment_lookup_id: payment_identifier.clone(),
986                                payment_proof: Some(update.payment_preimage),
987                                status: MeltQuoteState::Paid,
988                                total_spent,
989                            }
990                        }
991                        PaymentStatus::Failed => {
992                            // Status checks also run before dispatch and may
993                            // repeatedly observe the same recorded failure.
994                            tracing::debug!(
995                                payment_lookup_id = %payment_identifier,
996                                failure_code = update.failure_reason,
997                                failure_reason = update.failure_reason().as_str_name(),
998                                "LND outgoing payment status is failed",
999                            );
1000                            MakePaymentResponse {
1001                                payment_lookup_id: payment_identifier.clone(),
1002                                payment_proof: Some(update.payment_preimage),
1003                                status: MeltQuoteState::Failed,
1004                                total_spent: Amount::new(0, self.unit.clone()),
1005                            }
1006                        }
1007                    };
1008
1009                    return Ok(response);
1010                }
1011                Err(err) => {
1012                    // Handle the case where the update itself is an error (e.g., stream failure)
1013                    tracing::warn!(
1014                        payment_lookup_id = %payment_identifier,
1015                        rpc_code = %err.code(),
1016                        error = %err.message(),
1017                        "LND outgoing payment status stream failed; payment outcome remains unknown",
1018                    );
1019                    return Err(Error::UnknownPaymentStatus.into());
1020                }
1021            }
1022        }
1023
1024        // If the stream is exhausted without a final status
1025        tracing::warn!(
1026            payment_lookup_id = %payment_identifier,
1027            "LND outgoing payment status stream ended without a terminal result; payment outcome remains unknown",
1028        );
1029        Err(Error::UnknownPaymentStatus.into())
1030    }
1031}
1032
1033#[cfg(test)]
1034mod tests {
1035    use std::time::Duration;
1036
1037    use cdk_common::bitcoin::hashes::sha256;
1038    use cdk_common::bitcoin::secp256k1::{Secp256k1, SecretKey};
1039    use cdk_common::lightning_invoice::{Currency, InvoiceBuilder, PaymentSecret};
1040
1041    use super::*;
1042
1043    fn invoice_with_timestamp(timestamp: Duration) -> Bolt11Invoice {
1044        let key = SecretKey::from_slice(&[1; 32]).unwrap();
1045        InvoiceBuilder::new(Currency::Regtest)
1046            .description("expiry test".to_owned())
1047            .payment_hash(sha256::Hash::from_byte_array([42; 32]))
1048            .payment_secret(PaymentSecret([43; 32]))
1049            .duration_since_epoch(timestamp)
1050            .expiry_time(Duration::from_secs(3600))
1051            .min_final_cltv_expiry_delta(144)
1052            .build_signed(|hash| Secp256k1::new().sign_ecdsa_recoverable(hash, &key))
1053            .unwrap()
1054    }
1055
1056    #[test]
1057    fn expired_invoice_without_active_payment_fails_before_dispatch() {
1058        let invoice = invoice_with_timestamp(Duration::from_secs(1));
1059        let payment_lookup_id = PaymentIdentifier::PaymentHash(*invoice.payment_hash().as_ref());
1060
1061        for status in [
1062            MeltQuoteState::Unknown,
1063            MeltQuoteState::Unpaid,
1064            MeltQuoteState::Failed,
1065        ] {
1066            let pay_state = MakePaymentResponse {
1067                status,
1068                ..outgoing_payment_failure_response(&CurrencyUnit::Msat, payment_lookup_id.clone())
1069            };
1070            let response = bolt11_pre_dispatch_response(&CurrencyUnit::Sat, &invoice, pay_state)
1071                .unwrap()
1072                .unwrap();
1073
1074            assert_eq!(response.status, MeltQuoteState::Failed);
1075            assert_eq!(response.payment_lookup_id, payment_lookup_id);
1076            assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
1077            assert!(response.payment_proof.is_none());
1078        }
1079    }
1080
1081    #[test]
1082    fn existing_payment_response_uses_requested_unit() {
1083        for timestamp in [Duration::from_secs(1), Duration::from_secs(unix_time())] {
1084            let invoice = invoice_with_timestamp(timestamp);
1085            let payment_lookup_id =
1086                PaymentIdentifier::PaymentHash(*invoice.payment_hash().as_ref());
1087
1088            for status in [MeltQuoteState::Paid, MeltQuoteState::Pending] {
1089                for (total_msat, total_sat) in [(0, 0), (1_234, 2), (2_000, 2)] {
1090                    for (unit, expected) in [
1091                        (CurrencyUnit::Sat, total_sat),
1092                        (CurrencyUnit::Msat, total_msat),
1093                    ] {
1094                        let pay_state = MakePaymentResponse {
1095                            payment_lookup_id: payment_lookup_id.clone(),
1096                            payment_proof: Some("existing preimage".to_owned()),
1097                            status,
1098                            total_spent: Amount::new(total_msat, CurrencyUnit::Msat),
1099                        };
1100                        let response = bolt11_pre_dispatch_response(&unit, &invoice, pay_state)
1101                            .unwrap()
1102                            .unwrap();
1103
1104                        assert_eq!(response.status, status);
1105                        assert_eq!(response.payment_lookup_id, payment_lookup_id);
1106                        assert_eq!(response.total_spent, Amount::new(expected, unit));
1107                        assert_eq!(response.payment_proof.as_deref(), Some("existing preimage"));
1108                    }
1109                }
1110            }
1111        }
1112    }
1113
1114    #[test]
1115    fn unexpired_invoice_without_active_payment_can_dispatch() {
1116        let invoice = invoice_with_timestamp(Duration::from_secs(unix_time()));
1117        let payment_lookup_id = PaymentIdentifier::PaymentHash(*invoice.payment_hash().as_ref());
1118
1119        for status in [
1120            MeltQuoteState::Unknown,
1121            MeltQuoteState::Unpaid,
1122            MeltQuoteState::Failed,
1123        ] {
1124            let pay_state = MakePaymentResponse {
1125                status,
1126                ..outgoing_payment_failure_response(&CurrencyUnit::Msat, payment_lookup_id.clone())
1127            };
1128            assert!(
1129                bolt11_pre_dispatch_response(&CurrencyUnit::Sat, &invoice, pay_state)
1130                    .unwrap()
1131                    .is_none()
1132            );
1133        }
1134    }
1135
1136    #[test]
1137    fn lnrpc_payment_total_spent_uses_msat_fields() {
1138        let payment = lnrpc::Payment {
1139            value_msat: 1500,
1140            fee_msat: 500,
1141            value_sat: 1,
1142            fee_sat: 0,
1143            ..Default::default()
1144        };
1145
1146        let total_spent = lnrpc_payment_total_spent(&payment)
1147            .expect("sub-sat payment total should be calculated");
1148
1149        assert_eq!(
1150            total_spent
1151                .convert_to(&CurrencyUnit::Msat)
1152                .expect("msat amount should convert to msat")
1153                .value(),
1154            2000
1155        );
1156    }
1157
1158    #[test]
1159    fn lnrpc_payment_total_spent_rejects_overflow() {
1160        let payment = lnrpc::Payment {
1161            value_msat: i64::MAX,
1162            fee_msat: 1,
1163            ..Default::default()
1164        };
1165
1166        let err = lnrpc_payment_total_spent(&payment)
1167            .expect_err("overflowing payment total should be rejected");
1168
1169        assert!(matches!(err, Error::AmountOverflow));
1170    }
1171
1172    #[test]
1173    fn msat_total_spent_for_unit_rounds_up_sats() {
1174        let total_spent = msat_total_spent_for_unit(1501, &CurrencyUnit::Sat)
1175            .expect("msat total should convert to sat");
1176
1177        assert_eq!(total_spent, Amount::new(2, CurrencyUnit::Sat));
1178    }
1179
1180    #[test]
1181    fn authoritative_outgoing_failure_response_is_terminal_and_spends_nothing() {
1182        let payment_lookup_id = PaymentIdentifier::PaymentHash([42; 32]);
1183        let response =
1184            outgoing_payment_failure_response(&CurrencyUnit::Sat, payment_lookup_id.clone());
1185
1186        assert_eq!(response.payment_lookup_id, payment_lookup_id);
1187        assert_eq!(response.status, MeltQuoteState::Failed);
1188        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
1189        assert!(response.payment_proof.is_none());
1190    }
1191
1192    /// The dispatch-boundary variants must remain distinct from the
1193    /// pre-dispatch `PaymentFailed`, so only the former stay `Err` (ambiguous)
1194    /// and the latter can be converted to an authoritative `Failed` response.
1195    /// This guards against a future change re-collapsing the two.
1196    #[test]
1197    fn dispatch_boundary_errors_are_distinct_from_pre_dispatch_failure() {
1198        // `AmbiguousDispatch` is returned by send_* / stream failures (may have
1199        // been accepted by LND) and must never be treated as a terminal
1200        // pre-dispatch failure. It is a separate variant from `PaymentFailed`.
1201        assert_ne!(
1202            Error::AmbiguousDispatch.to_string(),
1203            Error::PaymentFailed.to_string()
1204        );
1205        assert_ne!(
1206            Error::UnknownPaymentStatus.to_string(),
1207            Error::PaymentFailed.to_string()
1208        );
1209    }
1210}