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