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