|  | @@ -0,0 +1,274 @@
 | 
	
		
			
				|  |  | +//! CDK lightning backend for lnbits
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +#![warn(missing_docs)]
 | 
	
		
			
				|  |  | +#![warn(rustdoc::bare_urls)]
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +use std::pin::Pin;
 | 
	
		
			
				|  |  | +use std::sync::Arc;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +use anyhow::anyhow;
 | 
	
		
			
				|  |  | +use async_trait::async_trait;
 | 
	
		
			
				|  |  | +use axum::Router;
 | 
	
		
			
				|  |  | +use cdk::amount::Amount;
 | 
	
		
			
				|  |  | +use cdk::cdk_lightning::{
 | 
	
		
			
				|  |  | +    self, to_unit, CreateInvoiceResponse, MintLightning, MintMeltSettings, PayInvoiceResponse,
 | 
	
		
			
				|  |  | +    PaymentQuoteResponse, Settings,
 | 
	
		
			
				|  |  | +};
 | 
	
		
			
				|  |  | +use cdk::mint::FeeReserve;
 | 
	
		
			
				|  |  | +use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState};
 | 
	
		
			
				|  |  | +use cdk::util::unix_time;
 | 
	
		
			
				|  |  | +use cdk::{mint, Bolt11Invoice};
 | 
	
		
			
				|  |  | +use error::Error;
 | 
	
		
			
				|  |  | +use futures::stream::StreamExt;
 | 
	
		
			
				|  |  | +use futures::Stream;
 | 
	
		
			
				|  |  | +use lnbits_rs::api::invoice::CreateInvoiceRequest;
 | 
	
		
			
				|  |  | +use lnbits_rs::LNBitsClient;
 | 
	
		
			
				|  |  | +use tokio::sync::Mutex;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +pub mod error;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +/// LNBits
 | 
	
		
			
				|  |  | +#[derive(Clone)]
 | 
	
		
			
				|  |  | +pub struct LNBits {
 | 
	
		
			
				|  |  | +    lnbits_api: LNBitsClient,
 | 
	
		
			
				|  |  | +    mint_settings: MintMeltSettings,
 | 
	
		
			
				|  |  | +    melt_settings: MintMeltSettings,
 | 
	
		
			
				|  |  | +    fee_reserve: FeeReserve,
 | 
	
		
			
				|  |  | +    receiver: Arc<Mutex<Option<tokio::sync::mpsc::Receiver<String>>>>,
 | 
	
		
			
				|  |  | +    webhook_url: String,
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +impl LNBits {
 | 
	
		
			
				|  |  | +    /// Create new [`LNBits`] wallet
 | 
	
		
			
				|  |  | +    #[allow(clippy::too_many_arguments)]
 | 
	
		
			
				|  |  | +    pub async fn new(
 | 
	
		
			
				|  |  | +        admin_api_key: String,
 | 
	
		
			
				|  |  | +        invoice_api_key: String,
 | 
	
		
			
				|  |  | +        api_url: String,
 | 
	
		
			
				|  |  | +        mint_settings: MintMeltSettings,
 | 
	
		
			
				|  |  | +        melt_settings: MintMeltSettings,
 | 
	
		
			
				|  |  | +        fee_reserve: FeeReserve,
 | 
	
		
			
				|  |  | +        receiver: Arc<Mutex<Option<tokio::sync::mpsc::Receiver<String>>>>,
 | 
	
		
			
				|  |  | +        webhook_url: String,
 | 
	
		
			
				|  |  | +    ) -> Result<Self, Error> {
 | 
	
		
			
				|  |  | +        let lnbits_api = LNBitsClient::new("", &admin_api_key, &invoice_api_key, &api_url, None)?;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        Ok(Self {
 | 
	
		
			
				|  |  | +            lnbits_api,
 | 
	
		
			
				|  |  | +            mint_settings,
 | 
	
		
			
				|  |  | +            melt_settings,
 | 
	
		
			
				|  |  | +            receiver,
 | 
	
		
			
				|  |  | +            fee_reserve,
 | 
	
		
			
				|  |  | +            webhook_url,
 | 
	
		
			
				|  |  | +        })
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +#[async_trait]
 | 
	
		
			
				|  |  | +impl MintLightning for LNBits {
 | 
	
		
			
				|  |  | +    type Err = cdk_lightning::Error;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    fn get_settings(&self) -> Settings {
 | 
	
		
			
				|  |  | +        Settings {
 | 
	
		
			
				|  |  | +            mpp: false,
 | 
	
		
			
				|  |  | +            unit: CurrencyUnit::Sat,
 | 
	
		
			
				|  |  | +            mint_settings: self.mint_settings,
 | 
	
		
			
				|  |  | +            melt_settings: self.melt_settings,
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    async fn wait_any_invoice(
 | 
	
		
			
				|  |  | +        &self,
 | 
	
		
			
				|  |  | +    ) -> Result<Pin<Box<dyn Stream<Item = String> + Send>>, Self::Err> {
 | 
	
		
			
				|  |  | +        let receiver = self
 | 
	
		
			
				|  |  | +            .receiver
 | 
	
		
			
				|  |  | +            .lock()
 | 
	
		
			
				|  |  | +            .await
 | 
	
		
			
				|  |  | +            .take()
 | 
	
		
			
				|  |  | +            .ok_or(anyhow!("No receiver"))?;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        let lnbits_api = self.lnbits_api.clone();
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        Ok(futures::stream::unfold(
 | 
	
		
			
				|  |  | +            (receiver, lnbits_api),
 | 
	
		
			
				|  |  | +            |(mut receiver, lnbits_api)| async move {
 | 
	
		
			
				|  |  | +                match receiver.recv().await {
 | 
	
		
			
				|  |  | +                    Some(msg) => {
 | 
	
		
			
				|  |  | +                        let check = lnbits_api.is_invoice_paid(&msg).await;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +                        match check {
 | 
	
		
			
				|  |  | +                            Ok(state) => {
 | 
	
		
			
				|  |  | +                                if state {
 | 
	
		
			
				|  |  | +                                    Some((msg, (receiver, lnbits_api)))
 | 
	
		
			
				|  |  | +                                } else {
 | 
	
		
			
				|  |  | +                                    None
 | 
	
		
			
				|  |  | +                                }
 | 
	
		
			
				|  |  | +                            }
 | 
	
		
			
				|  |  | +                            _ => None,
 | 
	
		
			
				|  |  | +                        }
 | 
	
		
			
				|  |  | +                    }
 | 
	
		
			
				|  |  | +                    None => None,
 | 
	
		
			
				|  |  | +                }
 | 
	
		
			
				|  |  | +            },
 | 
	
		
			
				|  |  | +        )
 | 
	
		
			
				|  |  | +        .boxed())
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    async fn get_payment_quote(
 | 
	
		
			
				|  |  | +        &self,
 | 
	
		
			
				|  |  | +        melt_quote_request: &MeltQuoteBolt11Request,
 | 
	
		
			
				|  |  | +    ) -> Result<PaymentQuoteResponse, Self::Err> {
 | 
	
		
			
				|  |  | +        if melt_quote_request.unit != CurrencyUnit::Sat {
 | 
	
		
			
				|  |  | +            return Err(Self::Err::Anyhow(anyhow!("Unsupported unit")));
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        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,
 | 
	
		
			
				|  |  | +        })
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    async fn pay_invoice(
 | 
	
		
			
				|  |  | +        &self,
 | 
	
		
			
				|  |  | +        melt_quote: mint::MeltQuote,
 | 
	
		
			
				|  |  | +        _partial_msats: Option<Amount>,
 | 
	
		
			
				|  |  | +        _max_fee_msats: Option<Amount>,
 | 
	
		
			
				|  |  | +    ) -> Result<PayInvoiceResponse, Self::Err> {
 | 
	
		
			
				|  |  | +        let pay_response = self
 | 
	
		
			
				|  |  | +            .lnbits_api
 | 
	
		
			
				|  |  | +            .pay_invoice(&melt_quote.request)
 | 
	
		
			
				|  |  | +            .await
 | 
	
		
			
				|  |  | +            .map_err(|err| {
 | 
	
		
			
				|  |  | +                tracing::error!("Could not pay invoice");
 | 
	
		
			
				|  |  | +                tracing::error!("{}", err.to_string());
 | 
	
		
			
				|  |  | +                Self::Err::Anyhow(anyhow!("Could not pay invoice"))
 | 
	
		
			
				|  |  | +            })?;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        let invoice_info = self
 | 
	
		
			
				|  |  | +            .lnbits_api
 | 
	
		
			
				|  |  | +            .find_invoice(&pay_response.payment_hash)
 | 
	
		
			
				|  |  | +            .await
 | 
	
		
			
				|  |  | +            .map_err(|err| {
 | 
	
		
			
				|  |  | +                tracing::error!("Could not find invoice");
 | 
	
		
			
				|  |  | +                tracing::error!("{}", err.to_string());
 | 
	
		
			
				|  |  | +                Self::Err::Anyhow(anyhow!("Could not find invoice"))
 | 
	
		
			
				|  |  | +            })?;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        let status = match invoice_info.pending {
 | 
	
		
			
				|  |  | +            true => MeltQuoteState::Unpaid,
 | 
	
		
			
				|  |  | +            false => MeltQuoteState::Paid,
 | 
	
		
			
				|  |  | +        };
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        let total_spent = Amount::from((invoice_info.amount + invoice_info.fee).unsigned_abs());
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        Ok(PayInvoiceResponse {
 | 
	
		
			
				|  |  | +            payment_hash: pay_response.payment_hash,
 | 
	
		
			
				|  |  | +            payment_preimage: Some(invoice_info.payment_hash),
 | 
	
		
			
				|  |  | +            status,
 | 
	
		
			
				|  |  | +            total_spent,
 | 
	
		
			
				|  |  | +        })
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    async fn create_invoice(
 | 
	
		
			
				|  |  | +        &self,
 | 
	
		
			
				|  |  | +        amount: Amount,
 | 
	
		
			
				|  |  | +        unit: &CurrencyUnit,
 | 
	
		
			
				|  |  | +        description: String,
 | 
	
		
			
				|  |  | +        unix_expiry: u64,
 | 
	
		
			
				|  |  | +    ) -> Result<CreateInvoiceResponse, Self::Err> {
 | 
	
		
			
				|  |  | +        if unit != &CurrencyUnit::Sat {
 | 
	
		
			
				|  |  | +            return Err(Self::Err::Anyhow(anyhow!("Unsupported unit")));
 | 
	
		
			
				|  |  | +        }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        let time_now = unix_time();
 | 
	
		
			
				|  |  | +        assert!(unix_expiry > time_now);
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        let expiry = unix_expiry - time_now;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        let invoice_request = CreateInvoiceRequest {
 | 
	
		
			
				|  |  | +            amount: to_unit(amount, unit, &CurrencyUnit::Sat)?.into(),
 | 
	
		
			
				|  |  | +            memo: Some(description),
 | 
	
		
			
				|  |  | +            unit: unit.to_string(),
 | 
	
		
			
				|  |  | +            expiry: Some(expiry),
 | 
	
		
			
				|  |  | +            webhook: Some(self.webhook_url.clone()),
 | 
	
		
			
				|  |  | +            internal: None,
 | 
	
		
			
				|  |  | +            out: false,
 | 
	
		
			
				|  |  | +        };
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        let create_invoice_response = self
 | 
	
		
			
				|  |  | +            .lnbits_api
 | 
	
		
			
				|  |  | +            .create_invoice(&invoice_request)
 | 
	
		
			
				|  |  | +            .await
 | 
	
		
			
				|  |  | +            .map_err(|err| {
 | 
	
		
			
				|  |  | +                tracing::error!("Could not create invoice");
 | 
	
		
			
				|  |  | +                tracing::error!("{}", err.to_string());
 | 
	
		
			
				|  |  | +                Self::Err::Anyhow(anyhow!("Could not create invoice"))
 | 
	
		
			
				|  |  | +            })?;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        let request: Bolt11Invoice = create_invoice_response.payment_request.parse()?;
 | 
	
		
			
				|  |  | +        let expiry = request.expires_at().map(|t| t.as_secs());
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        Ok(CreateInvoiceResponse {
 | 
	
		
			
				|  |  | +            request_lookup_id: create_invoice_response.payment_hash,
 | 
	
		
			
				|  |  | +            request,
 | 
	
		
			
				|  |  | +            expiry,
 | 
	
		
			
				|  |  | +        })
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +    async fn check_invoice_status(
 | 
	
		
			
				|  |  | +        &self,
 | 
	
		
			
				|  |  | +        request_lookup_id: &str,
 | 
	
		
			
				|  |  | +    ) -> Result<MintQuoteState, Self::Err> {
 | 
	
		
			
				|  |  | +        let paid = self
 | 
	
		
			
				|  |  | +            .lnbits_api
 | 
	
		
			
				|  |  | +            .is_invoice_paid(request_lookup_id)
 | 
	
		
			
				|  |  | +            .await
 | 
	
		
			
				|  |  | +            .map_err(|err| {
 | 
	
		
			
				|  |  | +                tracing::error!("Could not check invoice status");
 | 
	
		
			
				|  |  | +                tracing::error!("{}", err.to_string());
 | 
	
		
			
				|  |  | +                Self::Err::Anyhow(anyhow!("Could not check invoice status"))
 | 
	
		
			
				|  |  | +            })?;
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        let state = match paid {
 | 
	
		
			
				|  |  | +            true => MintQuoteState::Paid,
 | 
	
		
			
				|  |  | +            false => MintQuoteState::Unpaid,
 | 
	
		
			
				|  |  | +        };
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +        Ok(state)
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +}
 | 
	
		
			
				|  |  | +
 | 
	
		
			
				|  |  | +impl LNBits {
 | 
	
		
			
				|  |  | +    /// Create invoice webhook
 | 
	
		
			
				|  |  | +    pub async fn create_invoice_webhook_router(
 | 
	
		
			
				|  |  | +        &self,
 | 
	
		
			
				|  |  | +        webhook_endpoint: &str,
 | 
	
		
			
				|  |  | +        sender: tokio::sync::mpsc::Sender<String>,
 | 
	
		
			
				|  |  | +    ) -> anyhow::Result<Router> {
 | 
	
		
			
				|  |  | +        self.lnbits_api
 | 
	
		
			
				|  |  | +            .create_invoice_webhook_router(webhook_endpoint, sender)
 | 
	
		
			
				|  |  | +            .await
 | 
	
		
			
				|  |  | +    }
 | 
	
		
			
				|  |  | +}
 |