1#![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
44const 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#[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 pub const MAX_ROUTE_RETRIES: usize = 50;
76
77 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 if address.is_empty() {
87 return Err(Error::InvalidConfig("LND address cannot be empty".into()));
88 }
89
90 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 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 #[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
217fn 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
242fn 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 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 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 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 current_add_index = current_add_index.max(msg.add_index);
357 current_settle_index = current_settle_index.max(msg.settle_index);
358
359 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 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 tracing::error!("LND returned invalid payment hash");
406 continue;
408 }
409 } else {
410 tracing::debug!("LND: Received non-settled invoice, continuing to wait for settled invoices");
412 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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;
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 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 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 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 #[test]
1197 fn dispatch_boundary_errors_are_distinct_from_pre_dispatch_failure() {
1198 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}