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#[async_trait]
243impl MintPayment for Lnd {
244    type Err = payment::Error;
245
246    #[instrument(skip_all)]
247    async fn get_settings(&self) -> Result<SettingsResponse, Self::Err> {
248        Ok(self.settings.clone())
249    }
250
251    #[instrument(skip_all)]
252    fn is_payment_event_stream_active(&self) -> bool {
253        self.wait_invoice_is_active.load(Ordering::SeqCst)
254    }
255
256    #[instrument(skip_all)]
257    fn cancel_payment_event_stream(&self) {
258        self.wait_invoice_cancel_token.cancel()
259    }
260
261    #[instrument(skip_all)]
262    async fn wait_payment_event(
263        &self,
264    ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
265        let mut lnd_client = self.lnd_client.clone();
266
267        // Get last indices from KV store
268        let (last_add_index, last_settle_index) =
269            self.get_last_indices().await.unwrap_or((None, None));
270
271        let stream_req = lnrpc::InvoiceSubscription {
272            add_index: last_add_index.unwrap_or(0),
273            settle_index: last_settle_index.unwrap_or(0),
274        };
275
276        tracing::debug!(
277            "LND: Starting invoice subscription with add_index: {}, settle_index: {}",
278            stream_req.add_index,
279            stream_req.settle_index
280        );
281
282        let stream = lnd_client
283            .lightning()
284            .subscribe_invoices(stream_req)
285            .await
286            .map_err(|_err| {
287                tracing::error!("Could not subscribe to invoice");
288                Error::Connection
289            })?
290            .into_inner();
291
292        let cancel_token = self.wait_invoice_cancel_token.clone();
293        let kv_store = self.kv_store.clone();
294
295        let event_stream = futures::stream::unfold(
296            (
297                stream,
298                cancel_token,
299                Arc::clone(&self.wait_invoice_is_active),
300                kv_store,
301                last_add_index.unwrap_or(0),
302                last_settle_index.unwrap_or(0),
303            ),
304            |(
305                mut stream,
306                cancel_token,
307                is_active,
308                kv_store,
309                mut current_add_index,
310                mut current_settle_index,
311            )| async move {
312                is_active.store(true, Ordering::SeqCst);
313
314                loop {
315                    tokio::select! {
316                        _ = cancel_token.cancelled() => {
317                            // Stream is cancelled
318                            is_active.store(false, Ordering::SeqCst);
319                            tracing::info!("Waiting for lnd invoice ending");
320                            return None;
321                        }
322                        msg = stream.message() => {
323                            match msg {
324                                Ok(Some(msg)) => {
325                                    // Update indices based on the message
326                                    current_add_index = current_add_index.max(msg.add_index);
327                                    current_settle_index = current_settle_index.max(msg.settle_index);
328
329                                    // Store the updated indices in KV store regardless of settlement status
330                                    let add_index_str = current_add_index.to_string();
331                                    let settle_index_str = current_settle_index.to_string();
332
333                                    if let Ok(mut tx) = kv_store.begin_transaction().await {
334                                        let mut has_error = false;
335
336                                        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 {
337                                            tracing::warn!("LND: Failed to write add_index {} to KV store: {}", current_add_index, e);
338                                            has_error = true;
339                                        }
340
341                                        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 {
342                                            tracing::warn!("LND: Failed to write settle_index {} to KV store: {}", current_settle_index, e);
343                                            has_error = true;
344                                        }
345
346                                        if !has_error {
347                                            if let Err(e) = tx.commit().await {
348                                                tracing::warn!("LND: Failed to commit indices to KV store: {}", e);
349                                            } else {
350                                                tracing::debug!("LND: Stored updated indices - add_index: {}, settle_index: {}", current_add_index, current_settle_index);
351                                            }
352                                        }
353                                    } else {
354                                        tracing::warn!("LND: Failed to begin KV transaction for storing indices");
355                                    }
356
357                                    // Only emit event for settled invoices
358                                    if msg.state() == InvoiceState::Settled {
359                                        let hash_slice: Result<[u8;32], _> = msg.r_hash.try_into();
360
361                                        if let Ok(hash_slice) = hash_slice {
362                                            let hash = hex::encode(hash_slice);
363
364                                            tracing::info!("LND: Payment for {} with amount {} msat", hash,  msg.amt_paid_msat);
365
366                                            let wait_response = WaitPaymentResponse {
367                                                payment_identifier: PaymentIdentifier::PaymentHash(hash_slice),
368                                                payment_amount: Amount::new(msg.amt_paid_msat as u64, CurrencyUnit::Msat),
369                                                payment_id: hash,
370                                            };
371                                            let event = Event::PaymentReceived(wait_response);
372                                            return Some((event, (stream, cancel_token, is_active, kv_store, current_add_index, current_settle_index)));
373                                        } else {
374                                            // Invalid hash, skip this message but continue streaming
375                                            tracing::error!("LND returned invalid payment hash");
376                                            // Continue the loop without yielding
377                                            continue;
378                                        }
379                                    } else {
380                                        // Not a settled invoice, continue but don't emit event
381                                        tracing::debug!("LND: Received non-settled invoice, continuing to wait for settled invoices");
382                                        // Continue the loop without yielding
383                                        continue;
384                                    }
385                                }
386                                Ok(None) => {
387                                    is_active.store(false, Ordering::SeqCst);
388                                    tracing::info!("LND invoice stream ended.");
389                                    return None;
390                                }
391                                Err(err) => {
392                                    is_active.store(false, Ordering::SeqCst);
393                                    tracing::warn!("Encountered error in LND invoice stream. Stream ending");
394                                    tracing::error!("{:?}", err);
395                                    return None;
396                                }
397                            }
398                        }
399                    }
400                }
401            },
402        );
403
404        Ok(Box::pin(event_stream))
405    }
406
407    #[instrument(skip_all)]
408    async fn get_payment_quote(
409        &self,
410        unit: &CurrencyUnit,
411        options: OutgoingPaymentOptions,
412    ) -> Result<PaymentQuoteResponse, Self::Err> {
413        match options {
414            OutgoingPaymentOptions::Bolt11(bolt11_options) => {
415                let amount_msat = match bolt11_options.melt_options {
416                    Some(MeltOptions::Amountless { amountless }) => {
417                        let amount_msat = amountless.amount_msat;
418
419                        if let Some(invoice_amount) = bolt11_options.bolt11.amount_milli_satoshis()
420                        {
421                            if invoice_amount != u64::from(amount_msat) {
422                                return Err(payment::Error::AmountMismatch);
423                            }
424                        }
425
426                        amount_msat
427                    }
428                    Some(MeltOptions::Mpp { mpp }) => mpp.amount,
429                    None => bolt11_options
430                        .bolt11
431                        .amount_milli_satoshis()
432                        .ok_or(Error::UnknownInvoiceAmount)?
433                        .into(),
434                };
435
436                let amount =
437                    Amount::new(amount_msat.into(), CurrencyUnit::Msat).convert_to(unit)?;
438
439                let relative_fee_reserve =
440                    (self.fee_reserve.percent_fee_reserve * amount.value() as f32) as u64;
441
442                let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into();
443
444                let fee = max(relative_fee_reserve, absolute_fee_reserve);
445
446                Ok(PaymentQuoteResponse {
447                    request_lookup_id: Some(PaymentIdentifier::PaymentHash(
448                        *bolt11_options.bolt11.payment_hash().as_ref(),
449                    )),
450                    amount,
451                    fee: Amount::new(fee, unit.clone()),
452                    state: MeltQuoteState::Unpaid,
453                    extra_json: None,
454                    estimated_blocks: None,
455                    fee_options: None,
456                })
457            }
458            OutgoingPaymentOptions::Bolt12(_) => {
459                Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND")))
460            }
461            OutgoingPaymentOptions::Custom(_) | OutgoingPaymentOptions::Onchain(_) => {
462                Err(payment::Error::UnsupportedPaymentOption)
463            }
464        }
465    }
466
467    #[instrument(skip_all)]
468    async fn make_payment(
469        &self,
470        unit: &CurrencyUnit,
471        options: OutgoingPaymentOptions,
472    ) -> Result<MakePaymentResponse, Self::Err> {
473        match options {
474            OutgoingPaymentOptions::Bolt11(bolt11_options) => {
475                let bolt11 = bolt11_options.bolt11;
476                let payment_lookup_id =
477                    PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref());
478
479                // A prior lookup is authoritative evidence, not an error:
480                // report the already-recorded outcome so the mint reconciles
481                // against durable state instead of treating the duplicate melt
482                // as an ambiguous dispatch failure.
483                let pay_state = self.check_outgoing_payment(&payment_lookup_id).await?;
484
485                match pay_state.status {
486                    MeltQuoteState::Unpaid | MeltQuoteState::Unknown | MeltQuoteState::Failed => (),
487                    MeltQuoteState::Paid => {
488                        tracing::debug!("Melt attempted on invoice already paid");
489                        return Ok(MakePaymentResponse {
490                            payment_lookup_id: payment_lookup_id.clone(),
491                            ..pay_state
492                        });
493                    }
494                    MeltQuoteState::Pending => {
495                        tracing::debug!("Melt attempted on invoice already pending");
496                        return Ok(MakePaymentResponse {
497                            payment_lookup_id: payment_lookup_id.clone(),
498                            ..pay_state
499                        });
500                    }
501                }
502
503                // Detect partial payments
504                match bolt11_options.melt_options {
505                    Some(MeltOptions::Mpp { mpp }) => {
506                        let amount_msat: u64 = match bolt11.amount_milli_satoshis() {
507                            Some(amount_msat) => amount_msat,
508                            None => {
509                                // Invoice carries no amount; a local parse
510                                // failure before any dispatch.
511                                return Ok(outgoing_payment_failure_response(
512                                    unit,
513                                    payment_lookup_id,
514                                ));
515                            }
516                        };
517                        {
518                            let partial_amount_msat = mpp.amount;
519                            let invoice = bolt11;
520                            let max_fee: Option<Amount<CurrencyUnit>> =
521                                bolt11_options.max_fee_amount.clone();
522
523                            // Extract information from invoice
524                            let pub_key = invoice.get_payee_pub_key();
525                            let payer_addr = invoice.payment_secret().0.to_vec();
526                            let payment_hash = invoice.payment_hash();
527
528                            let mut lnd_client = self.lnd_client.clone();
529
530                            for attempt in 0..Self::MAX_ROUTE_RETRIES {
531                                // Create a request for the routes
532                                let route_req = lnrpc::QueryRoutesRequest {
533                                    pub_key: hex::encode(pub_key.serialize()),
534                                    amt_msat: u64::from(partial_amount_msat) as i64,
535                                    fee_limit: max_fee
536                                        .clone()
537                                        .map(|f| {
538                                            let fee_msat = f.to_msat()?;
539                                            let limit = Limit::FixedMsat(fee_msat as i64);
540                                            Ok::<_, Error>(FeeLimit { limit: Some(limit) })
541                                        })
542                                        .transpose()?,
543                                    use_mission_control: true,
544                                    ..Default::default()
545                                };
546
547                                // Query the routes
548                                let mut routes_response = lnd_client
549                                    .lightning()
550                                    .query_routes(route_req)
551                                    .await
552                                    .map_err(Error::LndError)?
553                                    .into_inner();
554
555                                // Get first route and update its MPP record. An
556                                // empty route set means LND found no path; the
557                                // payment was never dispatched.
558                                let route = match routes_response.routes.first_mut() {
559                                    Some(route) => route,
560                                    None => {
561                                        return Ok(outgoing_payment_failure_response(
562                                            unit,
563                                            payment_lookup_id,
564                                        ));
565                                    }
566                                };
567
568                                // attempt it and check the result
569                                let last_hop: &mut Hop = match route.hops.last_mut() {
570                                    Some(last_hop) => last_hop,
571                                    None => {
572                                        return Ok(outgoing_payment_failure_response(
573                                            unit,
574                                            payment_lookup_id,
575                                        ));
576                                    }
577                                };
578                                let mpp_record = MppRecord {
579                                    payment_addr: payer_addr.clone(),
580                                    total_amt_msat: amount_msat as i64,
581                                };
582                                last_hop.mpp_record = Some(mpp_record);
583
584                                let payment_response = lnd_client
585                                    .router()
586                                    .send_to_route_v2(routerrpc::SendToRouteRequest {
587                                        payment_hash: payment_hash.to_byte_array().to_vec(),
588                                        route: Some(route.clone()),
589                                        ..Default::default()
590                                    })
591                                    .await
592                                    .map_err(Error::LndError)?
593                                    .into_inner();
594
595                                if let Some(failure) = payment_response.failure {
596                                    if failure.code == 15 {
597                                        tracing::debug!(
598                                            "Attempt number {}: route has failed. Re-querying...",
599                                            attempt + 1
600                                        );
601                                        continue;
602                                    }
603                                }
604
605                                // Get status and maybe the preimage
606                                let (status, payment_preimage) = match payment_response.status {
607                                    0 => (MeltQuoteState::Pending, None),
608                                    1 => (
609                                        MeltQuoteState::Paid,
610                                        Some(hex::encode(payment_response.preimage)),
611                                    ),
612                                    2 => (MeltQuoteState::Unpaid, None),
613                                    _ => (MeltQuoteState::Unknown, None),
614                                };
615
616                                // Get the actual amount paid in msats
617                                let total_amt_msat: u64 = payment_response
618                                    .route
619                                    .map_or(0, |route| route.total_amt_msat as u64);
620
621                                return Ok(MakePaymentResponse {
622                                    payment_lookup_id: PaymentIdentifier::PaymentHash(
623                                        payment_hash.to_byte_array(),
624                                    ),
625                                    payment_proof: payment_preimage,
626                                    status,
627                                    total_spent: msat_total_spent_for_unit(total_amt_msat, unit)?,
628                                });
629                            }
630
631                            // "We have exhausted all tactical options" -- STEM, Upgrade (2018)
632                            // Every route query ended in a no-route result, so
633                            // no payment was ever dispatched.
634                            tracing::error!("Limit of retries reached, payment couldn't succeed.");
635                            Ok(outgoing_payment_failure_response(unit, payment_lookup_id))
636                        }
637                    }
638                    _ => {
639                        let mut lnd_client = self.lnd_client.clone();
640
641                        let max_fee: Option<Amount<CurrencyUnit>> = bolt11_options.max_fee_amount;
642
643                        let amount_msat = match bolt11_options.melt_options {
644                            Some(MeltOptions::Amountless { amountless }) => {
645                                let amount_msat = amountless.amount_msat;
646
647                                if let Some(invoice_amount) = bolt11.amount_milli_satoshis() {
648                                    if invoice_amount != u64::from(amount_msat) {
649                                        // Invoice/request amount disagreement is
650                                        // a local validation failure, before any
651                                        // dispatch to LND.
652                                        return Ok(outgoing_payment_failure_response(
653                                            unit,
654                                            payment_lookup_id,
655                                        ));
656                                    }
657                                }
658
659                                u64::from(amount_msat)
660                            }
661                            Some(MeltOptions::Mpp { mpp }) => u64::from(mpp.amount),
662                            None => 0,
663                        };
664
665                        let fee_limit_msat = match max_fee {
666                            Some(fee) => fee.convert_to(&CurrencyUnit::Msat)?.value() as i64,
667                            None => 0,
668                        };
669
670                        let pay_req = routerrpc::SendPaymentRequest {
671                            payment_request: bolt11.to_string(),
672                            fee_limit_msat,
673                            amt_msat: amount_msat as i64,
674                            ..Default::default()
675                        };
676
677                        let mut payment_stream = lnd_client
678                            .router()
679                            .send_payment_v2(pay_req)
680                            .await
681                            .map_err(|err| {
682                                tracing::warn!("Lightning payment dispatch error: {}", err);
683                                // A gRPC error here may arrive after LND accepted
684                                // the payment; the dispatch outcome is unknown.
685                                Error::AmbiguousDispatch
686                            })?
687                            .into_inner();
688
689                        while let Some(update) = payment_stream.message().await.map_err(|err| {
690                            tracing::warn!("Lightning payment stream error: {}", err);
691                            // The stream dropped after dispatch began; the payment
692                            // may still settle.
693                            Error::AmbiguousDispatch
694                        })? {
695                            let status = update.status();
696
697                            let response_status = match status {
698                                PaymentStatus::InFlight | PaymentStatus::Initiated => {
699                                    continue;
700                                }
701                                PaymentStatus::Succeeded => MeltQuoteState::Paid,
702                                PaymentStatus::Failed => MeltQuoteState::Failed,
703                                #[allow(deprecated)]
704                                PaymentStatus::Unknown => MeltQuoteState::Unknown,
705                            };
706
707                            let total_msat = update
708                                .value_msat
709                                .checked_add(update.fee_msat)
710                                .ok_or(Error::AmountOverflow)?;
711
712                            let payment_preimage = if update.payment_preimage.is_empty() {
713                                None
714                            } else {
715                                Some(update.payment_preimage)
716                            };
717
718                            let payment_identifier =
719                                PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref());
720
721                            return Ok(MakePaymentResponse {
722                                payment_lookup_id: payment_identifier,
723                                payment_proof: payment_preimage,
724                                status: response_status,
725                                total_spent: msat_total_spent_for_unit(total_msat as u64, unit)?,
726                            });
727                        }
728
729                        Err(Error::UnknownPaymentStatus.into())
730                    }
731                }
732            }
733            OutgoingPaymentOptions::Bolt12(_) => {
734                Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND")))
735            }
736            OutgoingPaymentOptions::Custom(_) | OutgoingPaymentOptions::Onchain(_) => {
737                Err(payment::Error::UnsupportedPaymentOption)
738            }
739        }
740    }
741
742    #[instrument(skip(self, options))]
743    async fn create_incoming_payment_request(
744        &self,
745        options: IncomingPaymentOptions,
746    ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
747        match options {
748            IncomingPaymentOptions::Bolt11(bolt11_options) => {
749                let description = bolt11_options.description.unwrap_or_default();
750                let amount = bolt11_options.amount;
751                let unix_expiry = bolt11_options.unix_expiry;
752
753                let amount_msat: Amount = amount.convert_to(&CurrencyUnit::Msat)?.into();
754
755                let invoice_request = lnrpc::Invoice {
756                    value_msat: u64::from(amount_msat) as i64,
757                    memo: description,
758                    expiry: unix_expiry
759                        .map(|t| {
760                            t.checked_sub(unix_time())
761                                .ok_or(payment::Error::InvalidExpiry)
762                        })
763                        .transpose()?
764                        .unwrap_or_default() as i64,
765                    ..Default::default()
766                };
767
768                let mut lnd_client = self.lnd_client.clone();
769
770                let invoice = lnd_client
771                    .lightning()
772                    .add_invoice(tonic::Request::new(invoice_request))
773                    .await
774                    .map_err(|e| payment::Error::Anyhow(anyhow!(e)))?
775                    .into_inner();
776
777                let bolt11 = Bolt11Invoice::from_str(&invoice.payment_request)?;
778
779                let payment_identifier =
780                    PaymentIdentifier::PaymentHash(*bolt11.payment_hash().as_ref());
781
782                let expiry = bolt11.expires_at().map(|t| t.as_secs());
783
784                Ok(CreateIncomingPaymentResponse {
785                    request_lookup_id: payment_identifier,
786                    request: bolt11.to_string(),
787                    expiry,
788                    extra_json: None,
789                })
790            }
791            IncomingPaymentOptions::Bolt12(_) => {
792                Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LND")))
793            }
794            IncomingPaymentOptions::Custom(_) | IncomingPaymentOptions::Onchain(_) => {
795                Err(payment::Error::UnsupportedPaymentOption)
796            }
797        }
798    }
799
800    #[instrument(skip(self))]
801    async fn check_incoming_payment_status(
802        &self,
803        payment_identifier: &PaymentIdentifier,
804    ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
805        let mut lnd_client = self.lnd_client.clone();
806
807        let invoice_request = lnrpc::PaymentHash {
808            r_hash: hex::decode(payment_identifier.to_string())?,
809            ..Default::default()
810        };
811
812        let invoice = lnd_client
813            .lightning()
814            .lookup_invoice(tonic::Request::new(invoice_request))
815            .await
816            .map_err(|e| payment::Error::Anyhow(anyhow!(e)))?
817            .into_inner();
818
819        if invoice.state() == InvoiceState::Settled {
820            Ok(vec![WaitPaymentResponse {
821                payment_identifier: payment_identifier.clone(),
822                payment_amount: Amount::new(invoice.amt_paid_msat as u64, CurrencyUnit::Msat),
823                payment_id: hex::encode(invoice.r_hash),
824            }])
825        } else {
826            Ok(vec![])
827        }
828    }
829
830    #[instrument(skip(self))]
831    async fn check_outgoing_payment(
832        &self,
833        payment_identifier: &PaymentIdentifier,
834    ) -> Result<MakePaymentResponse, Self::Err> {
835        let mut lnd_client = self.lnd_client.clone();
836
837        let payment_hash = &payment_identifier.to_string();
838
839        let track_request = routerrpc::TrackPaymentRequest {
840            payment_hash: hex::decode(payment_hash).map_err(|_| Error::InvalidHash)?,
841            no_inflight_updates: true,
842        };
843
844        let payment_response = lnd_client.router().track_payment_v2(track_request).await;
845
846        let mut payment_stream = match payment_response {
847            Ok(stream) => stream.into_inner(),
848            Err(err) => {
849                let err_code = err.code();
850                if err_code == tonic::Code::NotFound {
851                    return Ok(MakePaymentResponse {
852                        payment_lookup_id: payment_identifier.clone(),
853                        payment_proof: None,
854                        status: MeltQuoteState::Unknown,
855                        total_spent: Amount::new(0, self.unit.clone()),
856                    });
857                } else {
858                    return Err(payment::Error::UnknownPaymentState);
859                }
860            }
861        };
862
863        while let Some(update_result) = payment_stream.next().await {
864            match update_result {
865                Ok(update) => {
866                    let status = update.status();
867
868                    let response = match status {
869                        #[allow(deprecated)]
870                        PaymentStatus::Unknown => MakePaymentResponse {
871                            payment_lookup_id: payment_identifier.clone(),
872                            payment_proof: Some(update.payment_preimage),
873                            status: MeltQuoteState::Unknown,
874                            total_spent: Amount::new(0, self.unit.clone()),
875                        },
876                        PaymentStatus::InFlight | PaymentStatus::Initiated => {
877                            // Continue waiting for the next update
878                            continue;
879                        }
880                        PaymentStatus::Succeeded => {
881                            let total_spent = lnrpc_payment_total_spent(&update)?;
882
883                            MakePaymentResponse {
884                                payment_lookup_id: payment_identifier.clone(),
885                                payment_proof: Some(update.payment_preimage),
886                                status: MeltQuoteState::Paid,
887                                total_spent,
888                            }
889                        }
890                        PaymentStatus::Failed => MakePaymentResponse {
891                            payment_lookup_id: payment_identifier.clone(),
892                            payment_proof: Some(update.payment_preimage),
893                            status: MeltQuoteState::Failed,
894                            total_spent: Amount::new(0, self.unit.clone()),
895                        },
896                    };
897
898                    return Ok(response);
899                }
900                Err(_) => {
901                    // Handle the case where the update itself is an error (e.g., stream failure)
902                    return Err(Error::UnknownPaymentStatus.into());
903                }
904            }
905        }
906
907        // If the stream is exhausted without a final status
908        Err(Error::UnknownPaymentStatus.into())
909    }
910}
911
912#[cfg(test)]
913mod tests {
914    use super::*;
915
916    #[test]
917    fn lnrpc_payment_total_spent_uses_msat_fields() {
918        let payment = lnrpc::Payment {
919            value_msat: 1500,
920            fee_msat: 500,
921            value_sat: 1,
922            fee_sat: 0,
923            ..Default::default()
924        };
925
926        let total_spent = lnrpc_payment_total_spent(&payment)
927            .expect("sub-sat payment total should be calculated");
928
929        assert_eq!(
930            total_spent
931                .convert_to(&CurrencyUnit::Msat)
932                .expect("msat amount should convert to msat")
933                .value(),
934            2000
935        );
936    }
937
938    #[test]
939    fn lnrpc_payment_total_spent_rejects_overflow() {
940        let payment = lnrpc::Payment {
941            value_msat: i64::MAX,
942            fee_msat: 1,
943            ..Default::default()
944        };
945
946        let err = lnrpc_payment_total_spent(&payment)
947            .expect_err("overflowing payment total should be rejected");
948
949        assert!(matches!(err, Error::AmountOverflow));
950    }
951
952    #[test]
953    fn msat_total_spent_for_unit_rounds_up_sats() {
954        let total_spent = msat_total_spent_for_unit(1501, &CurrencyUnit::Sat)
955            .expect("msat total should convert to sat");
956
957        assert_eq!(total_spent, Amount::new(2, CurrencyUnit::Sat));
958    }
959
960    #[test]
961    fn authoritative_outgoing_failure_response_is_terminal_and_spends_nothing() {
962        let payment_lookup_id = PaymentIdentifier::PaymentHash([42; 32]);
963        let response =
964            outgoing_payment_failure_response(&CurrencyUnit::Sat, payment_lookup_id.clone());
965
966        assert_eq!(response.payment_lookup_id, payment_lookup_id);
967        assert_eq!(response.status, MeltQuoteState::Failed);
968        assert_eq!(response.total_spent, Amount::new(0, CurrencyUnit::Sat));
969        assert!(response.payment_proof.is_none());
970    }
971
972    /// The dispatch-boundary variants must remain distinct from the
973    /// pre-dispatch `PaymentFailed`, so only the former stay `Err` (ambiguous)
974    /// and the latter can be converted to an authoritative `Failed` response.
975    /// This guards against a future change re-collapsing the two.
976    #[test]
977    fn dispatch_boundary_errors_are_distinct_from_pre_dispatch_failure() {
978        // `AmbiguousDispatch` is returned by send_* / stream failures (may have
979        // been accepted by LND) and must never be treated as a terminal
980        // pre-dispatch failure. It is a separate variant from `PaymentFailed`.
981        assert_ne!(
982            Error::AmbiguousDispatch.to_string(),
983            Error::PaymentFailed.to_string()
984        );
985        assert_ne!(
986            Error::UnknownPaymentStatus.to_string(),
987            Error::PaymentFailed.to_string()
988        );
989    }
990}