refactor(cdk-lnbits): migrate to LNbits v1 websocket API and remove w… (#987)

* refactor(cdk-lnbits): migrate to LNbits v1 websocket API and remove webhook support

- Remove webhook-based payment notifications in favor of v1 websocket API
- Add explicit documentation that only LNbits v1 API is supported
- Remove webhook_url parameter and related router setup code
- Simplify payment status handling by removing pending status logic
- Switch to local lnbits-rs dependency for development
- Remove unused axum dependency and clean up imports
- Update configuration documentation and examples

* refactor(cdk-lnbits): extract payment processing logic into helper methods

Improve code readability by separating message processing, payment response
creation, and payment hash decoding into dedicated methods. This reduces
complexity in the main payment waiting loop while maintaining identical
functionality

* chore: bump lnbits-rs
This commit is contained in:
thesimplekid
2025-08-21 21:54:48 +01:00
committed by GitHub
parent 7e33078961
commit 8dec41dd55
9 changed files with 76 additions and 114 deletions

View File

@@ -22,7 +22,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-lnd**](./crates/cdk-lnd/): Lnd Lightning backend for mint.
* [**cdk-lnbits**](./crates/cdk-lnbits/): [LNbits](https://lnbits.com/) Lightning backend for mint.
* [**cdk-lnbits**](./crates/cdk-lnbits/): [LNbits](https://lnbits.com/) Lightning backend for mint. **Note: Only LNBits v1 API is supported.**
* [**cdk-fake-wallet**](./crates/cdk-fake-wallet/): Fake Lightning backend for mint. To be used only for testing, quotes are automatically filled.
* [**cdk-mint-rpc**](./crates/cdk-mint-rpc/): Mint management gRPC server and cli.
* Binaries:

View File

@@ -13,7 +13,6 @@ readme = "README.md"
[dependencies]
async-trait.workspace = true
anyhow.workspace = true
axum.workspace = true
bitcoin.workspace = true
cdk-common = { workspace = true, features = ["mint"] }
futures.workspace = true
@@ -21,6 +20,6 @@ tokio.workspace = true
tokio-util.workspace = true
tracing.workspace = true
thiserror.workspace = true
lnbits-rs = "0.6.0"
lnbits-rs = "0.8.0"
serde_json.workspace = true
rustls.workspace = true

View File

@@ -8,6 +8,8 @@
LNBits backend implementation for the Cashu Development Kit (CDK). This provides integration with [LNBits](https://lnbits.com/) for Lightning Network functionality.
**Note: Only LNBits v1 API is supported.** This backend uses the websocket-based v1 API for real-time payment notifications.
## Installation
Add this to your `Cargo.toml`:

View File

@@ -11,7 +11,6 @@ use std::sync::Arc;
use anyhow::anyhow;
use async_trait::async_trait;
use axum::Router;
use cdk_common::amount::{to_unit, Amount, MSAT_IN_SAT};
use cdk_common::common::FeeReserve;
use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState};
@@ -36,7 +35,6 @@ pub mod error;
pub struct LNbits {
lnbits_api: LNBitsClient,
fee_reserve: FeeReserve,
webhook_url: Option<String>,
wait_invoice_cancel_token: CancellationToken,
wait_invoice_is_active: Arc<AtomicBool>,
settings: Bolt11Settings,
@@ -50,14 +48,12 @@ impl LNbits {
invoice_api_key: String,
api_url: String,
fee_reserve: FeeReserve,
webhook_url: Option<String>,
) -> Result<Self, Error> {
let lnbits_api = LNBitsClient::new("", &admin_api_key, &invoice_api_key, &api_url, None)?;
Ok(Self {
lnbits_api,
fee_reserve,
webhook_url,
wait_invoice_cancel_token: CancellationToken::new(),
wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
settings: Bolt11Settings {
@@ -83,6 +79,64 @@ impl LNbits {
Error::Anyhow(err)
})
}
/// Process an incoming message from the websocket receiver
async fn process_message(
msg_option: Option<String>,
api: &LNBitsClient,
_is_active: &Arc<AtomicBool>,
) -> Option<WaitPaymentResponse> {
let msg = msg_option?;
let payment = match api.get_payment_info(&msg).await {
Ok(payment) => payment,
Err(_) => return None,
};
if !payment.paid {
tracing::warn!(
"Received payment notification but payment not paid for {}",
msg
);
return None;
}
Self::create_payment_response(&msg, &payment).unwrap_or_else(|e| {
tracing::error!("Failed to create payment response: {}", e);
None
})
}
/// Create a payment response from payment info
fn create_payment_response(
msg: &str,
payment: &lnbits_rs::api::payment::Payment,
) -> Result<Option<WaitPaymentResponse>, Error> {
let amount = payment.details.amount;
if amount == i64::MIN {
return Ok(None);
}
let hash = Self::decode_payment_hash(msg)?;
Ok(Some(WaitPaymentResponse {
payment_identifier: PaymentIdentifier::PaymentHash(hash),
payment_amount: Amount::from(amount.unsigned_abs()),
unit: CurrencyUnit::Msat,
payment_id: msg.to_string(),
}))
}
/// Decode a hex payment hash string into a byte array
fn decode_payment_hash(hash_str: &str) -> Result<[u8; 32], Error> {
let decoded = hex::decode(hash_str)
.map_err(|e| Error::Anyhow(anyhow!("Failed to decode payment hash: {}", e)))?;
decoded
.try_into()
.map_err(|_| Error::Anyhow(anyhow!("Invalid payment hash length")))
}
}
#[async_trait]
@@ -118,54 +172,14 @@ impl MintPayment for LNbits {
tokio::select! {
_ = cancel_token.cancelled() => {
// Stream is cancelled
is_active.store(false, Ordering::SeqCst);
tracing::info!("Waiting for lnbits invoice ending");
None
}
msg_option = receiver.recv() => {
match msg_option {
Some(msg) => {
let check = api.get_payment_info(&msg).await;
match check {
Ok(payment) => {
if payment.paid {
match hex::decode(msg.clone()) {
Ok(decoded) => {
match decoded.try_into() {
Ok(hash) => {
let response = WaitPaymentResponse {
payment_identifier: PaymentIdentifier::PaymentHash(hash),
payment_amount: Amount::from(payment.details.amount as u64),
unit: CurrencyUnit::Msat,
payment_id: msg.clone()
};
Some((response, (api, cancel_token, is_active)))
},
Err(e) => {
tracing::error!("Failed to convert payment hash bytes to array: {:?}", e);
None
}
}
},
Err(e) => {
tracing::error!("Failed to decode payment hash hex string: {}", e);
None
}
}
} else {
tracing::warn!("Received payment notification but could not check payment for {}", msg);
None
}
},
Err(_) => None
}
},
None => {
is_active.store(false, Ordering::SeqCst);
None
}
}
Self::process_message(msg_option, &api, &is_active)
.await
.map(|response| (response, (api, cancel_token, is_active)))
}
}
},
@@ -306,7 +320,6 @@ impl MintPayment for LNbits {
memo: Some(description),
unit: unit.to_string(),
expiry,
webhook: self.webhook_url.clone(),
internal: None,
out: false,
};
@@ -321,10 +334,8 @@ impl MintPayment for LNbits {
Self::Err::Anyhow(anyhow!("Could not create invoice"))
})?;
let request: Bolt11Invoice = create_invoice_response
.bolt11()
.ok_or_else(|| Self::Err::Anyhow(anyhow!("Missing bolt11 invoice")))?
.parse()?;
let request: Bolt11Invoice = create_invoice_response.bolt11().parse()?;
let expiry = request.expires_at().map(|t| t.as_secs());
Ok(CreateIncomingPaymentResponse {
@@ -389,7 +400,7 @@ impl MintPayment for LNbits {
let pay_response = MakePaymentResponse {
payment_lookup_id: payment_identifier.clone(),
payment_proof: payment.preimage,
status: lnbits_to_melt_status(&payment.details.status, payment.details.pending),
status: lnbits_to_melt_status(&payment.details.status),
total_spent: Amount::from(
payment.details.amount.unsigned_abs() + payment.details.fee.unsigned_abs(),
),
@@ -400,11 +411,7 @@ impl MintPayment for LNbits {
}
}
fn lnbits_to_melt_status(status: &str, pending: Option<bool>) -> MeltQuoteState {
if pending.unwrap_or_default() {
return MeltQuoteState::Pending;
}
fn lnbits_to_melt_status(status: &str) -> MeltQuoteState {
match status {
"success" => MeltQuoteState::Paid,
"failed" => MeltQuoteState::Unpaid,
@@ -412,15 +419,3 @@ fn lnbits_to_melt_status(status: &str, pending: Option<bool>) -> MeltQuoteState
_ => MeltQuoteState::Unknown,
}
}
impl LNbits {
/// Create invoice webhook
pub async fn create_invoice_webhook_router(
&self,
webhook_endpoint: &str,
) -> anyhow::Result<Router> {
self.lnbits_api
.create_invoice_webhook_router(webhook_endpoint)
.await
}
}

View File

@@ -80,8 +80,7 @@ reserve_fee_min = 4
# admin_api_key = ""
# invoice_api_key = ""
# lnbits_api = ""
# To be set true to support pre v1 lnbits api
# retro_api=false
# Note: Only LNBits v1 API is supported (websocket-based)
# [lnd]
# address = "https://domain:port"

View File

@@ -161,7 +161,6 @@ pub struct LNbits {
pub lnbits_api: String,
pub fee_percent: f32,
pub reserve_fee_min: Amount,
pub retro_api: bool,
}
#[cfg(feature = "cln")]

View File

@@ -103,7 +103,7 @@ pub fn setup_tracing(
logging_config: &config::LoggingConfig,
) -> Result<Option<tracing_appender::non_blocking::WorkerGuard>> {
let default_filter = "debug";
let hyper_filter = "hyper=warn";
let hyper_filter = "hyper=warn,rustls=warn,reqwest=warn";
let h2_filter = "h2=warn";
let tower_http = "tower_http=warn";

View File

@@ -10,8 +10,6 @@ use axum::Router;
#[cfg(feature = "fakewallet")]
use bip39::rand::{thread_rng, Rng};
use cdk::cdk_payment::MintPayment;
#[cfg(feature = "lnbits")]
use cdk::mint_url::MintUrl;
use cdk::nuts::CurrencyUnit;
#[cfg(any(
feature = "lnbits",
@@ -67,58 +65,28 @@ impl LnBackendSetup for config::Cln {
impl LnBackendSetup for config::LNbits {
async fn setup(
&self,
routers: &mut Vec<Router>,
settings: &Settings,
_routers: &mut Vec<Router>,
_settings: &Settings,
_unit: CurrencyUnit,
) -> anyhow::Result<cdk_lnbits::LNbits> {
let admin_api_key = &self.admin_api_key;
let invoice_api_key = &self.invoice_api_key;
// Channel used for lnbits web hook
let webhook_endpoint = "/webhook/lnbits/sat/invoice";
let fee_reserve = FeeReserve {
min_fee_reserve: self.reserve_fee_min,
percent_fee_reserve: self.fee_percent,
};
let webhook_url = if settings
.lnbits
.as_ref()
.expect("Lnbits must be defined")
.retro_api
{
let mint_url: MintUrl = settings.info.url.parse()?;
let webhook_url = mint_url.join(webhook_endpoint)?;
Some(webhook_url.to_string())
} else {
None
};
let lnbits = cdk_lnbits::LNbits::new(
admin_api_key.clone(),
invoice_api_key.clone(),
self.lnbits_api.clone(),
fee_reserve,
webhook_url,
)
.await?;
if settings
.lnbits
.as_ref()
.expect("Lnbits must be defined")
.retro_api
{
let router = lnbits
.create_invoice_webhook_router(webhook_endpoint)
.await?;
routers.push(router);
} else {
lnbits.subscribe_ws().await?;
};
// Use v1 websocket API
lnbits.subscribe_ws().await?;
Ok(lnbits)
}

View File

@@ -23,7 +23,7 @@ use crate::{Amount, Error, OidcClient};
/// JWT Claims structure for decoding tokens
#[derive(Debug, Serialize, Deserialize)]
struct Claims {
struct _Claims {
/// Subject
sub: Option<String>,
/// Expiration time (as UTC timestamp)