1#![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#[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 #[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 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 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}