cdk_lnbits/
lib.rs

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