//! CDK lightning backend for LND // Copyright (c) 2023 Steffen (MIT) #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] use std::path::PathBuf; use std::pin::Pin; use std::str::FromStr; use std::sync::Arc; use anyhow::anyhow; use async_trait::async_trait; use cdk::amount::Amount; use cdk::cdk_lightning::{ self, to_unit, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, MSAT_IN_SAT, }; use cdk::mint::FeeReserve; use cdk::nuts::{ CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, MintQuoteState, }; use cdk::util::{hex, unix_time}; use cdk::{mint, Bolt11Invoice}; use error::Error; use fedimint_tonic_lnd::lnrpc::fee_limit::Limit; use fedimint_tonic_lnd::lnrpc::FeeLimit; use fedimint_tonic_lnd::Client; use futures::{Stream, StreamExt}; use tokio::sync::Mutex; pub mod error; /// Lnd mint backend #[derive(Clone)] pub struct Lnd { address: String, cert_file: PathBuf, macaroon_file: PathBuf, client: Arc>, fee_reserve: FeeReserve, mint_settings: MintMethodSettings, melt_settings: MeltMethodSettings, } impl Lnd { /// Create new [`Lnd`] pub async fn new( address: String, cert_file: PathBuf, macaroon_file: PathBuf, fee_reserve: FeeReserve, mint_settings: MintMethodSettings, melt_settings: MeltMethodSettings, ) -> Result { let client = fedimint_tonic_lnd::connect(address.to_string(), &cert_file, &macaroon_file) .await .map_err(|err| { tracing::error!("Connection error: {}", err.to_string()); Error::Connection })?; Ok(Self { address, cert_file, macaroon_file, client: Arc::new(Mutex::new(client)), fee_reserve, mint_settings, melt_settings, }) } } #[async_trait] impl MintLightning for Lnd { type Err = cdk_lightning::Error; fn get_settings(&self) -> Settings { Settings { mpp: true, unit: CurrencyUnit::Msat, mint_settings: self.mint_settings, melt_settings: self.melt_settings, invoice_description: true, } } async fn wait_any_invoice( &self, ) -> Result + Send>>, Self::Err> { let mut client = fedimint_tonic_lnd::connect(self.address.clone(), &self.cert_file, &self.macaroon_file) .await .map_err(|_| Error::Connection)?; let stream_req = fedimint_tonic_lnd::lnrpc::InvoiceSubscription { add_index: 0, settle_index: 0, }; let stream = client .lightning() .subscribe_invoices(stream_req) .await .unwrap() .into_inner(); Ok(futures::stream::unfold(stream, |mut stream| async move { match stream.message().await { Ok(Some(msg)) => { if msg.state == 1 { Some((hex::encode(msg.r_hash), stream)) } else { None } } Ok(None) => None, // End of stream Err(_) => None, // Handle errors gracefully, ends the stream on error } }) .boxed()) } async fn get_payment_quote( &self, melt_quote_request: &MeltQuoteBolt11Request, ) -> Result { let invoice_amount_msat = melt_quote_request .request .amount_milli_satoshis() .ok_or(Error::UnknownInvoiceAmount)?; let amount = to_unit( invoice_amount_msat, &CurrencyUnit::Msat, &melt_quote_request.unit, )?; let relative_fee_reserve = (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64; let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into(); let fee = match relative_fee_reserve > absolute_fee_reserve { true => relative_fee_reserve, false => absolute_fee_reserve, }; Ok(PaymentQuoteResponse { request_lookup_id: melt_quote_request.request.payment_hash().to_string(), amount, fee: fee.into(), state: MeltQuoteState::Unpaid, }) } async fn pay_invoice( &self, melt_quote: mint::MeltQuote, partial_amount: Option, max_fee: Option, ) -> Result { let payment_request = melt_quote.request; let pay_req = fedimint_tonic_lnd::lnrpc::SendRequest { payment_request, fee_limit: max_fee.map(|f| { let limit = Limit::Fixed(u64::from(f) as i64); FeeLimit { limit: Some(limit) } }), amt_msat: partial_amount .map(|a| { let msat = to_unit(a, &melt_quote.unit, &CurrencyUnit::Msat).unwrap(); u64::from(msat) as i64 }) .unwrap_or_default(), ..Default::default() }; let payment_response = self .client .lock() .await .lightning() .send_payment_sync(fedimint_tonic_lnd::tonic::Request::new(pay_req)) .await .map_err(|_| Error::PaymentFailed)? .into_inner(); let total_amount = payment_response .payment_route .map_or(0, |route| route.total_amt_msat / MSAT_IN_SAT as i64) as u64; let (status, payment_preimage) = match total_amount == 0 { true => (MeltQuoteState::Unpaid, None), false => ( MeltQuoteState::Paid, Some(hex::encode(payment_response.payment_preimage)), ), }; Ok(PayInvoiceResponse { payment_hash: hex::encode(payment_response.payment_hash), payment_preimage, status, total_spent: total_amount.into(), unit: CurrencyUnit::Sat, }) } async fn create_invoice( &self, amount: Amount, unit: &CurrencyUnit, description: String, unix_expiry: u64, ) -> Result { let time_now = unix_time(); assert!(unix_expiry > time_now); let amount = to_unit(amount, unit, &CurrencyUnit::Msat)?; let invoice_request = fedimint_tonic_lnd::lnrpc::Invoice { value_msat: u64::from(amount) as i64, memo: description, ..Default::default() }; let invoice = self .client .lock() .await .lightning() .add_invoice(fedimint_tonic_lnd::tonic::Request::new(invoice_request)) .await .unwrap() .into_inner(); let bolt11 = Bolt11Invoice::from_str(&invoice.payment_request)?; Ok(CreateInvoiceResponse { request_lookup_id: bolt11.payment_hash().to_string(), request: bolt11, expiry: Some(unix_expiry), }) } async fn check_invoice_status( &self, request_lookup_id: &str, ) -> Result { let invoice_request = fedimint_tonic_lnd::lnrpc::PaymentHash { r_hash: hex::decode(request_lookup_id).unwrap(), ..Default::default() }; let invoice = self .client .lock() .await .lightning() .lookup_invoice(fedimint_tonic_lnd::tonic::Request::new(invoice_request)) .await .unwrap() .into_inner(); match invoice.state { // Open 0 => Ok(MintQuoteState::Unpaid), // Settled 1 => Ok(MintQuoteState::Paid), // Canceled 2 => Ok(MintQuoteState::Unpaid), // Accepted 3 => Ok(MintQuoteState::Unpaid), _ => Err(Self::Err::Anyhow(anyhow!("Invalid status"))), } } }