1#![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#[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 #[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 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 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 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 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 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 drop(receiver); tracing::warn!("LNbits websocket connection lost (receiver returned None), attempting to reconnect...");
206
207 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 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;
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}