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