Skip to main content

cdk_lnbits/
lib.rs

1//! CDK lightning backend for lnbits
2
3#![doc = include_str!("../README.md")]
4
5use std::cmp::max;
6use std::pin::Pin;
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::sync::Arc;
9
10use anyhow::anyhow;
11use async_trait::async_trait;
12use cdk_common::amount::{Amount, MSAT_IN_SAT};
13use cdk_common::common::FeeReserve;
14use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState};
15use cdk_common::payment::{
16    self, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse,
17    MintPayment, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, SettingsResponse,
18    WaitPaymentResponse,
19};
20use cdk_common::util::{hex, unix_time};
21use cdk_common::Bolt11Invoice;
22use error::Error;
23use futures::Stream;
24use lnbits_rs::api::invoice::CreateInvoiceRequest;
25use lnbits_rs::LNBitsClient;
26use tokio_util::sync::CancellationToken;
27
28pub mod error;
29
30/// LNbits
31#[derive(Clone)]
32pub struct LNbits {
33    lnbits_api: LNBitsClient,
34    fee_reserve: FeeReserve,
35    wait_invoice_cancel_token: CancellationToken,
36    wait_invoice_is_active: Arc<AtomicBool>,
37    settings: SettingsResponse,
38}
39
40impl std::fmt::Debug for LNbits {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("LNbits")
43            .field("fee_reserve", &self.fee_reserve)
44            .finish_non_exhaustive()
45    }
46}
47
48impl LNbits {
49    /// Create new [`LNbits`] wallet
50    #[allow(clippy::too_many_arguments)]
51    pub async fn new(
52        admin_api_key: String,
53        invoice_api_key: String,
54        api_url: String,
55        fee_reserve: FeeReserve,
56    ) -> Result<Self, Error> {
57        let lnbits_api = LNBitsClient::new("", &admin_api_key, &invoice_api_key, &api_url, None)?;
58
59        Ok(Self {
60            lnbits_api,
61            fee_reserve,
62            wait_invoice_cancel_token: CancellationToken::new(),
63            wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
64            settings: SettingsResponse {
65                unit: CurrencyUnit::Sat.to_string(),
66                bolt11: Some(payment::Bolt11Settings {
67                    mpp: false,
68                    amountless: false,
69                    invoice_description: true,
70                }),
71                bolt12: None,
72                custom: std::collections::HashMap::new(),
73            },
74        })
75    }
76
77    /// Subscribe to lnbits ws
78    pub async fn subscribe_ws(&self) -> Result<(), Error> {
79        if rustls::crypto::CryptoProvider::get_default().is_none() {
80            let _ = rustls::crypto::ring::default_provider().install_default();
81        }
82        self.lnbits_api
83            .subscribe_to_websocket()
84            .await
85            .map_err(|err| {
86                tracing::error!("Could not subscribe to lnbits ws");
87                Error::Anyhow(err)
88            })
89    }
90
91    /// Process an incoming message from the websocket receiver
92    async fn process_message(
93        msg_option: Option<String>,
94        api: &LNBitsClient,
95        _is_active: &Arc<AtomicBool>,
96    ) -> Option<WaitPaymentResponse> {
97        let msg = msg_option?;
98
99        let payment = match api.get_payment_info(&msg).await {
100            Ok(payment) => payment,
101            Err(_) => return None,
102        };
103
104        if !payment.paid {
105            tracing::warn!(
106                "Received payment notification but payment not paid for {}",
107                msg
108            );
109            return None;
110        }
111
112        Self::create_payment_response(&msg, &payment).unwrap_or_else(|e| {
113            tracing::error!("Failed to create payment response: {}", e);
114            None
115        })
116    }
117
118    /// Create a payment response from payment info
119    fn create_payment_response(
120        msg: &str,
121        payment: &lnbits_rs::api::payment::Payment,
122    ) -> Result<Option<WaitPaymentResponse>, Error> {
123        let amount = payment.details.amount;
124
125        if amount == i64::MIN {
126            return Ok(None);
127        }
128
129        let hash = Self::decode_payment_hash(msg)?;
130
131        Ok(Some(WaitPaymentResponse {
132            payment_identifier: PaymentIdentifier::PaymentHash(hash),
133            payment_amount: Amount::new(amount.unsigned_abs(), CurrencyUnit::Msat),
134            payment_id: msg.to_string(),
135        }))
136    }
137
138    /// Decode a hex payment hash string into a byte array
139    fn decode_payment_hash(hash_str: &str) -> Result<[u8; 32], Error> {
140        let decoded = hex::decode(hash_str)
141            .map_err(|e| Error::Anyhow(anyhow!("Failed to decode payment hash: {}", e)))?;
142
143        decoded
144            .try_into()
145            .map_err(|_| Error::Anyhow(anyhow!("Invalid payment hash length")))
146    }
147}
148
149#[async_trait]
150impl MintPayment for LNbits {
151    type Err = payment::Error;
152
153    async fn get_settings(&self) -> Result<SettingsResponse, Self::Err> {
154        Ok(self.settings.clone())
155    }
156
157    fn is_wait_invoice_active(&self) -> bool {
158        self.wait_invoice_is_active.load(Ordering::SeqCst)
159    }
160
161    fn cancel_wait_invoice(&self) {
162        self.wait_invoice_cancel_token.cancel()
163    }
164
165    async fn wait_payment_event(
166        &self,
167    ) -> Result<Pin<Box<dyn Stream<Item = Event> + Send>>, Self::Err> {
168        let api = self.lnbits_api.clone();
169        let cancel_token = self.wait_invoice_cancel_token.clone();
170        let is_active = Arc::clone(&self.wait_invoice_is_active);
171
172        Ok(Box::pin(futures::stream::unfold(
173            (api, cancel_token, is_active, 0u32),
174            |(api, cancel_token, is_active, mut retry_count)| async move {
175                is_active.store(true, Ordering::SeqCst);
176
177                loop {
178                    tracing::debug!("LNbits: Starting wait loop, attempting to get receiver");
179                    let receiver = api.receiver();
180                    let mut receiver = receiver.lock().await;
181                    tracing::debug!("LNbits: Got receiver lock, waiting for messages");
182
183                    tokio::select! {
184                        _ = cancel_token.cancelled() => {
185                            is_active.store(false, Ordering::SeqCst);
186                            tracing::info!("Waiting for lnbits invoice ending");
187                            return None;
188                        }
189                        msg_option = receiver.recv() => {
190                            tracing::debug!("LNbits: Received message from websocket: {:?}", msg_option.as_ref().map(|_| "Some(message)"));
191                            match msg_option {
192                                Some(_) => {
193                                    // Successfully received a message, reset retry count
194                                    retry_count = 0;
195                                    let result = Self::process_message(msg_option, &api, &is_active).await;
196                                    return result.map(|response| {
197                                        (Event::PaymentReceived(response), (api, cancel_token, is_active, retry_count))
198                                    });
199                                }
200                                None => {
201                                    // Connection lost, need to reconnect
202                                    drop(receiver); // Drop the lock before reconnecting
203
204                                    tracing::warn!("LNbits websocket connection lost (receiver returned None), attempting to reconnect...");
205
206                                    // Exponential backoff: 1s, 2s, 4s, 8s, max 10s
207                                    let backoff_secs = std::cmp::min(2u64.pow(retry_count), 10);
208                                    tracing::info!("Retrying in {} seconds (attempt {})", backoff_secs, retry_count + 1);
209                                    tokio::time::sleep(std::time::Duration::from_secs(backoff_secs)).await;
210
211                                    // Attempt to resubscribe
212                                    if let Err(err) = api.subscribe_to_websocket().await {
213                                        tracing::error!("Failed to resubscribe to LNbits websocket: {:?}", err);
214                                    } else {
215                                        tracing::info!("Successfully reconnected to LNbits websocket");
216                                    }
217
218                                    retry_count += 1;
219                                    // Continue the loop to try again
220                                    continue;
221                                }
222                            }
223                        }
224                    }
225                }
226            },
227        )))
228    }
229
230    async fn get_payment_quote(
231        &self,
232        unit: &CurrencyUnit,
233        options: OutgoingPaymentOptions,
234    ) -> Result<PaymentQuoteResponse, Self::Err> {
235        match options {
236            OutgoingPaymentOptions::Bolt11(bolt11_options) => {
237                let amount_msat = match bolt11_options.melt_options {
238                    Some(amount) => {
239                        if matches!(amount, MeltOptions::Mpp { mpp: _ }) {
240                            return Err(payment::Error::UnsupportedPaymentOption);
241                        }
242                        amount.amount_msat()
243                    }
244                    None => bolt11_options
245                        .bolt11
246                        .amount_milli_satoshis()
247                        .ok_or(Error::UnknownInvoiceAmount)?
248                        .into(),
249                };
250
251                let relative_fee_reserve =
252                    (self.fee_reserve.percent_fee_reserve * u64::from(amount_msat) as f32) as u64;
253
254                let absolute_fee_reserve: u64 =
255                    u64::from(self.fee_reserve.min_fee_reserve) * MSAT_IN_SAT;
256
257                let fee = max(relative_fee_reserve, absolute_fee_reserve);
258
259                Ok(PaymentQuoteResponse {
260                    request_lookup_id: Some(PaymentIdentifier::PaymentHash(
261                        *bolt11_options.bolt11.payment_hash().as_ref(),
262                    )),
263                    amount: Amount::new(amount_msat.into(), CurrencyUnit::Msat).convert_to(unit)?,
264                    fee: Amount::new(fee, CurrencyUnit::Msat).convert_to(unit)?,
265                    state: MeltQuoteState::Unpaid,
266                })
267            }
268            OutgoingPaymentOptions::Bolt12(_bolt12_options) => {
269                Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LNbits")))
270            }
271            OutgoingPaymentOptions::Custom(_) => Err(payment::Error::UnsupportedPaymentOption),
272        }
273    }
274
275    async fn make_payment(
276        &self,
277        unit: &CurrencyUnit,
278        options: OutgoingPaymentOptions,
279    ) -> Result<MakePaymentResponse, Self::Err> {
280        match options {
281            OutgoingPaymentOptions::Bolt11(bolt11_options) => {
282                let pay_response = self
283                    .lnbits_api
284                    .pay_invoice(&bolt11_options.bolt11.to_string(), None)
285                    .await
286                    .map_err(|err| {
287                        tracing::error!("Could not pay invoice");
288                        tracing::error!("{}", err.to_string());
289                        Self::Err::Anyhow(anyhow!("Could not pay invoice"))
290                    })?;
291
292                let invoice_info = self
293                    .lnbits_api
294                    .get_payment_info(&pay_response.payment_hash)
295                    .await
296                    .map_err(|err| {
297                        tracing::error!("Could not find invoice");
298                        tracing::error!("{}", err.to_string());
299                        Self::Err::Anyhow(anyhow!("Could not find invoice"))
300                    })?;
301
302                let status = if invoice_info.paid {
303                    MeltQuoteState::Paid
304                } else {
305                    MeltQuoteState::Unpaid
306                };
307
308                let total_spent_msat = Amount::new(
309                    invoice_info
310                        .details
311                        .amount
312                        .unsigned_abs()
313                        .checked_add(invoice_info.details.fee.unsigned_abs())
314                        .ok_or(Error::AmountOverflow)?,
315                    CurrencyUnit::Msat,
316                );
317
318                let total_spent = total_spent_msat.convert_to(unit)?;
319
320                Ok(MakePaymentResponse {
321                    payment_lookup_id: PaymentIdentifier::PaymentHash(
322                        hex::decode(pay_response.payment_hash)
323                            .map_err(|_| Error::InvalidPaymentHash)?
324                            .try_into()
325                            .map_err(|_| Error::InvalidPaymentHash)?,
326                    ),
327                    payment_proof: Some(invoice_info.details.payment_hash),
328                    status,
329                    total_spent,
330                })
331            }
332            OutgoingPaymentOptions::Bolt12(_) => {
333                Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LNbits")))
334            }
335            OutgoingPaymentOptions::Custom(_) => Err(payment::Error::UnsupportedPaymentOption),
336        }
337    }
338
339    async fn create_incoming_payment_request(
340        &self,
341        unit: &CurrencyUnit,
342        options: IncomingPaymentOptions,
343    ) -> Result<CreateIncomingPaymentResponse, Self::Err> {
344        match options {
345            IncomingPaymentOptions::Bolt11(bolt11_options) => {
346                let description = bolt11_options.description.unwrap_or_default();
347                let amount = bolt11_options.amount;
348                let unix_expiry = bolt11_options.unix_expiry;
349
350                let time_now = unix_time();
351                let expiry = unix_expiry.map(|t| t - time_now);
352
353                let invoice_request = CreateInvoiceRequest {
354                    amount: Amount::new(amount.into(), unit.clone())
355                        .convert_to(&CurrencyUnit::Sat)?
356                        .value(),
357                    memo: Some(description),
358                    unit: unit.to_string(),
359                    expiry,
360                    internal: None,
361                    out: false,
362                };
363
364                let create_invoice_response = self
365                    .lnbits_api
366                    .create_invoice(&invoice_request)
367                    .await
368                    .map_err(|err| {
369                        tracing::error!("Could not create invoice");
370                        tracing::error!("{}", err.to_string());
371                        Self::Err::Anyhow(anyhow!("Could not create invoice"))
372                    })?;
373
374                let request: Bolt11Invoice = create_invoice_response.bolt11().parse()?;
375
376                let expiry = request.expires_at().map(|t| t.as_secs());
377
378                Ok(CreateIncomingPaymentResponse {
379                    request_lookup_id: PaymentIdentifier::PaymentHash(
380                        *request.payment_hash().as_ref(),
381                    ),
382                    request: request.to_string(),
383                    expiry,
384                    extra_json: None,
385                })
386            }
387            IncomingPaymentOptions::Bolt12(_) => {
388                Err(Self::Err::Anyhow(anyhow!("BOLT12 not supported by LNbits")))
389            }
390            IncomingPaymentOptions::Custom(_) => Err(payment::Error::UnsupportedPaymentOption),
391        }
392    }
393
394    async fn check_incoming_payment_status(
395        &self,
396        payment_identifier: &PaymentIdentifier,
397    ) -> Result<Vec<WaitPaymentResponse>, Self::Err> {
398        let payment = self
399            .lnbits_api
400            .get_payment_info(&payment_identifier.to_string())
401            .await
402            .map_err(|err| {
403                tracing::error!("Could not check invoice status");
404                tracing::error!("{}", err.to_string());
405                Self::Err::Anyhow(anyhow!("Could not check invoice status"))
406            })?;
407
408        let amount = payment.details.amount;
409
410        if amount == i64::MIN {
411            return Err(Error::AmountOverflow.into());
412        }
413
414        match payment.paid {
415            true => Ok(vec![WaitPaymentResponse {
416                payment_identifier: payment_identifier.clone(),
417                payment_amount: Amount::new(amount.unsigned_abs(), CurrencyUnit::Msat),
418                payment_id: payment.details.payment_hash,
419            }]),
420            false => Ok(vec![]),
421        }
422    }
423
424    async fn check_outgoing_payment(
425        &self,
426        payment_identifier: &PaymentIdentifier,
427    ) -> Result<MakePaymentResponse, Self::Err> {
428        let payment = self
429            .lnbits_api
430            .get_payment_info(&payment_identifier.to_string())
431            .await
432            .map_err(|err| {
433                tracing::error!("Could not check invoice status");
434                tracing::error!("{}", err.to_string());
435                Self::Err::Anyhow(anyhow!("Could not check invoice status"))
436            })?;
437
438        let pay_response = MakePaymentResponse {
439            payment_lookup_id: payment_identifier.clone(),
440            payment_proof: payment.preimage,
441            status: lnbits_to_melt_status(&payment.details.status),
442            total_spent: Amount::new(
443                payment.details.amount.unsigned_abs() + payment.details.fee.unsigned_abs(),
444                CurrencyUnit::Msat,
445            ),
446        };
447
448        Ok(pay_response)
449    }
450}
451
452fn lnbits_to_melt_status(status: &str) -> MeltQuoteState {
453    match status {
454        "success" => MeltQuoteState::Paid,
455        "failed" => MeltQuoteState::Unpaid,
456        "pending" => MeltQuoteState::Pending,
457        _ => MeltQuoteState::Unknown,
458    }
459}