//! CDK lightning backend for Strike #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] use std::pin::Pin; use std::sync::Arc; use anyhow::{anyhow, bail}; use async_trait::async_trait; use axum::Router; use cdk::amount::Amount; use cdk::cdk_lightning::{ self, CreateInvoiceResponse, MintLightning, PayInvoiceResponse, PaymentQuoteResponse, Settings, }; use cdk::nuts::{ CurrencyUnit, MeltMethodSettings, MeltQuoteBolt11Request, MeltQuoteState, MintMethodSettings, MintQuoteState, }; use cdk::util::unix_time; use cdk::{mint, Bolt11Invoice}; use error::Error; use futures::stream::StreamExt; use futures::Stream; use strike_rs::{ Amount as StrikeAmount, Currency as StrikeCurrencyUnit, InvoiceRequest, InvoiceState, PayInvoiceQuoteRequest, Strike as StrikeApi, }; use tokio::sync::Mutex; use uuid::Uuid; pub mod error; /// Strike #[derive(Clone)] pub struct Strike { strike_api: StrikeApi, mint_settings: MintMethodSettings, melt_settings: MeltMethodSettings, unit: CurrencyUnit, receiver: Arc>>>, webhook_url: String, } impl Strike { /// Create new [`Strike`] wallet pub async fn new( api_key: String, mint_settings: MintMethodSettings, melt_settings: MeltMethodSettings, unit: CurrencyUnit, receiver: Arc>>>, webhook_url: String, ) -> Result { let strike = StrikeApi::new(&api_key, None)?; Ok(Self { strike_api: strike, mint_settings, melt_settings, receiver, unit, webhook_url, }) } } #[async_trait] impl MintLightning for Strike { type Err = cdk_lightning::Error; fn get_settings(&self) -> Settings { Settings { mpp: false, unit: self.unit, mint_settings: self.mint_settings.clone(), melt_settings: self.melt_settings, invoice_description: true, } } async fn wait_any_invoice( &self, ) -> Result + Send>>, Self::Err> { self.strike_api .subscribe_to_invoice_webhook(self.webhook_url.clone()) .await?; let receiver = self .receiver .lock() .await .take() .ok_or(anyhow!("No receiver"))?; let strike_api = self.strike_api.clone(); Ok(futures::stream::unfold( (receiver, strike_api), |(mut receiver, strike_api)| async move { match receiver.recv().await { Some(msg) => { let check = strike_api.find_invoice(&msg).await; match check { Ok(state) => { if state.state == InvoiceState::Paid { Some((msg, (receiver, strike_api))) } else { None } } _ => None, } } None => None, } }, ) .boxed()) } async fn get_payment_quote( &self, melt_quote_request: &MeltQuoteBolt11Request, ) -> Result { if melt_quote_request.unit != self.unit { return Err(Self::Err::Anyhow(anyhow!("Unsupported unit"))); } let source_currency = match melt_quote_request.unit { CurrencyUnit::Sat => StrikeCurrencyUnit::BTC, CurrencyUnit::Msat => StrikeCurrencyUnit::BTC, CurrencyUnit::Usd => StrikeCurrencyUnit::USD, CurrencyUnit::Eur => StrikeCurrencyUnit::EUR, _ => return Err(Self::Err::UnsupportedUnit), }; let payment_quote_request = PayInvoiceQuoteRequest { ln_invoice: melt_quote_request.request.to_string(), source_currency, }; let quote = self.strike_api.payment_quote(payment_quote_request).await?; let fee = from_strike_amount(quote.lightning_network_fee, &melt_quote_request.unit)?; Ok(PaymentQuoteResponse { request_lookup_id: quote.payment_quote_id, amount: from_strike_amount(quote.amount, &melt_quote_request.unit)?.into(), fee: fee.into(), state: MeltQuoteState::Unpaid, }) } async fn pay_invoice( &self, melt_quote: mint::MeltQuote, _partial_msats: Option, _max_fee_msats: Option, ) -> Result { let pay_response = self .strike_api .pay_quote(&melt_quote.request_lookup_id) .await?; let state = match pay_response.state { InvoiceState::Paid => MeltQuoteState::Paid, InvoiceState::Unpaid => MeltQuoteState::Unpaid, InvoiceState::Completed => MeltQuoteState::Paid, InvoiceState::Pending => MeltQuoteState::Pending, }; let total_spent = from_strike_amount(pay_response.total_amount, &melt_quote.unit)?.into(); let bolt11: Bolt11Invoice = melt_quote.request.parse()?; Ok(PayInvoiceResponse { payment_hash: bolt11.payment_hash().to_string(), payment_preimage: None, status: state, total_spent, unit: melt_quote.unit, }) } async fn create_invoice( &self, amount: Amount, _unit: &CurrencyUnit, description: String, unix_expiry: u64, ) -> Result { let time_now = unix_time(); assert!(unix_expiry > time_now); let request_lookup_id = Uuid::new_v4(); let invoice_request = InvoiceRequest { correlation_id: Some(request_lookup_id.to_string()), amount: to_strike_unit(amount, &self.unit)?, description: Some(description), }; let create_invoice_response = self.strike_api.create_invoice(invoice_request).await?; let quote = self .strike_api .invoice_quote(&create_invoice_response.invoice_id) .await?; let request: Bolt11Invoice = quote.ln_invoice.parse()?; let expiry = request.expires_at().map(|t| t.as_secs()); Ok(CreateInvoiceResponse { request_lookup_id: create_invoice_response.invoice_id, request: quote.ln_invoice.parse()?, expiry, }) } async fn check_invoice_status( &self, request_lookup_id: &str, ) -> Result { let invoice = self.strike_api.find_invoice(request_lookup_id).await?; let state = match invoice.state { InvoiceState::Paid => MintQuoteState::Paid, InvoiceState::Unpaid => MintQuoteState::Unpaid, InvoiceState::Completed => MintQuoteState::Paid, InvoiceState::Pending => MintQuoteState::Pending, }; Ok(state) } } impl Strike { /// Create invoice webhook pub async fn create_invoice_webhook( &self, webhook_endpoint: &str, sender: tokio::sync::mpsc::Sender, ) -> anyhow::Result { let subs = self.strike_api.get_current_subscriptions().await?; tracing::debug!("Got {} current subscriptions", subs.len()); for sub in subs { tracing::info!("Deleting webhook: {}", &sub.id); if let Err(err) = self.strike_api.delete_subscription(&sub.id).await { tracing::error!("Error deleting webhook subscription: {} {}", sub.id, err); } } self.strike_api .create_invoice_webhook_router(webhook_endpoint, sender) .await } } pub(crate) fn from_strike_amount( strike_amount: StrikeAmount, target_unit: &CurrencyUnit, ) -> anyhow::Result { match target_unit { CurrencyUnit::Sat => strike_amount.to_sats(), CurrencyUnit::Msat => Ok(strike_amount.to_sats()? * 1000), CurrencyUnit::Usd => { if strike_amount.currency == StrikeCurrencyUnit::USD { Ok((strike_amount.amount * 100.0).round() as u64) } else { bail!("Could not convert strike USD"); } } CurrencyUnit::Eur => { if strike_amount.currency == StrikeCurrencyUnit::EUR { Ok((strike_amount.amount * 100.0).round() as u64) } else { bail!("Could not convert to EUR"); } } _ => bail!("Unsupported unit"), } } pub(crate) fn to_strike_unit( amount: T, current_unit: &CurrencyUnit, ) -> anyhow::Result where T: Into, { let amount = amount.into(); match current_unit { CurrencyUnit::Sat => Ok(StrikeAmount::from_sats(amount)), CurrencyUnit::Msat => Ok(StrikeAmount::from_sats(amount / 1000)), CurrencyUnit::Usd => { let dollars = (amount as f64 / 100_f64) * 100.0; Ok(StrikeAmount { currency: StrikeCurrencyUnit::USD, amount: dollars.round() / 100.0, }) } CurrencyUnit::Eur => { let euro = (amount as f64 / 100_f64) * 100.0; Ok(StrikeAmount { currency: StrikeCurrencyUnit::EUR, amount: euro.round() / 100.0, }) } _ => bail!("Unsupported unit"), } }