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