1#![warn(missing_docs)]
4#![warn(rustdoc::bare_urls)]
5
6use std::pin::Pin;
7use std::str::FromStr;
8use std::sync::atomic::{AtomicBool, Ordering};
9use std::sync::Arc;
10
11use anyhow::anyhow;
12use async_trait::async_trait;
13use axum::Router;
14use cdk::amount::{to_unit, Amount, MSAT_IN_SAT};
15use cdk::cdk_lightning::{
16 self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings,
17};
18use cdk::mint::FeeReserve;
19use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState};
20use cdk::{mint, Bolt11Invoice};
21use error::Error;
22use futures::{Stream, StreamExt};
23use phoenixd_rs::webhooks::WebhookResponse;
24use phoenixd_rs::{InvoiceRequest, Phoenixd as PhoenixdApi};
25use tokio::sync::Mutex;
26use tokio_util::sync::CancellationToken;
27
28pub mod error;
29
30#[derive(Clone)]
32pub struct Phoenixd {
33 phoenixd_api: PhoenixdApi,
34 fee_reserve: FeeReserve,
35 receiver: Arc<Mutex<Option<tokio::sync::mpsc::Receiver<WebhookResponse>>>>,
36 webhook_url: String,
37 wait_invoice_cancel_token: CancellationToken,
38 wait_invoice_is_active: Arc<AtomicBool>,
39}
40
41impl Phoenixd {
42 pub fn new(
44 api_password: String,
45 api_url: String,
46 fee_reserve: FeeReserve,
47 receiver: Arc<Mutex<Option<tokio::sync::mpsc::Receiver<WebhookResponse>>>>,
48 webhook_url: String,
49 ) -> Result<Self, Error> {
50 let phoenixd = PhoenixdApi::new(&api_password, &api_url)?;
51 Ok(Self {
52 phoenixd_api: phoenixd,
53 fee_reserve,
54 receiver,
55 webhook_url,
56 wait_invoice_cancel_token: CancellationToken::new(),
57 wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
58 })
59 }
60
61 pub async fn create_invoice_webhook(
63 &self,
64 webhook_endpoint: &str,
65 sender: tokio::sync::mpsc::Sender<WebhookResponse>,
66 ) -> anyhow::Result<Router> {
67 self.phoenixd_api
68 .create_invoice_webhook_router(webhook_endpoint, sender)
69 .await
70 }
71}
72
73#[async_trait]
74impl MintLightning for Phoenixd {
75 type Err = cdk_lightning::Error;
76
77 fn get_settings(&self) -> Settings {
78 Settings {
79 mpp: false,
80 unit: CurrencyUnit::Sat,
81 invoice_description: true,
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 #[allow(clippy::incompatible_msrv)]
93 async fn wait_any_invoice(
94 &self,
95 ) -> Result<Pin<Box<dyn Stream<Item = String> + Send>>, Self::Err> {
96 let receiver = self
97 .receiver
98 .lock()
99 .await
100 .take()
101 .ok_or(anyhow!("No receiver"))?;
102
103 let phoenixd_api = self.phoenixd_api.clone();
104
105 let cancel_token = self.wait_invoice_cancel_token.clone();
106
107 Ok(futures::stream::unfold(
108 (receiver, phoenixd_api, cancel_token,
109 Arc::clone(&self.wait_invoice_is_active),
110 ),
111 |(mut receiver, phoenixd_api, cancel_token, is_active)| async move {
112
113 is_active.store(true, Ordering::SeqCst);
114 tokio::select! {
115 _ = cancel_token.cancelled() => {
116 is_active.store(false, Ordering::SeqCst);
118 tracing::info!("Waiting for phonixd invoice ending");
119 None
120 }
121 msg_option = receiver.recv() => {
122 match msg_option {
123 Some(msg) => {
124 let check = phoenixd_api.get_incoming_invoice(&msg.payment_hash).await;
125
126 match check {
127 Ok(state) => {
128 if state.is_paid {
129 Some((msg.payment_hash, (receiver, phoenixd_api, cancel_token, is_active)))
131 } else {
132 None
135 }
136 }
137 Err(e) => {
138 tracing::warn!("Error checking invoice state: {:?}", e);
140 None
141 }
142 }
143 }
144 None => {
145 None
147 }
148 }
149 }
150 }
151 },
152 )
153 .boxed())
154 }
155
156 async fn get_payment_quote(
157 &self,
158 melt_quote_request: &MeltQuoteBolt11Request,
159 ) -> Result<PaymentQuoteResponse, Self::Err> {
160 if CurrencyUnit::Sat != melt_quote_request.unit {
161 return Err(Error::UnsupportedUnit.into());
162 }
163
164 let amount = melt_quote_request.amount_msat()?;
165
166 let amount = amount / MSAT_IN_SAT.into();
167
168 let relative_fee_reserve =
169 (self.fee_reserve.percent_fee_reserve * u64::from(amount) as f32) as u64;
170
171 let absolute_fee_reserve: u64 = self.fee_reserve.min_fee_reserve.into();
172
173 let mut fee = match relative_fee_reserve > absolute_fee_reserve {
174 true => relative_fee_reserve,
175 false => absolute_fee_reserve,
176 };
177
178 fee = fee.checked_add(4).ok_or(Error::AmountOverflow)?;
180
181 Ok(PaymentQuoteResponse {
182 request_lookup_id: melt_quote_request.request.payment_hash().to_string(),
183 amount,
184 fee: fee.into(),
185 state: MeltQuoteState::Unpaid,
186 })
187 }
188
189 async fn pay_invoice(
190 &self,
191 melt_quote: mint::MeltQuote,
192 _partial_amount: Option<Amount>,
193 _max_fee_msats: Option<Amount>,
194 ) -> Result<PayInvoiceResponse, Self::Err> {
195 let msat_to_pay: Option<u64> = melt_quote
196 .msat_to_pay
197 .map(|a| <cdk::Amount as Into<u64>>::into(a) / MSAT_IN_SAT);
198
199 let pay_response = self
200 .phoenixd_api
201 .pay_bolt11_invoice(&melt_quote.request, msat_to_pay)
202 .await?;
203
204 let check_outgoing_response = self
206 .check_outgoing_payment(&pay_response.payment_id)
207 .await?;
208
209 let bolt11: Bolt11Invoice = melt_quote.request.parse()?;
210
211 Ok(PayInvoiceResponse {
212 payment_lookup_id: bolt11.payment_hash().to_string(),
213 payment_preimage: Some(pay_response.payment_preimage),
214 status: MeltQuoteState::Paid,
215 total_spent: check_outgoing_response.total_spent,
216 unit: CurrencyUnit::Sat,
217 })
218 }
219
220 async fn create_invoice(
221 &self,
222 amount: Amount,
223 unit: &CurrencyUnit,
224 description: String,
225 _unix_expiry: u64,
226 ) -> Result<CreateInvoiceResponse, Self::Err> {
227 let amount_sat = to_unit(amount, unit, &CurrencyUnit::Sat)?;
228
229 let invoice_request = InvoiceRequest {
230 external_id: None,
231 description: Some(description),
232 description_hash: None,
233 amount_sat: amount_sat.into(),
234 webhook_url: Some(self.webhook_url.clone()),
235 };
236
237 let create_invoice_response = self.phoenixd_api.create_invoice(invoice_request).await?;
238
239 let bolt11: Bolt11Invoice = create_invoice_response.serialized.parse()?;
240 let expiry = bolt11.expires_at().map(|t| t.as_secs());
241
242 Ok(CreateInvoiceResponse {
243 request_lookup_id: create_invoice_response.payment_hash,
244 request: bolt11.clone(),
245 expiry,
246 })
247 }
248
249 async fn check_incoming_invoice_status(
250 &self,
251 payment_hash: &str,
252 ) -> Result<MintQuoteState, Self::Err> {
253 let invoice = self.phoenixd_api.get_incoming_invoice(payment_hash).await?;
254
255 let state = match invoice.is_paid {
256 true => MintQuoteState::Paid,
257 false => MintQuoteState::Unpaid,
258 };
259
260 Ok(state)
261 }
262
263 async fn check_outgoing_payment(
265 &self,
266 payment_id: &str,
267 ) -> Result<PayInvoiceResponse, Self::Err> {
268 if let Err(_err) = uuid::Uuid::from_str(payment_id) {
271 tracing::warn!("Could not check status of payment, no payment id");
272 return Ok(PayInvoiceResponse {
273 payment_lookup_id: payment_id.to_string(),
274 payment_preimage: None,
275 status: MeltQuoteState::Unknown,
276 total_spent: Amount::ZERO,
277 unit: CurrencyUnit::Sat,
278 });
279 }
280
281 let res = self.phoenixd_api.get_outgoing_invoice(payment_id).await;
282
283 let state = match res {
284 Ok(res) => {
285 let status = match res.is_paid {
286 true => MeltQuoteState::Paid,
287 false => MeltQuoteState::Unpaid,
288 };
289
290 let total_spent = res.sent + (res.fees + 999) / MSAT_IN_SAT;
291
292 PayInvoiceResponse {
293 payment_lookup_id: res.payment_hash,
294 payment_preimage: Some(res.preimage),
295 status,
296 total_spent: total_spent.into(),
297 unit: CurrencyUnit::Sat,
298 }
299 }
300 Err(err) => match err {
301 phoenixd_rs::Error::NotFound => PayInvoiceResponse {
302 payment_lookup_id: payment_id.to_string(),
303 payment_preimage: None,
304 status: MeltQuoteState::Unknown,
305 total_spent: Amount::ZERO,
306 unit: CurrencyUnit::Sat,
307 },
308 _ => {
309 return Err(Error::from(err).into());
310 }
311 },
312 };
313
314 Ok(state)
315 }
316}