cdk_lnbits/
lib.rs

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