From 5fe09af133998ee798bf5fddfe6fcefd45c072dc Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 22 Jan 2026 16:15:28 -0800 Subject: [PATCH 1/4] feat: strike backend from before --- Cargo.toml | 1 + crates/cdk-integration-tests/Cargo.toml | 2 +- .../src/bin/start_regtest_mints.rs | 1 + crates/cdk-integration-tests/src/shared.rs | 3 + crates/cdk-mintd/Cargo.toml | 8 +- crates/cdk-mintd/example.config.toml | 6 +- crates/cdk-mintd/src/config.rs | 13 + crates/cdk-mintd/src/env_vars/mod.rs | 8 + crates/cdk-mintd/src/env_vars/strike.rs | 30 + crates/cdk-mintd/src/lib.rs | 50 +- crates/cdk-mintd/src/setup.rs | 28 + crates/cdk-strike/Cargo.toml | 26 + crates/cdk-strike/README.md | 22 + crates/cdk-strike/src/error.rs | 30 + crates/cdk-strike/src/lib.rs | 1485 +++++++++++++++++ justfile | 3 + 16 files changed, 1708 insertions(+), 8 deletions(-) create mode 100644 crates/cdk-mintd/src/env_vars/strike.rs create mode 100644 crates/cdk-strike/Cargo.toml create mode 100644 crates/cdk-strike/README.md create mode 100644 crates/cdk-strike/src/error.rs create mode 100644 crates/cdk-strike/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index 7fa5ebfb07..bacc2be3a3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,6 +122,7 @@ nostr-sdk = { version = "0.44.1", default-features = false, features = [ "nip59" ]} +cdk-strike = { path = "./crates/cdk-strike", version = "=0.13.0" } diff --git a/crates/cdk-integration-tests/Cargo.toml b/crates/cdk-integration-tests/Cargo.toml index 0141f15abc..2b93ee39c5 100644 --- a/crates/cdk-integration-tests/Cargo.toml +++ b/crates/cdk-integration-tests/Cargo.toml @@ -29,7 +29,7 @@ cdk-sqlite = { workspace = true } cdk-redb = { workspace = true } cdk-fake-wallet = { workspace = true } cdk-common = { workspace = true, features = ["mint", "wallet", "auth"] } -cdk-mintd = { workspace = true, features = ["cln", "lnd", "fakewallet", "grpc-processor", "auth", "lnbits", "management-rpc", "sqlite", "postgres", "ldk-node", "prometheus"] } +cdk-mintd = { workspace = true, features = ["cln", "lnd", "fakewallet", "grpc-processor", "auth", "lnbits", "management-rpc", "sqlite", "postgres", "ldk-node", "prometheus", "strike"] } futures = { workspace = true, default-features = false, features = [ "executor", ] } diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs index 62d9698b98..f23ca594b4 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -299,6 +299,7 @@ fn create_ldk_settings( mint_management_rpc: None, prometheus: None, auth: None, + strike: None, } } diff --git a/crates/cdk-integration-tests/src/shared.rs b/crates/cdk-integration-tests/src/shared.rs index 0c14f43776..3666c50fa1 100644 --- a/crates/cdk-integration-tests/src/shared.rs +++ b/crates/cdk-integration-tests/src/shared.rs @@ -238,6 +238,7 @@ pub fn create_fake_wallet_settings( mint_management_rpc: None, auth: None, prometheus: Some(Default::default()), + strike: None, } } @@ -288,6 +289,7 @@ pub fn create_cln_settings( mint_management_rpc: None, auth: None, prometheus: Some(Default::default()), + strike: None, } } @@ -336,5 +338,6 @@ pub fn create_lnd_settings( mint_management_rpc: None, auth: None, prometheus: Some(Default::default()), + strike: None, } } diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index afa81a7b7a..ca689fe1ee 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -11,7 +11,7 @@ rust-version.workspace = true readme = "README.md" [features] -default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "sqlite"] +default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "sqlite", "strike"] # Database features - at least one must be enabled sqlite = ["dep:cdk-sqlite"] postgres = ["dep:cdk-postgres"] @@ -30,6 +30,9 @@ redis = ["cdk-axum/redis"] auth = ["cdk/auth", "cdk-axum/auth", "cdk-sqlite?/auth", "cdk-postgres?/auth"] prometheus = ["cdk/prometheus", "dep:cdk-prometheus", "cdk-sqlite?/prometheus", "cdk-axum/prometheus"] +# Agicash Fork additions +strike = ["dep:cdk-strike"] + [dependencies] anyhow.workspace = true async-trait.workspace = true @@ -69,6 +72,9 @@ home.workspace = true utoipa = { workspace = true, optional = true } utoipa-swagger-ui = { version = "9.0.0", features = ["axum"], optional = true } +# Agicash Fork additions +cdk-strike = { workspace = true, optional = true } + [lints] workspace = true diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 723fb5ad6a..a3d1ef9c68 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -87,7 +87,7 @@ max_connections = 20 connection_timeout_seconds = 10 [ln] -# Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits', 'ldknode' +# Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits', 'ldknode', 'strike' ln_backend = "fakewallet" # min_mint=1 # max_mint=500000 @@ -175,6 +175,10 @@ max_delay_time = 3 # the get_settings() response. The mint will automatically create routes # for these methods (e.g., /v1/mint/quote/paypal, /v1/mint/paypal, etc.) +# [strike] +# api_key = "your_strike_api_key_here" +# supported_units = ["sat", "usd"] + # [auth] # Set to true to enable authentication features (defaults to false) # auth_enabled = false diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 1d9fbffa3d..3f6f28522b 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -138,6 +138,8 @@ pub enum LnBackend { LdkNode, #[cfg(feature = "grpc-processor")] GrpcProcessor, + #[cfg(feature = "strike")] + Strike, } impl std::str::FromStr for LnBackend { @@ -157,6 +159,8 @@ impl std::str::FromStr for LnBackend { "ldk-node" | "ldknode" => Ok(LnBackend::LdkNode), #[cfg(feature = "grpc-processor")] "grpcprocessor" => Ok(LnBackend::GrpcProcessor), + #[cfg(feature = "strike")] + "strike" => Ok(LnBackend::Strike), _ => Err(format!("Unknown Lightning backend: {s}")), } } @@ -424,6 +428,13 @@ fn default_grpc_port() -> u16 { 50051 } +#[cfg(feature = "strike")] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Strike { + pub api_key: String, + pub supported_units: Vec, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "lowercase")] pub enum DatabaseEngine { @@ -574,6 +585,8 @@ pub struct Settings { pub auth: Option, #[cfg(feature = "prometheus")] pub prometheus: Option, + #[cfg(feature = "strike")] + pub strike: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/cdk-mintd/src/env_vars/mod.rs b/crates/cdk-mintd/src/env_vars/mod.rs index a3e32f8682..84383918b5 100644 --- a/crates/cdk-mintd/src/env_vars/mod.rs +++ b/crates/cdk-mintd/src/env_vars/mod.rs @@ -28,6 +28,8 @@ mod lnd; mod management_rpc; #[cfg(feature = "prometheus")] mod prometheus; +#[cfg(feature = "strike")] +mod strike; use std::env; use std::str::FromStr; @@ -55,6 +57,8 @@ pub use management_rpc::*; pub use mint_info::*; #[cfg(feature = "prometheus")] pub use prometheus::*; +#[cfg(feature = "strike")] +pub use strike::*; use crate::config::{DatabaseEngine, LnBackend, Settings}; @@ -149,6 +153,10 @@ impl Settings { self.grpc_processor = Some(self.grpc_processor.clone().unwrap_or_default().from_env()); } + #[cfg(feature = "strike")] + LnBackend::Strike => { + self.strike = Some(self.strike.clone().unwrap_or_default().from_env()); + } LnBackend::None => bail!("Ln backend must be set"), #[allow(unreachable_patterns)] _ => bail!("Selected Ln backend is not enabled in this build"), diff --git a/crates/cdk-mintd/src/env_vars/strike.rs b/crates/cdk-mintd/src/env_vars/strike.rs new file mode 100644 index 0000000000..323e2aafa9 --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/strike.rs @@ -0,0 +1,30 @@ +//! Strike environment variables + +use std::env; + +use cdk::nuts::CurrencyUnit; + +use crate::config::Strike; + +pub const ENV_STRIKE_API_KEY: &str = "CDK_MINTD_STRIKE_API_KEY"; +pub const ENV_STRIKE_SUPPORTED_UNITS: &str = "CDK_MINTD_STRIKE_SUPPORTED_UNITS"; + +impl Strike { + pub fn from_env(mut self) -> Self { + if let Ok(api_key) = env::var(ENV_STRIKE_API_KEY) { + self.api_key = api_key; + } + + if let Ok(units_str) = env::var(ENV_STRIKE_SUPPORTED_UNITS) { + let units: Vec = units_str + .split(',') + .filter_map(|unit| unit.trim().parse().ok()) + .collect(); + if !units.is_empty() { + self.supported_units = units; + } + } + + self + } +} diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 67c661e0d4..2f6f5bb763 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -23,7 +23,8 @@ use cdk::nuts::nut00::KnownMethod; feature = "lnd", feature = "ldk-node", feature = "fakewallet", - feature = "grpc-processor" + feature = "grpc-processor", + feature = "strike" ))] use cdk::nuts::nut17::SupportedMethods; use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path}; @@ -32,6 +33,7 @@ use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path} feature = "lnbits", feature = "lnd", feature = "ldk-node" + feature = "strike" ))] use cdk::nuts::CurrencyUnit; #[cfg(feature = "auth")] @@ -352,7 +354,7 @@ async fn configure_mint_builder( let mint_builder = configure_basic_info(settings, mint_builder); // Configure lightning backend - let mint_builder = + let (mint_builder, additional_routers) = configure_lightning_backend(settings, mint_builder, runtime, work_dir, kv_store).await?; // Extract configured payment methods from mint_builder @@ -368,7 +370,7 @@ async fn configure_mint_builder( // Configure caching with payment methods let mint_builder = configure_cache(settings, mint_builder, &payment_methods); - Ok(mint_builder) + Ok((mint_builder, additional_routers)) } /// Configures basic mint information (name, contact info, descriptions, etc.) @@ -586,6 +588,41 @@ async fn configure_lightning_backend( ) .await?; } + #[cfg(feature = "strike")] + LnBackend::Strike => { + let strike_settings = settings.clone().strike.expect("Checked at config load"); + tracing::info!( + "Using Strike backend with supported units: {:?}", + strike_settings.supported_units + ); + + let mut webhook_routers = Vec::new(); + + for unit in &strike_settings.supported_units { + let kv_store = _kv_store + .clone() + .ok_or_else(|| anyhow!("KV store is required for Strike backend"))?; + let (strike, webhook_router) = strike_settings + .setup(settings, unit.clone(), kv_store) + .await?; + + webhook_routers.push(webhook_router); + + #[cfg(feature = "prometheus")] + let strike = MetricsMintPayment::new(strike); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + unit.clone(), + mint_melt_limits, + Arc::new(strike), + ) + .await?; + } + + return Ok((mint_builder, webhook_routers)); + } LnBackend::None => { tracing::error!( "Payment backend was not set or feature disabled. {:?}", @@ -595,7 +632,7 @@ async fn configure_lightning_backend( } }; - Ok(mint_builder) + Ok((mint_builder, vec![])) } /// Helper function to configure a mint builder with a lightning backend for a specific currency unit @@ -1370,7 +1407,7 @@ pub async fn run_mintd_with_shutdown( } }; - let mint_builder = + let (mint_builder, additional_routers) = configure_mint_builder(settings, maybe_mint_builder, runtime, work_dir, Some(kv)).await?; #[cfg(feature = "auth")] let (mint_builder, auth_localstore) = @@ -1384,6 +1421,9 @@ pub async fn run_mintd_with_shutdown( let mint = Arc::new(mint); + let mut routers = routers; + routers.extend(additional_routers); + start_services_with_shutdown( mint.clone(), settings, diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index a5de3eb0c7..373a7e1e2d 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -364,3 +364,31 @@ impl LnBackendSetup for config::LdkNode { Ok(ldk_node) } } + +#[cfg(feature = "strike")] +impl config::Strike { + pub async fn setup( + &self, + settings: &Settings, + unit: CurrencyUnit, + kv_store: cdk_common::database::mint::DynMintKVStore, + ) -> anyhow::Result<(cdk_strike::Strike, axum::Router)> { + use cdk::mint_url::MintUrl; + + let webhook_endpoint = format!("/webhook/strike/{}/invoice", unit); + let mint_url: MintUrl = settings.info.url.parse()?; + let webhook_url = mint_url.join(&webhook_endpoint)?; + + let strike = cdk_strike::Strike::new( + self.api_key.clone(), + unit, + webhook_url.to_string(), + kv_store, + ) + .await?; + + let webhook_router = strike.create_invoice_webhook(&webhook_endpoint).await?; + + Ok((strike, webhook_router)) + } +} diff --git a/crates/cdk-strike/Cargo.toml b/crates/cdk-strike/Cargo.toml new file mode 100644 index 0000000000..c6e4986215 --- /dev/null +++ b/crates/cdk-strike/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "cdk-strike" +version.workspace = true +edition.workspace = true +authors = ["CDK Developers"] +license.workspace = true +homepage = "https://github.com/cashubtc/cdk" +repository = "https://github.com/cashubtc/cdk.git" +rust-version.workspace = true # MSRV +description = "CDK ln backend for Strike" +readme = "README.md" + +[dependencies] +async-trait.workspace = true +anyhow.workspace = true +cdk-common = { workspace = true, features = ["mint"] } +futures.workspace = true +tokio.workspace = true +tokio-stream = { workspace = true, features = ["sync"] } +tokio-util.workspace = true +tracing.workspace = true +thiserror.workspace = true +serde_json.workspace = true +uuid.workspace = true +axum.workspace = true +strike-rs = { git = "https://github.com/gudnuf/strike-rs.git", rev = "eb59400365122f13e3dc074618f0b4251252e02a"} \ No newline at end of file diff --git a/crates/cdk-strike/README.md b/crates/cdk-strike/README.md new file mode 100644 index 0000000000..75932b8b30 --- /dev/null +++ b/crates/cdk-strike/README.md @@ -0,0 +1,22 @@ +# CDK Strike + +[![crates.io](https://img.shields.io/crates/v/cdk-strike.svg)](https://crates.io/crates/cdk-strike) +[![Documentation](https://docs.rs/cdk-strike/badge.svg)](https://docs.rs/cdk-strike) +[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE) + +**ALPHA** This library is in early development, the API will change and should be used with caution. + +Strike backend implementation for the Cashu Development Kit (CDK). + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +cdk-strike = "*" +``` + +## License + +This project is licensed under the [MIT License](../../LICENSE). \ No newline at end of file diff --git a/crates/cdk-strike/src/error.rs b/crates/cdk-strike/src/error.rs new file mode 100644 index 0000000000..51db70f16c --- /dev/null +++ b/crates/cdk-strike/src/error.rs @@ -0,0 +1,30 @@ +//! Error for Strike ln backend + +use strike_rs::Error as StrikeRsError; +use thiserror::Error; + +/// Strike Error +#[derive(Debug, Error)] +pub enum Error { + /// Invoice amount not defined + #[error("Unknown invoice amount")] + UnknownInvoiceAmount, + /// Unknown invoice + #[error("Unknown invoice")] + UnknownInvoice, + /// Unsupported unit + #[error("Unsupported unit")] + UnsupportedUnit, + /// Strike-rs error + #[error(transparent)] + StrikeRs(#[from] StrikeRsError), + /// Anyhow error + #[error(transparent)] + Anyhow(#[from] anyhow::Error), +} + +impl From for cdk_common::payment::Error { + fn from(e: Error) -> Self { + Self::Lightning(Box::new(e)) + } +} diff --git a/crates/cdk-strike/src/lib.rs b/crates/cdk-strike/src/lib.rs new file mode 100644 index 0000000000..1a047a70c2 --- /dev/null +++ b/crates/cdk-strike/src/lib.rs @@ -0,0 +1,1485 @@ +//! CDK lightning backend for Strike + +#![warn(missing_docs)] +#![warn(rustdoc::bare_urls)] + +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, bail}; +use async_trait::async_trait; +use axum::Router; +use cdk_common::amount::Amount; +use cdk_common::database::mint::DynMintKVStore; +use cdk_common::nuts::{CurrencyUnit, MeltQuoteState}; +use cdk_common::payment::{ + self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, + MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, + PaymentQuoteResponse, WaitPaymentResponse, +}; +use cdk_common::util::unix_time; +use cdk_common::Bolt11Invoice; +use error::Error; +use futures::stream::StreamExt; +use futures::Stream; +use serde_json::Value; +use strike_rs::{ + Amount as StrikeAmount, Currency as StrikeCurrencyUnit, CurrencyExchangeQuoteRequest, + ExchangeAmount, ExchangeQuoteState, FeePolicy, InvoiceQueryParams, InvoiceRequest, + InvoiceState, PayInvoiceQuoteRequest, Strike as StrikeApi, +}; +use tokio::sync::Mutex; +use tokio_stream::wrappers::BroadcastStream; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +pub mod error; + +const CORRELATION_ID_PREFIX: &str = "TXID:"; +const POLLING_INTERVAL: Duration = Duration::from_secs(3); +const INVOICE_EXPIRY_HOURS: u64 = 24; + +// KV Store constants for Strike +const STRIKE_KV_PRIMARY_NAMESPACE: &str = "cdk_strike_lightning_backend"; +const STRIKE_KV_SECONDARY_NAMESPACE: &str = "internal_settlements"; +const INTERNAL_SETTLEMENT_PREFIX: &str = "settlement_"; + +/// Extract correlation ID from bolt11 invoice description +fn extract_correlation_id(description: &str) -> Option<&str> { + description + .split(CORRELATION_ID_PREFIX) + .nth(1)? + .split_whitespace() + .next() + .filter(|id| !id.is_empty()) +} + +/// Create description with embedded correlation ID for Strike invoice tracking +fn create_invoice_description(base_description: &str, correlation_id: &Uuid) -> String { + format!( + "{} {}{}", + base_description, CORRELATION_ID_PREFIX, correlation_id + ) +} + +/// Convert CurrencyUnit to Strike's currency format +fn to_strike_currency(unit: &CurrencyUnit) -> Result { + match unit { + CurrencyUnit::Sat | CurrencyUnit::Msat => Ok(StrikeCurrencyUnit::BTC), + CurrencyUnit::Usd => Ok(StrikeCurrencyUnit::USD), + CurrencyUnit::Eur => Ok(StrikeCurrencyUnit::EUR), + _ => Err(payment::Error::UnsupportedUnit), + } +} + +/// Strike lightning backend implementation +#[derive(Clone)] +pub struct Strike { + strike_api: StrikeApi, + unit: CurrencyUnit, + webhook_url: String, + sender: tokio::sync::broadcast::Sender, + receiver: Arc>, + wait_invoice_cancel_token: CancellationToken, + wait_invoice_is_active: Arc, + pending_invoices: Arc>>, + webhook_mode_active: Arc, + kv_store: DynMintKVStore, +} + +impl std::fmt::Debug for Strike { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Strike") + .field("unit", &self.unit) + .field("webhook_url", &self.webhook_url) + .field( + "wait_invoice_is_active", + &self.wait_invoice_is_active.load(Ordering::SeqCst), + ) + .field( + "webhook_mode_active", + &self.webhook_mode_active.load(Ordering::SeqCst), + ) + .field( + "pending_invoices_count", + &self + .pending_invoices + .try_lock() + .map(|m| m.len()) + .unwrap_or(0), + ) + .finish() + } +} + +impl Strike { + /// Create new [`Strike`] wallet + pub async fn new( + api_key: String, + unit: CurrencyUnit, + webhook_url: String, + kv_store: DynMintKVStore, + ) -> Result { + let strike_api = StrikeApi::new(&api_key, None).map_err(Error::from)?; + + // Create broadcast channel for payment events (webhook notifications) + let (sender, receiver) = tokio::sync::broadcast::channel::(1000); + let receiver = Arc::new(receiver); + + Ok(Self { + strike_api, + sender, + receiver, + unit, + webhook_url, + wait_invoice_cancel_token: CancellationToken::new(), + wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + pending_invoices: Arc::new(Mutex::new(HashMap::new())), + webhook_mode_active: Arc::new(AtomicBool::new(false)), + kv_store, + }) + } + + /// Get a sender for webhook notifications + pub fn sender(&self) -> tokio::sync::broadcast::Sender { + self.sender.clone() + } + + async fn lookup_invoice_by_correlation_id( + &self, + correlation_id: &str, + ) -> Result { + let query_params = InvoiceQueryParams::new() + .filter(strike_rs::Filter::eq("correlationId", correlation_id)); + + let invoice_list = self + .strike_api + .get_invoices(Some(query_params)) + .await + .map_err(Error::from)?; + + invoice_list.items.first().cloned().ok_or_else(|| { + Error::Anyhow(anyhow!( + "Invoice not found for correlation ID: {}", + correlation_id + )) + }) + } + + fn create_webhook_stream( + &self, + receiver: tokio::sync::broadcast::Receiver, + cancel_token: CancellationToken, + is_active: Arc, + strike_api: StrikeApi, + unit: CurrencyUnit, + ) -> Pin + Send>> { + let response_stream = BroadcastStream::new(receiver) + .filter_map(move |result| { + let unit = unit.clone(); + let strike_api = strike_api.clone(); + let is_active = is_active.clone(); + let cancel_token = cancel_token.clone(); + async move { + tokio::select! { + _ = cancel_token.cancelled() => { + is_active.store(false, Ordering::SeqCst); + None + } + invoice_result = async { + match result { + Ok(invoice_id) if !invoice_id.is_empty() => { + match strike_api.get_incoming_invoice(&invoice_id).await { + Ok(invoice) if invoice.state == InvoiceState::Paid || invoice.state == InvoiceState::Completed => { + match Strike::from_strike_amount(invoice.amount, &unit) { + Ok(amount) => { + is_active.store(false, Ordering::SeqCst); + Some(Event::PaymentReceived(WaitPaymentResponse { + payment_identifier: PaymentIdentifier::CustomId(invoice_id.clone()), + payment_amount: amount.into(), + unit, + payment_id: invoice_id, + })) + } + Err(_) => None, + } + } + _ => None, + } + } + Err(err) => { + tracing::warn!("Error in webhook broadcast stream: {}", err); + None + } + _ => None, + } + } => invoice_result + } + } + }); + + Box::pin(response_stream) + } + + fn create_polling_stream( + &self, + receiver: tokio::sync::broadcast::Receiver, + cancel_token: CancellationToken, + is_active: Arc, + strike_api: StrikeApi, + pending_invoices: Arc>>, + unit: CurrencyUnit, + ) -> Pin + Send>> { + // Clone for separate branches to avoid move issues + let strike_api_broadcast = strike_api.clone(); + let pending_invoices_broadcast = pending_invoices.clone(); + let unit_broadcast = unit.clone(); + + let broadcast_stream = BroadcastStream::new(receiver) + .filter_map(move |result| { + let strike_api = strike_api_broadcast.clone(); + let pending_invoices = pending_invoices_broadcast.clone(); + let unit = unit_broadcast.clone(); + let cancel_token = cancel_token.clone(); + async move { + tokio::select! { + _ = cancel_token.cancelled() => None, + event = async { + match result { + Ok(invoice_id) => { + Self::process_invoice_message(&strike_api, &invoice_id, &unit, &pending_invoices).await + } + Err(err) => { + tracing::warn!("Error in polling broadcast stream: {}", err); + None + } + } + } => event + } + } + }); + + // Combine broadcast stream with periodic polling + let polling_stream = futures::stream::unfold( + (strike_api, pending_invoices, unit), + |(strike_api, pending_invoices, unit)| async move { + tokio::time::sleep(POLLING_INTERVAL).await; + let event = + Self::poll_pending_invoices(&strike_api, &pending_invoices, &unit).await; + + Self::cleanup_expired_invoices(&pending_invoices).await; + + Some((event, (strike_api, pending_invoices, unit))) + }, + ) + .filter_map(|event| async move { event }); + + let combined_stream = + futures::stream::select(broadcast_stream, polling_stream).inspect(move |_| { + is_active.store(false, Ordering::SeqCst); + }); + + Box::pin(combined_stream) + } + + async fn process_invoice_message( + strike_api: &StrikeApi, + invoice_id: &str, + unit: &CurrencyUnit, + pending_invoices: &Arc>>, + ) -> Option { + match strike_api.get_incoming_invoice(invoice_id).await { + Ok(invoice) + if invoice.state == InvoiceState::Paid + || invoice.state == InvoiceState::Completed => + { + { + let mut pending = pending_invoices.lock().await; + pending.remove(invoice_id); + } + + if let Ok(amount) = Strike::from_strike_amount(invoice.amount, unit) { + Some(Event::PaymentReceived(WaitPaymentResponse { + payment_identifier: PaymentIdentifier::CustomId(invoice_id.to_string()), + payment_amount: amount.into(), + unit: unit.clone(), + payment_id: invoice_id.to_string(), + })) + } else { + None + } + } + _ => None, + } + } + + async fn poll_pending_invoices( + strike_api: &StrikeApi, + pending_invoices: &Arc>>, + unit: &CurrencyUnit, + ) -> Option { + let invoices_to_check: Vec = { + let pending = pending_invoices.lock().await; + pending.keys().cloned().collect() + }; + + for invoice_id in invoices_to_check { + if let Some(event) = + Self::process_invoice_message(strike_api, &invoice_id, unit, pending_invoices).await + { + return Some(event); + } + } + None + } + + async fn cleanup_expired_invoices(pending_invoices: &Arc>>) { + let current_time = unix_time(); + let expiry_seconds = INVOICE_EXPIRY_HOURS * 60 * 60; + + let mut pending = pending_invoices.lock().await; + pending.retain(|_, creation_time| current_time - *creation_time < expiry_seconds); + } + + async fn handle_internal_payment_quote( + &self, + internal_invoice: strike_rs::InvoiceListItem, + source_currency: StrikeCurrencyUnit, + correlation_id: &str, + ) -> Result { + if internal_invoice.amount.currency == source_currency { + let amount = Strike::from_strike_amount(internal_invoice.amount, &self.unit)?; + return Ok(PaymentQuoteResponse { + request_lookup_id: Some(PaymentIdentifier::CustomId(format!( + "internal:{}", + correlation_id + ))), + amount: amount.into(), + unit: self.unit.clone(), + fee: Amount::ZERO, + state: MeltQuoteState::Unpaid, + }); + } + + // Currency exchange needed + let exchange_request = CurrencyExchangeQuoteRequest { + sell: source_currency, + buy: internal_invoice.amount.currency.clone(), + amount: ExchangeAmount { + amount: internal_invoice.amount.amount.to_string(), + currency: internal_invoice.amount.currency, + fee_policy: Some(FeePolicy::Exclusive), + }, + }; + + let currency_exchange_quote = self + .strike_api + .create_currency_exchange_quote(exchange_request) + .await + .map_err(Error::from)?; + + let converted_amount = + Strike::from_strike_amount(currency_exchange_quote.source.clone(), &self.unit)?; + let fee = if let Some(fee_info) = ¤cy_exchange_quote.fee { + if Strike::currency_unit_eq_strike(&self.unit, &fee_info.currency) { + Strike::from_strike_amount(fee_info.clone(), &self.unit)? + } else { + Strike::convert_fee_to_unit( + fee_info.clone(), + &self.unit, + currency_exchange_quote.conversion_rate.clone(), + )? + } + } else { + 0 + }; + + Ok(PaymentQuoteResponse { + request_lookup_id: Some(PaymentIdentifier::CustomId(format!( + "exchange:{}", + currency_exchange_quote.id + ))), + amount: converted_amount.into(), + unit: self.unit.clone(), + fee: fee.into(), + state: MeltQuoteState::Unpaid, + }) + } + + async fn check_internal_payment( + &self, + payment_identifier: &PaymentIdentifier, + correlation_id: &str, + ) -> Result { + let internal_invoice = self + .lookup_invoice_by_correlation_id(correlation_id) + .await?; + let state = match internal_invoice.state { + InvoiceState::Paid | InvoiceState::Completed => MeltQuoteState::Paid, + InvoiceState::Unpaid => MeltQuoteState::Unpaid, + InvoiceState::Pending => MeltQuoteState::Pending, + InvoiceState::Failed => MeltQuoteState::Failed, + }; + + let total_spent = Strike::from_strike_amount(internal_invoice.amount, &self.unit)?.into(); + + Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: None, + status: state, + total_spent, + unit: self.unit.clone(), + }) + } + + async fn check_exchange_payment( + &self, + payment_identifier: &PaymentIdentifier, + quote_id: &str, + ) -> Result { + let quote = self + .strike_api + .get_currency_exchange_quote(quote_id) + .await + .map_err(Error::from)?; + + let state = match quote.state { + ExchangeQuoteState::Completed => MeltQuoteState::Paid, + ExchangeQuoteState::Failed => MeltQuoteState::Failed, + ExchangeQuoteState::New => MeltQuoteState::Unpaid, + ExchangeQuoteState::Pending => MeltQuoteState::Pending, + }; + + let total_spent = Strike::from_strike_amount(quote.source, &self.unit)?.into(); + + Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: None, + status: state, + total_spent, + unit: self.unit.clone(), + }) + } + + async fn check_regular_payment( + &self, + payment_identifier: &PaymentIdentifier, + payment_id: &str, + ) -> Result { + match self.strike_api.get_outgoing_payment(payment_id).await { + Ok(invoice) => { + let state = match invoice.state { + InvoiceState::Paid | InvoiceState::Completed => MeltQuoteState::Paid, + InvoiceState::Unpaid => MeltQuoteState::Unpaid, + InvoiceState::Pending => MeltQuoteState::Pending, + InvoiceState::Failed => MeltQuoteState::Failed, + }; + + let total_spent = + Strike::from_strike_amount(invoice.total_amount, &self.unit)?.into(); + + Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: None, + status: state, + total_spent, + unit: self.unit.clone(), + }) + } + Err(strike_rs::Error::NotFound) => Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: None, + status: MeltQuoteState::Unknown, + total_spent: Amount::ZERO, + unit: self.unit.clone(), + }), + Err(err) => Err(Error::from(err).into()), + } + } +} + +#[async_trait] +impl MintPayment for Strike { + type Err = payment::Error; + + async fn get_settings(&self) -> Result { + let settings = Bolt11Settings { + mpp: false, + unit: self.unit.clone(), + invoice_description: true, + amountless: false, + bolt12: false, + }; + + Ok(serde_json::to_value(settings)?) + } + + fn is_wait_invoice_active(&self) -> bool { + self.wait_invoice_is_active.load(Ordering::SeqCst) + } + + fn cancel_wait_invoice(&self) { + self.wait_invoice_cancel_token.cancel(); + self.webhook_mode_active.store(false, Ordering::SeqCst); + } + + #[allow(clippy::incompatible_msrv)] + async fn wait_payment_event( + &self, + ) -> Result + Send>>, Self::Err> { + tracing::info!("Starting Strike payment event stream"); + + let receiver = self.receiver.resubscribe(); + + let strike_api = self.strike_api.clone(); + let cancel_token = self.wait_invoice_cancel_token.clone(); + let pending_invoices = Arc::clone(&self.pending_invoices); + let is_active = Arc::clone(&self.wait_invoice_is_active); + let unit = self.unit.clone(); + + self.wait_invoice_is_active.store(true, Ordering::SeqCst); + + // Try webhook subscription first, fallback to polling + match self + .strike_api + .subscribe_to_invoice_webhook(self.webhook_url.clone()) + .await + { + Ok(_) => { + tracing::info!("Using webhook mode for payment events"); + self.webhook_mode_active.store(true, Ordering::SeqCst); + Ok(self.create_webhook_stream(receiver, cancel_token, is_active, strike_api, unit)) + } + Err(_) => { + tracing::warn!("Webhook subscription failed, using polling mode"); + self.webhook_mode_active.store(false, Ordering::SeqCst); + Ok(self.create_polling_stream( + receiver, + cancel_token, + is_active, + strike_api, + pending_invoices, + unit, + )) + } + } + } + + async fn get_payment_quote( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + let bolt11 = match options { + OutgoingPaymentOptions::Bolt11(opts) => opts.bolt11, + OutgoingPaymentOptions::Bolt12(_) => { + return Err(Self::Err::UnsupportedPaymentOption); + } + }; + + if unit != &self.unit { + return Err(Self::Err::UnsupportedUnit); + } + + let description = bolt11.description().to_string(); + let correlation_id = extract_correlation_id(&description); + let source_currency = to_strike_currency(unit)?; + + // Check for internal invoice first + if let Some(correlation_id) = correlation_id { + if let Ok(internal_invoice) = + self.lookup_invoice_by_correlation_id(correlation_id).await + { + return self + .handle_internal_payment_quote( + internal_invoice, + source_currency, + correlation_id, + ) + .await; + } + } + + // Regular Lightning payment quote + let payment_quote_request = PayInvoiceQuoteRequest { + ln_invoice: bolt11.to_string(), + source_currency, + }; + + let quote = self + .strike_api + .payment_quote(payment_quote_request) + .await + .map_err(Error::from)?; + + let fee = quote + .lightning_network_fee + .map(|fee| Strike::from_strike_amount(fee, unit)) + .transpose()? + .unwrap_or(0); + + let amount = Strike::from_strike_amount(quote.amount, unit)?; + + Ok(PaymentQuoteResponse { + request_lookup_id: Some(PaymentIdentifier::CustomId(format!( + "payment:{}", + quote.payment_quote_id + ))), + amount: amount.into(), + unit: self.unit.clone(), + fee: fee.into(), + state: MeltQuoteState::Unpaid, + }) + } + + async fn make_payment( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + let bolt11 = match &options { + OutgoingPaymentOptions::Bolt11(opts) => &opts.bolt11, + OutgoingPaymentOptions::Bolt12(_) => { + return Err(Self::Err::UnsupportedPaymentOption); + } + }; + + // Check if this might be internal settlement + let description = bolt11.description().to_string(); + let correlation_id = extract_correlation_id(&description); + + if let Some(correlation_id) = correlation_id { + if let Ok(internal_invoice) = + self.lookup_invoice_by_correlation_id(correlation_id).await + { + let source_currency = to_strike_currency(unit)?; + // Only try internal settlement if currencies are different + if internal_invoice.amount.currency != source_currency { + return self + .process_internal_settlement(internal_invoice, unit) + .await; + } else { + // Same currency internal invoice should have been settled at mint level + tracing::error!( + "Internal invoice found for correlation ID {} with same currency {}. This should have been settled at the mint level, not Strike.", + correlation_id, source_currency + ); + return Err(Self::Err::Custom(format!( + "Internal invoice with same currency {} should be settled at mint level, not Strike backend", + source_currency + ))); + } + } + } + + // If not internal, proceed with external payment + let bolt11 = match options { + OutgoingPaymentOptions::Bolt11(opts) => opts.bolt11, + OutgoingPaymentOptions::Bolt12(_) => { + return Err(Self::Err::UnsupportedPaymentOption); + } + }; + + if unit != &self.unit { + return Err(Self::Err::UnsupportedUnit); + } + + let source_currency = to_strike_currency(unit)?; + + let payment_quote_request = PayInvoiceQuoteRequest { + ln_invoice: bolt11.to_string(), + source_currency, + }; + + let quote = self + .strike_api + .payment_quote(payment_quote_request) + .await + .map_err(Error::from)?; + + let pay_response = self + .strike_api + .pay_quote("e.payment_quote_id) + .await + .map_err(Error::from)?; + + let state = match pay_response.state { + InvoiceState::Paid | InvoiceState::Completed => MeltQuoteState::Paid, + InvoiceState::Unpaid => MeltQuoteState::Unpaid, + InvoiceState::Pending => MeltQuoteState::Pending, + InvoiceState::Failed => MeltQuoteState::Failed, + }; + + let total_spent = Strike::from_strike_amount(pay_response.total_amount, unit)?.into(); + + Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::CustomId(format!( + "payment:{}", + pay_response.payment_id + )), + payment_proof: None, + status: state, + total_spent, + unit: unit.clone(), + }) + } + + async fn create_incoming_payment_request( + &self, + unit: &CurrencyUnit, + options: IncomingPaymentOptions, + ) -> Result { + let (amount, description, unix_expiry) = match options { + IncomingPaymentOptions::Bolt11(opts) => ( + opts.amount, + opts.description.unwrap_or_default(), + opts.unix_expiry, + ), + IncomingPaymentOptions::Bolt12(_) => { + return Err(Self::Err::UnsupportedPaymentOption); + } + }; + + let time_now = unix_time(); + if let Some(expiry) = unix_expiry { + if expiry <= time_now { + // Unlikely to happen, but just in case + return Err(cdk_common::payment::Error::Custom( + "Payment request has expired".to_string(), + )); + } + } + + let correlation_id = Uuid::new_v4(); + let strike_amount = Strike::to_strike_unit(amount, unit)?; + + let invoice_request = InvoiceRequest { + correlation_id: Some(correlation_id.to_string()), + amount: strike_amount, + description: Some(create_invoice_description(&description, &correlation_id)), + }; + + let create_invoice_response = self + .strike_api + .create_invoice(invoice_request) + .await + .map_err(Error::from)?; + + let quote = self + .strike_api + .invoice_quote(&create_invoice_response.invoice_id) + .await + .map_err(Error::from)?; + + let request: Bolt11Invoice = quote.ln_invoice.parse()?; + let expiry = request.expires_at().map(|t| t.as_secs()); + + // Store the invoice ID for polling only if not in webhook mode + if !self.webhook_mode_active.load(Ordering::SeqCst) { + let mut pending_invoices = self.pending_invoices.lock().await; + pending_invoices.insert(create_invoice_response.invoice_id.clone(), time_now); + } + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: PaymentIdentifier::CustomId(create_invoice_response.invoice_id), + request: quote.ln_invoice, + expiry, + }) + } + + async fn check_incoming_payment_status( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + let request_lookup_id = payment_identifier.to_string(); + let invoice = self + .strike_api + .get_incoming_invoice(&request_lookup_id) + .await + .map_err(Error::from)?; + + match self.check_internal_settlement(&request_lookup_id).await { + Ok(true) => { + let amount = Strike::from_strike_amount(invoice.amount, &self.unit)?; + + Ok(vec![WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: amount.into(), + unit: self.unit.clone(), + payment_id: request_lookup_id, + }]) + } + Ok(false) => match invoice.state { + InvoiceState::Paid | InvoiceState::Completed => { + let amount = Strike::from_strike_amount(invoice.amount, &self.unit)?; + Ok(vec![WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: amount.into(), + unit: self.unit.clone(), + payment_id: invoice.invoice_id, + }]) + } + InvoiceState::Unpaid | InvoiceState::Pending | InvoiceState::Failed => Ok(vec![]), + }, + Err(err) => { + tracing::error!( + "Failed to check internal settlement status for invoice {}: {}.", + request_lookup_id, + err + ); + return Err(Self::Err::Custom(format!( + "KV store error checking internal settlement: {}", + err + ))); + } + } + } + + async fn check_outgoing_payment( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result { + let payment_lookup_id = payment_identifier.to_string(); + let (label, id) = payment_lookup_id + .split_once(":") + .unwrap_or(("payment", &payment_lookup_id)); + + match label { + "internal" => self.check_internal_payment(payment_identifier, id).await, + "exchange" => self.check_exchange_payment(payment_identifier, id).await, + _ => self.check_regular_payment(payment_identifier, id).await, + } + } +} + +impl Strike { + /// Record an internal settlement in the KV store + async fn record_internal_settlement( + &self, + invoice_id: &str, + ) -> Result<(), cdk_common::database::Error> { + let key = format!("{}{}", INTERNAL_SETTLEMENT_PREFIX, invoice_id); + let settlement_data = serde_json::json!({ + "settled_at": unix_time(), + "invoice_id": invoice_id + }); + let value = serde_json::to_vec(&settlement_data)?; + + let mut tx = self.kv_store.begin_transaction().await?; + tx.kv_write( + STRIKE_KV_PRIMARY_NAMESPACE, + STRIKE_KV_SECONDARY_NAMESPACE, + &key, + &value, + ) + .await?; + tx.commit().await?; + + Ok(()) + } + + /// Check if an invoice was settled internally + async fn check_internal_settlement( + &self, + invoice_id: &str, + ) -> Result { + let key = format!("{}{}", INTERNAL_SETTLEMENT_PREFIX, invoice_id); + + let settlement_exists = self + .kv_store + .kv_read( + STRIKE_KV_PRIMARY_NAMESPACE, + STRIKE_KV_SECONDARY_NAMESPACE, + &key, + ) + .await? + .is_some(); + + Ok(settlement_exists) + } + + /// Create invoice webhook router + pub async fn create_invoice_webhook(&self, webhook_endpoint: &str) -> anyhow::Result { + // Create an adapter channel to bridge mpsc -> broadcast + let (mpsc_sender, mut mpsc_receiver) = tokio::sync::mpsc::channel::(1000); + let broadcast_sender = self.sender(); + + // Spawn a task to forward messages from mpsc to broadcast + tokio::spawn(async move { + while let Some(invoice_id) = mpsc_receiver.recv().await { + if let Err(err) = broadcast_sender.send(invoice_id) { + tracing::warn!( + "Failed to forward webhook message to broadcast channel: {}", + err + ); + } + } + }); + + self.strike_api + .create_invoice_webhook_router(webhook_endpoint, mpsc_sender) + .await + } + + /// Execute currency exchange for internal payment (by quote id only) + async fn execute_currency_exchange_by_id(&self, quote_id: &str) -> Result<(u64, u64), Error> { + match self + .strike_api + .execute_currency_exchange_quote(quote_id) + .await + { + Ok(_) => (), + Err(strike_rs::Error::ApiError(api_error)) => { + if api_error + .is_error_code(&strike_rs::StrikeErrorCode::CurrencyExchangeQuoteExpired) + { + tracing::warn!("Currency exchange quote {} has expired", quote_id); + return Err(Error::Anyhow(anyhow!( + "Currency exchange quote has expired" + ))); + } else { + return Err(strike_rs::Error::ApiError(api_error).into()); + } + } + Err(e) => return Err(e.into()), + } + // After execution, fetch the quote to get the amounts/fees + let quote = self + .strike_api + .get_currency_exchange_quote(quote_id) + .await + .map_err(Error::from)?; + let converted_amount = Strike::from_strike_amount(quote.source.clone(), &self.unit)?; + let fee = if let Some(fee_info) = quote.fee.clone() { + if Strike::currency_unit_eq_strike(&self.unit, &fee_info.currency) { + Strike::from_strike_amount(fee_info.clone(), &self.unit)? + } else { + Strike::convert_fee_to_unit(fee_info, &self.unit, quote.conversion_rate)? + } + } else { + 0 + }; + Ok((converted_amount, fee)) + } + + /// Process internal settlement for cross-currency payments only + async fn process_internal_settlement( + &self, + internal_invoice: strike_rs::InvoiceListItem, + unit: &CurrencyUnit, + ) -> Result { + let source_currency = to_strike_currency(unit)?; + + let invoice_amount = internal_invoice.amount.clone(); + let exchange_request = CurrencyExchangeQuoteRequest { + sell: source_currency, + buy: invoice_amount.currency.clone(), + amount: ExchangeAmount { + amount: invoice_amount.amount.to_string(), + currency: invoice_amount.currency.clone(), + fee_policy: Some(FeePolicy::Exclusive), + }, + }; + + let quote = self + .strike_api + .create_currency_exchange_quote(exchange_request) + .await + .map_err(Error::from)?; + + let (converted_amount, _fee) = self.execute_currency_exchange_by_id("e.id).await?; + + let response = MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::CustomId(format!("exchange:{}", quote.id)), + payment_proof: None, + status: MeltQuoteState::Paid, + total_spent: converted_amount.into(), + unit: unit.clone(), + }; + + if let Err(err) = self + .record_internal_settlement(&internal_invoice.invoice_id) + .await + { + tracing::warn!("Failed to record internal settlement: {}", err); + } + + // Notify the payment stream that this invoice has been settled + if let Err(err) = self.sender.send(internal_invoice.invoice_id.clone()) { + tracing::warn!( + "Failed to notify payment stream of internal settlement: {}", + err + ); + } + + Ok(response) + } +} + +impl Strike { + fn from_strike_amount( + strike_amount: StrikeAmount, + target_unit: &CurrencyUnit, + ) -> anyhow::Result { + match target_unit { + CurrencyUnit::Sat => { + if strike_amount.currency == StrikeCurrencyUnit::BTC { + strike_amount.to_sats() + } else { + bail!("Cannot convert Strike amount: expected BTC currency for Sat unit, got {:?} currency with amount {}", + strike_amount.currency, strike_amount.amount); + } + } + CurrencyUnit::Msat => { + if strike_amount.currency == StrikeCurrencyUnit::BTC { + Ok(strike_amount.to_sats()? * 1000) + } else { + bail!("Cannot convert Strike amount: expected BTC currency for Msat unit, got {:?} currency with amount {}", + strike_amount.currency, strike_amount.amount); + } + } + CurrencyUnit::Usd => { + if strike_amount.currency == StrikeCurrencyUnit::USD { + Ok((strike_amount.amount * 100.0).round() as u64) + } else { + bail!("Cannot convert Strike amount: expected USD currency for Usd unit, got {:?} currency with amount {}", + strike_amount.currency, strike_amount.amount); + } + } + CurrencyUnit::Eur => { + if strike_amount.currency == StrikeCurrencyUnit::EUR { + Ok((strike_amount.amount * 100.0).round() as u64) + } else { + bail!("Cannot convert Strike amount: expected EUR currency for Eur unit, got {:?} currency with amount {}", + strike_amount.currency, strike_amount.amount); + } + } + _ => bail!("Unsupported unit: {:?}", target_unit), + } + } + + fn to_strike_unit>( + amount: T, + current_unit: &CurrencyUnit, + ) -> anyhow::Result { + 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.0; + Ok(StrikeAmount { + currency: StrikeCurrencyUnit::USD, + amount: dollars, + }) + } + CurrencyUnit::Eur => { + let euro = amount as f64 / 100.0; + Ok(StrikeAmount { + currency: StrikeCurrencyUnit::EUR, + amount: euro, + }) + } + _ => bail!("Unsupported unit"), + } + } + + fn currency_unit_eq_strike(unit: &CurrencyUnit, strike: &StrikeCurrencyUnit) -> bool { + match (unit, strike) { + (CurrencyUnit::Sat, StrikeCurrencyUnit::BTC) => true, + (CurrencyUnit::Msat, StrikeCurrencyUnit::BTC) => true, // msat is subunit of BTC + (CurrencyUnit::Usd, StrikeCurrencyUnit::USD) => true, + (CurrencyUnit::Eur, StrikeCurrencyUnit::EUR) => true, + _ => false, + } + } + + fn convert_fee_to_unit( + fee_amount: StrikeAmount, + target_unit: &CurrencyUnit, + rate: strike_rs::ConversionRate, + ) -> anyhow::Result { + // Only support conversion between BTC (sats) and USD/EUR for now + let rate = rate.amount; + match (&fee_amount.currency, target_unit) { + (StrikeCurrencyUnit::USD, CurrencyUnit::Sat) + | (StrikeCurrencyUnit::EUR, CurrencyUnit::Sat) => { + // rate: X USD per BTC, so 1 USD = 1/X BTC = 100_000_000/X sats + let sats = (fee_amount.amount * 100_000_000.0 / rate).round() as u64; + Ok(sats) + } + (StrikeCurrencyUnit::USD, CurrencyUnit::Msat) + | (StrikeCurrencyUnit::EUR, CurrencyUnit::Msat) => { + let msats = (fee_amount.amount * 100_000_000_000.0 / rate).round() as u64; + Ok(msats) + } + (StrikeCurrencyUnit::USD, CurrencyUnit::Usd) + | (StrikeCurrencyUnit::EUR, CurrencyUnit::Eur) => { + // fee is already in correct fiat unit, return as cents + Ok((fee_amount.amount * 100.0).round() as u64) + } + _ => Err(anyhow!( + "Unsupported fee currency/unit conversion: {:?} -> {:?}", + fee_amount.currency, + target_unit + )), + } + } +} + +#[cfg(test)] +mod tests { + // Mock KV store for testing + use std::collections::HashMap; + use std::sync::Arc; + + use async_trait::async_trait; + use cdk_common::database::mint::{ + DbTransactionFinalizer, KVStore, KVStoreDatabase, KVStoreTransaction, + }; + use cdk_common::database::Error as DatabaseError; + use cdk_common::nuts::CurrencyUnit; + use strike_rs::{Amount as StrikeAmount, Currency as StrikeCurrencyUnit}; + use tokio::sync::Mutex; + use uuid::Uuid; + + use super::*; + + #[derive(Debug, Default)] + struct MockKVStore { + data: Arc>>>, + } + + #[async_trait] + impl KVStoreDatabase for MockKVStore { + type Err = DatabaseError; + + async fn kv_read( + &self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result>, Self::Err> { + let full_key = format!("{}:{}:{}", primary_namespace, secondary_namespace, key); + let data = self.data.lock().await; + Ok(data.get(&full_key).cloned()) + } + + async fn kv_list( + &self, + _primary_namespace: &str, + _secondary_namespace: &str, + ) -> Result, Self::Err> { + Ok(vec![]) + } + } + + struct MockKVTransaction { + store: Arc, + changes: HashMap>>, + } + + #[async_trait] + impl<'a> KVStoreTransaction<'a, DatabaseError> for MockKVTransaction { + async fn kv_read( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result>, DatabaseError> { + self.store + .kv_read(primary_namespace, secondary_namespace, key) + .await + } + + async fn kv_write( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + value: &[u8], + ) -> Result<(), DatabaseError> { + let full_key = format!("{}:{}:{}", primary_namespace, secondary_namespace, key); + self.changes.insert(full_key, Some(value.to_vec())); + Ok(()) + } + + async fn kv_remove( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + key: &str, + ) -> Result<(), DatabaseError> { + let full_key = format!("{}:{}:{}", primary_namespace, secondary_namespace, key); + self.changes.insert(full_key, None); + Ok(()) + } + + async fn kv_list( + &mut self, + _primary_namespace: &str, + _secondary_namespace: &str, + ) -> Result, DatabaseError> { + Ok(vec![]) + } + } + + #[async_trait] + impl DbTransactionFinalizer for MockKVTransaction { + type Err = DatabaseError; + + async fn commit(self: Box) -> Result<(), Self::Err> { + let mut data = self.store.data.lock().await; + for (key, value) in self.changes { + match value { + Some(v) => { + data.insert(key, v); + } + None => { + data.remove(&key); + } + } + } + Ok(()) + } + + async fn rollback(self: Box) -> Result<(), Self::Err> { + Ok(()) + } + } + + #[async_trait] + impl KVStore for MockKVStore { + async fn begin_transaction<'a>( + &'a self, + ) -> Result + Send + Sync + 'a>, DatabaseError> + { + Ok(Box::new(MockKVTransaction { + store: Arc::new(MockKVStore { + data: self.data.clone(), + }), + changes: HashMap::new(), + })) + } + } + + fn create_mock_kv_store() -> DynMintKVStore { + Arc::new(MockKVStore::default()) + } + + // Helper function tests + #[test] + fn test_extract_correlation_id() { + // Test valid extraction + assert_eq!( + extract_correlation_id("Payment TXID:abc123 text"), + Some("abc123") + ); + + // Test no correlation ID + assert_eq!(extract_correlation_id("Payment description"), None); + + // Test empty correlation ID + assert_eq!(extract_correlation_id("Payment TXID:"), None); + } + + #[test] + fn test_create_invoice_description() { + let correlation_id = Uuid::parse_str("550e8400-e29b-41d4-a716-446655440000").unwrap(); + let result = create_invoice_description("Payment", &correlation_id); + + assert!(result.contains("Payment")); + assert!(result.contains("TXID:550e8400-e29b-41d4-a716-446655440000")); + } + + #[test] + fn test_to_strike_currency() { + assert_eq!( + to_strike_currency(&CurrencyUnit::Sat).unwrap(), + StrikeCurrencyUnit::BTC + ); + assert_eq!( + to_strike_currency(&CurrencyUnit::Msat).unwrap(), + StrikeCurrencyUnit::BTC + ); + assert_eq!( + to_strike_currency(&CurrencyUnit::Usd).unwrap(), + StrikeCurrencyUnit::USD + ); + assert_eq!( + to_strike_currency(&CurrencyUnit::Eur).unwrap(), + StrikeCurrencyUnit::EUR + ); + } + + // Amount conversion tests - core functionality + #[test] + fn test_from_strike_amount_btc() { + // BTC to sats + let amount = StrikeAmount { + currency: StrikeCurrencyUnit::BTC, + amount: 1.0, + }; + assert_eq!( + Strike::from_strike_amount(amount, &CurrencyUnit::Sat).unwrap(), + 100_000_000 + ); + + // BTC to msats + let amount = StrikeAmount { + currency: StrikeCurrencyUnit::BTC, + amount: 0.001, + }; + assert_eq!( + Strike::from_strike_amount(amount, &CurrencyUnit::Msat).unwrap(), + 100_000_000 + ); + } + + #[test] + fn test_from_strike_amount_fiat() { + // USD to cents + let amount = StrikeAmount { + currency: StrikeCurrencyUnit::USD, + amount: 10.50, + }; + assert_eq!( + Strike::from_strike_amount(amount, &CurrencyUnit::Usd).unwrap(), + 1050 + ); + + // EUR to cents + let amount = StrikeAmount { + currency: StrikeCurrencyUnit::EUR, + amount: 25.75, + }; + assert_eq!( + Strike::from_strike_amount(amount, &CurrencyUnit::Eur).unwrap(), + 2575 + ); + } + + #[test] + fn test_from_strike_amount_currency_mismatch() { + let amount = StrikeAmount { + currency: StrikeCurrencyUnit::USD, + amount: 10.0, + }; + // USD to BTC should fail + assert!(Strike::from_strike_amount(amount, &CurrencyUnit::Sat).is_err()); + } + + #[test] + fn test_to_strike_unit() { + // Sats to BTC + let result = Strike::to_strike_unit(100_000_000u64, &CurrencyUnit::Sat).unwrap(); + assert_eq!(result.currency, StrikeCurrencyUnit::BTC); + assert_eq!(result.amount, 1.0); + + // USD cents to dollars + let result = Strike::to_strike_unit(1050u64, &CurrencyUnit::Usd).unwrap(); + assert_eq!(result.currency, StrikeCurrencyUnit::USD); + assert_eq!(result.amount, 10.50); + } + + #[test] + fn test_roundtrip_conversions() { + // Test that conversions are lossless + let original_sats = 12345678u64; + let strike_amount = Strike::to_strike_unit(original_sats, &CurrencyUnit::Sat).unwrap(); + let converted_back = Strike::from_strike_amount(strike_amount, &CurrencyUnit::Sat).unwrap(); + assert_eq!(original_sats, converted_back); + } + + #[test] + fn test_currency_unit_eq_strike() { + assert!(Strike::currency_unit_eq_strike( + &CurrencyUnit::Sat, + &StrikeCurrencyUnit::BTC + )); + assert!(Strike::currency_unit_eq_strike( + &CurrencyUnit::Usd, + &StrikeCurrencyUnit::USD + )); + assert!(!Strike::currency_unit_eq_strike( + &CurrencyUnit::Sat, + &StrikeCurrencyUnit::USD + )); + } + + // Fee conversion test + #[test] + fn test_convert_fee_to_unit() { + let fee_amount = StrikeAmount { + currency: StrikeCurrencyUnit::USD, + amount: 1.0, + }; + + let rate = strike_rs::ConversionRate { + amount: 50000.0, + source_currency: StrikeCurrencyUnit::USD, + target_currency: StrikeCurrencyUnit::BTC, + }; + + let result = Strike::convert_fee_to_unit(fee_amount, &CurrencyUnit::Sat, rate).unwrap(); + assert_eq!(result, 2000); // $1 at $50k/BTC = 2000 sats + } + + // Strike instance tests + #[tokio::test] + async fn test_strike_creation() { + let kv_store = create_mock_kv_store(); + let strike = Strike::new( + "test_api_key".to_string(), + CurrencyUnit::Sat, + "http://localhost:3000/webhook".to_string(), + kv_store, + ) + .await; + + assert!(strike.is_ok()); + let strike = strike.unwrap(); + assert_eq!(strike.unit, CurrencyUnit::Sat); + assert!(!strike.is_wait_invoice_active()); + } + + #[tokio::test] + async fn test_wait_payment_event_multiple_calls() { + let kv_store = create_mock_kv_store(); + let strike = Strike::new( + "test_api_key".to_string(), + CurrencyUnit::Sat, + "http://localhost:3000/webhook".to_string(), + kv_store, + ) + .await + .unwrap(); + + // Multiple calls should succeed + let result1 = strike.wait_payment_event().await; + assert!(result1.is_ok()); + + strike.cancel_wait_invoice(); + + let result2 = strike.wait_payment_event().await; + assert!(result2.is_ok()); + } + + #[test] + fn test_zero_amounts() { + let zero_btc = StrikeAmount { + currency: StrikeCurrencyUnit::BTC, + amount: 0.0, + }; + assert_eq!( + Strike::from_strike_amount(zero_btc, &CurrencyUnit::Sat).unwrap(), + 0 + ); + + let result = Strike::to_strike_unit(0u64, &CurrencyUnit::Sat).unwrap(); + assert_eq!(result.amount, 0.0); + } +} diff --git a/justfile b/justfile index facaa3858d..9f225e0a17 100644 --- a/justfile +++ b/justfile @@ -511,6 +511,7 @@ release m="": "-p cdk-payment-processor" "-p cdk-cli" "-p cdk-mintd" + "-p cdk-strike" ) for arg in "${args[@]}"; @@ -552,6 +553,7 @@ check-docs: "-p cdk-cli" "-p cdk-mintd" "-p cdk-ffi" + "-p cdk-strike" ) for arg in "${args[@]}"; do @@ -586,6 +588,7 @@ docs-strict: "-p cdk-cli" "-p cdk-mintd" "-p cdk-ffi" + "-p cdk-strike" ) for arg in "${args[@]}"; do From 93161a9a39cc3a2e9b2c2da01a734bd4c59ac888 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 22 Jan 2026 19:21:41 -0800 Subject: [PATCH 2/4] refactor: simplify strike backend to sats-only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove all fiat currency (USD/EUR) support from the Strike backend, keeping only BTC/sats/msats. This removes ~400 lines of currency exchange complexity including cross-currency payment handling. Changes: - Simplify conversion functions to only handle Sat/Msat ↔ BTC - Remove exchange logic (execute_currency_exchange_by_id, process_internal_settlement, check_exchange_payment) - Remove cross-currency branch from handle_internal_payment_quote - Remove internal invoice currency check from make_payment - Update to new cdk-common API (DynKVStore, Amount) - Update example config to show only sat as supported unit Co-Authored-By: Claude Opus 4.5 --- Cargo.lock | 40 +++ Cargo.toml | 2 +- crates/cdk-mintd/example.config.toml | 2 +- crates/cdk-strike/src/lib.rs | 499 ++++----------------------- 4 files changed, 101 insertions(+), 442 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 76afe269fb..cfd77c5eee 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1525,6 +1525,7 @@ dependencies = [ "cdk-prometheus", "cdk-signatory", "cdk-sqlite", + "cdk-strike", "clap", "config", "futures", @@ -1721,6 +1722,25 @@ dependencies = [ "uuid", ] +[[package]] +name = "cdk-strike" +version = "0.14.0" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.8.8", + "cdk-common", + "futures", + "serde_json", + "strike-rs", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -6820,6 +6840,26 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strike-rs" +version = "0.5.0" +source = "git+https://github.com/gudnuf/strike-rs.git?rev=eb59400365122f13e3dc074618f0b4251252e02a#eb59400365122f13e3dc074618f0b4251252e02a" +dependencies = [ + "anyhow", + "axum 0.8.8", + "log", + "rand 0.9.2", + "reqwest", + "ring 0.17.14", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tower 0.5.3", + "url", + "urlencoding", +] + [[package]] name = "stringprep" version = "0.1.5" diff --git a/Cargo.toml b/Cargo.toml index bacc2be3a3..0fb5a2c6a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,7 +122,7 @@ nostr-sdk = { version = "0.44.1", default-features = false, features = [ "nip59" ]} -cdk-strike = { path = "./crates/cdk-strike", version = "=0.13.0" } +cdk-strike = { path = "./crates/cdk-strike", version = "=0.14.0" } diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index a3d1ef9c68..e3d44c5e71 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -177,7 +177,7 @@ max_delay_time = 3 # [strike] # api_key = "your_strike_api_key_here" -# supported_units = ["sat", "usd"] +# supported_units = ["sat"] # [auth] # Set to true to enable authentication features (defaults to false) diff --git a/crates/cdk-strike/src/lib.rs b/crates/cdk-strike/src/lib.rs index 1a047a70c2..7ef61dffe7 100644 --- a/crates/cdk-strike/src/lib.rs +++ b/crates/cdk-strike/src/lib.rs @@ -13,22 +13,20 @@ use anyhow::{anyhow, bail}; use async_trait::async_trait; use axum::Router; use cdk_common::amount::Amount; -use cdk_common::database::mint::DynMintKVStore; +use cdk_common::database::DynKVStore; use cdk_common::nuts::{CurrencyUnit, MeltQuoteState}; use cdk_common::payment::{ self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, - PaymentQuoteResponse, WaitPaymentResponse, + PaymentQuoteResponse, SettingsResponse, WaitPaymentResponse, }; use cdk_common::util::unix_time; use cdk_common::Bolt11Invoice; use error::Error; use futures::stream::StreamExt; use futures::Stream; -use serde_json::Value; use strike_rs::{ - Amount as StrikeAmount, Currency as StrikeCurrencyUnit, CurrencyExchangeQuoteRequest, - ExchangeAmount, ExchangeQuoteState, FeePolicy, InvoiceQueryParams, InvoiceRequest, + Amount as StrikeAmount, Currency as StrikeCurrencyUnit, InvoiceQueryParams, InvoiceRequest, InvoiceState, PayInvoiceQuoteRequest, Strike as StrikeApi, }; use tokio::sync::Mutex; @@ -69,8 +67,6 @@ fn create_invoice_description(base_description: &str, correlation_id: &Uuid) -> fn to_strike_currency(unit: &CurrencyUnit) -> Result { match unit { CurrencyUnit::Sat | CurrencyUnit::Msat => Ok(StrikeCurrencyUnit::BTC), - CurrencyUnit::Usd => Ok(StrikeCurrencyUnit::USD), - CurrencyUnit::Eur => Ok(StrikeCurrencyUnit::EUR), _ => Err(payment::Error::UnsupportedUnit), } } @@ -87,7 +83,7 @@ pub struct Strike { wait_invoice_is_active: Arc, pending_invoices: Arc>>, webhook_mode_active: Arc, - kv_store: DynMintKVStore, + kv_store: DynKVStore, } impl std::fmt::Debug for Strike { @@ -121,7 +117,7 @@ impl Strike { api_key: String, unit: CurrencyUnit, webhook_url: String, - kv_store: DynMintKVStore, + kv_store: DynKVStore, ) -> Result { let strike_api = StrikeApi::new(&api_key, None).map_err(Error::from)?; @@ -199,8 +195,7 @@ impl Strike { is_active.store(false, Ordering::SeqCst); Some(Event::PaymentReceived(WaitPaymentResponse { payment_identifier: PaymentIdentifier::CustomId(invoice_id.clone()), - payment_amount: amount.into(), - unit, + payment_amount: Amount::new(amount, unit.clone()), payment_id: invoice_id, })) } @@ -304,8 +299,7 @@ impl Strike { if let Ok(amount) = Strike::from_strike_amount(invoice.amount, unit) { Some(Event::PaymentReceived(WaitPaymentResponse { payment_identifier: PaymentIdentifier::CustomId(invoice_id.to_string()), - payment_amount: amount.into(), - unit: unit.clone(), + payment_amount: Amount::new(amount, unit.clone()), payment_id: invoice_id.to_string(), })) } else { @@ -347,64 +341,16 @@ impl Strike { async fn handle_internal_payment_quote( &self, internal_invoice: strike_rs::InvoiceListItem, - source_currency: StrikeCurrencyUnit, correlation_id: &str, ) -> Result { - if internal_invoice.amount.currency == source_currency { - let amount = Strike::from_strike_amount(internal_invoice.amount, &self.unit)?; - return Ok(PaymentQuoteResponse { - request_lookup_id: Some(PaymentIdentifier::CustomId(format!( - "internal:{}", - correlation_id - ))), - amount: amount.into(), - unit: self.unit.clone(), - fee: Amount::ZERO, - state: MeltQuoteState::Unpaid, - }); - } - - // Currency exchange needed - let exchange_request = CurrencyExchangeQuoteRequest { - sell: source_currency, - buy: internal_invoice.amount.currency.clone(), - amount: ExchangeAmount { - amount: internal_invoice.amount.amount.to_string(), - currency: internal_invoice.amount.currency, - fee_policy: Some(FeePolicy::Exclusive), - }, - }; - - let currency_exchange_quote = self - .strike_api - .create_currency_exchange_quote(exchange_request) - .await - .map_err(Error::from)?; - - let converted_amount = - Strike::from_strike_amount(currency_exchange_quote.source.clone(), &self.unit)?; - let fee = if let Some(fee_info) = ¤cy_exchange_quote.fee { - if Strike::currency_unit_eq_strike(&self.unit, &fee_info.currency) { - Strike::from_strike_amount(fee_info.clone(), &self.unit)? - } else { - Strike::convert_fee_to_unit( - fee_info.clone(), - &self.unit, - currency_exchange_quote.conversion_rate.clone(), - )? - } - } else { - 0 - }; - + let amount = Strike::from_strike_amount(internal_invoice.amount, &self.unit)?; Ok(PaymentQuoteResponse { request_lookup_id: Some(PaymentIdentifier::CustomId(format!( - "exchange:{}", - currency_exchange_quote.id + "internal:{}", + correlation_id ))), - amount: converted_amount.into(), - unit: self.unit.clone(), - fee: fee.into(), + amount: Amount::new(amount, self.unit.clone()), + fee: Amount::new(0, self.unit.clone()), state: MeltQuoteState::Unpaid, }) } @@ -424,43 +370,13 @@ impl Strike { InvoiceState::Failed => MeltQuoteState::Failed, }; - let total_spent = Strike::from_strike_amount(internal_invoice.amount, &self.unit)?.into(); - - Ok(MakePaymentResponse { - payment_lookup_id: payment_identifier.clone(), - payment_proof: None, - status: state, - total_spent, - unit: self.unit.clone(), - }) - } - - async fn check_exchange_payment( - &self, - payment_identifier: &PaymentIdentifier, - quote_id: &str, - ) -> Result { - let quote = self - .strike_api - .get_currency_exchange_quote(quote_id) - .await - .map_err(Error::from)?; - - let state = match quote.state { - ExchangeQuoteState::Completed => MeltQuoteState::Paid, - ExchangeQuoteState::Failed => MeltQuoteState::Failed, - ExchangeQuoteState::New => MeltQuoteState::Unpaid, - ExchangeQuoteState::Pending => MeltQuoteState::Pending, - }; - - let total_spent = Strike::from_strike_amount(quote.source, &self.unit)?.into(); + let total_spent = Strike::from_strike_amount(internal_invoice.amount, &self.unit)?; Ok(MakePaymentResponse { payment_lookup_id: payment_identifier.clone(), payment_proof: None, status: state, - total_spent, - unit: self.unit.clone(), + total_spent: Amount::new(total_spent, self.unit.clone()), }) } @@ -478,23 +394,20 @@ impl Strike { InvoiceState::Failed => MeltQuoteState::Failed, }; - let total_spent = - Strike::from_strike_amount(invoice.total_amount, &self.unit)?.into(); + let total_spent = Strike::from_strike_amount(invoice.total_amount, &self.unit)?; Ok(MakePaymentResponse { payment_lookup_id: payment_identifier.clone(), payment_proof: None, status: state, - total_spent, - unit: self.unit.clone(), + total_spent: Amount::new(total_spent, self.unit.clone()), }) } Err(strike_rs::Error::NotFound) => Ok(MakePaymentResponse { payment_lookup_id: payment_identifier.clone(), payment_proof: None, status: MeltQuoteState::Unknown, - total_spent: Amount::ZERO, - unit: self.unit.clone(), + total_spent: Amount::new(0, self.unit.clone()), }), Err(err) => Err(Error::from(err).into()), } @@ -505,16 +418,17 @@ impl Strike { impl MintPayment for Strike { type Err = payment::Error; - async fn get_settings(&self) -> Result { - let settings = Bolt11Settings { - mpp: false, - unit: self.unit.clone(), - invoice_description: true, - amountless: false, - bolt12: false, - }; - - Ok(serde_json::to_value(settings)?) + async fn get_settings(&self) -> Result { + Ok(SettingsResponse { + unit: self.unit.to_string(), + bolt11: Some(Bolt11Settings { + mpp: false, + amountless: false, + invoice_description: true, + }), + bolt12: None, + custom: std::collections::HashMap::new(), + }) } fn is_wait_invoice_active(&self) -> bool { @@ -575,7 +489,7 @@ impl MintPayment for Strike { ) -> Result { let bolt11 = match options { OutgoingPaymentOptions::Bolt11(opts) => opts.bolt11, - OutgoingPaymentOptions::Bolt12(_) => { + OutgoingPaymentOptions::Bolt12(_) | OutgoingPaymentOptions::Custom(_) => { return Err(Self::Err::UnsupportedPaymentOption); } }; @@ -594,11 +508,7 @@ impl MintPayment for Strike { self.lookup_invoice_by_correlation_id(correlation_id).await { return self - .handle_internal_payment_quote( - internal_invoice, - source_currency, - correlation_id, - ) + .handle_internal_payment_quote(internal_invoice, correlation_id) .await; } } @@ -628,9 +538,8 @@ impl MintPayment for Strike { "payment:{}", quote.payment_quote_id ))), - amount: amount.into(), - unit: self.unit.clone(), - fee: fee.into(), + amount: Amount::new(amount, self.unit.clone()), + fee: Amount::new(fee, self.unit.clone()), state: MeltQuoteState::Unpaid, }) } @@ -640,45 +549,9 @@ impl MintPayment for Strike { unit: &CurrencyUnit, options: OutgoingPaymentOptions, ) -> Result { - let bolt11 = match &options { - OutgoingPaymentOptions::Bolt11(opts) => &opts.bolt11, - OutgoingPaymentOptions::Bolt12(_) => { - return Err(Self::Err::UnsupportedPaymentOption); - } - }; - - // Check if this might be internal settlement - let description = bolt11.description().to_string(); - let correlation_id = extract_correlation_id(&description); - - if let Some(correlation_id) = correlation_id { - if let Ok(internal_invoice) = - self.lookup_invoice_by_correlation_id(correlation_id).await - { - let source_currency = to_strike_currency(unit)?; - // Only try internal settlement if currencies are different - if internal_invoice.amount.currency != source_currency { - return self - .process_internal_settlement(internal_invoice, unit) - .await; - } else { - // Same currency internal invoice should have been settled at mint level - tracing::error!( - "Internal invoice found for correlation ID {} with same currency {}. This should have been settled at the mint level, not Strike.", - correlation_id, source_currency - ); - return Err(Self::Err::Custom(format!( - "Internal invoice with same currency {} should be settled at mint level, not Strike backend", - source_currency - ))); - } - } - } - - // If not internal, proceed with external payment let bolt11 = match options { OutgoingPaymentOptions::Bolt11(opts) => opts.bolt11, - OutgoingPaymentOptions::Bolt12(_) => { + OutgoingPaymentOptions::Bolt12(_) | OutgoingPaymentOptions::Custom(_) => { return Err(Self::Err::UnsupportedPaymentOption); } }; @@ -713,7 +586,7 @@ impl MintPayment for Strike { InvoiceState::Failed => MeltQuoteState::Failed, }; - let total_spent = Strike::from_strike_amount(pay_response.total_amount, unit)?.into(); + let total_spent = Strike::from_strike_amount(pay_response.total_amount, unit)?; Ok(MakePaymentResponse { payment_lookup_id: PaymentIdentifier::CustomId(format!( @@ -722,8 +595,7 @@ impl MintPayment for Strike { )), payment_proof: None, status: state, - total_spent, - unit: unit.clone(), + total_spent: Amount::new(total_spent, unit.clone()), }) } @@ -738,7 +610,7 @@ impl MintPayment for Strike { opts.description.unwrap_or_default(), opts.unix_expiry, ), - IncomingPaymentOptions::Bolt12(_) => { + IncomingPaymentOptions::Bolt12(_) | IncomingPaymentOptions::Custom(_) => { return Err(Self::Err::UnsupportedPaymentOption); } }; @@ -787,6 +659,7 @@ impl MintPayment for Strike { request_lookup_id: PaymentIdentifier::CustomId(create_invoice_response.invoice_id), request: quote.ln_invoice, expiry, + extra_json: None, }) } @@ -807,8 +680,7 @@ impl MintPayment for Strike { Ok(vec![WaitPaymentResponse { payment_identifier: payment_identifier.clone(), - payment_amount: amount.into(), - unit: self.unit.clone(), + payment_amount: Amount::new(amount, self.unit.clone()), payment_id: request_lookup_id, }]) } @@ -817,8 +689,7 @@ impl MintPayment for Strike { let amount = Strike::from_strike_amount(invoice.amount, &self.unit)?; Ok(vec![WaitPaymentResponse { payment_identifier: payment_identifier.clone(), - payment_amount: amount.into(), - unit: self.unit.clone(), + payment_amount: Amount::new(amount, self.unit.clone()), payment_id: invoice.invoice_id, }]) } @@ -849,38 +720,12 @@ impl MintPayment for Strike { match label { "internal" => self.check_internal_payment(payment_identifier, id).await, - "exchange" => self.check_exchange_payment(payment_identifier, id).await, _ => self.check_regular_payment(payment_identifier, id).await, } } } impl Strike { - /// Record an internal settlement in the KV store - async fn record_internal_settlement( - &self, - invoice_id: &str, - ) -> Result<(), cdk_common::database::Error> { - let key = format!("{}{}", INTERNAL_SETTLEMENT_PREFIX, invoice_id); - let settlement_data = serde_json::json!({ - "settled_at": unix_time(), - "invoice_id": invoice_id - }); - let value = serde_json::to_vec(&settlement_data)?; - - let mut tx = self.kv_store.begin_transaction().await?; - tx.kv_write( - STRIKE_KV_PRIMARY_NAMESPACE, - STRIKE_KV_SECONDARY_NAMESPACE, - &key, - &value, - ) - .await?; - tx.commit().await?; - - Ok(()) - } - /// Check if an invoice was settled internally async fn check_internal_settlement( &self, @@ -923,100 +768,6 @@ impl Strike { .create_invoice_webhook_router(webhook_endpoint, mpsc_sender) .await } - - /// Execute currency exchange for internal payment (by quote id only) - async fn execute_currency_exchange_by_id(&self, quote_id: &str) -> Result<(u64, u64), Error> { - match self - .strike_api - .execute_currency_exchange_quote(quote_id) - .await - { - Ok(_) => (), - Err(strike_rs::Error::ApiError(api_error)) => { - if api_error - .is_error_code(&strike_rs::StrikeErrorCode::CurrencyExchangeQuoteExpired) - { - tracing::warn!("Currency exchange quote {} has expired", quote_id); - return Err(Error::Anyhow(anyhow!( - "Currency exchange quote has expired" - ))); - } else { - return Err(strike_rs::Error::ApiError(api_error).into()); - } - } - Err(e) => return Err(e.into()), - } - // After execution, fetch the quote to get the amounts/fees - let quote = self - .strike_api - .get_currency_exchange_quote(quote_id) - .await - .map_err(Error::from)?; - let converted_amount = Strike::from_strike_amount(quote.source.clone(), &self.unit)?; - let fee = if let Some(fee_info) = quote.fee.clone() { - if Strike::currency_unit_eq_strike(&self.unit, &fee_info.currency) { - Strike::from_strike_amount(fee_info.clone(), &self.unit)? - } else { - Strike::convert_fee_to_unit(fee_info, &self.unit, quote.conversion_rate)? - } - } else { - 0 - }; - Ok((converted_amount, fee)) - } - - /// Process internal settlement for cross-currency payments only - async fn process_internal_settlement( - &self, - internal_invoice: strike_rs::InvoiceListItem, - unit: &CurrencyUnit, - ) -> Result { - let source_currency = to_strike_currency(unit)?; - - let invoice_amount = internal_invoice.amount.clone(); - let exchange_request = CurrencyExchangeQuoteRequest { - sell: source_currency, - buy: invoice_amount.currency.clone(), - amount: ExchangeAmount { - amount: invoice_amount.amount.to_string(), - currency: invoice_amount.currency.clone(), - fee_policy: Some(FeePolicy::Exclusive), - }, - }; - - let quote = self - .strike_api - .create_currency_exchange_quote(exchange_request) - .await - .map_err(Error::from)?; - - let (converted_amount, _fee) = self.execute_currency_exchange_by_id("e.id).await?; - - let response = MakePaymentResponse { - payment_lookup_id: PaymentIdentifier::CustomId(format!("exchange:{}", quote.id)), - payment_proof: None, - status: MeltQuoteState::Paid, - total_spent: converted_amount.into(), - unit: unit.clone(), - }; - - if let Err(err) = self - .record_internal_settlement(&internal_invoice.invoice_id) - .await - { - tracing::warn!("Failed to record internal settlement: {}", err); - } - - // Notify the payment stream that this invoice has been settled - if let Err(err) = self.sender.send(internal_invoice.invoice_id.clone()) { - tracing::warn!( - "Failed to notify payment stream of internal settlement: {}", - err - ); - } - - Ok(response) - } } impl Strike { @@ -1029,7 +780,7 @@ impl Strike { if strike_amount.currency == StrikeCurrencyUnit::BTC { strike_amount.to_sats() } else { - bail!("Cannot convert Strike amount: expected BTC currency for Sat unit, got {:?} currency with amount {}", + bail!("Cannot convert Strike amount: expected BTC currency for Sat unit, got {:?} currency with amount {}", strike_amount.currency, strike_amount.amount); } } @@ -1037,23 +788,7 @@ impl Strike { if strike_amount.currency == StrikeCurrencyUnit::BTC { Ok(strike_amount.to_sats()? * 1000) } else { - bail!("Cannot convert Strike amount: expected BTC currency for Msat unit, got {:?} currency with amount {}", - strike_amount.currency, strike_amount.amount); - } - } - CurrencyUnit::Usd => { - if strike_amount.currency == StrikeCurrencyUnit::USD { - Ok((strike_amount.amount * 100.0).round() as u64) - } else { - bail!("Cannot convert Strike amount: expected USD currency for Usd unit, got {:?} currency with amount {}", - strike_amount.currency, strike_amount.amount); - } - } - CurrencyUnit::Eur => { - if strike_amount.currency == StrikeCurrencyUnit::EUR { - Ok((strike_amount.amount * 100.0).round() as u64) - } else { - bail!("Cannot convert Strike amount: expected EUR currency for Eur unit, got {:?} currency with amount {}", + bail!("Cannot convert Strike amount: expected BTC currency for Msat unit, got {:?} currency with amount {}", strike_amount.currency, strike_amount.amount); } } @@ -1069,65 +804,9 @@ impl Strike { 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.0; - Ok(StrikeAmount { - currency: StrikeCurrencyUnit::USD, - amount: dollars, - }) - } - CurrencyUnit::Eur => { - let euro = amount as f64 / 100.0; - Ok(StrikeAmount { - currency: StrikeCurrencyUnit::EUR, - amount: euro, - }) - } _ => bail!("Unsupported unit"), } } - - fn currency_unit_eq_strike(unit: &CurrencyUnit, strike: &StrikeCurrencyUnit) -> bool { - match (unit, strike) { - (CurrencyUnit::Sat, StrikeCurrencyUnit::BTC) => true, - (CurrencyUnit::Msat, StrikeCurrencyUnit::BTC) => true, // msat is subunit of BTC - (CurrencyUnit::Usd, StrikeCurrencyUnit::USD) => true, - (CurrencyUnit::Eur, StrikeCurrencyUnit::EUR) => true, - _ => false, - } - } - - fn convert_fee_to_unit( - fee_amount: StrikeAmount, - target_unit: &CurrencyUnit, - rate: strike_rs::ConversionRate, - ) -> anyhow::Result { - // Only support conversion between BTC (sats) and USD/EUR for now - let rate = rate.amount; - match (&fee_amount.currency, target_unit) { - (StrikeCurrencyUnit::USD, CurrencyUnit::Sat) - | (StrikeCurrencyUnit::EUR, CurrencyUnit::Sat) => { - // rate: X USD per BTC, so 1 USD = 1/X BTC = 100_000_000/X sats - let sats = (fee_amount.amount * 100_000_000.0 / rate).round() as u64; - Ok(sats) - } - (StrikeCurrencyUnit::USD, CurrencyUnit::Msat) - | (StrikeCurrencyUnit::EUR, CurrencyUnit::Msat) => { - let msats = (fee_amount.amount * 100_000_000_000.0 / rate).round() as u64; - Ok(msats) - } - (StrikeCurrencyUnit::USD, CurrencyUnit::Usd) - | (StrikeCurrencyUnit::EUR, CurrencyUnit::Eur) => { - // fee is already in correct fiat unit, return as cents - Ok((fee_amount.amount * 100.0).round() as u64) - } - _ => Err(anyhow!( - "Unsupported fee currency/unit conversion: {:?} -> {:?}", - fee_amount.currency, - target_unit - )), - } - } } #[cfg(test)] @@ -1137,10 +816,10 @@ mod tests { use std::sync::Arc; use async_trait::async_trait; - use cdk_common::database::mint::{ - DbTransactionFinalizer, KVStore, KVStoreDatabase, KVStoreTransaction, - }; use cdk_common::database::Error as DatabaseError; + use cdk_common::database::{ + DbTransactionFinalizer, DynKVStore, KVStore, KVStoreDatabase, KVStoreTransaction, + }; use cdk_common::nuts::CurrencyUnit; use strike_rs::{Amount as StrikeAmount, Currency as StrikeCurrencyUnit}; use tokio::sync::Mutex; @@ -1183,7 +862,7 @@ mod tests { } #[async_trait] - impl<'a> KVStoreTransaction<'a, DatabaseError> for MockKVTransaction { + impl KVStoreTransaction for MockKVTransaction { async fn kv_read( &mut self, primary_namespace: &str, @@ -1253,10 +932,9 @@ mod tests { #[async_trait] impl KVStore for MockKVStore { - async fn begin_transaction<'a>( - &'a self, - ) -> Result + Send + Sync + 'a>, DatabaseError> - { + async fn begin_transaction( + &self, + ) -> Result + Send + Sync>, DatabaseError> { Ok(Box::new(MockKVTransaction { store: Arc::new(MockKVStore { data: self.data.clone(), @@ -1266,7 +944,7 @@ mod tests { } } - fn create_mock_kv_store() -> DynMintKVStore { + fn create_mock_kv_store() -> DynKVStore { Arc::new(MockKVStore::default()) } @@ -1305,14 +983,9 @@ mod tests { to_strike_currency(&CurrencyUnit::Msat).unwrap(), StrikeCurrencyUnit::BTC ); - assert_eq!( - to_strike_currency(&CurrencyUnit::Usd).unwrap(), - StrikeCurrencyUnit::USD - ); - assert_eq!( - to_strike_currency(&CurrencyUnit::Eur).unwrap(), - StrikeCurrencyUnit::EUR - ); + // Fiat currencies are no longer supported + assert!(to_strike_currency(&CurrencyUnit::Usd).is_err()); + assert!(to_strike_currency(&CurrencyUnit::Eur).is_err()); } // Amount conversion tests - core functionality @@ -1339,29 +1012,6 @@ mod tests { ); } - #[test] - fn test_from_strike_amount_fiat() { - // USD to cents - let amount = StrikeAmount { - currency: StrikeCurrencyUnit::USD, - amount: 10.50, - }; - assert_eq!( - Strike::from_strike_amount(amount, &CurrencyUnit::Usd).unwrap(), - 1050 - ); - - // EUR to cents - let amount = StrikeAmount { - currency: StrikeCurrencyUnit::EUR, - amount: 25.75, - }; - assert_eq!( - Strike::from_strike_amount(amount, &CurrencyUnit::Eur).unwrap(), - 2575 - ); - } - #[test] fn test_from_strike_amount_currency_mismatch() { let amount = StrikeAmount { @@ -1379,10 +1029,13 @@ mod tests { assert_eq!(result.currency, StrikeCurrencyUnit::BTC); assert_eq!(result.amount, 1.0); - // USD cents to dollars - let result = Strike::to_strike_unit(1050u64, &CurrencyUnit::Usd).unwrap(); - assert_eq!(result.currency, StrikeCurrencyUnit::USD); - assert_eq!(result.amount, 10.50); + // Msats to BTC + let result = Strike::to_strike_unit(100_000_000_000u64, &CurrencyUnit::Msat).unwrap(); + assert_eq!(result.currency, StrikeCurrencyUnit::BTC); + assert_eq!(result.amount, 1.0); + + // Fiat currencies are no longer supported + assert!(Strike::to_strike_unit(1050u64, &CurrencyUnit::Usd).is_err()); } #[test] @@ -1394,40 +1047,6 @@ mod tests { assert_eq!(original_sats, converted_back); } - #[test] - fn test_currency_unit_eq_strike() { - assert!(Strike::currency_unit_eq_strike( - &CurrencyUnit::Sat, - &StrikeCurrencyUnit::BTC - )); - assert!(Strike::currency_unit_eq_strike( - &CurrencyUnit::Usd, - &StrikeCurrencyUnit::USD - )); - assert!(!Strike::currency_unit_eq_strike( - &CurrencyUnit::Sat, - &StrikeCurrencyUnit::USD - )); - } - - // Fee conversion test - #[test] - fn test_convert_fee_to_unit() { - let fee_amount = StrikeAmount { - currency: StrikeCurrencyUnit::USD, - amount: 1.0, - }; - - let rate = strike_rs::ConversionRate { - amount: 50000.0, - source_currency: StrikeCurrencyUnit::USD, - target_currency: StrikeCurrencyUnit::BTC, - }; - - let result = Strike::convert_fee_to_unit(fee_amount, &CurrencyUnit::Sat, rate).unwrap(); - assert_eq!(result, 2000); // $1 at $50k/BTC = 2000 sats - } - // Strike instance tests #[tokio::test] async fn test_strike_creation() { From 4f87da96836dd18f9495766532644c39b7cc3711 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 22 Jan 2026 19:43:34 -0800 Subject: [PATCH 3/4] feat: add explicit webhook_url config for strike backend Add optional webhook_url configuration to override auto-constructed webhook URLs when the mint runs behind NAT or has different internal/external URLs. Configuration via config.toml: [strike] webhook_url = "https://public.example.com" Or environment variable: CDK_MINTD_STRIKE_WEBHOOK_URL="https://public.example.com" Also fixes pre-existing bugs: - Missing comma in cfg attribute for strike feature - Incorrect return types for configure_mint_builder and configure_lightning_backend functions - Wrong type DynMintKVStore -> DynKVStore in setup.rs --- crates/cdk-mintd/example.config.toml | 2 ++ crates/cdk-mintd/src/config.rs | 4 ++++ crates/cdk-mintd/src/env_vars/strike.rs | 5 +++++ crates/cdk-mintd/src/lib.rs | 6 +++--- crates/cdk-mintd/src/setup.rs | 16 +++++++++++++--- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index e3d44c5e71..9a047855f0 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -178,6 +178,8 @@ max_delay_time = 3 # [strike] # api_key = "your_strike_api_key_here" # supported_units = ["sat"] +# Optional: Override webhook URL when mint is behind NAT or has different internal/external URLs +# webhook_url = "https://your-public-domain.com" # [auth] # Set to true to enable authentication features (defaults to false) diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 3f6f28522b..20eb31c5ea 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -433,6 +433,10 @@ fn default_grpc_port() -> u16 { pub struct Strike { pub api_key: String, pub supported_units: Vec, + /// Optional webhook URL base. If not set, uses the mint's info.url. + /// Set this when your mint runs behind NAT or has different internal/external URLs. + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook_url: Option, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] diff --git a/crates/cdk-mintd/src/env_vars/strike.rs b/crates/cdk-mintd/src/env_vars/strike.rs index 323e2aafa9..99b73a8399 100644 --- a/crates/cdk-mintd/src/env_vars/strike.rs +++ b/crates/cdk-mintd/src/env_vars/strike.rs @@ -8,6 +8,7 @@ use crate::config::Strike; pub const ENV_STRIKE_API_KEY: &str = "CDK_MINTD_STRIKE_API_KEY"; pub const ENV_STRIKE_SUPPORTED_UNITS: &str = "CDK_MINTD_STRIKE_SUPPORTED_UNITS"; +pub const ENV_STRIKE_WEBHOOK_URL: &str = "CDK_MINTD_STRIKE_WEBHOOK_URL"; impl Strike { pub fn from_env(mut self) -> Self { @@ -25,6 +26,10 @@ impl Strike { } } + if let Ok(webhook_url) = env::var(ENV_STRIKE_WEBHOOK_URL) { + self.webhook_url = Some(webhook_url); + } + self } } diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 2f6f5bb763..59960c0910 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -32,7 +32,7 @@ use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path} feature = "cln", feature = "lnbits", feature = "lnd", - feature = "ldk-node" + feature = "ldk-node", feature = "strike" ))] use cdk::nuts::CurrencyUnit; @@ -349,7 +349,7 @@ async fn configure_mint_builder( runtime: Option>, work_dir: &Path, kv_store: Option + Send + Sync>>, -) -> Result { +) -> Result<(MintBuilder, Vec)> { // Configure basic mint information let mint_builder = configure_basic_info(settings, mint_builder); @@ -449,7 +449,7 @@ async fn configure_lightning_backend( _runtime: Option>, work_dir: &Path, _kv_store: Option + Send + Sync>>, -) -> Result { +) -> Result<(MintBuilder, Vec)> { let mint_melt_limits = MintMeltLimits { mint_min: settings.ln.min_mint, mint_max: settings.ln.max_mint, diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index 373a7e1e2d..8178247a32 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -371,13 +371,23 @@ impl config::Strike { &self, settings: &Settings, unit: CurrencyUnit, - kv_store: cdk_common::database::mint::DynMintKVStore, + kv_store: cdk_common::database::DynKVStore, ) -> anyhow::Result<(cdk_strike::Strike, axum::Router)> { use cdk::mint_url::MintUrl; let webhook_endpoint = format!("/webhook/strike/{}/invoice", unit); - let mint_url: MintUrl = settings.info.url.parse()?; - let webhook_url = mint_url.join(&webhook_endpoint)?; + + // Use explicit webhook_url if provided, otherwise fall back to mint's info.url + let webhook_url = match &self.webhook_url { + Some(base_url) => { + let base: MintUrl = base_url.parse()?; + base.join(&webhook_endpoint)? + } + None => { + let mint_url: MintUrl = settings.info.url.parse()?; + mint_url.join(&webhook_endpoint)? + } + }; let strike = cdk_strike::Strike::new( self.api_key.clone(), From d2edd3153a1432b711536b0937a68b6066cee934 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 22 Jan 2026 21:48:08 -0800 Subject: [PATCH 4/4] refactor: vendor strike API client, fix invoice state deserialization - Replace strike-rs git dependency with vendored api module - Add serde rename for InvoiceState to handle Strike's uppercase values (UNPAID, PAID, etc.) - Handle InvoiceState::Cancelled in all match arms - Add amount field to PayInvoiceQuoteRequest --- Cargo.lock | 28 +- crates/cdk-mintd/src/setup.rs | 2 +- crates/cdk-strike/Cargo.toml | 8 +- crates/cdk-strike/src/api/error.rs | 351 +++++++++++++ crates/cdk-strike/src/api/mod.rs | 383 ++++++++++++++ crates/cdk-strike/src/api/types.rs | 722 +++++++++++++++++++++++++++ crates/cdk-strike/src/api/webhook.rs | 294 +++++++++++ crates/cdk-strike/src/error.rs | 6 +- crates/cdk-strike/src/lib.rs | 49 +- 9 files changed, 1798 insertions(+), 45 deletions(-) create mode 100644 crates/cdk-strike/src/api/error.rs create mode 100644 crates/cdk-strike/src/api/mod.rs create mode 100644 crates/cdk-strike/src/api/types.rs create mode 100644 crates/cdk-strike/src/api/webhook.rs diff --git a/Cargo.lock b/Cargo.lock index cfd77c5eee..35769f9d99 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1731,13 +1731,19 @@ dependencies = [ "axum 0.8.8", "cdk-common", "futures", + "hex", + "rand 0.9.2", + "reqwest", + "ring 0.17.14", + "serde", "serde_json", - "strike-rs", "thiserror 2.0.17", "tokio", "tokio-stream", "tokio-util", "tracing", + "url", + "urlencoding", "uuid", ] @@ -6840,26 +6846,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" -[[package]] -name = "strike-rs" -version = "0.5.0" -source = "git+https://github.com/gudnuf/strike-rs.git?rev=eb59400365122f13e3dc074618f0b4251252e02a#eb59400365122f13e3dc074618f0b4251252e02a" -dependencies = [ - "anyhow", - "axum 0.8.8", - "log", - "rand 0.9.2", - "reqwest", - "ring 0.17.14", - "serde", - "serde_json", - "thiserror 2.0.17", - "tokio", - "tower 0.5.3", - "url", - "urlencoding", -] - [[package]] name = "stringprep" version = "0.1.5" diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index 8178247a32..109da6c0ac 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -397,7 +397,7 @@ impl config::Strike { ) .await?; - let webhook_router = strike.create_invoice_webhook(&webhook_endpoint).await?; + let webhook_router = strike.create_invoice_webhook(&webhook_endpoint)?; Ok((strike, webhook_router)) } diff --git a/crates/cdk-strike/Cargo.toml b/crates/cdk-strike/Cargo.toml index c6e4986215..2e104d41fe 100644 --- a/crates/cdk-strike/Cargo.toml +++ b/crates/cdk-strike/Cargo.toml @@ -23,4 +23,10 @@ thiserror.workspace = true serde_json.workspace = true uuid.workspace = true axum.workspace = true -strike-rs = { git = "https://github.com/gudnuf/strike-rs.git", rev = "eb59400365122f13e3dc074618f0b4251252e02a"} \ No newline at end of file +reqwest.workspace = true +serde.workspace = true +url.workspace = true +rand.workspace = true +ring = "0.17" +hex = "0.4" +urlencoding = "2" \ No newline at end of file diff --git a/crates/cdk-strike/src/api/error.rs b/crates/cdk-strike/src/api/error.rs new file mode 100644 index 0000000000..146d977da0 --- /dev/null +++ b/crates/cdk-strike/src/api/error.rs @@ -0,0 +1,351 @@ +//! Strike API error types +//! +//! See for the complete error reference. +//! +//! # Error Handling +//! +//! The Strike API uses standard HTTP response codes: +//! - 2xx: Success +//! - 4xx: Client errors (invalid request, insufficient permissions, etc.) +//! - 5xx: Server errors +//! +//! Error responses follow this JSON structure: +//! +//! ```json +//! { +//! "traceId": "optional-trace-id", +//! "data": { +//! "status": 400, +//! "code": "INVALID_DATA", +//! "message": "Human-readable error message", +//! "validationErrors": {} +//! } +//! } +//! ``` +//! +//! Note: Error messages are for developer reference only and may change without +//! notice. Do not display them to end users. +//! +//! # Error Codes +//! +//! [`StrikeErrorCode`] covers all known Strike API error codes. Key codes include: +//! +//! - `RATE_LIMIT_EXCEEDED` / `TOO_MANY_ATTEMPTS` - Retry with exponential backoff +//! - `BALANCE_TOO_LOW` - Insufficient account balance +//! - `PAYMENT_QUOTE_EXPIRED` - Quote expired, create a new one +//! - `LN_ROUTE_NOT_FOUND` - Lightning payment routing failed +//! - `INVALID_LN_INVOICE` - Malformed BOLT11 invoice +//! - `DUPLICATE_PAYMENT_QUOTE` - Use idempotency-key header to prevent duplicates +//! +//! Use [`StrikeErrorCode::is_retryable()`] to check if an error is transient. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use thiserror::Error; + +/// Strike API error +#[derive(Debug, Error)] +pub enum Error { + /// Resource not found (404) + #[error("Not found")] + NotFound, + + /// Invalid URL format + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + + /// HTTP request error + #[error("HTTP error: {0}")] + Reqwest(#[from] reqwest::Error), + + /// JSON serialization/deserialization error + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// Strike API returned an error response + #[error("Strike API error: {0}")] + Api(#[from] StrikeApiError), +} + +/// Detailed Strike API error response +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Error)] +#[serde(rename_all = "camelCase")] +#[error("{}", self.data.message)] +pub struct StrikeApiError { + /// Optional trace ID for debugging + #[serde(default)] + pub trace_id: Option, + /// Error details + pub data: StrikeApiErrorData, +} + +impl StrikeApiError { + /// Get the HTTP status code + pub fn status(&self) -> u16 { + self.data.status + } + + /// Get the error code + pub fn code(&self) -> &StrikeErrorCode { + &self.data.code + } + + /// Get the error message + pub fn message(&self) -> &str { + &self.data.message + } + + /// Check if this error matches a specific code + pub fn is_error_code(&self, code: &StrikeErrorCode) -> bool { + &self.data.code == code + } + + /// Check if this is a rate limit error + pub fn is_rate_limit_error(&self) -> bool { + matches!( + self.data.code, + StrikeErrorCode::RateLimitExceeded | StrikeErrorCode::TooManyAttempts + ) + } + + /// Check if this is a server error (5xx) + pub fn is_server_error(&self) -> bool { + self.data.status >= 500 + } + + /// Check if this is a client error (4xx) + pub fn is_client_error(&self) -> bool { + self.data.status >= 400 && self.data.status < 500 + } + + /// Check if this error is retryable + pub fn is_retryable(&self) -> bool { + self.data.code.is_retryable() + } +} + +/// Strike API error data payload +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrikeApiErrorData { + /// HTTP status code + pub status: u16, + /// Error code enum + pub code: StrikeErrorCode, + /// Human-readable error message + pub message: String, + /// Additional context values + #[serde(default)] + pub values: HashMap, + /// Field-specific validation errors + #[serde(default)] + pub validation_errors: HashMap>, + /// Debug information (only in non-production) + #[serde(default)] + pub debug: Option, +} + +/// Validation error for a specific field +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ValidationError { + /// Validation error code + pub code: ValidationErrorCode, + /// Human-readable validation message + pub message: String, + /// Additional context values + #[serde(default)] + pub values: HashMap, +} + +/// Debug information included in error responses +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct DebugInfo { + /// Full debug output + #[serde(default)] + pub full: Option, + /// Request body that caused the error + #[serde(default)] + pub body: Option, +} + +/// Strike API error codes +/// +/// These codes are returned in the `code` field of error responses. +/// Use [`is_retryable`](StrikeErrorCode::is_retryable) to check if an error +/// should be retried with backoff. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum StrikeErrorCode { + /// Resource not found + NotFound, + /// Internal server error + InternalServerError, + /// Bad gateway + BadGateway, + /// Service in maintenance mode + MaintenanceMode, + /// Service temporarily unavailable + ServiceUnavailable, + /// Gateway timeout + GatewayTimeout, + /// Rate limit exceeded + RateLimitExceeded, + /// Too many attempts + TooManyAttempts, + /// Concurrent processing conflict + ProcessingConflict, + /// Authentication required + Unauthorized, + /// Permission denied + Forbidden, + /// Invalid request data + InvalidData, + /// Invalid query parameters + InvalidDataQuery, + /// Request could not be processed + UnprocessableEntity, + /// Account setup incomplete + AccountNotReady, + /// Invoice already paid + InvalidStateForInvoicePaid, + /// Invoice already reversed + InvalidStateForInvoiceReversed, + /// Invoice already cancelled + InvalidStateForInvoiceCancelled, + /// Invalid payment recipient + InvalidRecipient, + /// Payment currently processing + ProcessingPayment, + /// Duplicate invoice + DuplicateInvoice, + /// Cannot pay to self + SelfPaymentNotAllowed, + /// Currency not available for user + UserCurrencyUnavailable, + /// Lightning Network unavailable + LnUnavailable, + /// Exchange rate not available + ExchangeRateNotAvailable, + /// Insufficient balance + BalanceTooLow, + /// Invalid amount + InvalidAmount, + /// Amount exceeds maximum + AmountTooHigh, + /// Amount below minimum + AmountTooLow, + /// Payment method not supported + UnsupportedPaymentMethod, + /// Invalid payment method + InvalidPaymentMethod, + /// Currency not supported + CurrencyUnsupported, + /// Plaid linking failed + PlaidLinkingFailed, + /// Payment method not ready + PaymentMethodNotReady, + /// Payout originator not approved + PayoutOriginatorNotApproved, + /// Payout already initiated + PayoutAlreadyInitiated, + /// Invoice has expired + InvalidStateForInvoiceExpired, + /// Payment already processed + PaymentProcessed, + /// Invalid Lightning invoice + InvalidLnInvoice, + /// Lightning invoice already processed + LnInvoiceProcessed, + /// Invalid Bitcoin address + InvalidBitcoinAddress, + /// Lightning route not found + LnRouteNotFound, + /// Payment quote has expired + PaymentQuoteExpired, + /// Duplicate payment quote + DuplicatePaymentQuote, + /// Too many transactions + TooManyTransactions, + /// Duplicate currency exchange quote + DuplicateCurrencyExchangeQuote, + /// Currency exchange quote already processed + CurrencyExchangeQuoteProcessed, + /// Currency exchange quote expired + CurrencyExchangeQuoteExpired, + /// Currency exchange pair not supported + CurrencyExchangePairNotSupported, + /// Currency exchange amount too low + CurrencyExchangeAmountTooLow, + /// Deposit limit exceeded + DepositLimitExceeded, + /// Duplicate deposit + DuplicateDeposit, + /// Unknown error code + #[serde(other)] + Unknown, +} + +impl StrikeErrorCode { + /// Get the typical HTTP status for this error code + pub fn typical_status(&self) -> u16 { + match self { + StrikeErrorCode::NotFound => 404, + StrikeErrorCode::InternalServerError => 500, + StrikeErrorCode::BadGateway => 502, + StrikeErrorCode::MaintenanceMode | StrikeErrorCode::ServiceUnavailable => 503, + StrikeErrorCode::GatewayTimeout => 504, + StrikeErrorCode::RateLimitExceeded | StrikeErrorCode::TooManyAttempts => 429, + StrikeErrorCode::ProcessingConflict | StrikeErrorCode::DuplicateInvoice => 409, + StrikeErrorCode::Unauthorized => 401, + StrikeErrorCode::Forbidden => 403, + StrikeErrorCode::InvalidData | StrikeErrorCode::InvalidDataQuery => 400, + StrikeErrorCode::AccountNotReady => 425, + _ => 422, + } + } + + /// Check if this error is retryable + /// + /// Returns `true` for transient errors that may succeed on retry: + /// - Server errors (5xx) + /// - Rate limiting + /// - Processing conflicts + /// - Lightning Network unavailable + pub fn is_retryable(&self) -> bool { + matches!( + self, + StrikeErrorCode::InternalServerError + | StrikeErrorCode::BadGateway + | StrikeErrorCode::MaintenanceMode + | StrikeErrorCode::ServiceUnavailable + | StrikeErrorCode::GatewayTimeout + | StrikeErrorCode::RateLimitExceeded + | StrikeErrorCode::TooManyAttempts + | StrikeErrorCode::ProcessingConflict + | StrikeErrorCode::LnUnavailable + ) + } +} + +/// Validation error codes for field-level errors +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ValidationErrorCode { + /// Generic invalid data + InvalidData, + /// Required field missing + InvalidDataRequired, + /// Invalid field length + InvalidDataLength, + /// Field too short + InvalidDataMinlength, + /// Field too long + InvalidDataMaxlength, + /// Invalid field value + InvalidDataValue, + /// Invalid currency + InvalidDataCurrency, + /// Unknown validation error + #[serde(other)] + Unknown, +} diff --git a/crates/cdk-strike/src/api/mod.rs b/crates/cdk-strike/src/api/mod.rs new file mode 100644 index 0000000000..83f1e63f4e --- /dev/null +++ b/crates/cdk-strike/src/api/mod.rs @@ -0,0 +1,383 @@ +//! Strike API client +//! +//! This module implements the Strike API v1 for Lightning Network payments. +//! See for the complete API reference. +//! +//! # Overview +//! +//! Strike enables receiving and sending payments via the Bitcoin Lightning Network. +//! This client supports: +//! - Creating invoices to receive payments +//! - Generating payment quotes and executing payments +//! - Currency exchange between supported currencies +//! - Webhook subscriptions for real-time notifications +//! +//! # Endpoints +//! +//! ## Invoices (Receiving Payments) +//! +//! | Method | Endpoint | Description | +//! |--------|----------|-------------| +//! | POST | `/v1/invoices` | Create an invoice for a specific amount | +//! | GET | `/v1/invoices/{id}` | Get invoice by ID | +//! | GET | `/v1/invoices` | List invoices with OData filtering | +//! | POST | `/v1/invoices/{id}/quote` | Generate BOLT11 Lightning invoice | +//! +//! **Invoice Flow:** +//! 1. Create invoice (state: `UNPAID`) +//! 2. Generate quote to get BOLT11 (expires in 30s cross-currency, 1hr same-currency) +//! 3. Payer pays the BOLT11 +//! 4. Invoice transitions to `PAID` +//! +//! ## Payment Quotes (Sending Payments) +//! +//! | Method | Endpoint | Description | +//! |--------|----------|-------------| +//! | POST | `/v1/payment-quotes/lightning` | Quote for paying a BOLT11 invoice | +//! | PATCH | `/v1/payment-quotes/{id}/execute` | Execute the payment | +//! | GET | `/v1/payments/{id}` | Get payment status | +//! +//! **Payment States:** `PENDING` (awaiting confirmation), `COMPLETED`, `FAILED` +//! +//! ## Currency Exchange +//! +//! | Method | Endpoint | Description | +//! |--------|----------|-------------| +//! | POST | `/v1/currency-exchange-quotes` | Create exchange quote | +//! | GET | `/v1/currency-exchange-quotes/{id}` | Get exchange quote | +//! | PATCH | `/v1/currency-exchange-quotes/{id}/execute` | Execute exchange | +//! +//! ## Webhooks +//! +//! | Method | Endpoint | Description | +//! |--------|----------|-------------| +//! | POST | `/v1/subscriptions` | Create webhook subscription | +//! | GET | `/v1/subscriptions` | List subscriptions | +//! | DELETE | `/v1/subscriptions/{id}` | Delete subscription | +//! +//! **Event Types:** `invoice.updated`, `payment.updated`, `currency-exchange-quote.updated` +//! +//! # Authentication +//! +//! All requests require Bearer token authentication via the `Authorization` header. +//! API keys are generated in the Strike Dashboard at . +//! +//! # Supported Currencies +//! +//! `BTC`, `USD`, `EUR`, `USDT`, `GBP`, `AUD` +//! +//! USD invoices are ideal for e-commerce since the received amount is guaranteed +//! regardless of Bitcoin price fluctuations. + +pub mod error; +pub mod types; +pub mod webhook; + +use error::{Error, StrikeApiError}; +use rand::Rng; +use reqwest::Client; +use serde::Serialize; +use std::time::Duration; +use tracing::{debug, warn}; +use types::*; +use url::Url; + +/// Strike API client +#[derive(Debug, Clone)] +pub struct StrikeApi { + api_key: String, + base_url: Url, + client: Client, + webhook_secret: String, +} + +impl StrikeApi { + /// Create a new Strike API client + pub fn new(api_key: &str, api_url: Option<&str>, timeout_ms: u64) -> anyhow::Result { + let base_url = api_url.unwrap_or("https://api.strike.me"); + let base_url = Url::parse(base_url)?; + + let client = Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .build()?; + + // Generate a random webhook secret + let mut rng = rand::rng(); + let webhook_secret: String = (0..15) + .map(|_| { + let idx = rng.random_range(0..62); + let chars = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + chars[idx] as char + }) + .collect(); + + Ok(Self { + api_key: api_key.to_string(), + base_url, + client, + webhook_secret, + }) + } + + /// Get the webhook secret for signature verification + pub fn webhook_secret(&self) -> &str { + &self.webhook_secret + } + + /// Make a GET request + async fn get(&self, path: &str) -> Result { + let url = self.base_url.join(path)?; + debug!("GET {}", url); + + let response = self + .client + .get(url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Accept", "application/json") + .send() + .await?; + + self.handle_response(response).await + } + + /// Make a POST request + async fn post(&self, path: &str, body: &T) -> Result { + let url = self.base_url.join(path)?; + debug!("POST {}", url); + + let response = self + .client + .post(url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(body) + .send() + .await?; + + self.handle_response(response).await + } + + /// Make a PATCH request (no body) + async fn patch(&self, path: &str) -> Result { + let url = self.base_url.join(path)?; + debug!("PATCH {}", url); + + let response = self + .client + .patch(url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Accept", "application/json") + .send() + .await?; + + self.handle_response(response).await + } + + /// Make a DELETE request + async fn delete(&self, path: &str) -> Result<(), Error> { + let url = self.base_url.join(path)?; + debug!("DELETE {}", url); + + let response = self + .client + .delete(url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + let text = response.text().await?; + let error: StrikeApiError = serde_json::from_str(&text)?; + Err(Error::Api(error)) + } + } + + /// Handle API response + async fn handle_response( + &self, + response: reqwest::Response, + ) -> Result { + let status = response.status(); + let text = response.text().await?; + + if status.is_success() { + let json: serde_json::Value = serde_json::from_str(&text)?; + Ok(json) + } else if status == reqwest::StatusCode::NOT_FOUND { + Err(Error::NotFound) + } else { + warn!("Strike API error: {} - {}", status, text); + let error: StrikeApiError = serde_json::from_str(&text)?; + Err(Error::Api(error)) + } + } + + // ==================== Invoice Endpoints ==================== + + /// Create an invoice + pub async fn create_invoice(&self, request: InvoiceRequest) -> Result { + let json = self.post("/v1/invoices", &request).await?; + Ok(serde_json::from_value(json)?) + } + + /// Get an invoice by ID + pub async fn get_incoming_invoice(&self, invoice_id: &str) -> Result { + let json = self.get(&format!("/v1/invoices/{}", invoice_id)).await?; + Ok(serde_json::from_value(json)?) + } + + /// List invoices with optional query parameters + pub async fn get_invoices( + &self, + params: Option, + ) -> Result { + let query_string = params.map(|p| p.to_query_string()).unwrap_or_default(); + let json = self.get(&format!("/v1/invoices{}", query_string)).await?; + Ok(serde_json::from_value(json)?) + } + + /// Get a quote for an invoice (returns BOLT11) + /// + /// See + pub async fn invoice_quote(&self, invoice_id: &str) -> Result { + self.invoice_quote_with_options(invoice_id, InvoiceQuoteRequest::default()) + .await + } + + /// Get a quote for an invoice with options (returns BOLT11) + /// + /// Use this method when you need to specify a description hash for BOLT11 compliance. + /// See + pub async fn invoice_quote_with_options( + &self, + invoice_id: &str, + request: InvoiceQuoteRequest, + ) -> Result { + let json = self + .post(&format!("/v1/invoices/{}/quote", invoice_id), &request) + .await?; + Ok(serde_json::from_value(json)?) + } + + // ==================== Payment Endpoints ==================== + + /// Get a quote for paying a Lightning invoice + pub async fn payment_quote( + &self, + request: PayInvoiceQuoteRequest, + ) -> Result { + let json = self.post("/v1/payment-quotes/lightning", &request).await?; + Ok(serde_json::from_value(json)?) + } + + /// Execute a payment quote + pub async fn pay_quote(&self, quote_id: &str) -> Result { + let json = self + .patch(&format!("/v1/payment-quotes/{}/execute", quote_id)) + .await?; + Ok(serde_json::from_value(json)?) + } + + /// Get an outgoing payment by ID + pub async fn get_outgoing_payment( + &self, + payment_id: &str, + ) -> Result { + let json = self.get(&format!("/v1/payments/{}", payment_id)).await?; + Ok(serde_json::from_value(json)?) + } + + // ==================== Currency Exchange Endpoints ==================== + + /// Create a currency exchange quote + pub async fn create_currency_exchange_quote( + &self, + request: CurrencyExchangeQuoteRequest, + ) -> Result { + let json = self.post("/v1/currency-exchange-quotes", &request).await?; + Ok(serde_json::from_value(json)?) + } + + /// Get a currency exchange quote by ID + pub async fn get_currency_exchange_quote( + &self, + quote_id: &str, + ) -> Result { + let json = self + .get(&format!("/v1/currency-exchange-quotes/{}", quote_id)) + .await?; + Ok(serde_json::from_value(json)?) + } + + /// Execute a currency exchange quote + pub async fn execute_currency_exchange_quote(&self, quote_id: &str) -> Result<(), Error> { + self.patch(&format!( + "/v1/currency-exchange-quotes/{}/execute", + quote_id + )) + .await?; + Ok(()) + } + + // ==================== Webhook Endpoints ==================== + + /// Subscribe to invoice webhooks + pub async fn subscribe_to_invoice_webhook(&self, webhook_url: String) -> Result<(), Error> { + let request = webhook::WebhookRequest { + webhook_url, + webhook_version: "v1".to_string(), + secret: self.webhook_secret.clone(), + enabled: true, + event_types: vec!["invoice.updated".to_string()], + }; + + self.post("/v1/subscriptions", &request).await?; + Ok(()) + } + + /// Subscribe to currency exchange webhooks + pub async fn subscribe_to_currency_exchange_webhook( + &self, + webhook_url: String, + ) -> Result<(), Error> { + let request = webhook::WebhookRequest { + webhook_url, + webhook_version: "v1".to_string(), + secret: self.webhook_secret.clone(), + enabled: true, + event_types: vec!["currency-exchange-quote.updated".to_string()], + }; + + self.post("/v1/subscriptions", &request).await?; + Ok(()) + } + + /// Get current webhook subscriptions + pub async fn get_current_subscriptions( + &self, + ) -> Result, Error> { + let json = self.get("/v1/subscriptions").await?; + Ok(serde_json::from_value(json)?) + } + + /// Delete a webhook subscription + pub async fn delete_subscription(&self, webhook_id: &str) -> Result<(), Error> { + self.delete(&format!("/v1/subscriptions/{}", webhook_id)) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_client() { + let client = StrikeApi::new("test_key", None, 30000).unwrap(); + assert_eq!(client.base_url.as_str(), "https://api.strike.me/"); + assert_eq!(client.webhook_secret.len(), 15); + } +} diff --git a/crates/cdk-strike/src/api/types.rs b/crates/cdk-strike/src/api/types.rs new file mode 100644 index 0000000000..94a0e1776e --- /dev/null +++ b/crates/cdk-strike/src/api/types.rs @@ -0,0 +1,722 @@ +//! Strike API type definitions +//! +//! This module contains request and response types for the Strike API v1. +//! See for the complete API reference. +//! +//! # Type Definitions +//! +//! All types use `camelCase` serialization to match the API's JSON format. +//! +//! ## Amount Handling +//! +//! Strike API returns amounts as strings (e.g., `"0.00001234"` for BTC). +//! The [`Amount`] type handles this with custom deserialization via +//! [`parse_f64_from_string`]. +//! +//! ## Invoice States +//! +//! [`InvoiceState`] covers both invoice and payment states: +//! - Invoice states: `UNPAID`, `PENDING`, `PAID`, `CANCELLED` +//! - Payment states: `PENDING`, `COMPLETED`, `FAILED` +//! +//! ## Currency Exchange States +//! +//! [`ExchangeQuoteState`]: `NEW`, `PENDING`, `COMPLETED`, `FAILED` +//! +//! ## Supported Currencies +//! +//! `BTC`, `USD`, `EUR`, `USDT`, `GBP`, `AUD` +//! +//! ## OData Query Support +//! +//! [`InvoiceQueryParams`] provides builder-style construction of OData queries +//! for filtering and paginating invoice lists. Supports `$filter`, `$orderby`, +//! `$skip`, and `$top` parameters. + +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; + +/// Parse f64 from a string (Strike API returns amounts as strings) +pub fn parse_f64_from_string<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrFloat { + String(String), + Float(f64), + } + + match StringOrFloat::deserialize(deserializer)? { + StringOrFloat::String(s) => s.parse().map_err(serde::de::Error::custom), + StringOrFloat::Float(f) => Ok(f), + } +} + +/// Supported currencies in the Strike API +/// +/// See +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum Currency { + /// US Dollar - ideal for e-commerce transactions + USD, + /// Euro + EUR, + /// Bitcoin - used for Lightning Network operations + BTC, + /// Tether USD (stablecoin) + USDT, + /// British Pound + GBP, + /// Australian Dollar + AUD, +} + +impl fmt::Display for Currency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Currency::USD => write!(f, "USD"), + Currency::EUR => write!(f, "EUR"), + Currency::BTC => write!(f, "BTC"), + Currency::USDT => write!(f, "USDT"), + Currency::GBP => write!(f, "GBP"), + Currency::AUD => write!(f, "AUD"), + } + } +} + +impl std::str::FromStr for Currency { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_str() { + "USD" => Ok(Currency::USD), + "EUR" => Ok(Currency::EUR), + "BTC" => Ok(Currency::BTC), + "USDT" => Ok(Currency::USDT), + "GBP" => Ok(Currency::GBP), + "AUD" => Ok(Currency::AUD), + // Also support lowercase unit names + "SAT" | "SATS" => Ok(Currency::BTC), + "MSAT" | "MSATS" => Ok(Currency::BTC), + _ => Err(format!("Unknown currency: {}", s)), + } + } +} + +/// Amount with currency +/// +/// Strike API returns amounts as decimal strings (e.g., "0.00001234" for BTC). +/// This type handles both string and float deserialization. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct Amount { + /// The currency type (BTC, USD, EUR, USDT, GBP, AUD) + pub currency: Currency, + /// The amount value as a decimal + #[serde(deserialize_with = "parse_f64_from_string")] + pub amount: f64, +} + +impl Amount { + /// Create an amount from satoshis + pub fn from_sats(sats: u64) -> Self { + Self { + currency: Currency::BTC, + amount: sats as f64 / 100_000_000.0, + } + } + + /// Create an amount from millisatoshis + pub fn from_msats(msats: u64) -> Self { + Self { + currency: Currency::BTC, + amount: msats as f64 / 100_000_000_000.0, + } + } + + /// Convert to satoshis (only valid for BTC) + pub fn to_sats(&self) -> anyhow::Result { + if self.currency != Currency::BTC { + anyhow::bail!("Cannot convert {} to sats", self.currency); + } + Ok((self.amount * 100_000_000.0).round() as u64) + } + + /// Convert to millisatoshis (only valid for BTC) + pub fn to_msats(&self) -> anyhow::Result { + if self.currency != Currency::BTC { + anyhow::bail!("Cannot convert {} to msats", self.currency); + } + Ok((self.amount * 100_000_000_000.0).round() as u64) + } + + /// Create an amount in USD + pub fn usd(amount: f64) -> Self { + Self { + currency: Currency::USD, + amount, + } + } + + /// Create an amount in EUR + pub fn eur(amount: f64) -> Self { + Self { + currency: Currency::EUR, + amount, + } + } +} + +/// Fee policy for payments +/// +/// Determines how fees are handled relative to the payment amount. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize, Serialize)] +pub enum FeePolicy { + /// Fee is included in the amount - reduces the amount sent to recipient + Inclusive, + /// Fee is added on top of the amount - recipient receives full amount (default) + Exclusive, +} + +/// Payment amount with optional fee policy +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAmount { + /// The amount value + #[serde(deserialize_with = "parse_f64_from_string")] + pub amount: f64, + /// The currency type + pub currency: Currency, + /// Optional fee policy + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_policy: Option, +} + +/// Invoice and payment state +/// +/// This enum covers states for both invoices and payments: +/// +/// **Invoice states:** +/// - `UNPAID` - Invoice created, awaiting payment +/// - `PENDING` - Payment in progress +/// - `PAID` - Payment received successfully +/// - `CANCELLED` - Invoice was cancelled +/// +/// **Payment states:** +/// - `PENDING` - Payment in progress, awaiting blockchain confirmation +/// - `COMPLETED` - Payment completed successfully +/// - `FAILED` - Payment failed +/// +/// See +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum InvoiceState { + /// Payment completed successfully (payment state) + Completed, + /// Invoice/payment has been paid (invoice state) + Paid, + /// Invoice is unpaid, awaiting payment (invoice state) + Unpaid, + /// Payment in progress, awaiting confirmation + Pending, + /// Payment failed (payment state) + Failed, + /// Invoice was cancelled (invoice state) + Cancelled, +} + +impl fmt::Display for InvoiceState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InvoiceState::Completed => write!(f, "COMPLETED"), + InvoiceState::Paid => write!(f, "PAID"), + InvoiceState::Unpaid => write!(f, "UNPAID"), + InvoiceState::Pending => write!(f, "PENDING"), + InvoiceState::Failed => write!(f, "FAILED"), + InvoiceState::Cancelled => write!(f, "CANCELLED"), + } + } +} + +/// Request to create an invoice +/// +/// Invoices are used to receive payments for a specific amount. They begin in the +/// UNPAID state and transition to PAID upon successful payment delivery. +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceRequest { + /// Universally unique identifier for invoice tracking. + /// Use this to correlate invoices with your internal systems. + #[serde(skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, + /// Optional invoice description that will be included in the Lightning invoice. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The invoice amount with currency. USD invoices are ideal for e-commerce + /// since the received amount is guaranteed regardless of BTC price fluctuations. + pub amount: Amount, +} + +/// Response from creating an invoice +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceResponse { + /// Unique invoice identifier (UUID) + pub invoice_id: String, + /// The invoice amount with currency + pub amount: Amount, + /// Current invoice state: UNPAID, PENDING, PAID, or CANCELLED + pub state: InvoiceState, + /// Creation timestamp (ISO 8601) + pub created: String, + /// Universally unique identifier for invoice tracking, provided in the create request + #[serde(default)] + pub correlation_id: Option, + /// Optional invoice description + #[serde(default)] + pub description: Option, + /// ID of the invoice issuer (UUID) + pub issuer_id: String, + /// ID of the payment receiver (UUID) + pub receiver_id: String, +} + +/// Request for an invoice quote +/// +/// See +#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceQuoteRequest { + /// Optional description hash for BOLT11 spec compliance. + /// When provided, the resulting Lightning invoice will include this hash + /// instead of the plain text description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description_hash: Option, +} + +/// Response from getting an invoice quote (includes BOLT11) +/// +/// After creating an invoice, generate a quote to get the BOLT11 Lightning invoice. +/// Quotes have an expiration time: 30 seconds for cross-currency, 3600 seconds (1 hour) +/// for same-currency invoices. +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceQuoteResponse { + /// Unique quote identifier (UUID) + pub quote_id: String, + /// Optional description forwarded from the invoice + #[serde(default)] + pub description: Option, + /// BOLT11 Lightning invoice string. This alphanumeric code contains the + /// payment amount and destination, and can be presented as a QR code. + pub ln_invoice: String, + /// Optional on-chain Bitcoin address for fallback payment + #[serde(default)] + pub onchain_address: Option, + /// Quote expiration timestamp (ISO 8601) + pub expiration: String, + /// Seconds until quote expiration. 30 sec for cross-currency, 3600 sec for same-currency. + pub expiration_in_sec: u64, + /// Source amount - what the payer sends in BTC + pub source_amount: Amount, + /// Target amount - what the receiver gets in their chosen currency + pub target_amount: Amount, + /// Currency conversion rate. Value is 1 for BTC-to-BTC invoices. + pub conversion_rate: ConversionRate, +} + +/// Invoice list item +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceListItem { + /// Unique invoice identifier + pub invoice_id: String, + /// The invoice amount + pub amount: Amount, + /// Current invoice state + pub state: InvoiceState, + /// Creation timestamp (ISO 8601) + pub created: String, + /// Optional correlation ID for tracking + #[serde(default)] + pub correlation_id: Option, + /// Optional invoice description + #[serde(default)] + pub description: Option, + /// ID of the invoice issuer + pub issuer_id: String, + /// ID of the payment receiver + pub receiver_id: String, + /// ID of the payer (if paid) + #[serde(default)] + pub payer_id: Option, +} + +/// Response from listing invoices +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct InvoiceListResponse { + /// List of invoices + pub items: Vec, + /// Total count of matching invoices + pub count: i64, +} + +/// Conversion rate between currencies +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversionRate { + /// The conversion rate value + #[serde(deserialize_with = "parse_f64_from_string")] + pub amount: f64, + /// Source currency + pub source_currency: Currency, + /// Target currency + pub target_currency: Currency, +} + +/// Request to get a Lightning payment quote +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PayInvoiceQuoteRequest { + /// BOLT11 Lightning invoice to pay (min length: 1) + pub ln_invoice: String, + /// Currency to send from. Defaults to the user's default currency if not specified. + pub source_currency: Currency, + /// Amount to pay. Required only for zero-amount invoices; omit otherwise. + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, +} + +/// Response from getting a Lightning payment quote +/// +/// Contains the cost breakdown for paying a Lightning invoice, including +/// the amount, fees, and conversion rate for cross-currency payments. +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PayInvoiceQuoteResponse { + /// Unique payment quote identifier (UUID) + pub payment_quote_id: String, + /// Optional description forwarded from the Lightning invoice + #[serde(default)] + pub description: Option, + /// Quote expiration timestamp (ISO 8601). Execute before this time. + #[serde(default)] + pub valid_until: Option, + /// Currency conversion rate for cross-currency payments. + /// Shows how much of source currency equals 1 unit of target currency (BTC). + #[serde(default)] + pub conversion_rate: Option, + /// The payment amount in the source currency + pub amount: Amount, + /// Lightning Network routing fee charged by the network + #[serde(default)] + pub lightning_network_fee: Option, + /// Total fee including all applicable fees + #[serde(default)] + pub total_fee: Option, + /// Total amount the sender will spend (amount + all fees) + pub total_amount: Amount, + /// Optional reward amount (e.g., cashback) + #[serde(default)] + pub reward: Option, +} + +/// Response from executing a payment +/// +/// Returned after executing a payment quote. The payment state indicates +/// whether the payment is PENDING (awaiting blockchain confirmation) or +/// COMPLETED (successfully delivered). +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoicePaymentResponse { + /// Unique payment identifier (UUID) + pub payment_id: String, + /// Current payment state: PENDING or COMPLETED + pub state: InvoiceState, + /// Completion timestamp (ISO 8601), present when state is COMPLETED + #[serde(default)] + pub completed: Option, + /// Currency conversion rate used for cross-currency payments + #[serde(default)] + pub conversion_rate: Option, + /// The payment amount in the source currency + pub amount: Amount, + /// Total fee charged for the payment + #[serde(default)] + pub total_fee: Option, + /// Lightning Network routing fee + #[serde(default)] + pub lightning_network_fee: Option, + /// Total amount spent (amount + all fees) + pub total_amount: Amount, + /// Optional reward earned (e.g., cashback) + #[serde(default)] + pub reward: Option, + /// Lightning-specific payment details + #[serde(default)] + pub lightning: Option, + /// On-chain payment details (for on-chain payments) + #[serde(default)] + pub onchain: Option, +} + +/// Lightning-specific payment details +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LightningPaymentDetails { + /// Optional network fee + #[serde(default)] + pub network_fee: Option, +} + +/// On-chain payment details +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OnchainPaymentDetails { + /// Optional transaction ID + #[serde(default)] + pub txn_id: Option, +} + +/// Currency exchange quote state +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize, Serialize)] +pub enum ExchangeQuoteState { + /// Quote newly created + New, + /// Exchange is pending + Pending, + /// Exchange completed + Completed, + /// Exchange failed + Failed, +} + +/// Request for a currency exchange quote +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +pub struct CurrencyExchangeQuoteRequest { + /// Currency to sell + pub sell: Currency, + /// Currency to buy + pub buy: Currency, + /// Amount to exchange + pub amount: ExchangeAmount, +} + +/// Amount for currency exchange +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExchangeAmount { + /// Amount as string + pub amount: String, + /// Currency type + pub currency: Currency, + /// Optional fee policy + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_policy: Option, +} + +/// Response from creating a currency exchange quote +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CurrencyExchangeQuoteResponse { + /// Unique quote identifier + pub id: String, + /// Creation timestamp (ISO 8601) + pub created: String, + /// Quote expiration timestamp (ISO 8601) + pub valid_until: String, + /// Source amount + pub source: Amount, + /// Target amount + pub target: Amount, + /// Optional fee amount + #[serde(default)] + pub fee: Option, + /// Conversion rate + pub conversion_rate: ConversionRate, + /// Current quote state + pub state: ExchangeQuoteState, + /// Completion timestamp (ISO 8601) + #[serde(default)] + pub completed: Option, +} + +/// Filter operation for OData queries +#[derive(Debug, Clone, Copy)] +pub enum FilterOp { + /// Equal + Eq, + /// Not equal + Ne, + /// Greater than + Gt, + /// Less than + Lt, + /// Greater than or equal + Ge, + /// Less than or equal + Le, +} + +impl fmt::Display for FilterOp { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + FilterOp::Eq => write!(f, "eq"), + FilterOp::Ne => write!(f, "ne"), + FilterOp::Gt => write!(f, "gt"), + FilterOp::Lt => write!(f, "lt"), + FilterOp::Ge => write!(f, "ge"), + FilterOp::Le => write!(f, "le"), + } + } +} + +/// Filter for OData queries +#[derive(Debug, Clone)] +pub struct Filter { + /// Field name to filter on + pub field: &'static str, + /// Filter operation + pub op: FilterOp, + /// Filter value + pub value: String, +} + +impl Filter { + /// Create an equality filter + pub fn eq(field: &'static str, value: impl ToString) -> Self { + Self { + field, + op: FilterOp::Eq, + value: value.to_string(), + } + } + + /// Create a not-equal filter + pub fn ne(field: &'static str, value: impl ToString) -> Self { + Self { + field, + op: FilterOp::Ne, + value: value.to_string(), + } + } +} + +impl fmt::Display for Filter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} {} '{}'", self.field, self.op, self.value) + } +} + +/// Query parameters for listing invoices +#[derive(Clone, Debug, Default)] +pub struct InvoiceQueryParams { + /// List of filters to apply + pub filters: Vec, + /// Optional sort order + pub orderby: Option, + /// Number of results to skip + pub skip: Option, + /// Maximum number of results + pub top: Option, +} + +impl InvoiceQueryParams { + /// Create new empty query params + pub fn new() -> Self { + Self::default() + } + + /// Add a filter + pub fn filter(mut self, filter: Filter) -> Self { + self.filters.push(filter); + self + } + + /// Set the sort order + pub fn orderby(mut self, orderby: impl Into) -> Self { + self.orderby = Some(orderby.into()); + self + } + + /// Set the number of results to skip + pub fn skip(mut self, skip: i32) -> Self { + self.skip = Some(skip); + self + } + + /// Set the maximum number of results (max 100) + pub fn top(mut self, top: i32) -> Self { + self.top = Some(top.min(100)); // Max 100 + self + } + + /// Convert to OData query string + pub fn to_query_string(&self) -> String { + let mut parts = Vec::new(); + + if !self.filters.is_empty() { + let filter_str = self + .filters + .iter() + .map(|f| f.to_string()) + .collect::>() + .join(" and "); + parts.push(format!("$filter={}", urlencoding::encode(&filter_str))); + } + + if let Some(ref orderby) = self.orderby { + parts.push(format!("$orderby={}", urlencoding::encode(orderby))); + } + + if let Some(skip) = self.skip { + parts.push(format!("$skip={}", skip)); + } + + if let Some(top) = self.top { + parts.push(format!("$top={}", top)); + } + + if parts.is_empty() { + String::new() + } else { + format!("?{}", parts.join("&")) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_amount_from_sats() { + let amount = Amount::from_sats(100_000_000); + assert_eq!(amount.to_sats().unwrap(), 100_000_000); + } + + #[test] + fn test_query_params() { + let params = InvoiceQueryParams::new() + .filter(Filter::eq("state", "PAID")) + .top(10); + + let qs = params.to_query_string(); + assert!(qs.contains("$filter=")); + assert!(qs.contains("$top=10")); + } +} diff --git a/crates/cdk-strike/src/api/webhook.rs b/crates/cdk-strike/src/api/webhook.rs new file mode 100644 index 0000000000..2bb329d6e5 --- /dev/null +++ b/crates/cdk-strike/src/api/webhook.rs @@ -0,0 +1,294 @@ +//! Strike webhook handling +//! +//! Webhooks are HTTP-based notifications that Strike sends when specific events +//! occur, such as invoice state changes or payment completions. +//! +//! See for the official documentation. +//! +//! # Signature Verification +//! +//! All webhooks are signed using HMAC-SHA256 for authentication: +//! +//! 1. Extract signature from `X-Webhook-Signature` header (hex-encoded) +//! 2. Compute HMAC-SHA256 of the raw request body using your webhook secret +//! 3. Compare signatures using timing-safe comparison to prevent timing attacks +//! +//! See for details. +//! +//! # Event Types +//! +//! This module handles subscriptions for: +//! - `invoice.updated` - Invoice state changes (UNPAID → PAID) +//! - `currency-exchange-quote.updated` - Exchange quote state changes +//! +//! Other available event types (not currently used): +//! - `payment.created`, `payment.updated` +//! - `receive-request.receive-pending`, `receive-request.receive-completed` +//! +//! # Webhook Payload +//! +//! ```json +//! { +//! "id": "event-uuid", +//! "eventType": "invoice.updated", +//! "webhookVersion": "v1", +//! "data": { +//! "entityId": "invoice-or-quote-uuid", +//! "changes": ["state"] +//! }, +//! "created": "2024-01-15T12:00:00Z", +//! "deliverySuccess": true +//! } +//! ``` +//! +//! **Important:** The webhook payload does NOT contain the new state value. +//! You must fetch the entity via the API to get the current state. + +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::post, + Router, +}; +use ring::hmac; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tracing::{debug, warn}; + +/// Webhook subscription request sent to Strike API +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookRequest { + /// URL to receive webhook notifications + pub webhook_url: String, + /// Webhook API version (currently "v1") + pub webhook_version: String, + /// Secret for HMAC signature verification + pub secret: String, + /// Whether the webhook is enabled + pub enabled: bool, + /// Event types to subscribe to + pub event_types: Vec, +} + +/// Webhook subscription info response from Strike API +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookInfoResponse { + /// Unique subscription identifier + pub id: String, + /// URL receiving webhook notifications + pub webhook_url: String, + /// Webhook API version + pub webhook_version: String, + /// Whether the webhook is enabled + pub enabled: bool, + /// Subscribed event types + pub event_types: Vec, +} + +/// Webhook event payload received from Strike +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookEvent { + /// Unique event identifier + pub id: String, + /// Event type (e.g., "invoice.updated") + pub event_type: String, + /// Webhook API version + pub webhook_version: String, + /// Event data payload + pub data: WebhookData, + /// Event creation timestamp (ISO 8601) + pub created: String, + /// Whether delivery was successful (for retries) + #[serde(default)] + pub delivery_success: Option, +} + +/// Webhook event data payload +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookData { + /// ID of the affected entity (invoice or quote) + pub entity_id: String, + /// List of changed fields + #[serde(default)] + pub changes: Vec, +} + +/// State for webhook handlers +#[derive(Clone)] +pub struct WebhookState { + /// Channel sender for forwarding entity IDs + pub sender: mpsc::Sender, + /// Secret for signature verification + pub secret: String, +} + +/// Verify webhook request signature using HMAC-SHA256 +fn verify_signature(signature: &str, body: &[u8], secret: &[u8]) -> anyhow::Result<()> { + let key = hmac::Key::new(hmac::HMAC_SHA256, secret); + + let signature_bytes = + hex::decode(signature).map_err(|_| anyhow::anyhow!("Invalid signature hex"))?; + + hmac::verify(&key, body, &signature_bytes).map_err(|_| anyhow::anyhow!("Invalid signature"))?; + + Ok(()) +} + +/// Middleware to verify webhook signatures +async fn verify_request_body( + State(state): State, + request: Request, + next: Next, +) -> Result { + // Extract signature header + let signature = request + .headers() + .get("X-Webhook-Signature") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + warn!("Missing X-Webhook-Signature header"); + (StatusCode::UNAUTHORIZED, "Missing signature").into_response() + })? + .to_string(); + + // Collect body bytes + let (parts, body) = request.into_parts(); + let bytes = axum::body::to_bytes(body, 1024 * 1024).await.map_err(|e| { + warn!("Failed to read request body: {}", e); + (StatusCode::BAD_REQUEST, "Invalid body").into_response() + })?; + + // Verify signature + if let Err(e) = verify_signature(&signature, &bytes, state.secret.as_bytes()) { + warn!("Webhook signature verification failed: {}", e); + return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into_response()); + } + + debug!("Webhook signature verified"); + + // Reconstruct request with body + let request = Request::from_parts(parts, Body::from(bytes)); + Ok(next.run(request).await) +} + +/// Handle invoice webhook events +async fn handle_invoice_webhook( + State(state): State, + body: axum::body::Bytes, +) -> impl IntoResponse { + let event: WebhookEvent = match serde_json::from_slice(&body) { + Ok(e) => e, + Err(e) => { + warn!("Failed to parse webhook event: {}", e); + return StatusCode::BAD_REQUEST; + } + }; + + debug!( + "Received invoice webhook: {} - {}", + event.event_type, event.data.entity_id + ); + + // Send entity ID to channel + if let Err(e) = state.sender.send(event.data.entity_id).await { + warn!("Failed to send webhook event to channel: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR; + } + + StatusCode::OK +} + +/// Handle currency exchange webhook events +async fn handle_exchange_webhook( + State(state): State, + body: axum::body::Bytes, +) -> impl IntoResponse { + let event: WebhookEvent = match serde_json::from_slice(&body) { + Ok(e) => e, + Err(e) => { + warn!("Failed to parse webhook event: {}", e); + return StatusCode::BAD_REQUEST; + } + }; + + debug!( + "Received exchange webhook: {} - {}", + event.event_type, event.data.entity_id + ); + + // Send entity ID to channel + if let Err(e) = state.sender.send(event.data.entity_id).await { + warn!("Failed to send webhook event to channel: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR; + } + + StatusCode::OK +} + +/// Create an Axum router for invoice webhooks +/// +/// The router handles POST requests to the specified endpoint, verifies +/// the webhook signature, and forwards the entity ID to the provided channel. +pub fn create_invoice_webhook_router( + endpoint: &str, + sender: mpsc::Sender, + secret: String, +) -> Router { + let state = WebhookState { sender, secret }; + + Router::new() + .route(endpoint, post(handle_invoice_webhook)) + .layer(middleware::from_fn_with_state( + state.clone(), + verify_request_body, + )) + .with_state(state) +} + +/// Create an Axum router for currency exchange webhooks +/// +/// The router handles POST requests to the specified endpoint, verifies +/// the webhook signature, and forwards the entity ID to the provided channel. +pub fn create_exchange_webhook_router( + endpoint: &str, + sender: mpsc::Sender, + secret: String, +) -> Router { + let state = WebhookState { sender, secret }; + + Router::new() + .route(endpoint, post(handle_exchange_webhook)) + .layer(middleware::from_fn_with_state( + state.clone(), + verify_request_body, + )) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_verify_signature() { + // Generate a test signature + let secret = b"test_secret"; + let body = b"test body"; + let key = hmac::Key::new(hmac::HMAC_SHA256, secret); + let tag = hmac::sign(&key, body); + let signature = hex::encode(tag.as_ref()); + + // Verify should succeed + assert!(verify_signature(&signature, body, secret).is_ok()); + + // Wrong body should fail + assert!(verify_signature(&signature, b"wrong body", secret).is_err()); + } +} diff --git a/crates/cdk-strike/src/error.rs b/crates/cdk-strike/src/error.rs index 51db70f16c..fcfa88a3df 100644 --- a/crates/cdk-strike/src/error.rs +++ b/crates/cdk-strike/src/error.rs @@ -1,6 +1,6 @@ //! Error for Strike ln backend -use strike_rs::Error as StrikeRsError; +use crate::api::error::Error as StrikeApiError; use thiserror::Error; /// Strike Error @@ -15,9 +15,9 @@ pub enum Error { /// Unsupported unit #[error("Unsupported unit")] UnsupportedUnit, - /// Strike-rs error + /// Strike API error #[error(transparent)] - StrikeRs(#[from] StrikeRsError), + StrikeApi(#[from] StrikeApiError), /// Anyhow error #[error(transparent)] Anyhow(#[from] anyhow::Error), diff --git a/crates/cdk-strike/src/lib.rs b/crates/cdk-strike/src/lib.rs index 7ef61dffe7..b6f3598c0b 100644 --- a/crates/cdk-strike/src/lib.rs +++ b/crates/cdk-strike/src/lib.rs @@ -10,6 +10,13 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, bail}; +use api::{ + types::{ + Amount as StrikeAmount, Currency as StrikeCurrencyUnit, Filter, InvoiceQueryParams, + InvoiceRequest, InvoiceState, PayInvoiceQuoteRequest, + }, + StrikeApi, +}; use async_trait::async_trait; use axum::Router; use cdk_common::amount::Amount; @@ -25,15 +32,12 @@ use cdk_common::Bolt11Invoice; use error::Error; use futures::stream::StreamExt; use futures::Stream; -use strike_rs::{ - Amount as StrikeAmount, Currency as StrikeCurrencyUnit, InvoiceQueryParams, InvoiceRequest, - InvoiceState, PayInvoiceQuoteRequest, Strike as StrikeApi, -}; use tokio::sync::Mutex; use tokio_stream::wrappers::BroadcastStream; use tokio_util::sync::CancellationToken; use uuid::Uuid; +pub mod api; pub mod error; const CORRELATION_ID_PREFIX: &str = "TXID:"; @@ -119,7 +123,7 @@ impl Strike { webhook_url: String, kv_store: DynKVStore, ) -> Result { - let strike_api = StrikeApi::new(&api_key, None).map_err(Error::from)?; + let strike_api = StrikeApi::new(&api_key, None, 30_000).map_err(Error::from)?; // Create broadcast channel for payment events (webhook notifications) let (sender, receiver) = tokio::sync::broadcast::channel::(1000); @@ -147,9 +151,9 @@ impl Strike { async fn lookup_invoice_by_correlation_id( &self, correlation_id: &str, - ) -> Result { - let query_params = InvoiceQueryParams::new() - .filter(strike_rs::Filter::eq("correlationId", correlation_id)); + ) -> Result { + let query_params = + InvoiceQueryParams::new().filter(Filter::eq("correlationId", correlation_id)); let invoice_list = self .strike_api @@ -340,7 +344,7 @@ impl Strike { async fn handle_internal_payment_quote( &self, - internal_invoice: strike_rs::InvoiceListItem, + internal_invoice: api::types::InvoiceListItem, correlation_id: &str, ) -> Result { let amount = Strike::from_strike_amount(internal_invoice.amount, &self.unit)?; @@ -367,7 +371,7 @@ impl Strike { InvoiceState::Paid | InvoiceState::Completed => MeltQuoteState::Paid, InvoiceState::Unpaid => MeltQuoteState::Unpaid, InvoiceState::Pending => MeltQuoteState::Pending, - InvoiceState::Failed => MeltQuoteState::Failed, + InvoiceState::Failed | InvoiceState::Cancelled => MeltQuoteState::Failed, }; let total_spent = Strike::from_strike_amount(internal_invoice.amount, &self.unit)?; @@ -391,7 +395,7 @@ impl Strike { InvoiceState::Paid | InvoiceState::Completed => MeltQuoteState::Paid, InvoiceState::Unpaid => MeltQuoteState::Unpaid, InvoiceState::Pending => MeltQuoteState::Pending, - InvoiceState::Failed => MeltQuoteState::Failed, + InvoiceState::Failed | InvoiceState::Cancelled => MeltQuoteState::Failed, }; let total_spent = Strike::from_strike_amount(invoice.total_amount, &self.unit)?; @@ -403,7 +407,7 @@ impl Strike { total_spent: Amount::new(total_spent, self.unit.clone()), }) } - Err(strike_rs::Error::NotFound) => Ok(MakePaymentResponse { + Err(api::error::Error::NotFound) => Ok(MakePaymentResponse { payment_lookup_id: payment_identifier.clone(), payment_proof: None, status: MeltQuoteState::Unknown, @@ -517,6 +521,7 @@ impl MintPayment for Strike { let payment_quote_request = PayInvoiceQuoteRequest { ln_invoice: bolt11.to_string(), source_currency, + amount: None, }; let quote = self @@ -565,6 +570,7 @@ impl MintPayment for Strike { let payment_quote_request = PayInvoiceQuoteRequest { ln_invoice: bolt11.to_string(), source_currency, + amount: None, }; let quote = self @@ -583,7 +589,7 @@ impl MintPayment for Strike { InvoiceState::Paid | InvoiceState::Completed => MeltQuoteState::Paid, InvoiceState::Unpaid => MeltQuoteState::Unpaid, InvoiceState::Pending => MeltQuoteState::Pending, - InvoiceState::Failed => MeltQuoteState::Failed, + InvoiceState::Failed | InvoiceState::Cancelled => MeltQuoteState::Failed, }; let total_spent = Strike::from_strike_amount(pay_response.total_amount, unit)?; @@ -693,7 +699,10 @@ impl MintPayment for Strike { payment_id: invoice.invoice_id, }]) } - InvoiceState::Unpaid | InvoiceState::Pending | InvoiceState::Failed => Ok(vec![]), + InvoiceState::Unpaid + | InvoiceState::Pending + | InvoiceState::Failed + | InvoiceState::Cancelled => Ok(vec![]), }, Err(err) => { tracing::error!( @@ -747,7 +756,7 @@ impl Strike { } /// Create invoice webhook router - pub async fn create_invoice_webhook(&self, webhook_endpoint: &str) -> anyhow::Result { + pub fn create_invoice_webhook(&self, webhook_endpoint: &str) -> anyhow::Result { // Create an adapter channel to bridge mpsc -> broadcast let (mpsc_sender, mut mpsc_receiver) = tokio::sync::mpsc::channel::(1000); let broadcast_sender = self.sender(); @@ -764,9 +773,11 @@ impl Strike { } }); - self.strike_api - .create_invoice_webhook_router(webhook_endpoint, mpsc_sender) - .await + Ok(api::webhook::create_invoice_webhook_router( + webhook_endpoint, + mpsc_sender, + self.strike_api.webhook_secret().to_string(), + )) } } @@ -815,13 +826,13 @@ mod tests { use std::collections::HashMap; use std::sync::Arc; + use crate::api::types::{Amount as StrikeAmount, Currency as StrikeCurrencyUnit}; use async_trait::async_trait; use cdk_common::database::Error as DatabaseError; use cdk_common::database::{ DbTransactionFinalizer, DynKVStore, KVStore, KVStoreDatabase, KVStoreTransaction, }; use cdk_common::nuts::CurrencyUnit; - use strike_rs::{Amount as StrikeAmount, Currency as StrikeCurrencyUnit}; use tokio::sync::Mutex; use uuid::Uuid;