cdk_lnbits/
lib.rs

1//! CDK lightning backend for lnbits
2
3#![doc = include_str!("../README.md")]
4#![warn(missing_docs)]
5#![warn(rustdoc::bare_urls)]
6
7use std::cmp::max;
8use std::pin::Pin;
9use std::str::FromStr;
10use std::sync::atomic::{AtomicBool, Ordering};
11use std::sync::Arc;
12
13use anyhow::anyhow;
14use async_trait::async_trait;
15use axum::Router;
16use cdk_common::amount::{to_unit, Amount, MSAT_IN_SAT};
17use cdk_common::common::FeeReserve;
18use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState, MintQuoteState};
19use cdk_common::payment::{
20    self, Bolt11Settings, CreateIncomingPaymentResponse, MakePaymentResponse, MintPayment,
21    PaymentQuoteResponse,
22};
23use cdk_common::util::unix_time;
24use cdk_common::{mint, Bolt11Invoice};
25use error::Error;
26use futures::Stream;
27use lnbits_rs::api::invoice::CreateInvoiceRequest;
28use lnbits_rs::LNBitsClient;
29use serde_json::Value;
30use tokio_util::sync::CancellationToken;
31
32pub mod error;
33
34/// LNbits
35#[derive(Clone)]
36pub struct LNbits {
37    lnbits_api: LNBitsClient,
38    fee_reserve: FeeReserve,
39    webhook_url: Option<String>,
40    wait_invoice_cancel_token: CancellationToken,
41    wait_invoice_is_active: Arc<AtomicBool>,
42    settings: Bolt11Settings,
43}
44
45impl LNbits {
46    /// Create new [`LNbits`] wallet
47    #[allow(clippy::too_many_arguments)]
48    pub async fn new(
49        admin_api_key: String,
50        invoice_api_key: String,
51        api_url: String,
52        fee_reserve: FeeReserve,
53        webhook_url: Option<String>,
54    ) -> Result<Self, Error> {
55        let lnbits_api = LNBitsClient::new("", &admin_api_key, &invoice_api_key, &api_url, None)?;
56
57        Ok(Self {
58            lnbits_api,
59            fee_reserve,
60            webhook_url,
61            wait_invoice_cancel_token: CancellationToken::new(),
62            wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
63            settings: Bolt11Settings {
64                mpp: false,
65                unit: CurrencyUnit::Sat,
66                invoice_description: true,
67                amountless: false,
68            },
69        })
70    }
71
72    /// Subscribe to lnbits ws
73    pub async fn subscribe_ws(&self) -> Result<(), Error> {
74        self.lnbits_api
75            .subscribe_to_websocket()
76            .await
77            .map_err(|err| {
78                tracing::error!("Could not subscribe to lnbits ws");
79                Error::Anyhow(err)
80            })
81    }
82}
83
84#[async_trait]
85impl MintPayment for LNbits {
86    type Err = payment::Error;
87
88    async fn get_settings(&self) -> Result<Value, Self::Err> {
89        Ok(serde_json::to_value(&self.settings)?)
90    }
91
92    fn is_wait_invoice_active(&self) -> bool {
93        self.wait_invoice_is_active.load(Ordering::SeqCst)
94    }
95
96    fn cancel_wait_invoice(&self) {
97        self.wait_invoice_cancel_token.cancel()
98    }
99
100    async fn wait_any_incoming_payment(
101        &self,
102    ) -> Result<Pin<Box<dyn Stream<Item = String> + Send>>, Self::Err> {
103        let api = self.lnbits_api.clone();
104        let cancel_token = self.wait_invoice_cancel_token.clone();
105        let is_active = Arc::clone(&self.wait_invoice_is_active);
106
107        Ok(Box::pin(futures::stream::unfold(
108            (api, cancel_token, is_active),
109            |(api, cancel_token, is_active)| async move {
110                is_active.store(true, Ordering::SeqCst);
111
112                let receiver = api.receiver();
113                let mut receiver = receiver.lock().await;
114
115                tokio::select! {
116                    _ = cancel_token.cancelled() => {
117                        // Stream is cancelled
118                        is_active.store(false, Ordering::SeqCst);
119                        tracing::info!("Waiting for lnbits invoice ending");
120                        None
121                    }
122                    msg_option = receiver.recv() => {
123                        match msg_option {
124                            Some(msg) => {
125                                let check = api.is_invoice_paid(&msg).await;
126
127                                match check {
128                                    Ok(state) => {
129                                        if state {
130                                            Some((msg, (api, cancel_token, is_active)))
131                                        } else {
132                                            Some(("".to_string(), (api, cancel_token, is_active)))
133                                        }
134                                    }
135                                    _ => Some(("".to_string(), (api, cancel_token, is_active))),
136                                }
137                            }
138                            None => {
139                                is_active.store(false, Ordering::SeqCst);
140                                None
141                            },
142                        }
143                    }
144                }
145            },
146        )))
147    }
148
149    async fn get_payment_quote(
150        &self,
151        request: &str,
152        unit: &CurrencyUnit,
153        options: Option<MeltOptions>,
154    ) -> Result<PaymentQuoteResponse, Self::Err> {
155        if unit != &CurrencyUnit::Sat {
156            return Err(Self::Err::Anyhow(anyhow!("Unsupported unit")));
157        }
158
159        let bolt11 = Bolt11Invoice::from_str(request)?;
160
161        let amount_msat = match options {
162            Some(amount) => {
163                if matches!(amount, MeltOptions::Mpp { mpp: _ }) {
164                    return Err(payment::Error::UnsupportedPaymentOption);
165                }
166                amount.amount_msat()
167            }
168            None => bolt11
169                .amount_milli_satoshis()
170                .ok_or(Error::UnknownInvoiceAmount)?
171                .into(),
172        };
173
174        let amount = amount_msat / MSAT_IN_SAT.into();
175
176        let relative_fee_reserve =
177            (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64;
178
179        let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into();
180
181        let fee = max(relative_fee_reserve, absolute_fee_reserve);
182
183        Ok(PaymentQuoteResponse {
184            request_lookup_id: bolt11.payment_hash().to_string(),
185            amount,
186            unit: unit.clone(),
187            fee: fee.into(),
188            state: MeltQuoteState::Unpaid,
189        })
190    }
191
192    async fn make_payment(
193        &self,
194        melt_quote: mint::MeltQuote,
195        _partial_msats: Option<Amount>,
196        _max_fee_msats: Option<Amount>,
197    ) -> Result<MakePaymentResponse, Self::Err> {
198        let pay_response = self
199            .lnbits_api
200            .pay_invoice(&melt_quote.request, None)
201            .await
202            .map_err(|err| {
203                tracing::error!("Could not pay invoice");
204                tracing::error!("{}", err.to_string());
205                Self::Err::Anyhow(anyhow!("Could not pay invoice"))
206            })?;
207
208        let invoice_info = self
209            .lnbits_api
210            .get_payment_info(&pay_response.payment_hash)
211            .await
212            .map_err(|err| {
213                tracing::error!("Could not find invoice");
214                tracing::error!("{}", err.to_string());
215                Self::Err::Anyhow(anyhow!("Could not find invoice"))
216            })?;
217
218        let status = match invoice_info.paid {
219            true => MeltQuoteState::Paid,
220            false => MeltQuoteState::Unpaid,
221        };
222
223        let total_spent = Amount::from(
224            (invoice_info
225                .details
226                .amount
227                .checked_add(invoice_info.details.fee)
228                .ok_or(Error::AmountOverflow)?)
229            .unsigned_abs(),
230        );
231
232        Ok(MakePaymentResponse {
233            payment_lookup_id: pay_response.payment_hash,
234            payment_proof: invoice_info.details.preimage,
235            status,
236            total_spent,
237            unit: CurrencyUnit::Sat,
238        })
239    }
240
241    async fn create_incoming_payment_request(
242        &self,
243        amount: Amount,
244        unit: &CurrencyUnit,
245        description: String,
246        unix_expiry: Option<u64>,
247    ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
248        if unit != &CurrencyUnit::Sat {
249            return Err(Self::Err::Anyhow(anyhow!("Unsupported unit")));
250        }
251
252        let time_now = unix_time();
253
254        let expiry = unix_expiry.map(|t| t - time_now);
255
256        let invoice_request = CreateInvoiceRequest {
257            amount: to_unit(amount, unit, &CurrencyUnit::Sat)?.into(),
258            memo: Some(description),
259            unit: unit.to_string(),
260            expiry,
261            webhook: self.webhook_url.clone(),
262            internal: None,
263            out: false,
264        };
265
266        let create_invoice_response = self
267            .lnbits_api
268            .create_invoice(&invoice_request)
269            .await
270            .map_err(|err| {
271                tracing::error!("Could not create invoice");
272                tracing::error!("{}", err.to_string());
273                Self::Err::Anyhow(anyhow!("Could not create invoice"))
274            })?;
275
276        let request: Bolt11Invoice = create_invoice_response
277            .bolt11()
278            .ok_or_else(|| Self::Err::Anyhow(anyhow!("Missing bolt11 invoice")))?
279            .parse()?;
280        let expiry = request.expires_at().map(|t| t.as_secs());
281
282        Ok(CreateIncomingPaymentResponse {
283            request_lookup_id: create_invoice_response.payment_hash().to_string(),
284            request: request.to_string(),
285            expiry,
286        })
287    }
288
289    async fn check_incoming_payment_status(
290        &self,
291        payment_hash: &str,
292    ) -> Result<MintQuoteState, Self::Err> {
293        let paid = self
294            .lnbits_api
295            .is_invoice_paid(payment_hash)
296            .await
297            .map_err(|err| {
298                tracing::error!("Could not check invoice status");
299                tracing::error!("{}", err.to_string());
300                Self::Err::Anyhow(anyhow!("Could not check invoice status"))
301            })?;
302
303        let state = match paid {
304            true => MintQuoteState::Paid,
305            false => MintQuoteState::Unpaid,
306        };
307
308        Ok(state)
309    }
310
311    async fn check_outgoing_payment(
312        &self,
313        payment_hash: &str,
314    ) -> Result<MakePaymentResponse, Self::Err> {
315        let payment = self
316            .lnbits_api
317            .get_payment_info(payment_hash)
318            .await
319            .map_err(|err| {
320                tracing::error!("Could not check invoice status");
321                tracing::error!("{}", err.to_string());
322                Self::Err::Anyhow(anyhow!("Could not check invoice status"))
323            })?;
324
325        let pay_response = MakePaymentResponse {
326            payment_lookup_id: payment.details.payment_hash,
327            payment_proof: payment.preimage,
328            status: lnbits_to_melt_status(&payment.details.status, payment.details.pending),
329            total_spent: Amount::from(
330                payment.details.amount.unsigned_abs()
331                    + payment.details.fee.unsigned_abs() / MSAT_IN_SAT,
332            ),
333            unit: self.settings.unit.clone(),
334        };
335
336        Ok(pay_response)
337    }
338}
339
340fn lnbits_to_melt_status(status: &str, pending: Option<bool>) -> MeltQuoteState {
341    if pending.unwrap_or_default() {
342        return MeltQuoteState::Pending;
343    }
344
345    match status {
346        "success" => MeltQuoteState::Paid,
347        "failed" => MeltQuoteState::Unpaid,
348        "pending" => MeltQuoteState::Pending,
349        _ => MeltQuoteState::Unknown,
350    }
351}
352
353impl LNbits {
354    /// Create invoice webhook
355    pub async fn create_invoice_webhook_router(
356        &self,
357        webhook_endpoint: &str,
358    ) -> anyhow::Result<Router> {
359        self.lnbits_api
360            .create_invoice_webhook_router(webhook_endpoint)
361            .await
362    }
363}