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