diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7f4258d2..96d47bbe 100644 --- a/.github/workflows/ci.yml +++ b/.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, diff --git a/Cargo.toml b/Cargo.toml index a95ca061..679b310f 100644 --- a/Cargo.toml +++ b/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 } diff --git a/README.md b/README.md index aed3ab0f..d3105646 100644 --- a/README.md +++ b/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. diff --git a/crates/cdk-lnbits/Cargo.toml b/crates/cdk-lnbits/Cargo.toml new file mode 100644 index 00000000..bda2c7ca --- /dev/null +++ b/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" diff --git a/crates/cdk-lnbits/src/error.rs b/crates/cdk-lnbits/src/error.rs new file mode 100644 index 00000000..34dba31d --- /dev/null +++ b/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 for cdk::cdk_lightning::Error { + fn from(e: Error) -> Self { + Self::Lightning(Box::new(e)) + } +} diff --git a/crates/cdk-lnbits/src/lib.rs b/crates/cdk-lnbits/src/lib.rs new file mode 100644 index 00000000..a5f75c7e --- /dev/null +++ b/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>>>, + 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>>>, + webhook_url: String, + ) -> Result { + 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 + 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 { + 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, + _max_fee_msats: Option, + ) -> Result { + 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 { + 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 { + 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, + ) -> anyhow::Result { + self.lnbits_api + .create_invoice_webhook_router(webhook_endpoint, sender) + .await + } +} diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index c53e1d65..4876ef05 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/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 } diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index bdf3072d..79b8a36d 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/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 = "" diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index ace9c654..951d15f5 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -21,6 +21,7 @@ pub enum LnBackend { #[default] Cln, Strike, + LNBits, FakeWallet, // Greenlight, // Ldk, @@ -40,6 +41,13 @@ pub struct Strike { pub supported_units: Option>, } +#[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, pub strike: Option, + pub lnbits: Option, pub fake_wallet: Option, 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) diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 74faeed4..114ffd92 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/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 = 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;