cdk_phoenixd/
lib.rs

1//! CDK lightning backend for Phoenixd
2
3#![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/// Phoenixd
31#[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    /// Create new [`Phoenixd`] wallet
43    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    /// Create invoice webhook
62    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                    // Stream is cancelled
117                    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                                        // Yield the payment hash and continue the stream
130                                        Some((msg.payment_hash, (receiver, phoenixd_api, cancel_token, is_active)))
131                                    } else {
132                                        // Invoice not paid yet, continue waiting
133                                        // We need to continue the stream, so we return the same state
134                                        None
135                                    }
136                                }
137                                Err(e) => {
138                                    // Log the error and continue
139                                    tracing::warn!("Error checking invoice state: {:?}", e);
140                                    None
141                                }
142                            }
143                        }
144                        None => {
145                            // The receiver stream has ended
146                            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 in phoenixd is always 0.04 + 4 sat
179        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        // The pay invoice response does not give the needed fee info so we have to check.
205        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    /// Check the status of an outgoing invoice
264    async fn check_outgoing_payment(
265        &self,
266        payment_id: &str,
267    ) -> Result<PayInvoiceResponse, Self::Err> {
268        // We can only check the status of the payment if we have the payment id not if we only have a payment hash.
269        // In phd this is a uuid, that we get after getting a response from the pay invoice
270        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}