Просмотр исходного кода

feat: lnbits ln backend

fix: check webhook url is valid
thesimplekid 6 месяцев назад
Родитель
Сommit
5e5345074d

+ 1 - 0
.github/workflows/ci.yml

@@ -36,6 +36,7 @@ jobs:
             -p cdk-cln,
             -p cdk-fake-wallet,
             -p cdk-strike,
+            -p cdk-lnbits
             -p cdk-integration-tests,
             --bin cdk-cli,
             --bin cdk-mintd,

+ 1 - 0
Cargo.toml

@@ -33,6 +33,7 @@ cdk-rexie = { version = "0.3", path = "./crates/cdk-rexie", default-features = f
 cdk-sqlite = { version = "0.3", path = "./crates/cdk-sqlite", default-features = false }
 cdk-redb = { version = "0.3", path = "./crates/cdk-redb", default-features = false }
 cdk-cln = { version = "0.3", path = "./crates/cdk-cln", default-features = false }
+cdk-lnbits = { version = "0.3", path = "./crates/cdk-lnbits", default-features = false }
 cdk-axum = { version = "0.3", path = "./crates/cdk-axum", default-features = false }
 cdk-fake-wallet = { version = "0.3", path = "./crates/cdk-fake-wallet", default-features = false }
 cdk-strike = { version = "0.3", path = "./crates/cdk-strike", default-features = false }

+ 1 - 0
README.md

@@ -21,6 +21,7 @@ The project is split up into several crates in the `crates/` directory:
     * [**cdk-axum**](./crates/cdk-axum/): Axum webserver for mint.
     * [**cdk-cln**](./crates/cdk-cln/): CLN Lightning backend for mint.
     * [**cdk-strike**](./crates/cdk-strike/): Strike Lightning backend for mint.
+    * [**cdk-lnbits**](./crates/cdk-lnbits/): [LNBits](https://lnbits.com/) Lightning backend for mint.
     * [**cdk-fake-wallet**](./crates/cdk-fake-wallet/): Fake Lightning backend for mint. To be used only for testing, quotes are automatically filled.
 * Binaries:
     * [**cdk-cli**](./crates/cdk-cli/): Cashu wallet CLI.

+ 22 - 0
crates/cdk-lnbits/Cargo.toml

@@ -0,0 +1,22 @@
+[package]
+name = "cdk-lnbits"
+version = { workspace = true }
+edition = "2021"
+authors = ["CDK Developers"]
+homepage.workspace = true
+repository.workspace = true
+rust-version.workspace = true # MSRV
+license.workspace = true
+description = "CDK ln backend for lnbits"
+
+[dependencies]
+async-trait.workspace = true
+anyhow.workspace = true
+axum.workspace = true
+bitcoin.workspace = true
+cdk = { workspace = true, default-features = false, features = ["mint"] }
+futures.workspace = true
+tokio.workspace = true
+tracing.workspace = true
+thiserror.workspace = true
+lnbits-rs = "0.1.0"

+ 23 - 0
crates/cdk-lnbits/src/error.rs

@@ -0,0 +1,23 @@
+//! Error for Strike ln backend
+
+use thiserror::Error;
+
+/// Strike Error
+#[derive(Debug, Error)]
+pub enum Error {
+    /// Invoice amount not defined
+    #[error("Unknown invoice amount")]
+    UnknownInvoiceAmount,
+    /// Unknown invoice
+    #[error("Unknown invoice")]
+    UnknownInvoice,
+    /// Anyhow error
+    #[error(transparent)]
+    Anyhow(#[from] anyhow::Error),
+}
+
+impl From<Error> for cdk::cdk_lightning::Error {
+    fn from(e: Error) -> Self {
+        Self::Lightning(Box::new(e))
+    }
+}

+ 274 - 0
crates/cdk-lnbits/src/lib.rs

@@ -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
+    }
+}

+ 1 - 0
crates/cdk-mintd/Cargo.toml

@@ -16,6 +16,7 @@ cdk = { workspace = true, default-features = false, features = ["mint"] }
 cdk-redb = { workspace = true, default-features = false, features = ["mint"] }
 cdk-sqlite = { workspace = true, default-features = false, features = ["mint"] }
 cdk-cln = { workspace = true, default-features = false }
+cdk-lnbits = { workspace = true, default-features = false }
 cdk-fake-wallet = { workspace = true, default-features = false }
 cdk-strike.workspace = true
 cdk-axum = { workspace = true, default-features = false }

+ 6 - 0
crates/cdk-mintd/example.config.toml

@@ -37,3 +37,9 @@ ln_backend = "cln"
 # api_key=""
 # Optional default sats
 # supported_units=[""]
+
+
+# [lnbits]
+# admin_api_key = ""
+# invoice_api_key = ""
+# lnbits_api = ""

+ 11 - 1
crates/cdk-mintd/src/config.rs

@@ -21,6 +21,7 @@ pub enum LnBackend {
     #[default]
     Cln,
     Strike,
+    LNBits,
     FakeWallet,
     //  Greenlight,
     //  Ldk,
@@ -41,6 +42,13 @@ pub struct Strike {
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
+pub struct LNBits {
+    pub admin_api_key: String,
+    pub invoice_api_key: String,
+    pub lnbits_api: String,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Default)]
 pub struct Cln {
     pub rpc_path: PathBuf,
 }
@@ -78,6 +86,7 @@ pub struct Settings {
     pub ln: Ln,
     pub cln: Option<Cln>,
     pub strike: Option<Strike>,
+    pub lnbits: Option<LNBits>,
     pub fake_wallet: Option<FakeWallet>,
     pub database: Database,
 }
@@ -141,8 +150,9 @@ impl Settings {
 
         match settings.ln.ln_backend {
             LnBackend::Cln => assert!(settings.cln.is_some()),
-            LnBackend::FakeWallet => (),
             LnBackend::Strike => assert!(settings.strike.is_some()),
+            LnBackend::LNBits => assert!(settings.lnbits.is_some()),
+            LnBackend::FakeWallet => (),
         }
 
         Ok(settings)

+ 43 - 2
crates/cdk-mintd/src/main.rs

@@ -15,6 +15,7 @@ use cdk::cdk_database::{self, MintDatabase};
 use cdk::cdk_lightning;
 use cdk::cdk_lightning::{MintLightning, MintMeltSettings};
 use cdk::mint::{FeeReserve, Mint};
+use cdk::mint_url::MintUrl;
 use cdk::nuts::{
     nut04, nut05, ContactInfo, CurrencyUnit, MeltMethodSettings, MintInfo, MintMethodSettings,
     MintVersion, MppMethodSettings, Nuts, PaymentMethod,
@@ -22,6 +23,7 @@ use cdk::nuts::{
 use cdk_axum::LnKey;
 use cdk_cln::Cln;
 use cdk_fake_wallet::FakeWallet;
+use cdk_lnbits::LNBits;
 use cdk_redb::MintRedbDatabase;
 use cdk_sqlite::MintSqliteDatabase;
 use cdk_strike::Strike;
@@ -128,6 +130,8 @@ async fn main() -> anyhow::Result<()> {
     let mut supported_units = HashMap::new();
     let input_fee_ppk = settings.info.input_fee_ppk.unwrap_or(0);
 
+    let mint_url: MintUrl = settings.info.url.parse()?;
+
     let ln_routers: Vec<Router> = match settings.ln.ln_backend {
         LnBackend::Cln => {
             let cln_socket = expand_path(
@@ -168,7 +172,7 @@ async fn main() -> anyhow::Result<()> {
                 let (sender, receiver) = tokio::sync::mpsc::channel(8);
                 let webhook_endpoint = format!("/webhook/{}/invoice", unit);
 
-                let webhook_url = format!("{}{}", settings.info.url, webhook_endpoint);
+                let webhook_url = mint_url.join(&webhook_endpoint)?;
 
                 let strike = Strike::new(
                     api_key.clone(),
@@ -176,7 +180,7 @@ async fn main() -> anyhow::Result<()> {
                     MintMeltSettings::default(),
                     unit,
                     Arc::new(Mutex::new(Some(receiver))),
-                    webhook_url,
+                    webhook_url.to_string(),
                 )
                 .await?;
 
@@ -194,6 +198,43 @@ async fn main() -> anyhow::Result<()> {
 
             routers
         }
+        LnBackend::LNBits => {
+            let lnbits_settings = settings.lnbits.expect("Checked on config load");
+            let admin_api_key = lnbits_settings.admin_api_key;
+            let invoice_api_key = lnbits_settings.invoice_api_key;
+
+            // Channel used for lnbits web hook
+            let (sender, receiver) = tokio::sync::mpsc::channel(8);
+            let webhook_endpoint = "/webhook/lnbits/sat/invoice";
+
+            let webhook_url = mint_url.join(webhook_endpoint)?;
+
+            let lnbits = LNBits::new(
+                admin_api_key,
+                invoice_api_key,
+                lnbits_settings.lnbits_api,
+                MintMeltSettings::default(),
+                MintMeltSettings::default(),
+                fee_reserve,
+                Arc::new(Mutex::new(Some(receiver))),
+                webhook_url.to_string(),
+            )
+            .await?;
+
+            let router = lnbits
+                .create_invoice_webhook_router(webhook_endpoint, sender)
+                .await?;
+
+            let unit = CurrencyUnit::Sat;
+
+            let ln_key = LnKey::new(unit, PaymentMethod::Bolt11);
+
+            ln_backends.insert(ln_key, Arc::new(lnbits));
+
+            supported_units.insert(unit, (input_fee_ppk, 64));
+
+            vec![router]
+        }
         LnBackend::FakeWallet => {
             let units = settings.fake_wallet.unwrap_or_default().supported_units;