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