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, bail};
11use async_trait::async_trait;
12use axum::Router;
13use cdk::amount::Amount;
14use cdk::cdk_lightning::{
15 self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings,
16};
17use cdk::nuts::{CurrencyUnit, MeltQuoteBolt11Request, MeltQuoteState, MintQuoteState};
18use cdk::util::unix_time;
19use cdk::{mint, Bolt11Invoice};
20use error::Error;
21use futures::stream::StreamExt;
22use futures::Stream;
23use strike_rs::{
24 Amount as StrikeAmount, Currency as StrikeCurrencyUnit, InvoiceRequest, InvoiceState,
25 PayInvoiceQuoteRequest, Strike as StrikeApi,
26};
27use tokio::sync::Mutex;
28use tokio_util::sync::CancellationToken;
29use uuid::Uuid;
30
31pub mod error;
32
33#[derive(Clone)]
35pub struct Strike {
36 strike_api: StrikeApi,
37 unit: CurrencyUnit,
38 receiver: Arc<Mutex<Option<tokio::sync::mpsc::Receiver<String>>>>,
39 webhook_url: String,
40 wait_invoice_cancel_token: CancellationToken,
41 wait_invoice_is_active: Arc<AtomicBool>,
42}
43
44impl Strike {
45 pub async fn new(
47 api_key: String,
48 unit: CurrencyUnit,
49 receiver: Arc<Mutex<Option<tokio::sync::mpsc::Receiver<String>>>>,
50 webhook_url: String,
51 ) -> Result<Self, Error> {
52 let strike = StrikeApi::new(&api_key, None)?;
53 Ok(Self {
54 strike_api: strike,
55 receiver,
56 unit,
57 webhook_url,
58 wait_invoice_cancel_token: CancellationToken::new(),
59 wait_invoice_is_active: Arc::new(AtomicBool::new(false)),
60 })
61 }
62}
63
64#[async_trait]
65impl MintLightning for Strike {
66 type Err = cdk_lightning::Error;
67
68 fn get_settings(&self) -> Settings {
69 Settings {
70 mpp: false,
71 unit: self.unit.clone(),
72 invoice_description: true,
73 }
74 }
75
76 fn is_wait_invoice_active(&self) -> bool {
77 self.wait_invoice_is_active.load(Ordering::SeqCst)
78 }
79
80 fn cancel_wait_invoice(&self) {
81 self.wait_invoice_cancel_token.cancel()
82 }
83
84 #[allow(clippy::incompatible_msrv)]
85 async fn wait_any_invoice(
86 &self,
87 ) -> Result<Pin<Box<dyn Stream<Item = String> + Send>>, Self::Err> {
88 self.strike_api
89 .subscribe_to_invoice_webhook(self.webhook_url.clone())
90 .await?;
91
92 let receiver = self
93 .receiver
94 .lock()
95 .await
96 .take()
97 .ok_or(anyhow!("No receiver"))?;
98
99 let strike_api = self.strike_api.clone();
100 let cancel_token = self.wait_invoice_cancel_token.clone();
101
102 Ok(futures::stream::unfold(
103 (
104 receiver,
105 strike_api,
106 cancel_token,
107 Arc::clone(&self.wait_invoice_is_active),
108 ),
109 |(mut receiver, strike_api, cancel_token, is_active)| async move {
110 tokio::select! {
111
112 _ = cancel_token.cancelled() => {
113 is_active.store(false, Ordering::SeqCst);
115 tracing::info!("Waiting for phonixd invoice ending");
116 None
117 }
118
119 msg_option = receiver.recv() => {
120 match msg_option {
121 Some(msg) => {
122 let check = strike_api.get_incoming_invoice(&msg).await;
123
124 match check {
125 Ok(state) => {
126 if state.state == InvoiceState::Paid {
127 Some((msg, (receiver, strike_api, cancel_token, is_active)))
128 } else {
129 None
130 }
131 }
132 _ => None,
133 }
134 }
135 None => None,
136 }
137
138 }
139 }
140 },
141 )
142 .boxed())
143 }
144
145 async fn get_payment_quote(
146 &self,
147 melt_quote_request: &MeltQuoteBolt11Request,
148 ) -> Result<PaymentQuoteResponse, Self::Err> {
149 if melt_quote_request.unit != self.unit {
150 return Err(Self::Err::Anyhow(anyhow!("Unsupported unit")));
151 }
152
153 let source_currency = match melt_quote_request.unit {
154 CurrencyUnit::Sat => StrikeCurrencyUnit::BTC,
155 CurrencyUnit::Msat => StrikeCurrencyUnit::BTC,
156 CurrencyUnit::Usd => StrikeCurrencyUnit::USD,
157 CurrencyUnit::Eur => StrikeCurrencyUnit::EUR,
158 _ => return Err(Self::Err::UnsupportedUnit),
159 };
160
161 let payment_quote_request = PayInvoiceQuoteRequest {
162 ln_invoice: melt_quote_request.request.to_string(),
163 source_currency,
164 };
165
166 let quote = self.strike_api.payment_quote(payment_quote_request).await?;
167
168 let fee = from_strike_amount(quote.lightning_network_fee, &melt_quote_request.unit)?;
169
170 let amount = from_strike_amount(quote.amount, &melt_quote_request.unit)?.into();
171
172 Ok(PaymentQuoteResponse {
173 request_lookup_id: quote.payment_quote_id,
174 amount,
175 fee: fee.into(),
176 state: MeltQuoteState::Unpaid,
177 })
178 }
179
180 async fn pay_invoice(
181 &self,
182 melt_quote: mint::MeltQuote,
183 _partial_msats: Option<Amount>,
184 _max_fee_msats: Option<Amount>,
185 ) -> Result<PayInvoiceResponse, Self::Err> {
186 let pay_response = self
187 .strike_api
188 .pay_quote(&melt_quote.request_lookup_id)
189 .await?;
190
191 let state = match pay_response.state {
192 InvoiceState::Paid => MeltQuoteState::Paid,
193 InvoiceState::Unpaid => MeltQuoteState::Unpaid,
194 InvoiceState::Completed => MeltQuoteState::Paid,
195 InvoiceState::Pending => MeltQuoteState::Pending,
196 };
197
198 let total_spent = from_strike_amount(pay_response.total_amount, &melt_quote.unit)?.into();
199
200 Ok(PayInvoiceResponse {
201 payment_lookup_id: pay_response.payment_id,
202 payment_preimage: None,
203 status: state,
204 total_spent,
205 unit: melt_quote.unit,
206 })
207 }
208
209 async fn create_invoice(
210 &self,
211 amount: Amount,
212 _unit: &CurrencyUnit,
213 description: String,
214 unix_expiry: u64,
215 ) -> Result<CreateInvoiceResponse, Self::Err> {
216 let time_now = unix_time();
217 assert!(unix_expiry > time_now);
218 let request_lookup_id = Uuid::new_v4();
219
220 let invoice_request = InvoiceRequest {
221 correlation_id: Some(request_lookup_id.to_string()),
222 amount: to_strike_unit(amount, &self.unit)?,
223 description: Some(description),
224 };
225
226 let create_invoice_response = self.strike_api.create_invoice(invoice_request).await?;
227
228 let quote = self
229 .strike_api
230 .invoice_quote(&create_invoice_response.invoice_id)
231 .await?;
232
233 let request: Bolt11Invoice = quote.ln_invoice.parse()?;
234 let expiry = request.expires_at().map(|t| t.as_secs());
235
236 Ok(CreateInvoiceResponse {
237 request_lookup_id: create_invoice_response.invoice_id,
238 request: quote.ln_invoice.parse()?,
239 expiry,
240 })
241 }
242
243 async fn check_incoming_invoice_status(
244 &self,
245 request_lookup_id: &str,
246 ) -> Result<MintQuoteState, Self::Err> {
247 let invoice = self
248 .strike_api
249 .get_incoming_invoice(request_lookup_id)
250 .await?;
251
252 let state = match invoice.state {
253 InvoiceState::Paid => MintQuoteState::Paid,
254 InvoiceState::Unpaid => MintQuoteState::Unpaid,
255 InvoiceState::Completed => MintQuoteState::Paid,
256 InvoiceState::Pending => MintQuoteState::Pending,
257 };
258
259 Ok(state)
260 }
261
262 async fn check_outgoing_payment(
263 &self,
264 payment_id: &str,
265 ) -> Result<PayInvoiceResponse, Self::Err> {
266 let invoice = self.strike_api.get_outgoing_payment(payment_id).await;
267
268 let pay_invoice_response = match invoice {
269 Ok(invoice) => {
270 let state = match invoice.state {
271 InvoiceState::Paid => MeltQuoteState::Paid,
272 InvoiceState::Unpaid => MeltQuoteState::Unpaid,
273 InvoiceState::Completed => MeltQuoteState::Paid,
274 InvoiceState::Pending => MeltQuoteState::Pending,
275 };
276
277 PayInvoiceResponse {
278 payment_lookup_id: invoice.payment_id,
279 payment_preimage: None,
280 status: state,
281 total_spent: from_strike_amount(invoice.total_amount, &self.unit)?.into(),
282 unit: self.unit.clone(),
283 }
284 }
285 Err(err) => match err {
286 strike_rs::Error::NotFound => PayInvoiceResponse {
287 payment_lookup_id: payment_id.to_string(),
288 payment_preimage: None,
289 status: MeltQuoteState::Unknown,
290 total_spent: Amount::ZERO,
291 unit: self.unit.clone(),
292 },
293 _ => {
294 return Err(Error::from(err).into());
295 }
296 },
297 };
298
299 Ok(pay_invoice_response)
300 }
301}
302
303impl Strike {
304 pub async fn create_invoice_webhook(
306 &self,
307 webhook_endpoint: &str,
308 sender: tokio::sync::mpsc::Sender<String>,
309 ) -> anyhow::Result<Router> {
310 let subs = self.strike_api.get_current_subscriptions().await?;
311
312 tracing::debug!("Got {} current subscriptions", subs.len());
313
314 for sub in subs {
315 tracing::info!("Deleting webhook: {}", &sub.id);
316 if let Err(err) = self.strike_api.delete_subscription(&sub.id).await {
317 tracing::error!("Error deleting webhook subscription: {} {}", sub.id, err);
318 }
319 }
320
321 self.strike_api
322 .create_invoice_webhook_router(webhook_endpoint, sender)
323 .await
324 }
325}
326
327pub(crate) fn from_strike_amount(
328 strike_amount: StrikeAmount,
329 target_unit: &CurrencyUnit,
330) -> anyhow::Result<u64> {
331 match target_unit {
332 CurrencyUnit::Sat => strike_amount.to_sats(),
333 CurrencyUnit::Msat => Ok(strike_amount.to_sats()? * 1000),
334 CurrencyUnit::Usd => {
335 if strike_amount.currency == StrikeCurrencyUnit::USD {
336 Ok((strike_amount.amount * 100.0).round() as u64)
337 } else {
338 bail!("Could not convert strike USD");
339 }
340 }
341 CurrencyUnit::Eur => {
342 if strike_amount.currency == StrikeCurrencyUnit::EUR {
343 Ok((strike_amount.amount * 100.0).round() as u64)
344 } else {
345 bail!("Could not convert to EUR");
346 }
347 }
348 _ => bail!("Unsupported unit"),
349 }
350}
351
352pub(crate) fn to_strike_unit<T>(
353 amount: T,
354 current_unit: &CurrencyUnit,
355) -> anyhow::Result<StrikeAmount>
356where
357 T: Into<u64>,
358{
359 let amount = amount.into();
360 match current_unit {
361 CurrencyUnit::Sat => Ok(StrikeAmount::from_sats(amount)),
362 CurrencyUnit::Msat => Ok(StrikeAmount::from_sats(amount / 1000)),
363 CurrencyUnit::Usd => {
364 let dollars = (amount as f64 / 100_f64) * 100.0;
365
366 Ok(StrikeAmount {
367 currency: StrikeCurrencyUnit::USD,
368 amount: dollars.round() / 100.0,
369 })
370 }
371 CurrencyUnit::Eur => {
372 let euro = (amount as f64 / 100_f64) * 100.0;
373
374 Ok(StrikeAmount {
375 currency: StrikeCurrencyUnit::EUR,
376 amount: euro.round() / 100.0,
377 })
378 }
379 _ => bail!("Unsupported unit"),
380 }
381}