From 6973e5fd2974bc77b1a7627b6623b48044df9423 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Fri, 6 Feb 2026 14:08:43 -0800 Subject: [PATCH 1/7] fix: add Prefer header to CORS allowed headers The Prefer header is used by NUT-20 for long-polling support but was being blocked by the CORS middleware. Add it to the allowed headers list for both auth and non-auth configurations. --- crates/cdk-axum/src/lib.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index 4f5846a9b9..46cc7ad43e 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -228,9 +228,9 @@ async fn cors_middleware( next: axum::middleware::Next, ) -> Response { #[cfg(feature = "auth")] - let allowed_headers = "Content-Type, Clear-auth, Blind-auth"; + let allowed_headers = "Content-Type, Clear-auth, Blind-auth, Prefer"; #[cfg(not(feature = "auth"))] - let allowed_headers = "Content-Type"; + let allowed_headers = "Content-Type, Prefer"; // Handle preflight requests if req.method() == axum::http::Method::OPTIONS { From bcbfaf1e5cb097240f8ae9f5b20dce26240f7177 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Fri, 6 Feb 2026 16:46:16 -0800 Subject: [PATCH 2/7] refactor: remove KV store dependency and simplify webhook management Drop internal payment/settlement tracking and correlation ID embedding which were unused complexity. Add webhook subscription rotation to prevent hitting Strike's 50-subscription limit, and validate webhook URLs require HTTPS. --- crates/cdk-mintd/src/lib.rs | 8 +- crates/cdk-mintd/src/setup.rs | 10 +- crates/cdk-strike/src/api/error.rs | 22 +- crates/cdk-strike/src/api/mod.rs | 83 ++++- crates/cdk-strike/src/api/webhook.rs | 10 + crates/cdk-strike/src/lib.rs | 459 ++++++--------------------- 6 files changed, 190 insertions(+), 402 deletions(-) diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 59960c0910..9372c423b6 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -599,12 +599,8 @@ async fn configure_lightning_backend( 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?; + let (strike, webhook_router) = + strike_settings.setup(settings, unit.clone()).await?; webhook_routers.push(webhook_router); diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index 109da6c0ac..af98dd3b32 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -371,7 +371,6 @@ impl config::Strike { &self, settings: &Settings, unit: CurrencyUnit, - kv_store: cdk_common::database::DynKVStore, ) -> anyhow::Result<(cdk_strike::Strike, axum::Router)> { use cdk::mint_url::MintUrl; @@ -389,13 +388,8 @@ impl config::Strike { } }; - let strike = cdk_strike::Strike::new( - self.api_key.clone(), - unit, - webhook_url.to_string(), - kv_store, - ) - .await?; + let strike = + cdk_strike::Strike::new(self.api_key.clone(), unit, webhook_url.to_string()).await?; let webhook_router = strike.create_invoice_webhook(&webhook_endpoint)?; diff --git a/crates/cdk-strike/src/api/error.rs b/crates/cdk-strike/src/api/error.rs index 146d977da0..b4c1655620 100644 --- a/crates/cdk-strike/src/api/error.rs +++ b/crates/cdk-strike/src/api/error.rs @@ -65,6 +65,10 @@ pub enum Error { /// Strike API returned an error response #[error("Strike API error: {0}")] Api(#[from] StrikeApiError), + + /// Webhook URL must use HTTPS + #[error("Webhook URL must use HTTPS, got: {0}")] + WebhookUrlNotHttps(String), } /// Detailed Strike API error response @@ -286,24 +290,6 @@ pub enum StrikeErrorCode { } 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: diff --git a/crates/cdk-strike/src/api/mod.rs b/crates/cdk-strike/src/api/mod.rs index 83f1e63f4e..95b04296a1 100644 --- a/crates/cdk-strike/src/api/mod.rs +++ b/crates/cdk-strike/src/api/mod.rs @@ -209,8 +209,24 @@ impl StrikeApi { } 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)?; + if error.is_retryable() { + warn!( + "Strike: API error (retryable): {} - {} [code: {:?}, trace: {:?}]", + status, + error.message(), + error.code(), + error.trace_id + ); + } else { + warn!( + "Strike: API error: {} - {} [code: {:?}, trace: {:?}]", + status, + error.message(), + error.code(), + error.trace_id + ); + } Err(Error::Api(error)) } } @@ -324,8 +340,17 @@ impl StrikeApi { // ==================== Webhook Endpoints ==================== + /// Validate that a webhook URL uses HTTPS + fn validate_webhook_url(url: &str) -> Result<(), Error> { + if url.starts_with("http://") { + return Err(Error::WebhookUrlNotHttps(url.to_string())); + } + Ok(()) + } + /// Subscribe to invoice webhooks pub async fn subscribe_to_invoice_webhook(&self, webhook_url: String) -> Result<(), Error> { + Self::validate_webhook_url(&webhook_url)?; let request = webhook::WebhookRequest { webhook_url, webhook_version: "v1".to_string(), @@ -343,6 +368,7 @@ impl StrikeApi { &self, webhook_url: String, ) -> Result<(), Error> { + Self::validate_webhook_url(&webhook_url)?; let request = webhook::WebhookRequest { webhook_url, webhook_version: "v1".to_string(), @@ -368,6 +394,61 @@ impl StrikeApi { self.delete(&format!("/v1/subscriptions/{}", webhook_id)) .await } + + /// Rotate webhook subscription: delete existing ones for this URL, create fresh one + /// + /// This method ensures only one subscription exists for the given webhook URL by: + /// 1. Fetching all current subscriptions + /// 2. Deleting any that match the webhook URL + /// 3. Creating a fresh subscription with the current secret + /// + /// This prevents accumulating orphaned subscriptions (Strike has a 50 subscription limit). + pub async fn rotate_webhook_subscription( + &self, + webhook_url: &str, + event_types: Vec, + ) -> Result<(), Error> { + Self::validate_webhook_url(webhook_url)?; + + // Get current subscriptions + let subscriptions = match self.get_current_subscriptions().await { + Ok(subs) => subs, + Err(e) => { + warn!( + "Failed to get current subscriptions, will attempt to create new: {}", + e + ); + vec![] + } + }; + + // Delete any matching our webhook URL + for sub in subscriptions { + if sub.webhook_url == webhook_url { + tracing::info!( + "Deleting existing webhook subscription {} for {}", + sub.id, + webhook_url + ); + if let Err(e) = self.delete_subscription(&sub.id).await { + warn!("Failed to delete subscription {}: {}", sub.id, e); + } + } + } + + // Create fresh subscription + let request = webhook::WebhookRequest { + webhook_url: webhook_url.to_string(), + webhook_version: "v1".to_string(), + secret: self.webhook_secret.clone(), + enabled: true, + event_types, + }; + + self.post("/v1/subscriptions", &request).await?; + tracing::info!("Created new webhook subscription for {}", webhook_url); + Ok(()) + } } #[cfg(test)] diff --git a/crates/cdk-strike/src/api/webhook.rs b/crates/cdk-strike/src/api/webhook.rs index 2bb329d6e5..157463d2d7 100644 --- a/crates/cdk-strike/src/api/webhook.rs +++ b/crates/cdk-strike/src/api/webhook.rs @@ -191,6 +191,11 @@ async fn handle_invoice_webhook( } }; + if event.event_type != "invoice.updated" { + debug!("Ignoring non-invoice webhook event: {}", event.event_type); + return StatusCode::OK; + } + debug!( "Received invoice webhook: {} - {}", event.event_type, event.data.entity_id @@ -218,6 +223,11 @@ async fn handle_exchange_webhook( } }; + if event.event_type != "currency-exchange-quote.updated" { + debug!("Ignoring non-exchange webhook event: {}", event.event_type); + return StatusCode::OK; + } + debug!( "Received exchange webhook: {} - {}", event.event_type, event.data.entity_id diff --git a/crates/cdk-strike/src/lib.rs b/crates/cdk-strike/src/lib.rs index b6f3598c0b..53dd380b18 100644 --- a/crates/cdk-strike/src/lib.rs +++ b/crates/cdk-strike/src/lib.rs @@ -9,18 +9,17 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; -use anyhow::{anyhow, bail}; +use anyhow::bail; use api::{ types::{ - Amount as StrikeAmount, Currency as StrikeCurrencyUnit, Filter, InvoiceQueryParams, - InvoiceRequest, InvoiceState, PayInvoiceQuoteRequest, + Amount as StrikeAmount, Currency as StrikeCurrencyUnit, InvoiceRequest, InvoiceState, + PayInvoiceQuoteRequest, }, StrikeApi, }; use async_trait::async_trait; use axum::Router; use cdk_common::amount::Amount; -use cdk_common::database::DynKVStore; use cdk_common::nuts::{CurrencyUnit, MeltQuoteState}; use cdk_common::payment::{ self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, @@ -40,33 +39,9 @@ use uuid::Uuid; pub mod api; 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 { @@ -82,12 +57,11 @@ pub struct Strike { 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: DynKVStore, + webhook_subscribed: Arc, } impl std::fmt::Debug for Strike { @@ -103,6 +77,10 @@ impl std::fmt::Debug for Strike { "webhook_mode_active", &self.webhook_mode_active.load(Ordering::SeqCst), ) + .field( + "webhook_subscribed", + &self.webhook_subscribed.load(Ordering::SeqCst), + ) .field( "pending_invoices_count", &self @@ -121,25 +99,22 @@ impl Strike { api_key: String, unit: CurrencyUnit, webhook_url: String, - kv_store: DynKVStore, ) -> Result { 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); - let receiver = Arc::new(receiver); + let (sender, _receiver) = tokio::sync::broadcast::channel::(1000); 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, + webhook_subscribed: Arc::new(AtomicBool::new(false)), }) } @@ -148,27 +123,6 @@ impl Strike { self.sender.clone() } - async fn lookup_invoice_by_correlation_id( - &self, - correlation_id: &str, - ) -> Result { - let query_params = - InvoiceQueryParams::new().filter(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, @@ -341,81 +295,6 @@ impl Strike { 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: api::types::InvoiceListItem, - correlation_id: &str, - ) -> Result { - let amount = Strike::from_strike_amount(internal_invoice.amount, &self.unit)?; - Ok(PaymentQuoteResponse { - request_lookup_id: Some(PaymentIdentifier::CustomId(format!( - "internal:{}", - correlation_id - ))), - amount: Amount::new(amount, self.unit.clone()), - fee: Amount::new(0, self.unit.clone()), - 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 | InvoiceState::Cancelled => MeltQuoteState::Failed, - }; - - 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: Amount::new(total_spent, 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 | InvoiceState::Cancelled => MeltQuoteState::Failed, - }; - - 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: Amount::new(total_spent, self.unit.clone()), - }) - } - Err(api::error::Error::NotFound) => Ok(MakePaymentResponse { - payment_lookup_id: payment_identifier.clone(), - payment_proof: None, - status: MeltQuoteState::Unknown, - total_spent: Amount::new(0, self.unit.clone()), - }), - Err(err) => Err(Error::from(err).into()), - } - } } #[async_trait] @@ -442,6 +321,7 @@ impl MintPayment for Strike { fn cancel_wait_invoice(&self) { self.wait_invoice_cancel_token.cancel(); self.webhook_mode_active.store(false, Ordering::SeqCst); + self.webhook_subscribed.store(false, Ordering::SeqCst); } #[allow(clippy::incompatible_msrv)] @@ -450,7 +330,7 @@ impl MintPayment for Strike { ) -> Result + Send>>, Self::Err> { tracing::info!("Starting Strike payment event stream"); - let receiver = self.receiver.resubscribe(); + let receiver = self.sender.subscribe(); let strike_api = self.strike_api.clone(); let cancel_token = self.wait_invoice_cancel_token.clone(); @@ -460,19 +340,53 @@ impl MintPayment for Strike { self.wait_invoice_is_active.store(true, Ordering::SeqCst); - // Try webhook subscription first, fallback to polling + // If already subscribed this session, reuse webhook mode + if self.webhook_subscribed.load(Ordering::SeqCst) { + tracing::debug!("Reusing existing webhook subscription"); + self.webhook_mode_active.store(true, Ordering::SeqCst); + return Ok(self.create_webhook_stream( + receiver, + cancel_token, + is_active, + strike_api, + unit, + )); + } + + // Try to rotate/create subscription (deletes old ones for this URL first) match self .strike_api - .subscribe_to_invoice_webhook(self.webhook_url.clone()) + .rotate_webhook_subscription(&self.webhook_url, vec!["invoice.updated".to_string()]) .await { Ok(_) => { tracing::info!("Using webhook mode for payment events"); + self.webhook_subscribed.store(true, Ordering::SeqCst); 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"); + Err(err) => { + match &err { + api::error::Error::Api(api_err) if api_err.is_rate_limit_error() => { + tracing::warn!( + "Strike: Webhook subscription rate limited, using polling mode [trace: {:?}]", + api_err.trace_id + ); + } + api::error::Error::Api(api_err) if api_err.is_retryable() => { + tracing::warn!( + "Strike: Webhook subscription failed (transient), using polling mode: {} [trace: {:?}]", + api_err.message(), + api_err.trace_id + ); + } + _ => { + tracing::warn!( + "Strike: Webhook subscription failed, using polling mode: {}", + err + ); + } + } self.webhook_mode_active.store(false, Ordering::SeqCst); Ok(self.create_polling_stream( receiver, @@ -502,22 +416,8 @@ impl MintPayment for Strike { 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, correlation_id) - .await; - } - } - - // Regular Lightning payment quote let payment_quote_request = PayInvoiceQuoteRequest { ln_invoice: bolt11.to_string(), source_currency, @@ -624,7 +524,6 @@ impl MintPayment for Strike { 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(), )); @@ -637,7 +536,11 @@ impl MintPayment for Strike { let invoice_request = InvoiceRequest { correlation_id: Some(correlation_id.to_string()), amount: strike_amount, - description: Some(create_invoice_description(&description, &correlation_id)), + description: if description.is_empty() { + None + } else { + Some(description) + }, }; let create_invoice_response = self @@ -680,41 +583,19 @@ impl MintPayment for Strike { .await .map_err(Error::from)?; - match self.check_internal_settlement(&request_lookup_id).await { - Ok(true) => { + 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::new(amount, self.unit.clone()), - payment_id: request_lookup_id, + payment_id: invoice.invoice_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::new(amount, self.unit.clone()), - payment_id: invoice.invoice_id, - }]) - } - InvoiceState::Unpaid - | InvoiceState::Pending - | InvoiceState::Failed - | InvoiceState::Cancelled => 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 - ))); - } + InvoiceState::Unpaid + | InvoiceState::Pending + | InvoiceState::Failed + | InvoiceState::Cancelled => Ok(vec![]), } } @@ -723,38 +604,40 @@ impl MintPayment for Strike { 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)); + let payment_id = payment_lookup_id + .strip_prefix("payment:") + .unwrap_or(&payment_lookup_id); - match label { - "internal" => self.check_internal_payment(payment_identifier, id).await, - _ => self.check_regular_payment(payment_identifier, id).await, + 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 | InvoiceState::Cancelled => MeltQuoteState::Failed, + }; + + 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: Amount::new(total_spent, self.unit.clone()), + }) + } + Err(api::error::Error::NotFound) => Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: None, + status: MeltQuoteState::Unknown, + total_spent: Amount::new(0, self.unit.clone()), + }), + Err(err) => Err(Error::from(err).into()), } } } impl Strike { - /// 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 fn create_invoice_webhook(&self, webhook_endpoint: &str) -> anyhow::Result { // Create an adapter channel to bridge mpsc -> broadcast @@ -822,168 +705,11 @@ impl Strike { #[cfg(test)] mod tests { - // Mock KV store for testing - 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 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 KVStoreTransaction 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( - &self, - ) -> Result + Send + Sync>, DatabaseError> { - Ok(Box::new(MockKVTransaction { - store: Arc::new(MockKVStore { - data: self.data.clone(), - }), - changes: HashMap::new(), - })) - } - } - - fn create_mock_kv_store() -> DynKVStore { - 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!( @@ -1058,15 +784,12 @@ mod tests { assert_eq!(original_sats, converted_back); } - // 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; @@ -1078,12 +801,10 @@ mod tests { #[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(); From 08195874fac7751402d10c5302c5cefb0cc90257 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 9 Feb 2026 12:06:18 -0800 Subject: [PATCH 3/7] feat: init closed loop config --- crates/cashu/src/nuts/mod.rs | 2 +- crates/cashu/src/nuts/nut06.rs | 19 ++++++++++ crates/cdk-ffi/src/types/mint.rs | 27 +++++++++++++ .../src/bin/start_regtest_mints.rs | 1 + crates/cdk-integration-tests/src/shared.rs | 3 ++ crates/cdk-mintd/example.config.toml | 7 ++++ crates/cdk-mintd/src/config.rs | 38 +++++++++++++++++++ crates/cdk-mintd/src/env_vars/agicash.rs | 37 ++++++++++++++++++ crates/cdk-mintd/src/env_vars/mod.rs | 6 +++ crates/cdk-mintd/src/lib.rs | 9 +++++ crates/cdk-sql-common/src/wallet/mod.rs | 2 + crates/cdk/src/mint/builder.rs | 6 +++ 12 files changed, 156 insertions(+), 1 deletion(-) create mode 100644 crates/cdk-mintd/src/env_vars/agicash.rs diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index b7e362ac25..cd08a1ea87 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -58,7 +58,7 @@ pub use nut05::{ MeltMethodSettings, MeltQuoteCustomRequest, MeltQuoteCustomResponse, MeltRequest, QuoteState as MeltQuoteState, Settings as NUT05Settings, }; -pub use nut06::{ContactInfo, MintInfo, MintVersion, Nuts}; +pub use nut06::{AgicashInfo, ContactInfo, MintInfo, MintVersion, Nuts}; pub use nut07::{CheckStateRequest, CheckStateResponse, ProofState, State}; pub use nut09::{RestoreRequest, RestoreResponse}; pub use nut10::{Kind, Secret as Nut10Secret, SecretData, SpendingConditionVerification}; diff --git a/crates/cashu/src/nuts/nut06.rs b/crates/cashu/src/nuts/nut06.rs index 77c1ebc119..a25f26d7e0 100644 --- a/crates/cashu/src/nuts/nut06.rs +++ b/crates/cashu/src/nuts/nut06.rs @@ -16,6 +16,14 @@ use super::{nut04, nut05, nut15, nut19, MppMethodSettings}; use super::{AuthRequired, BlindAuthSettings, ClearAuthSettings, ProtectedEndpoint}; use crate::CurrencyUnit; +/// Agicash-specific mint information +#[derive(Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct AgicashInfo { + /// Whether the mint operates in closed-loop mode + pub closed_loop: bool, +} + /// Mint Version #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] @@ -105,6 +113,9 @@ pub struct MintInfo { /// terms of url service of the mint #[serde(skip_serializing_if = "Option::is_none")] pub tos_url: Option, + /// Agicash-specific mint information + #[serde(skip_serializing_if = "Option::is_none")] + pub agicash: Option, } impl MintInfo { @@ -219,6 +230,14 @@ impl MintInfo { } } + /// Set agicash info + pub fn agicash(self, agicash: AgicashInfo) -> Self { + Self { + agicash: Some(agicash), + ..self + } + } + /// Get protected endpoints #[cfg(feature = "auth")] pub fn protected_endpoints(&self) -> HashMap { diff --git a/crates/cdk-ffi/src/types/mint.rs b/crates/cdk-ffi/src/types/mint.rs index 97abb2b527..263f9884f2 100644 --- a/crates/cdk-ffi/src/types/mint.rs +++ b/crates/cdk-ffi/src/types/mint.rs @@ -600,6 +600,29 @@ pub fn encode_nuts(nuts: Nuts) -> Result { Ok(serde_json::to_string(&nuts)?) } +/// FFI-compatible AgicashInfo +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct AgicashInfo { + /// Whether the mint operates in closed-loop mode + pub closed_loop: bool, +} + +impl From for AgicashInfo { + fn from(info: cdk::nuts::AgicashInfo) -> Self { + Self { + closed_loop: info.closed_loop, + } + } +} + +impl From for cdk::nuts::AgicashInfo { + fn from(info: AgicashInfo) -> Self { + Self { + closed_loop: info.closed_loop, + } + } +} + /// FFI-compatible MintInfo #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct MintInfo { @@ -627,6 +650,8 @@ pub struct MintInfo { pub time: Option, /// terms of url service of the mint pub tos_url: Option, + /// Agicash-specific mint information + pub agicash: Option, } impl From for MintInfo { @@ -646,6 +671,7 @@ impl From for MintInfo { motd: info.motd, time: info.time, tos_url: info.tos_url, + agicash: info.agicash.map(Into::into), } } } @@ -669,6 +695,7 @@ impl From for cdk::nuts::MintInfo { motd: info.motd, time: info.time, tos_url: info.tos_url, + agicash: info.agicash.map(Into::into), } } } 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 f23ca594b4..64efd92229 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -300,6 +300,7 @@ fn create_ldk_settings( prometheus: None, auth: None, strike: None, + agicash: None, } } diff --git a/crates/cdk-integration-tests/src/shared.rs b/crates/cdk-integration-tests/src/shared.rs index 3666c50fa1..f670bc6a67 100644 --- a/crates/cdk-integration-tests/src/shared.rs +++ b/crates/cdk-integration-tests/src/shared.rs @@ -239,6 +239,7 @@ pub fn create_fake_wallet_settings( auth: None, prometheus: Some(Default::default()), strike: None, + agicash: None, } } @@ -290,6 +291,7 @@ pub fn create_cln_settings( auth: None, prometheus: Some(Default::default()), strike: None, + agicash: None, } } @@ -339,5 +341,6 @@ pub fn create_lnd_settings( auth: None, prometheus: Some(Default::default()), strike: None, + agicash: None, } } diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 9a047855f0..1ed89ee480 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -181,6 +181,13 @@ max_delay_time = 3 # Optional: Override webhook URL when mint is behind NAT or has different internal/external URLs # webhook_url = "https://your-public-domain.com" +# [agicash.closed_loop] +# Closed loop payment configuration +# When configured, only allows melting to invoices that match the specified criteria +# +# closed_loop_type = "square" +# valid_destination_name = "this mint" + # [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 20eb31c5ea..97513ba753 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -439,6 +439,43 @@ pub struct Strike { pub webhook_url: Option, } +/// Type of closed loop validation +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum ClosedLoopType { + /// Square payments - validates against Square payment system + #[default] + Square, +} + +impl std::str::FromStr for ClosedLoopType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "square" => Ok(ClosedLoopType::Square), + _ => Err(format!("Unknown closed loop type: {s}")), + } + } +} + +/// Closed loop payment configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClosedLoop { + /// Type of closed loop validation + #[serde(default)] + pub closed_loop_type: ClosedLoopType, + /// Valid destination name to display in error messages + pub valid_destination_name: String, +} + +/// Agicash configuration +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Agicash { + /// Closed loop payment configuration (None = disabled) + pub closed_loop: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "lowercase")] pub enum DatabaseEngine { @@ -591,6 +628,7 @@ pub struct Settings { pub prometheus: Option, #[cfg(feature = "strike")] pub strike: Option, + pub agicash: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/cdk-mintd/src/env_vars/agicash.rs b/crates/cdk-mintd/src/env_vars/agicash.rs new file mode 100644 index 0000000000..0cb665799d --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/agicash.rs @@ -0,0 +1,37 @@ +//! Agicash environment variables + +use std::env; + +use crate::config::{Agicash, ClosedLoop, ClosedLoopType}; + +pub const ENV_AGICASH_CLOSED_LOOP_TYPE: &str = "CDK_MINTD_AGICASH_CLOSED_LOOP_TYPE"; +pub const ENV_AGICASH_CLOSED_LOOP_VALID_DESTINATION: &str = + "CDK_MINTD_AGICASH_CLOSED_LOOP_VALID_DESTINATION"; + +impl Agicash { + pub fn from_env(mut self) -> Self { + let has_closed_loop_env = env::var(ENV_AGICASH_CLOSED_LOOP_TYPE).is_ok() + || env::var(ENV_AGICASH_CLOSED_LOOP_VALID_DESTINATION).is_ok(); + + if has_closed_loop_env { + let mut closed_loop = self.closed_loop.unwrap_or_else(|| ClosedLoop { + closed_loop_type: ClosedLoopType::Square, + valid_destination_name: String::new(), + }); + + if let Ok(loop_type_str) = env::var(ENV_AGICASH_CLOSED_LOOP_TYPE) { + if let Ok(loop_type) = loop_type_str.parse() { + closed_loop.closed_loop_type = loop_type; + } + } + + if let Ok(valid_dest) = env::var(ENV_AGICASH_CLOSED_LOOP_VALID_DESTINATION) { + closed_loop.valid_destination_name = valid_dest; + } + + self.closed_loop = Some(closed_loop); + } + + self + } +} diff --git a/crates/cdk-mintd/src/env_vars/mod.rs b/crates/cdk-mintd/src/env_vars/mod.rs index 84383918b5..8a29acc1cd 100644 --- a/crates/cdk-mintd/src/env_vars/mod.rs +++ b/crates/cdk-mintd/src/env_vars/mod.rs @@ -4,6 +4,7 @@ //! This module contains all environment variable definitions and parsing logic //! organized by component. +mod agicash; mod common; mod database; mod info; @@ -59,6 +60,7 @@ pub use mint_info::*; pub use prometheus::*; #[cfg(feature = "strike")] pub use strike::*; +pub use agicash::*; use crate::config::{DatabaseEngine, LnBackend, Settings}; @@ -99,6 +101,10 @@ impl Settings { self.mint_info = self.mint_info.clone().from_env(); self.ln = self.ln.clone().from_env(); + if let Some(agicash) = &self.agicash { + self.agicash = Some(agicash.clone().from_env()); + } + #[cfg(feature = "auth")] { // Check env vars for auth config even if None diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 9372c423b6..7ddbe150c8 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -440,6 +440,15 @@ fn configure_basic_info(settings: &config::Settings, mint_builder: MintBuilder) } } + // Set agicash info if closed loop is configured + if settings + .agicash + .as_ref() + .map_or(false, |a| a.closed_loop.is_some()) + { + builder = builder.with_agicash(cdk::nuts::AgicashInfo { closed_loop: true }); + } + builder } /// Configures Lightning Network backend based on the specified backend type diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs index 3ab67f7d8e..f522781a4b 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -1011,6 +1011,7 @@ where motd, time, tos_url, + .. } = mint_info; ( @@ -1349,6 +1350,7 @@ fn sql_row_to_mint_info(row: Vec) -> Result { motd: column_as_nullable_string!(motd), time: column_as_nullable_number!(mint_time).map(|t| t), tos_url: column_as_nullable_string!(tos_url), + agicash: None, }) } diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index 94c89dc9df..456843edb8 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -178,6 +178,12 @@ impl MintBuilder { self } + /// Set agicash info + pub fn with_agicash(mut self, agicash: crate::nuts::AgicashInfo) -> Self { + self.mint_info.agicash = Some(agicash); + self + } + /// Set description pub fn with_description(mut self, description: String) -> Self { self.mint_info.description = Some(description); From 6e6c4973b9cd067b0851ceddcb35bdaa7da61a39 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 11 Feb 2026 11:57:27 -0800 Subject: [PATCH 4/7] feat: add closed-loop Square payment validation (cdk-agicash) Add cdk-agicash crate that wraps Strike backend to restrict melts to invoices from a configured Square merchant. Validates by matching invoice description and timestamp against synced Square payments (polling + webhook + on-demand sync). Rejected invoices return PaymentNotAllowed (20739) with user-facing message. --- Cargo.lock | 336 +++++++++++++++- Cargo.toml | 1 + Dockerfile | 2 +- Dockerfile.arm | 2 +- crates/cdk-agicash/Cargo.toml | 36 ++ crates/cdk-agicash/src/config.rs | 36 ++ crates/cdk-agicash/src/credentials.rs | 105 +++++ crates/cdk-agicash/src/error.rs | 32 ++ crates/cdk-agicash/src/lib.rs | 227 +++++++++++ crates/cdk-agicash/src/square_api.rs | 149 ++++++++ crates/cdk-agicash/src/sync.rs | 463 +++++++++++++++++++++++ crates/cdk-agicash/src/types.rs | 195 ++++++++++ crates/cdk-agicash/src/webhook.rs | 180 +++++++++ crates/cdk-common/src/error.rs | 10 + crates/cdk-common/src/payment.rs | 3 + crates/cdk-mintd/Cargo.toml | 5 +- crates/cdk-mintd/example.config.toml | 11 + crates/cdk-mintd/src/config.rs | 35 ++ crates/cdk-mintd/src/env_vars/agicash.rs | 54 ++- crates/cdk-mintd/src/env_vars/mod.rs | 9 +- crates/cdk-mintd/src/lib.rs | 142 ++++++- 21 files changed, 2022 insertions(+), 11 deletions(-) create mode 100644 crates/cdk-agicash/Cargo.toml create mode 100644 crates/cdk-agicash/src/config.rs create mode 100644 crates/cdk-agicash/src/credentials.rs create mode 100644 crates/cdk-agicash/src/error.rs create mode 100644 crates/cdk-agicash/src/lib.rs create mode 100644 crates/cdk-agicash/src/square_api.rs create mode 100644 crates/cdk-agicash/src/sync.rs create mode 100644 crates/cdk-agicash/src/types.rs create mode 100644 crates/cdk-agicash/src/webhook.rs diff --git a/Cargo.lock b/Cargo.lock index 35769f9d99..ae91970295 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "amplify" version = "4.9.0" @@ -488,6 +494,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic" version = "0.5.3" @@ -1225,6 +1240,28 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cdk-agicash" +version = "0.14.0" +dependencies = [ + "async-trait", + "axum 0.8.8", + "base64 0.22.1", + "cdk-common", + "futures", + "hex", + "lightning-invoice 0.34.0", + "reqwest", + "ring 0.17.14", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "cdk-axum" version = "0.14.0" @@ -1512,6 +1549,7 @@ dependencies = [ "bip39", "bitcoin 0.32.8", "cdk", + "cdk-agicash", "cdk-axum", "cdk-cln", "cdk-common", @@ -1533,6 +1571,7 @@ dependencies = [ "lightning-invoice 0.34.0", "serde", "tokio", + "tokio-util", "tower 0.5.3", "tower-http", "tracing", @@ -2100,6 +2139,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -2561,6 +2615,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -2636,6 +2696,9 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "electrum-client" @@ -2771,6 +2834,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -2912,6 +2986,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3037,6 +3122,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -3253,6 +3349,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", + "allocator-api2", "serde", ] @@ -6744,6 +6841,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smawk" @@ -6782,6 +6882,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -6793,6 +6896,213 @@ dependencies = [ "der", ] +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27144619c6e5802f1380337a209d2ac1c431002dd74c6e60aebff3c506dc4f0c" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b" +dependencies = [ + "atoi", + "byteorder", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.14.5", + "hashlink 0.9.1", + "hex", + "indexmap 2.13.0", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.25.4", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23217eb7d86c584b8cbe0337b9eacf12ab76fe7673c513141ec42565698bb88" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.114", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a099220ae541c5db479c6424bdf1b200987934033c2584f79a0e1693601e776" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.114", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "tracing", + "url", +] + [[package]] name = "ssh-cipher" version = "0.2.0" @@ -7304,7 +7614,7 @@ dependencies = [ "socket2 0.6.1", "tokio", "tokio-util", - "whoami", + "whoami 2.0.2", ] [[package]] @@ -8591,6 +8901,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "uniffi" version = "0.29.5" @@ -8958,6 +9274,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasite" version = "1.0.2" @@ -9155,6 +9477,16 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite 0.1.0", +] + [[package]] name = "whoami" version = "2.0.2" @@ -9162,7 +9494,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace4d5c7b5ab3d99629156d4e0997edbe98a4beb6d5ba99e2cae830207a81983" dependencies = [ "libredox", - "wasite", + "wasite 1.0.2", "web-sys", ] diff --git a/Cargo.toml b/Cargo.toml index 0fb5a2c6a0..f97b7834a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,6 +123,7 @@ nostr-sdk = { version = "0.44.1", default-features = false, features = [ ]} cdk-strike = { path = "./crates/cdk-strike", version = "=0.14.0" } +cdk-agicash = { path = "./crates/cdk-agicash", version = "=0.14.0" } diff --git a/Dockerfile b/Dockerfile index ed06de19f2..7e4c991de6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ COPY Cargo.toml ./Cargo.toml COPY crates ./crates # Start the Nix daemon and develop the environment -RUN nix develop --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres --features prometheus +RUN nix develop --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres --features prometheus --features agicash # Create a runtime stage FROM debian:trixie-slim diff --git a/Dockerfile.arm b/Dockerfile.arm index f256cf73b5..a7c1505d51 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -13,7 +13,7 @@ COPY crates ./crates RUN echo 'filter-syscalls = false' > /etc/nix/nix.conf # Start the Nix daemon and develop the environment -RUN nix develop --extra-platforms aarch64-linux --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres +RUN nix develop --extra-platforms aarch64-linux --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres --features agicash # Create a runtime stage FROM debian:trixie-slim diff --git a/crates/cdk-agicash/Cargo.toml b/crates/cdk-agicash/Cargo.toml new file mode 100644 index 0000000000..ea73327bd3 --- /dev/null +++ b/crates/cdk-agicash/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "cdk-agicash" +version.workspace = true +edition.workspace = true +authors = ["Agicash Developers"] +license.workspace = true +homepage = "https://github.com/MakePrisms/cdk" +repository = "https://github.com/MakePrisms/cdk.git" +rust-version.workspace = true +description = "CDK closed-loop payment validation with Square" +readme = "README.md" + +[features] +default = [] +postgres = ["dep:sqlx"] + +[dependencies] +async-trait.workspace = true +cdk-common = { workspace = true, features = ["mint"] } +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +serde_json.workspace = true +serde.workspace = true +axum.workspace = true +reqwest.workspace = true +ring = "0.17" +hex = "0.4" +lightning-invoice.workspace = true +tokio-util.workspace = true +futures.workspace = true +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"], optional = true } +base64 = "0.22" + +[lints] +workspace = true diff --git a/crates/cdk-agicash/src/config.rs b/crates/cdk-agicash/src/config.rs new file mode 100644 index 0000000000..bd127b43e5 --- /dev/null +++ b/crates/cdk-agicash/src/config.rs @@ -0,0 +1,36 @@ +//! Square configuration types + +use serde::{Deserialize, Serialize}; + +/// Square environment +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SquareEnvironment { + /// Sandbox environment for testing + Sandbox, + /// Production environment + #[default] + Production, +} + +impl SquareEnvironment { + /// Get the base URL for the Square API + pub fn base_url(&self) -> &str { + match self { + SquareEnvironment::Sandbox => "https://connect.squareupsandbox.com", + SquareEnvironment::Production => "https://connect.squareup.com", + } + } +} + +impl std::str::FromStr for SquareEnvironment { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "sandbox" => Ok(SquareEnvironment::Sandbox), + "production" => Ok(SquareEnvironment::Production), + _ => Err(format!("Unknown Square environment: {s}")), + } + } +} diff --git a/crates/cdk-agicash/src/credentials.rs b/crates/cdk-agicash/src/credentials.rs new file mode 100644 index 0000000000..092214d136 --- /dev/null +++ b/crates/cdk-agicash/src/credentials.rs @@ -0,0 +1,105 @@ +//! Square credential management + +use async_trait::async_trait; + +use crate::error::Error; + +/// Provides Square API access tokens +#[async_trait] +pub trait CredentialProvider: Send + Sync { + /// Get a valid access token + async fn get_access_token(&self) -> Result; + /// Invalidate cached credentials (e.g., on 401 response) + async fn invalidate(&self); +} + +#[cfg(feature = "postgres")] +use std::sync::Arc; +#[cfg(feature = "postgres")] +use tokio::sync::RwLock; + +/// Cached credential entry +#[cfg(feature = "postgres")] +struct CachedCred { + access_token: String, + fetched_at: u64, +} + +/// Cache TTL in seconds +#[cfg(feature = "postgres")] +const CACHE_TTL_SECS: u64 = 300; + +/// PostgreSQL-backed credential provider reading from square_credentials table +#[cfg(feature = "postgres")] +pub struct PgCredentialProvider { + pool: sqlx::PgPool, + cache: Arc>>, +} + +#[cfg(feature = "postgres")] +impl std::fmt::Debug for PgCredentialProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PgCredentialProvider").finish() + } +} + +#[cfg(feature = "postgres")] +impl PgCredentialProvider { + /// Create a new PgCredentialProvider + pub async fn new(database_url: &str) -> Result { + let pool = sqlx::PgPool::connect(database_url) + .await + .map_err(|e| Error::Credential(format!("Failed to connect to database: {e}")))?; + + Ok(Self { + pool, + cache: Arc::new(RwLock::new(None)), + }) + } + + async fn fetch_from_db(&self) -> Result { + let row: (String,) = sqlx::query_as("SELECT access_token FROM square_credentials LIMIT 1") + .fetch_one(&self.pool) + .await + .map_err(|e| Error::Credential(format!("Failed to fetch credentials: {e}")))?; + + Ok(row.0) + } +} + +#[cfg(feature = "postgres")] +#[async_trait] +impl CredentialProvider for PgCredentialProvider { + async fn get_access_token(&self) -> Result { + // Check cache + { + let cache = self.cache.read().await; + if let Some(cached) = cache.as_ref() { + let now = cdk_common::util::unix_time(); + if now - cached.fetched_at < CACHE_TTL_SECS { + return Ok(cached.access_token.clone()); + } + } + } + + // Cache miss or expired — fetch from DB + let access_token = self.fetch_from_db().await?; + + // Update cache + { + let mut cache = self.cache.write().await; + *cache = Some(CachedCred { + access_token: access_token.clone(), + fetched_at: cdk_common::util::unix_time(), + }); + } + + Ok(access_token) + } + + async fn invalidate(&self) { + let mut cache = self.cache.write().await; + *cache = None; + tracing::info!("Invalidated Square credential cache"); + } +} diff --git a/crates/cdk-agicash/src/error.rs b/crates/cdk-agicash/src/error.rs new file mode 100644 index 0000000000..2aaba5aa6a --- /dev/null +++ b/crates/cdk-agicash/src/error.rs @@ -0,0 +1,32 @@ +//! Error types for the agicash closed-loop payment backend + +use thiserror::Error; + +/// Agicash Error +#[derive(Debug, Error)] +pub enum Error { + /// Invoice is not from a known Square merchant + #[error("Invoice not from a valid merchant: {0}")] + InvalidMerchant(String), + /// Invoice not matched to any Square payment + #[error("Invoice not matched to any Square payment")] + InvoiceNotFound, + /// Square API error + #[error("Square API error: {0}")] + SquareApi(String), + /// Square API authentication error + #[error("Square API authentication error")] + SquareAuth, + /// KV store error + #[error("KV store error: {0}")] + KvStore(String), + /// Credential error + #[error("Credential error: {0}")] + Credential(String), +} + +impl From for cdk_common::payment::Error { + fn from(e: Error) -> Self { + Self::Lightning(Box::new(e)) + } +} diff --git a/crates/cdk-agicash/src/lib.rs b/crates/cdk-agicash/src/lib.rs new file mode 100644 index 0000000000..725345b8d8 --- /dev/null +++ b/crates/cdk-agicash/src/lib.rs @@ -0,0 +1,227 @@ +//! CDK closed-loop payment validation with Square +//! +//! Wraps a MintPayment backend to restrict melt operations to invoices +//! generated by a configured Square merchant. + +#![warn(missing_docs)] +#![warn(rustdoc::bare_urls)] + +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use cdk_common::payment::{ + self, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, + MintPayment, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, SettingsResponse, + WaitPaymentResponse, +}; +use cdk_common::Bolt11Invoice; +use futures::Stream; +use tokio_util::sync::CancellationToken; +pub mod config; +pub mod credentials; +pub mod error; +pub mod square_api; +pub mod sync; +pub mod types; +pub mod webhook; + +use sync::SquareSync; + +/// Closed-loop payment wrapper that validates invoices against Square payments +/// before delegating to the inner payment backend. +pub struct ClosedLoopPayment { + inner: Arc + Send + Sync>, + sync: Arc, + valid_destination_name: String, + cancel_token: CancellationToken, +} + +impl std::fmt::Debug for ClosedLoopPayment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ClosedLoopPayment") + .field("valid_destination_name", &self.valid_destination_name) + .finish() + } +} + +impl ClosedLoopPayment { + /// Create a new ClosedLoopPayment wrapper + pub fn new( + inner: Arc + Send + Sync>, + sync: Arc, + valid_destination_name: String, + cancel_token: CancellationToken, + ) -> Self { + Self { + inner, + sync, + valid_destination_name, + cancel_token, + } + } + + /// Start a background polling sync task + pub fn start_polling_sync( + sync: Arc, + interval_secs: u64, + cancel_token: CancellationToken, + ) { + let interval = Duration::from_secs(interval_secs); + + tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + tracing::info!("Square polling sync cancelled"); + break; + } + _ = tokio::time::sleep(interval) => { + if let Err(e) = sync.sync_from_last().await { + tracing::warn!("Square polling sync error: {e}"); + } + if let Err(e) = sync.cleanup_expired().await { + tracing::warn!("Square cleanup error: {e}"); + } + } + } + } + }); + } + + /// Validate that a bolt11 invoice is from a known Square merchant + async fn validate_invoice(&self, bolt11: &Bolt11Invoice) -> Result<(), payment::Error> { + // Pre-filter: check description contains valid_destination_name + let description_matches = match bolt11.description() { + lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(desc) => { + desc.to_string().contains(&self.valid_destination_name) + } + lightning_invoice::Bolt11InvoiceDescriptionRef::Hash(_) => { + // Cannot verify description hash, allow through to timestamp check + true + } + }; + + if !description_matches { + tracing::debug!( + "Invoice description does not match '{}', rejecting", + self.valid_destination_name + ); + return Err(payment::Error::PaymentNotAllowed(format!( + "This ecash can only be spent at {}", + self.valid_destination_name + ))); + } + + // Check invoice timestamp against Square payment timestamps + let invoice_epoch = bolt11.duration_since_epoch().as_secs(); + + // KV lookup (check +/- 1 second window) + if self.sync.has_payment_near_timestamp(invoice_epoch).await? { + tracing::debug!("Square payment found near invoice timestamp {invoice_epoch}"); + return Ok(()); + } + + // On-demand sync then re-check + tracing::debug!( + "No Square payment found near timestamp {invoice_epoch}, triggering on-demand sync" + ); + if self + .sync + .on_demand_sync_for_timestamp(invoice_epoch) + .await? + { + tracing::debug!( + "Square payment found after on-demand sync near timestamp {invoice_epoch}" + ); + return Ok(()); + } + + // Not found + tracing::info!("No Square payment found near invoice timestamp {invoice_epoch}"); + Err(payment::Error::PaymentNotAllowed(format!( + "This ecash can only be spent at {}", + self.valid_destination_name + ))) + } +} + +#[async_trait] +impl MintPayment for ClosedLoopPayment { + type Err = payment::Error; + + async fn start(&self) -> Result<(), Self::Err> { + self.inner.start().await + } + + async fn stop(&self) -> Result<(), Self::Err> { + self.cancel_token.cancel(); + self.inner.stop().await + } + + async fn get_settings(&self) -> Result { + self.inner.get_settings().await + } + + async fn create_incoming_payment_request( + &self, + unit: &cdk_common::nuts::CurrencyUnit, + options: IncomingPaymentOptions, + ) -> Result { + self.inner + .create_incoming_payment_request(unit, options) + .await + } + + async fn get_payment_quote( + &self, + unit: &cdk_common::nuts::CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + // Validate the invoice before getting a quote + if let OutgoingPaymentOptions::Bolt11(ref opts) = options { + self.validate_invoice(&opts.bolt11).await?; + } + + self.inner.get_payment_quote(unit, options).await + } + + async fn make_payment( + &self, + unit: &cdk_common::nuts::CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + self.inner.make_payment(unit, options).await + } + + async fn wait_payment_event( + &self, + ) -> Result + Send>>, Self::Err> { + self.inner.wait_payment_event().await + } + + fn is_wait_invoice_active(&self) -> bool { + self.inner.is_wait_invoice_active() + } + + fn cancel_wait_invoice(&self) { + self.inner.cancel_wait_invoice(); + } + + async fn check_incoming_payment_status( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + self.inner + .check_incoming_payment_status(payment_identifier) + .await + } + + async fn check_outgoing_payment( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result { + self.inner.check_outgoing_payment(payment_identifier).await + } +} diff --git a/crates/cdk-agicash/src/square_api.rs b/crates/cdk-agicash/src/square_api.rs new file mode 100644 index 0000000000..040e5bb38c --- /dev/null +++ b/crates/cdk-agicash/src/square_api.rs @@ -0,0 +1,149 @@ +//! Square API client for listing payments +//! +//! Uses the Square Payments API (). +//! All requests are pinned to a specific API version via the `Square-Version` header. + +use std::sync::Arc; + +use reqwest::Client; +use tracing; + +use crate::config::SquareEnvironment; +use crate::credentials::CredentialProvider; +use crate::error::Error; +use crate::types::ListPaymentsResponse; + +/// Square API version to pin requests to. +/// See +/// NOTE: This value is duplicated in https://github.com/MakePrisms/agicash-mints/blob/master/mint-provisioner/src/services/square_oauth.rs +const SQUARE_API_VERSION: &str = "2026-01-22"; + +/// Square API client +pub struct SquareApiClient { + client: Client, + base_url: String, + cred_provider: Arc, +} + +impl std::fmt::Debug for SquareApiClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SquareApiClient") + .field("base_url", &self.base_url) + .finish() + } +} + +impl SquareApiClient { + /// Create a new Square API client + pub fn new( + environment: &SquareEnvironment, + cred_provider: Arc, + ) -> Self { + Self { + client: Client::new(), + base_url: environment.base_url().to_string(), + cred_provider, + } + } + + /// List payments from Square API with time range and pagination. + /// + /// Calls `GET /v2/payments` with time-range filtering on `created_at`. + /// See + /// + /// # Parameters + /// - `begin_time` / `end_time`: RFC 3339 timestamps filtering on `created_at` + /// - `cursor`: pagination cursor from a previous response + /// - `limit`: max results per page (capped at 100 by Square) + pub async fn list_payments( + &self, + begin_time: &str, + end_time: &str, + cursor: Option<&str>, + limit: u32, + ) -> Result { + let url = format!("{}/v2/payments", self.base_url); + + let mut params = vec![ + ("begin_time", begin_time.to_string()), + ("end_time", end_time.to_string()), + ("sort_order", "ASC".to_string()), + ("limit", limit.to_string()), + ]; + + if let Some(c) = cursor { + params.push(("cursor", c.to_string())); + } + + self.request_with_retry(&url, ¶ms).await + } + + async fn request_with_retry( + &self, + url: &str, + params: &[(&str, String)], + ) -> Result { + let token = self.cred_provider.get_access_token().await?; + + let resp = self + .client + .get(url) + .header("Square-Version", SQUARE_API_VERSION) + .query(params) + .bearer_auth(&token) + .send() + .await + .map_err(|e| Error::SquareApi(format!("Request failed: {e}")))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + tracing::warn!("Square API returned 401, invalidating credentials and retrying"); + self.cred_provider.invalidate().await; + + let new_token = self.cred_provider.get_access_token().await?; + let resp = self + .client + .get(url) + .header("Square-Version", SQUARE_API_VERSION) + .query(params) + .bearer_auth(&new_token) + .send() + .await + .map_err(|e| Error::SquareApi(format!("Retry request failed: {e}")))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(Error::SquareAuth); + } + + return Self::parse_response(resp).await; + } + + Self::parse_response(resp).await + } + + async fn parse_response(resp: reqwest::Response) -> Result { + if !resp.status().is_success() { + let status = resp.status(); + let body = resp + .text() + .await + .unwrap_or_else(|_| "Failed to read body".to_string()); + return Err(Error::SquareApi(format!( + "Square API error {status}: {body}" + ))); + } + + let response: ListPaymentsResponse = resp + .json() + .await + .map_err(|e| Error::SquareApi(format!("Failed to parse response: {e}")))?; + + // Log any errors returned by Square alongside successful results + if let Some(errors) = &response.errors { + if !errors.is_empty() { + tracing::warn!("Square API returned errors: {:?}", errors); + } + } + + Ok(response) + } +} diff --git a/crates/cdk-agicash/src/sync.rs b/crates/cdk-agicash/src/sync.rs new file mode 100644 index 0000000000..8eb2f158df --- /dev/null +++ b/crates/cdk-agicash/src/sync.rs @@ -0,0 +1,463 @@ +//! Square payment sync logic using KV store + +use std::sync::Arc; + +use cdk_common::database::KVStore; +use cdk_common::Bolt11Invoice; +use tracing; + +use crate::error::Error; +use crate::square_api::SquareApiClient; +use crate::types::SquareInvoiceEntry; + +const PRIMARY_NS: &str = "agicash"; +const INVOICE_NS: &str = "square_invoices"; +const PAYMENT_TIMES_NS: &str = "square_payment_times"; +const SYNC_NS: &str = "sync_state"; +const LAST_SYNC_KEY: &str = "last_sync_time"; +const PAGE_LIMIT: u32 = 100; + +/// Manages syncing Square payments to the KV store +pub struct SquareSync { + api_client: SquareApiClient, + kv_store: Arc + Send + Sync>, + invoice_expiry_secs: u64, +} + +impl std::fmt::Debug for SquareSync { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SquareSync") + .field("invoice_expiry_secs", &self.invoice_expiry_secs) + .finish() + } +} + +impl SquareSync { + /// Create a new SquareSync instance + pub fn new( + api_client: SquareApiClient, + kv_store: Arc + Send + Sync>, + invoice_expiry_secs: u64, + ) -> Self { + Self { + api_client, + kv_store, + invoice_expiry_secs, + } + } + + /// Check if a payment hash is known in the KV store + pub async fn is_known_payment_hash(&self, payment_hash_hex: &str) -> Result { + let result = self + .kv_store + .kv_read(PRIMARY_NS, INVOICE_NS, payment_hash_hex) + .await + .map_err(|e| Error::KvStore(format!("Failed to read KV store: {e}")))?; + + Ok(result.is_some()) + } + + /// Perform an on-demand sync then check for the payment hash + pub async fn on_demand_sync(&self, payment_hash_hex: &str) -> Result { + // Sync from the last known time + self.sync_from_last().await?; + + // Re-check + self.is_known_payment_hash(payment_hash_hex).await + } + + /// Sync payments from the last sync time to now + pub async fn sync_from_last(&self) -> Result { + let now = cdk_common::util::unix_time(); + + // Read last sync time from KV + let last_sync = self.read_last_sync_time().await?; + + // Calculate begin_time: max(last_sync, now - expiry) + let earliest_allowed = now.saturating_sub(self.invoice_expiry_secs); + let begin_epoch = last_sync.unwrap_or(earliest_allowed).max(earliest_allowed); + + let begin_time = epoch_to_rfc3339(begin_epoch); + let end_time = epoch_to_rfc3339(now); + + let mut total_synced: u64 = 0; + let mut cursor: Option = None; + + loop { + let response = self + .api_client + .list_payments(&begin_time, &end_time, cursor.as_deref(), PAGE_LIMIT) + .await?; + + if let Some(payments) = &response.payments { + for payment in payments { + // Store by timestamp for all payments (primary validation method) + if let Some(epoch) = parse_iso8601_epoch(&payment.created_at) { + self.store_payment_time(epoch, &payment.id).await?; + total_synced += 1; + } + + // Also store by payment hash when extractable + if let Some(hash) = extract_payment_hash(payment) { + let entry = SquareInvoiceEntry { + square_payment_id: payment.id.clone(), + created_at_epoch: parse_iso8601_epoch(&payment.created_at) + .unwrap_or(now), + }; + + self.store_payment_hash(&hash, &entry).await?; + } + } + } + + cursor = response.cursor; + if cursor.is_none() { + break; + } + } + + // Update last sync time + self.write_last_sync_time(now).await?; + + if total_synced > 0 { + tracing::info!("Synced {total_synced} Square payments"); + } + + Ok(total_synced) + } + + /// Clean up expired entries from the KV store + pub async fn cleanup_expired(&self) -> Result { + let now = cdk_common::util::unix_time(); + let expiry_threshold = now.saturating_sub(self.invoice_expiry_secs + 3600); // 1hr buffer + + // Collect expired invoice keys + let invoice_keys = self + .kv_store + .kv_list(PRIMARY_NS, INVOICE_NS) + .await + .map_err(|e| Error::KvStore(format!("Failed to list KV keys: {e}")))?; + + let mut expired_keys: Vec<(String, &str)> = Vec::new(); + for key in &invoice_keys { + let value = self + .kv_store + .kv_read(PRIMARY_NS, INVOICE_NS, key) + .await + .map_err(|e| Error::KvStore(format!("Failed to read KV entry: {e}")))?; + + if let Some(data) = value { + if let Ok(entry) = serde_json::from_slice::(&data) { + if entry.created_at_epoch < expiry_threshold { + expired_keys.push((key.clone(), INVOICE_NS)); + } + } + } + } + + // Collect expired timestamp keys + let time_keys = self + .kv_store + .kv_list(PRIMARY_NS, PAYMENT_TIMES_NS) + .await + .map_err(|e| Error::KvStore(format!("Failed to list KV time keys: {e}")))?; + + for key in &time_keys { + if let Ok(epoch) = key.parse::() { + if epoch < expiry_threshold { + expired_keys.push((key.clone(), PAYMENT_TIMES_NS)); + } + } + } + + if expired_keys.is_empty() { + return Ok(0); + } + + // Batch delete in a single transaction + let mut txn = self + .kv_store + .begin_transaction() + .await + .map_err(|e| Error::KvStore(format!("Failed to begin txn: {e}")))?; + + for (key, ns) in &expired_keys { + txn.kv_remove(PRIMARY_NS, ns, key) + .await + .map_err(|e| Error::KvStore(format!("Failed to remove expired entry: {e}")))?; + } + + txn.commit() + .await + .map_err(|e| Error::KvStore(format!("Failed to commit txn: {e}")))?; + + let removed = expired_keys.len() as u64; + tracing::debug!("Cleaned up {removed} expired Square payment entries"); + + Ok(removed) + } + + /// Store a payment by its creation timestamp (epoch seconds) + pub async fn store_payment_time(&self, epoch_secs: u64, payment_id: &str) -> Result<(), Error> { + let key = epoch_secs.to_string(); + let value = payment_id.as_bytes().to_vec(); + + let mut txn = self + .kv_store + .begin_transaction() + .await + .map_err(|e| Error::KvStore(format!("Failed to begin txn: {e}")))?; + + txn.kv_write(PRIMARY_NS, PAYMENT_TIMES_NS, &key, &value) + .await + .map_err(|e| Error::KvStore(format!("Failed to write payment time: {e}")))?; + + txn.commit() + .await + .map_err(|e| Error::KvStore(format!("Failed to commit txn: {e}")))?; + + Ok(()) + } + + /// Check if a Square payment exists near the given timestamp (within +/- 1 second) + pub async fn has_payment_near_timestamp(&self, epoch_secs: u64) -> Result { + for ts in [epoch_secs.saturating_sub(1), epoch_secs, epoch_secs + 1] { + let key = ts.to_string(); + let result = self + .kv_store + .kv_read(PRIMARY_NS, PAYMENT_TIMES_NS, &key) + .await + .map_err(|e| Error::KvStore(format!("Failed to read KV store: {e}")))?; + + if result.is_some() { + return Ok(true); + } + } + + Ok(false) + } + + /// Perform an on-demand sync then check for a payment near the given timestamp + pub async fn on_demand_sync_for_timestamp(&self, epoch_secs: u64) -> Result { + self.sync_from_last().await?; + self.has_payment_near_timestamp(epoch_secs).await + } + + /// Store a payment hash in the KV store + pub async fn store_payment_hash( + &self, + payment_hash_hex: &str, + entry: &SquareInvoiceEntry, + ) -> Result<(), Error> { + let value = serde_json::to_vec(entry) + .map_err(|e| Error::KvStore(format!("Failed to serialize entry: {e}")))?; + + let txn = self + .kv_store + .begin_transaction() + .await + .map_err(|e| Error::KvStore(format!("Failed to begin txn: {e}")))?; + + let mut txn = txn; + txn.kv_write(PRIMARY_NS, INVOICE_NS, payment_hash_hex, &value) + .await + .map_err(|e| Error::KvStore(format!("Failed to write payment hash: {e}")))?; + + txn.commit() + .await + .map_err(|e| Error::KvStore(format!("Failed to commit txn: {e}")))?; + + Ok(()) + } + + async fn read_last_sync_time(&self) -> Result, Error> { + let result = self + .kv_store + .kv_read(PRIMARY_NS, SYNC_NS, LAST_SYNC_KEY) + .await + .map_err(|e| Error::KvStore(format!("Failed to read last sync time: {e}")))?; + + match result { + Some(data) => { + let s = String::from_utf8(data) + .map_err(|e| Error::KvStore(format!("Invalid sync time data: {e}")))?; + let epoch: u64 = s + .parse() + .map_err(|e| Error::KvStore(format!("Invalid sync time: {e}")))?; + Ok(Some(epoch)) + } + None => Ok(None), + } + } + + async fn write_last_sync_time(&self, epoch: u64) -> Result<(), Error> { + let value = epoch.to_string().into_bytes(); + + let txn = self + .kv_store + .begin_transaction() + .await + .map_err(|e| Error::KvStore(format!("Failed to begin txn: {e}")))?; + + let mut txn = txn; + txn.kv_write(PRIMARY_NS, SYNC_NS, LAST_SYNC_KEY, &value) + .await + .map_err(|e| Error::KvStore(format!("Failed to write sync time: {e}")))?; + + txn.commit() + .await + .map_err(|e| Error::KvStore(format!("Failed to commit txn: {e}")))?; + + Ok(()) + } +} + +/// Extract payment hash hex from a Square payment's Lightning bolt11 invoice. +/// +/// Accepts payments where `wallet_details.brand` is `"LIGHTNING"` or absent +/// (the `brand` field is optional per the Square API spec). Rejects payments +/// with any other brand value. +/// +/// The `lightning_details.payment_url` field is undocumented in Square's public +/// API reference but is returned in practice for Lightning payments. +fn extract_payment_hash(payment: &crate::types::SquarePayment) -> Option { + let wallet = payment.wallet_details.as_ref()?; + + // Verify this is actually a Lightning payment + match wallet.brand { + Some(crate::types::DigitalWalletBrand::Lightning) => {} + Some(other) => { + tracing::trace!("Skipping non-Lightning wallet payment (brand={other:?})"); + return None; + } + None => { + // Fall through — accept payments without a brand if they have lightning_details, + // since the brand field is optional per the Square API spec. + } + } + + let bolt11_str = wallet.lightning_details.as_ref()?.payment_url.as_ref()?; + + let invoice: Bolt11Invoice = bolt11_str.parse().ok()?; + Some(invoice.payment_hash().to_string()) +} + +/// Convert epoch seconds to RFC 3339 UTC string +fn epoch_to_rfc3339(epoch: u64) -> String { + // Simple manual conversion — avoid pulling in chrono + let secs = epoch; + // Calculate date/time components from Unix timestamp + let days = secs / 86400; + let time_of_day = secs % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let seconds = time_of_day % 60; + + // Calculate year/month/day from days since epoch (1970-01-01) + let (year, month, day) = days_to_ymd(days); + + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z") +} + +/// Convert days since Unix epoch to (year, month, day) +fn days_to_ymd(days: u64) -> (u64, u64, u64) { + // Algorithm based on Howard Hinnant's civil_from_days + let z = days as i64 + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = (z - era * 146097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + (y as u64, m, d) +} + +/// Parse ISO 8601 timestamp to epoch seconds (basic parsing) +pub(crate) fn parse_iso8601_epoch(s: &str) -> Option { + // Parse format: "2024-01-15T12:00:00Z" or "2024-01-15T12:00:00.000Z" + let s = s.trim_end_matches('Z'); + let s = s.split('.').next()?; // Remove fractional seconds + + let parts: Vec<&str> = s.split('T').collect(); + if parts.len() != 2 { + return None; + } + + let date_parts: Vec = parts[0].split('-').filter_map(|p| p.parse().ok()).collect(); + let time_parts: Vec = parts[1].split(':').filter_map(|p| p.parse().ok()).collect(); + + if date_parts.len() != 3 || time_parts.len() != 3 { + return None; + } + + let (year, month, day) = (date_parts[0], date_parts[1], date_parts[2]); + let (hour, minute, second) = (time_parts[0], time_parts[1], time_parts[2]); + + // Convert to epoch using reverse of days_to_ymd + let days = ymd_to_days(year, month, day)?; + let epoch = days * 86400 + hour * 3600 + minute * 60 + second; + + Some(epoch) +} + +/// Convert (year, month, day) to days since Unix epoch +fn ymd_to_days(year: u64, month: u64, day: u64) -> Option { + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + + // Howard Hinnant's days_from_civil + let y = if month <= 2 { + year as i64 - 1 + } else { + year as i64 + }; + let m = if month <= 2 { month + 9 } else { month - 3 }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = (y - era * 400) as u64; + let doy = (153 * m + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146097 + doe as i64 - 719468; + + if days < 0 { + None + } else { + Some(days as u64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_epoch_to_rfc3339() { + assert_eq!(epoch_to_rfc3339(0), "1970-01-01T00:00:00Z"); + assert_eq!(epoch_to_rfc3339(1705312800), "2024-01-15T10:00:00Z"); + } + + #[test] + fn test_parse_iso8601_epoch() { + assert_eq!(parse_iso8601_epoch("1970-01-01T00:00:00Z"), Some(0)); + assert_eq!( + parse_iso8601_epoch("2024-01-15T10:00:00Z"), + Some(1705312800) + ); + assert_eq!( + parse_iso8601_epoch("2024-01-15T10:00:00.000Z"), + Some(1705312800) + ); + } + + #[test] + fn test_roundtrip_epoch() { + let epoch = 1705312800u64; + let rfc3339 = epoch_to_rfc3339(epoch); + let parsed = parse_iso8601_epoch(&rfc3339).expect("Failed to parse"); + assert_eq!(epoch, parsed); + } +} diff --git a/crates/cdk-agicash/src/types.rs b/crates/cdk-agicash/src/types.rs new file mode 100644 index 0000000000..7893ba3f30 --- /dev/null +++ b/crates/cdk-agicash/src/types.rs @@ -0,0 +1,195 @@ +//! Square API response types and KV store value types +//! +//! See for the +//! official Payments API reference. + +use serde::{Deserialize, Serialize}; + +/// Square List Payments API response. +/// +/// See +#[derive(Debug, Clone, Deserialize)] +pub struct ListPaymentsResponse { + /// List of payments (may be absent if no results) + pub payments: Option>, + /// Pagination cursor for the next page (absent on the final page) + pub cursor: Option, + /// Errors returned by Square (may accompany partial results) + pub errors: Option>, +} + +/// A Square API error object. +/// +/// See +#[derive(Debug, Clone, Deserialize)] +pub struct SquareApiError { + /// Error category (e.g., `API_ERROR`, `AUTHENTICATION_ERROR`) + pub category: Option, + /// Specific error code + pub code: Option, + /// Human-readable error detail + pub detail: Option, + /// The field that caused the error, if applicable + pub field: Option, +} + +/// Square payment source type. +/// +/// See +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum PaymentSourceType { + /// Card payment (`CARD`) + #[serde(rename = "CARD")] + Card, + /// Bank account payment (`BANK_ACCOUNT`) + #[serde(rename = "BANK_ACCOUNT")] + BankAccount, + /// Digital wallet payment (`WALLET`) + #[serde(rename = "WALLET")] + Wallet, + /// Buy now pay later (`BUY_NOW_PAY_LATER`) + #[serde(rename = "BUY_NOW_PAY_LATER")] + BuyNowPayLater, + /// Square account balance (`SQUARE_ACCOUNT`) + #[serde(rename = "SQUARE_ACCOUNT")] + SquareAccount, + /// Cash payment (`CASH`) + #[serde(rename = "CASH")] + Cash, + /// External payment (`EXTERNAL`) + #[serde(rename = "EXTERNAL")] + External, +} + +/// A Square payment object (subset of fields we use). +/// +/// See +#[derive(Debug, Clone, Deserialize)] +pub struct SquarePayment { + /// Square payment ID (max 192 chars) + pub id: String, + /// RFC 3339 timestamp when the payment was created + pub created_at: String, + /// The source type for this payment. + /// + /// `wallet_details` is only populated when this is [`PaymentSourceType::Wallet`]. + pub source_type: Option, + /// Digital wallet details, present when `source_type` is `WALLET`. + /// + /// See + pub wallet_details: Option, +} + +/// Square digital wallet brand. +/// +/// See +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum DigitalWalletBrand { + /// Cash App (`CASH_APP`) + #[serde(rename = "CASH_APP")] + CashApp, + /// PayPay (`PAYPAY`) + #[serde(rename = "PAYPAY")] + PayPay, + /// Alipay (`ALIPAY`) + #[serde(rename = "ALIPAY")] + Alipay, + /// Rakuten Pay (`RAKUTEN_PAY`) + #[serde(rename = "RAKUTEN_PAY")] + RakutenPay, + /// au PAY (`AU_PAY`) + #[serde(rename = "AU_PAY")] + AuPay, + /// d barai (`D_BARAI`) + #[serde(rename = "D_BARAI")] + DBarai, + /// Merpay (`MERPAY`) + #[serde(rename = "MERPAY")] + Merpay, + /// WeChat Pay (`WECHAT_PAY`) + #[serde(rename = "WECHAT_PAY")] + WechatPay, + /// Lightning Network (`LIGHTNING`) + #[serde(rename = "LIGHTNING")] + Lightning, + /// Unknown brand (`UNKNOWN`) + #[serde(rename = "UNKNOWN")] + Unknown, +} + +/// Digital wallet payment details (`DigitalWalletDetails` in Square docs). +/// +/// See +#[derive(Debug, Clone, Deserialize)] +pub struct WalletDetails { + /// Wallet brand. + /// + /// See [`DigitalWalletBrand`] for documented values. + pub brand: Option, + /// Lightning-specific details containing the bolt11 invoice. + /// + /// **NOTE:** This field is **not documented** in the public Square API reference + /// as of 2026-01-22. It is returned in practice for Lightning payments + /// (where `brand == "LIGHTNING"`) but could change without notice. + pub lightning_details: Option, +} + +/// Lightning payment details within a Square digital wallet payment. +/// +/// **NOTE:** This type corresponds to an **undocumented** Square API object. +/// It is returned in practice for Lightning payments but is not part of the +/// public API contract. +#[derive(Debug, Clone, Deserialize)] +pub struct LightningDetails { + /// The BOLT11 payment request string + pub payment_url: Option, +} + +/// Entry stored in KV store for a known Square invoice +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SquareInvoiceEntry { + /// Square payment ID + pub square_payment_id: String, + /// Unix epoch seconds when the payment was created + pub created_at_epoch: u64, +} + +/// Square webhook event payload. +/// +/// See +#[derive(Debug, Clone, Deserialize)] +pub struct SquareWebhookEvent { + /// Merchant ID + pub merchant_id: Option, + /// Event type (e.g., `payment.created`, `payment.updated`). + /// Serialized as `"type"` in JSON. + #[serde(rename = "type")] + pub event_type: String, + /// Unique event ID + pub event_id: Option, + /// Event data containing the affected object + pub data: Option, +} + +/// Square webhook event data (`EventData` in Square docs). +#[derive(Debug, Clone, Deserialize)] +pub struct SquareWebhookData { + /// Name of the affected object's type (e.g., `"payment"`). + /// Serialized as `"type"` in JSON. + #[serde(rename = "type")] + pub data_type: Option, + /// ID of the affected object + pub id: Option, + /// The affected object (absent if the object was deleted) + pub object: Option, +} + +/// Square webhook data object wrapper. +/// +/// For `payment.created` and `payment.updated` events, this contains the +/// full Payment object under the `payment` key. +#[derive(Debug, Clone, Deserialize)] +pub struct SquareWebhookObject { + /// Payment object (present for payment events) + pub payment: Option, +} diff --git a/crates/cdk-agicash/src/webhook.rs b/crates/cdk-agicash/src/webhook.rs new file mode 100644 index 0000000000..29d85d1d25 --- /dev/null +++ b/crates/cdk-agicash/src/webhook.rs @@ -0,0 +1,180 @@ +//! Square webhook handler with HMAC-SHA256 signature verification + +use std::sync::Arc; + +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::post, + Router, +}; +use ring::hmac; +use tracing::{debug, warn}; + +use crate::sync::{parse_iso8601_epoch, SquareSync}; +use crate::types::{SquareInvoiceEntry, SquareWebhookEvent}; + +/// State for Square webhook handlers +#[derive(Clone)] +struct SquareWebhookState { + sync: Arc, + signature_key: String, + notification_url: String, +} + +/// Verify Square webhook signature using HMAC-SHA256 +/// +/// Square computes: HMAC-SHA256(notification_url + body, signature_key) → base64 +fn verify_square_signature( + signature: &str, + body: &[u8], + signature_key: &str, + notification_url: &str, +) -> bool { + let key = hmac::Key::new(hmac::HMAC_SHA256, signature_key.as_bytes()); + + // Square signs: notification_url + raw body + let mut payload = notification_url.as_bytes().to_vec(); + payload.extend_from_slice(body); + + let computed = hmac::sign(&key, &payload); + let computed_b64 = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + computed.as_ref(), + ); + + // Constant-time comparison + computed_b64.len() == signature.len() + && computed_b64 + .as_bytes() + .iter() + .zip(signature.as_bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 +} + +/// Middleware to verify Square webhook signatures +async fn verify_request_body( + State(state): State, + request: Request, + next: Next, +) -> Result { + let signature = request + .headers() + .get("x-square-hmacsha256-signature") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + warn!("Missing x-square-hmacsha256-signature header"); + (StatusCode::UNAUTHORIZED, "Missing signature").into_response() + })? + .to_string(); + + 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() + })?; + + if !verify_square_signature( + &signature, + &bytes, + &state.signature_key, + &state.notification_url, + ) { + warn!("Square webhook signature verification failed"); + return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into_response()); + } + + debug!("Square webhook signature verified"); + + let request = Request::from_parts(parts, Body::from(bytes)); + Ok(next.run(request).await) +} + +/// Handle Square payment webhook events +async fn handle_payment_webhook( + State(state): State, + body: axum::body::Bytes, +) -> impl IntoResponse { + let event: SquareWebhookEvent = match serde_json::from_slice(&body) { + Ok(e) => e, + Err(e) => { + warn!("Failed to parse Square webhook event: {}", e); + return StatusCode::BAD_REQUEST; + } + }; + + match event.event_type.as_str() { + "payment.created" | "payment.updated" => { + debug!("Received Square payment event: {}", event.event_type); + + if let Some(data) = &event.data { + if let Some(object) = &data.object { + if let Some(payment) = &object.payment { + // Store by timestamp (primary validation method) + let epoch = parse_iso8601_epoch(&payment.created_at) + .unwrap_or_else(cdk_common::util::unix_time); + + if let Err(e) = state.sync.store_payment_time(epoch, &payment.id).await { + warn!("Failed to store payment time from webhook: {}", e); + } else { + debug!("Stored payment time from webhook: epoch={}", epoch); + } + + // Also store by payment hash when extractable + let bolt11_str = payment + .wallet_details + .as_ref() + .and_then(|wd| wd.lightning_details.as_ref()) + .and_then(|ld| ld.payment_url.as_ref()); + + if let Some(bolt11_str) = bolt11_str { + if let Ok(invoice) = bolt11_str.parse::() { + let hash = invoice.payment_hash().to_string(); + let entry = SquareInvoiceEntry { + square_payment_id: payment.id.clone(), + created_at_epoch: epoch, + }; + + if let Err(e) = state.sync.store_payment_hash(&hash, &entry).await { + warn!("Failed to store payment hash from webhook: {}", e); + } + } + } + } + } + } + + StatusCode::OK + } + _ => { + debug!("Ignoring Square webhook event: {}", event.event_type); + StatusCode::OK + } + } +} + +/// Create an Axum router for Square payment webhooks +pub fn create_square_webhook_router( + endpoint: &str, + sync: Arc, + signature_key: String, + notification_url: String, +) -> Router { + let state = SquareWebhookState { + sync, + signature_key, + notification_url, + }; + + Router::new() + .route(endpoint, post(handle_payment_webhook)) + .layer(middleware::from_fn_with_state( + state.clone(), + verify_request_body, + )) + .with_state(state) +} diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index f77f614fd4..a71e196e91 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -624,6 +624,11 @@ impl From for ErrorResponse { code: ErrorCode::PubkeyRequired, detail: err.to_string(), }, + #[cfg(feature = "mint")] + Error::Payment(crate::payment::Error::PaymentNotAllowed(msg)) => ErrorResponse { + code: ErrorCode::PaymentNotAllowed, + detail: msg, + }, Error::PaidQuote => ErrorResponse { code: ErrorCode::InvoiceAlreadyPaid, detail: err.to_string(), @@ -793,6 +798,7 @@ impl From for Error { ErrorCode::QuoteExpired => Self::ExpiredQuote(0, 0), ErrorCode::WitnessMissingOrInvalid => Self::SignatureMissingOrInvalid, ErrorCode::PubkeyRequired => Self::PubkeyRequired, + ErrorCode::PaymentNotAllowed => Self::UnknownErrorResponse(err.to_string()), // 30xxx - Clear auth errors ErrorCode::ClearAuthRequired => Self::ClearAuthRequired, ErrorCode::ClearAuthFailed => Self::ClearAuthFailed, @@ -867,6 +873,8 @@ pub enum ErrorCode { WitnessMissingOrInvalid, /// Pubkey required for mint quote (20009) PubkeyRequired, + /// Payment not allowed to this destination (20420) + PaymentNotAllowed, // 30xxx - Clear auth errors /// Endpoint requires clear auth (30001) @@ -921,6 +929,7 @@ impl ErrorCode { 20007 => Self::QuoteExpired, 20008 => Self::WitnessMissingOrInvalid, 20009 => Self::PubkeyRequired, + 20739 => Self::PaymentNotAllowed, // 30xxx - Clear auth errors 30001 => Self::ClearAuthRequired, 30002 => Self::ClearAuthFailed, @@ -965,6 +974,7 @@ impl ErrorCode { Self::QuoteExpired => 20007, Self::WitnessMissingOrInvalid => 20008, Self::PubkeyRequired => 20009, + Self::PaymentNotAllowed => 20739, // 30xxx - Clear auth errors Self::ClearAuthRequired => 30001, Self::ClearAuthFailed => 30002, diff --git a/crates/cdk-common/src/payment.rs b/crates/cdk-common/src/payment.rs index 26c0565add..bd9f3afc56 100644 --- a/crates/cdk-common/src/payment.rs +++ b/crates/cdk-common/src/payment.rs @@ -70,6 +70,9 @@ pub enum Error { /// Invalid hash #[error("Invalid hash")] InvalidHash, + /// Payment not allowed to this destination + #[error("{0}")] + PaymentNotAllowed(String), /// Custom #[error("`{0}`")] Custom(String), diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index ca689fe1ee..37896ab7d3 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -14,7 +14,7 @@ readme = "README.md" 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"] +postgres = ["dep:cdk-postgres", "cdk-agicash?/postgres"] # Ensure at least one lightning backend is enabled management-rpc = ["cdk-mint-rpc"] cln = ["dep:cdk-cln"] @@ -32,6 +32,7 @@ prometheus = ["cdk/prometheus", "dep:cdk-prometheus", "cdk-sqlite?/prometheus", # Agicash Fork additions strike = ["dep:cdk-strike"] +agicash = ["dep:cdk-agicash", "dep:tokio-util", "strike"] [dependencies] anyhow.workspace = true @@ -74,6 +75,8 @@ utoipa-swagger-ui = { version = "9.0.0", features = ["axum"], optional = true } # Agicash Fork additions cdk-strike = { workspace = true, optional = true } +cdk-agicash = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } [lints] workspace = true diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 1ed89ee480..25d93b8db4 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -188,6 +188,17 @@ max_delay_time = 3 # closed_loop_type = "square" # valid_destination_name = "this mint" +# [agicash.square] +# Square payment validation settings (requires agicash + postgres features) +# When configured with closed_loop, validates melt invoices against Square payments +# +# environment = "production" # "sandbox" or "production" +# webhook_enabled = false +# webhook_url = "https://your-public-domain.com/webhook/square/payment" +# webhook_signature_key = "your_square_webhook_signature_key" +# sync_interval_secs = 5 # Polling sync interval in secs (0 = disabled, only used when webhook_enabled = false) +# invoice_expiry_secs = 3600 # How long to keep payment hashes in KV store + # [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 97513ba753..0b735ac24d 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -469,11 +469,46 @@ pub struct ClosedLoop { pub valid_destination_name: String, } +/// Square payment validation settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SquareSettings { + /// Square environment: "sandbox" or "production" + #[serde(default = "default_square_environment")] + pub environment: String, + /// Whether to enable the Square webhook endpoint + #[serde(default)] + pub webhook_enabled: bool, + /// URL for webhook signature verification + pub webhook_url: Option, + /// HMAC-SHA256 signature key for verifying Square webhooks + pub webhook_signature_key: Option, + /// Polling sync interval in seconds (0 = disabled) + #[serde(default = "default_sync_interval")] + pub sync_interval_secs: u64, + /// Invoice expiry in seconds + #[serde(default = "default_invoice_expiry")] + pub invoice_expiry_secs: u64, +} + +fn default_square_environment() -> String { + "production".to_string() +} + +fn default_sync_interval() -> u64 { + 5 +} + +fn default_invoice_expiry() -> u64 { + 3600 +} + /// Agicash configuration #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Agicash { /// Closed loop payment configuration (None = disabled) pub closed_loop: Option, + /// Square payment validation settings (None = disabled) + pub square: Option, } #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] diff --git a/crates/cdk-mintd/src/env_vars/agicash.rs b/crates/cdk-mintd/src/env_vars/agicash.rs index 0cb665799d..3a35bda510 100644 --- a/crates/cdk-mintd/src/env_vars/agicash.rs +++ b/crates/cdk-mintd/src/env_vars/agicash.rs @@ -2,12 +2,20 @@ use std::env; -use crate::config::{Agicash, ClosedLoop, ClosedLoopType}; +use crate::config::{Agicash, ClosedLoop, ClosedLoopType, SquareSettings}; pub const ENV_AGICASH_CLOSED_LOOP_TYPE: &str = "CDK_MINTD_AGICASH_CLOSED_LOOP_TYPE"; pub const ENV_AGICASH_CLOSED_LOOP_VALID_DESTINATION: &str = "CDK_MINTD_AGICASH_CLOSED_LOOP_VALID_DESTINATION"; +pub const ENV_AGICASH_SQUARE_ENVIRONMENT: &str = "CDK_MINTD_AGICASH_SQUARE_ENVIRONMENT"; +pub const ENV_AGICASH_SQUARE_WEBHOOK_ENABLED: &str = "CDK_MINTD_AGICASH_SQUARE_WEBHOOK_ENABLED"; +pub const ENV_AGICASH_SQUARE_WEBHOOK_URL: &str = "CDK_MINTD_AGICASH_SQUARE_WEBHOOK_URL"; +pub const ENV_AGICASH_SQUARE_WEBHOOK_SIGNATURE_KEY: &str = + "CDK_MINTD_AGICASH_SQUARE_WEBHOOK_SIGNATURE_KEY"; +pub const ENV_AGICASH_SQUARE_SYNC_INTERVAL: &str = "CDK_MINTD_AGICASH_SQUARE_SYNC_INTERVAL_SECS"; +pub const ENV_AGICASH_SQUARE_INVOICE_EXPIRY: &str = "CDK_MINTD_AGICASH_SQUARE_INVOICE_EXPIRY_SECS"; + impl Agicash { pub fn from_env(mut self) -> Self { let has_closed_loop_env = env::var(ENV_AGICASH_CLOSED_LOOP_TYPE).is_ok() @@ -32,6 +40,50 @@ impl Agicash { self.closed_loop = Some(closed_loop); } + // Square settings + let has_square_env = env::var(ENV_AGICASH_SQUARE_ENVIRONMENT).is_ok(); + + if has_square_env { + let mut square = self.square.unwrap_or_else(|| SquareSettings { + environment: "production".to_string(), + webhook_enabled: false, + webhook_url: None, + webhook_signature_key: None, + sync_interval_secs: 300, + invoice_expiry_secs: 3600, + }); + + if let Ok(env_str) = env::var(ENV_AGICASH_SQUARE_ENVIRONMENT) { + square.environment = env_str; + } + + if let Ok(webhook_enabled) = env::var(ENV_AGICASH_SQUARE_WEBHOOK_ENABLED) { + square.webhook_enabled = webhook_enabled == "true" || webhook_enabled == "1"; + } + + if let Ok(webhook_url) = env::var(ENV_AGICASH_SQUARE_WEBHOOK_URL) { + square.webhook_url = Some(webhook_url); + } + + if let Ok(sig_key) = env::var(ENV_AGICASH_SQUARE_WEBHOOK_SIGNATURE_KEY) { + square.webhook_signature_key = Some(sig_key); + } + + if let Ok(interval) = env::var(ENV_AGICASH_SQUARE_SYNC_INTERVAL) { + if let Ok(secs) = interval.parse() { + square.sync_interval_secs = secs; + } + } + + if let Ok(expiry) = env::var(ENV_AGICASH_SQUARE_INVOICE_EXPIRY) { + if let Ok(secs) = expiry.parse() { + square.invoice_expiry_secs = secs; + } + } + + self.square = Some(square); + } + self } } diff --git a/crates/cdk-mintd/src/env_vars/mod.rs b/crates/cdk-mintd/src/env_vars/mod.rs index 8a29acc1cd..9a85354a5e 100644 --- a/crates/cdk-mintd/src/env_vars/mod.rs +++ b/crates/cdk-mintd/src/env_vars/mod.rs @@ -35,6 +35,7 @@ mod strike; use std::env; use std::str::FromStr; +pub use agicash::*; use anyhow::{anyhow, bail, Result}; #[cfg(feature = "auth")] pub use auth::*; @@ -60,7 +61,6 @@ pub use mint_info::*; pub use prometheus::*; #[cfg(feature = "strike")] pub use strike::*; -pub use agicash::*; use crate::config::{DatabaseEngine, LnBackend, Settings}; @@ -101,8 +101,11 @@ impl Settings { self.mint_info = self.mint_info.clone().from_env(); self.ln = self.ln.clone().from_env(); - if let Some(agicash) = &self.agicash { - self.agicash = Some(agicash.clone().from_env()); + // Always process agicash env vars (even if no [agicash] section in config) + // so that agicash can be configured entirely via environment variables + let agicash = self.agicash.clone().unwrap_or_default().from_env(); + if agicash.closed_loop.is_some() || agicash.square.is_some() { + self.agicash = Some(agicash); } #[cfg(feature = "auth")] diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 7ddbe150c8..87f2d8b5b1 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -444,7 +444,7 @@ fn configure_basic_info(settings: &config::Settings, mint_builder: MintBuilder) if settings .agicash .as_ref() - .map_or(false, |a| a.closed_loop.is_some()) + .is_some_and(|a| a.closed_loop.is_some()) { builder = builder.with_agicash(cdk::nuts::AgicashInfo { closed_loop: true }); } @@ -607,6 +607,11 @@ async fn configure_lightning_backend( let mut webhook_routers = Vec::new(); + // Check if agicash closed-loop with Square validation is configured + #[cfg(feature = "agicash")] + let agicash_sync = + setup_agicash_square(settings, &_kv_store, &mut webhook_routers).await?; + for unit in &strike_settings.supported_units { let (strike, webhook_router) = strike_settings.setup(settings, unit.clone()).await?; @@ -616,12 +621,32 @@ async fn configure_lightning_backend( #[cfg(feature = "prometheus")] let strike = MetricsMintPayment::new(strike); + // Wrap with ClosedLoopPayment if agicash is configured + #[cfg(feature = "agicash")] + let backend: Arc< + dyn MintPayment + Send + Sync, + > = if let Some((ref sync, ref name, ref cancel_token)) = agicash_sync { + Arc::new(cdk_agicash::ClosedLoopPayment::new( + Arc::new(strike), + sync.clone(), + name.clone(), + cancel_token.clone(), + )) + } else { + Arc::new(strike) + }; + + #[cfg(not(feature = "agicash"))] + let backend: Arc< + dyn MintPayment + Send + Sync, + > = Arc::new(strike); + mint_builder = configure_backend_for_unit( settings, mint_builder, unit.clone(), mint_melt_limits, - Arc::new(strike), + backend, ) .await?; } @@ -640,6 +665,119 @@ async fn configure_lightning_backend( Ok((mint_builder, vec![])) } +/// Set up agicash Square closed-loop validation if configured +#[cfg(feature = "agicash")] +#[allow(unused_variables)] +async fn setup_agicash_square( + settings: &config::Settings, + kv_store: &Option + Send + Sync>>, + webhook_routers: &mut Vec, +) -> Result< + Option<( + Arc, + String, + tokio_util::sync::CancellationToken, + )>, +> { + let agicash_config = match settings.agicash.as_ref() { + Some(c) => c, + None => return Ok(None), + }; + let square = match agicash_config.square.as_ref() { + Some(s) => s, + None => return Ok(None), + }; + let closed_loop = match agicash_config.closed_loop.as_ref() { + Some(cl) => cl, + None => return Ok(None), + }; + + let kv = kv_store + .as_ref() + .ok_or_else(|| anyhow!("Agicash Square configured but no KV store available"))?; + + let square_env: cdk_agicash::config::SquareEnvironment = square + .environment + .parse() + .unwrap_or(cdk_agicash::config::SquareEnvironment::Production); + + let pg_url = settings + .database + .postgres + .as_ref() + .map(|p| p.url.clone()) + .unwrap_or_default(); + + if pg_url.is_empty() { + bail!("Agicash Square configured but no postgres URL for credentials"); + } + + #[cfg(not(feature = "postgres"))] + { + bail!("Agicash Square requires postgres feature for credential lookup"); + } + + #[cfg(feature = "postgres")] + { + let provider = cdk_agicash::credentials::PgCredentialProvider::new(&pg_url) + .await + .map_err(|e| anyhow!("Failed to create Square credential provider: {e}"))?; + + let api_client = + cdk_agicash::square_api::SquareApiClient::new(&square_env, Arc::new(provider)); + let sync = Arc::new(cdk_agicash::sync::SquareSync::new( + api_client, + kv.clone(), + square.invoice_expiry_secs, + )); + + let cancel_token = tokio_util::sync::CancellationToken::new(); + + if square.webhook_enabled { + // Webhook mode: require signature key and URL + let sig_key = square.webhook_signature_key.as_ref().ok_or_else(|| { + anyhow!("Square webhook_enabled but webhook_signature_key is missing") + })?; + let wh_url = square + .webhook_url + .as_ref() + .ok_or_else(|| anyhow!("Square webhook_enabled but webhook_url is missing"))?; + + let square_webhook_router = cdk_agicash::webhook::create_square_webhook_router( + "/webhook/square/payment", + sync.clone(), + sig_key.clone(), + wh_url.clone(), + ); + webhook_routers.push(square_webhook_router); + tracing::info!("Square webhook enabled at /webhook/square/payment"); + } else if square.sync_interval_secs > 0 { + // Polling mode: only when webhook is not configured + cdk_agicash::ClosedLoopPayment::start_polling_sync( + sync.clone(), + square.sync_interval_secs, + cancel_token.clone(), + ); + tracing::info!( + "Square polling sync started with interval {}s", + square.sync_interval_secs + ); + } + + // Initial sync on startup + if let Err(e) = sync.sync_from_last().await { + tracing::warn!("Initial Square sync failed: {e}"); + } + + tracing::info!("Agicash Square closed-loop validation enabled"); + Ok(Some(( + sync, + closed_loop.valid_destination_name.clone(), + cancel_token, + ))) + } +} + /// Helper function to configure a mint builder with a lightning backend for a specific currency unit async fn configure_backend_for_unit( settings: &config::Settings, From d62d1c5baa4e9d19c9c31e42ee9f79e8f54d0ca6 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 16 Feb 2026 17:34:07 -0800 Subject: [PATCH 5/7] feat: add internal closed-loop payment validation for FakeWallet Refactor ClosedLoopPayment in cdk-agicash to support multiple validation modes via a ClosedLoopValidator enum. The existing Square mode is preserved (new_square constructor), and a new Internal mode (new_internal) tracks payment hashes from created invoices and rejects unknown hashes at melt time. A new `closed-loop` feature in cdk-mintd allows using cdk-agicash without requiring the Strike backend. Co-Authored-By: Claude Opus 4.6 --- crates/cdk-agicash/src/lib.rs | 160 +++++++++-- crates/cdk-mintd/Cargo.toml | 3 +- crates/cdk-mintd/src/config.rs | 3 + crates/cdk-mintd/src/lib.rs | 33 ++- ...at-internal-closed-loop-fakewallet-plan.md | 256 ++++++++++++++++++ 5 files changed, 423 insertions(+), 32 deletions(-) create mode 100644 docs/plans/2026-02-16-feat-internal-closed-loop-fakewallet-plan.md diff --git a/crates/cdk-agicash/src/lib.rs b/crates/cdk-agicash/src/lib.rs index 725345b8d8..e075291916 100644 --- a/crates/cdk-agicash/src/lib.rs +++ b/crates/cdk-agicash/src/lib.rs @@ -1,16 +1,20 @@ -//! CDK closed-loop payment validation with Square +//! CDK closed-loop payment validation //! //! Wraps a MintPayment backend to restrict melt operations to invoices -//! generated by a configured Square merchant. +//! that pass closed-loop validation. Supports multiple validation modes: +//! - **Square**: validates against Square merchant payment timestamps +//! - **Internal**: only allows melting invoices created by the same mint #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] +use std::collections::HashSet; use std::pin::Pin; -use std::sync::Arc; +use std::sync::{Arc, RwLock}; use std::time::Duration; use async_trait::async_trait; +use cdk_common::bitcoin::hashes::Hash; use cdk_common::payment::{ self, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, SettingsResponse, @@ -29,26 +33,54 @@ pub mod webhook; use sync::SquareSync; -/// Closed-loop payment wrapper that validates invoices against Square payments -/// before delegating to the inner payment backend. +/// Validation strategy for closed-loop payments +#[allow(missing_debug_implementations)] +pub enum ClosedLoopValidator { + /// Square merchant validation — checks invoice timestamps against Square payments + Square { + /// Square sync service for payment timestamp lookups + sync: Arc, + /// Merchant name shown in rejection error messages + valid_destination_name: String, + /// Token to cancel background Square polling + cancel_token: CancellationToken, + }, + /// Internal validation — only invoices created by this mint are allowed + Internal { + /// Payment hashes from invoices created by this mint + known_hashes: Arc>>, + /// Destination name shown in rejection error messages + destination_name: String, + }, +} + +/// Closed-loop payment wrapper that validates invoices before delegating +/// to the inner payment backend. pub struct ClosedLoopPayment { inner: Arc + Send + Sync>, - sync: Arc, - valid_destination_name: String, - cancel_token: CancellationToken, + validator: ClosedLoopValidator, } impl std::fmt::Debug for ClosedLoopPayment { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match &self.validator { + ClosedLoopValidator::Square { + valid_destination_name, + .. + } => valid_destination_name, + ClosedLoopValidator::Internal { + destination_name, .. + } => destination_name, + }; f.debug_struct("ClosedLoopPayment") - .field("valid_destination_name", &self.valid_destination_name) + .field("destination_name", name) .finish() } } impl ClosedLoopPayment { - /// Create a new ClosedLoopPayment wrapper - pub fn new( + /// Create a new ClosedLoopPayment with Square merchant validation + pub fn new_square( inner: Arc + Send + Sync>, sync: Arc, valid_destination_name: String, @@ -56,13 +88,29 @@ impl ClosedLoopPayment { ) -> Self { Self { inner, - sync, - valid_destination_name, - cancel_token, + validator: ClosedLoopValidator::Square { + sync, + valid_destination_name, + cancel_token, + }, } } - /// Start a background polling sync task + /// Create a new ClosedLoopPayment with internal validation (mint-only invoices) + pub fn new_internal( + inner: Arc + Send + Sync>, + destination_name: String, + ) -> Self { + Self { + inner, + validator: ClosedLoopValidator::Internal { + known_hashes: Arc::new(RwLock::new(HashSet::new())), + destination_name, + }, + } + } + + /// Start a background polling sync task (Square mode only) pub fn start_polling_sync( sync: Arc, interval_secs: u64, @@ -90,12 +138,31 @@ impl ClosedLoopPayment { }); } - /// Validate that a bolt11 invoice is from a known Square merchant + /// Validate a bolt11 invoice against the configured closed-loop validator async fn validate_invoice(&self, bolt11: &Bolt11Invoice) -> Result<(), payment::Error> { + match &self.validator { + ClosedLoopValidator::Square { + sync, + valid_destination_name, + .. + } => Self::validate_square(bolt11, sync, valid_destination_name).await, + ClosedLoopValidator::Internal { + known_hashes, + destination_name, + } => Self::validate_internal(bolt11, known_hashes, destination_name), + } + } + + /// Square validation: check invoice description and timestamp against Square payments + async fn validate_square( + bolt11: &Bolt11Invoice, + sync: &SquareSync, + valid_destination_name: &str, + ) -> Result<(), payment::Error> { // Pre-filter: check description contains valid_destination_name let description_matches = match bolt11.description() { lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(desc) => { - desc.to_string().contains(&self.valid_destination_name) + desc.to_string().contains(valid_destination_name) } lightning_invoice::Bolt11InvoiceDescriptionRef::Hash(_) => { // Cannot verify description hash, allow through to timestamp check @@ -106,11 +173,11 @@ impl ClosedLoopPayment { if !description_matches { tracing::debug!( "Invoice description does not match '{}', rejecting", - self.valid_destination_name + valid_destination_name ); return Err(payment::Error::PaymentNotAllowed(format!( "This ecash can only be spent at {}", - self.valid_destination_name + valid_destination_name ))); } @@ -118,7 +185,7 @@ impl ClosedLoopPayment { let invoice_epoch = bolt11.duration_since_epoch().as_secs(); // KV lookup (check +/- 1 second window) - if self.sync.has_payment_near_timestamp(invoice_epoch).await? { + if sync.has_payment_near_timestamp(invoice_epoch).await? { tracing::debug!("Square payment found near invoice timestamp {invoice_epoch}"); return Ok(()); } @@ -127,11 +194,7 @@ impl ClosedLoopPayment { tracing::debug!( "No Square payment found near timestamp {invoice_epoch}, triggering on-demand sync" ); - if self - .sync - .on_demand_sync_for_timestamp(invoice_epoch) - .await? - { + if sync.on_demand_sync_for_timestamp(invoice_epoch).await? { tracing::debug!( "Square payment found after on-demand sync near timestamp {invoice_epoch}" ); @@ -142,9 +205,43 @@ impl ClosedLoopPayment { tracing::info!("No Square payment found near invoice timestamp {invoice_epoch}"); Err(payment::Error::PaymentNotAllowed(format!( "This ecash can only be spent at {}", - self.valid_destination_name + valid_destination_name ))) } + + /// Internal validation: check if payment hash was created by this mint + fn validate_internal( + bolt11: &Bolt11Invoice, + known_hashes: &RwLock>, + destination_name: &str, + ) -> Result<(), payment::Error> { + let payment_hash: [u8; 32] = *bolt11.payment_hash().as_byte_array(); + let hashes = known_hashes.read().expect("known_hashes lock poisoned"); + if hashes.contains(&payment_hash) { + tracing::debug!("Internal closed-loop: payment hash found, allowing melt"); + Ok(()) + } else { + tracing::info!("Internal closed-loop: unknown payment hash, rejecting melt"); + Err(payment::Error::PaymentNotAllowed(format!( + "This ecash can only be spent at {}", + destination_name + ))) + } + } + + /// Record a payment hash from a newly created invoice (Internal mode) + fn record_payment_hash(&self, response: &CreateIncomingPaymentResponse) { + if let ClosedLoopValidator::Internal { + ref known_hashes, .. + } = self.validator + { + if let PaymentIdentifier::PaymentHash(hash) = &response.request_lookup_id { + let mut hashes = known_hashes.write().expect("known_hashes lock poisoned"); + hashes.insert(*hash); + tracing::debug!("Internal closed-loop: recorded payment hash"); + } + } + } } #[async_trait] @@ -156,7 +253,9 @@ impl MintPayment for ClosedLoopPayment { } async fn stop(&self) -> Result<(), Self::Err> { - self.cancel_token.cancel(); + if let ClosedLoopValidator::Square { cancel_token, .. } = &self.validator { + cancel_token.cancel(); + } self.inner.stop().await } @@ -169,9 +268,12 @@ impl MintPayment for ClosedLoopPayment { unit: &cdk_common::nuts::CurrencyUnit, options: IncomingPaymentOptions, ) -> Result { - self.inner + let response = self + .inner .create_incoming_payment_request(unit, options) - .await + .await?; + self.record_payment_hash(&response); + Ok(response) } async fn get_payment_quote( diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index 37896ab7d3..c87ac2cb49 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -32,7 +32,8 @@ prometheus = ["cdk/prometheus", "dep:cdk-prometheus", "cdk-sqlite?/prometheus", # Agicash Fork additions strike = ["dep:cdk-strike"] -agicash = ["dep:cdk-agicash", "dep:tokio-util", "strike"] +closed-loop = ["dep:cdk-agicash"] +agicash = ["closed-loop", "dep:tokio-util", "strike"] [dependencies] anyhow.workspace = true diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 0b735ac24d..e3d209a8c8 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -446,6 +446,8 @@ pub enum ClosedLoopType { /// Square payments - validates against Square payment system #[default] Square, + /// Internal - only invoices created by the same mint can be melted + Internal, } impl std::str::FromStr for ClosedLoopType { @@ -454,6 +456,7 @@ impl std::str::FromStr for ClosedLoopType { fn from_str(s: &str) -> Result { match s.to_lowercase().as_str() { "square" => Ok(ClosedLoopType::Square), + "internal" => Ok(ClosedLoopType::Internal), _ => Err(format!("Unknown closed loop type: {s}")), } } diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 87f2d8b5b1..38178568d9 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -531,6 +531,14 @@ async fn configure_lightning_backend( let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined"); tracing::info!("Using fake wallet: {:?}", fake_wallet); + // Check if internal closed-loop is configured + #[cfg(feature = "closed-loop")] + let internal_closed_loop = settings + .agicash + .as_ref() + .and_then(|a| a.closed_loop.as_ref()) + .filter(|cl| cl.closed_loop_type == config::ClosedLoopType::Internal); + for unit in fake_wallet.clone().supported_units { let fake = fake_wallet .setup(settings, unit.clone(), None, work_dir, _kv_store.clone()) @@ -538,12 +546,33 @@ async fn configure_lightning_backend( #[cfg(feature = "prometheus")] let fake = MetricsMintPayment::new(fake); + #[cfg(feature = "closed-loop")] + let backend: Arc< + dyn MintPayment + Send + Sync, + > = if let Some(cl) = internal_closed_loop { + tracing::info!( + "Wrapping FakeWallet with internal closed-loop (destination: {})", + cl.valid_destination_name + ); + Arc::new(cdk_agicash::ClosedLoopPayment::new_internal( + Arc::new(fake), + cl.valid_destination_name.clone(), + )) + } else { + Arc::new(fake) + }; + + #[cfg(not(feature = "closed-loop"))] + let backend: Arc< + dyn MintPayment + Send + Sync, + > = Arc::new(fake); + mint_builder = configure_backend_for_unit( settings, mint_builder, unit.clone(), mint_melt_limits, - Arc::new(fake), + backend, ) .await?; } @@ -626,7 +655,7 @@ async fn configure_lightning_backend( let backend: Arc< dyn MintPayment + Send + Sync, > = if let Some((ref sync, ref name, ref cancel_token)) = agicash_sync { - Arc::new(cdk_agicash::ClosedLoopPayment::new( + Arc::new(cdk_agicash::ClosedLoopPayment::new_square( Arc::new(strike), sync.clone(), name.clone(), diff --git a/docs/plans/2026-02-16-feat-internal-closed-loop-fakewallet-plan.md b/docs/plans/2026-02-16-feat-internal-closed-loop-fakewallet-plan.md new file mode 100644 index 0000000000..200b259da5 --- /dev/null +++ b/docs/plans/2026-02-16-feat-internal-closed-loop-fakewallet-plan.md @@ -0,0 +1,256 @@ +--- +title: "feat: Add internal closed-loop payment validation for FakeWallet" +type: feat +status: completed +date: 2026-02-16 +--- + +# feat: Add internal closed-loop payment validation for FakeWallet + +## Overview + +Add a new "internal" closed-loop payment type that restricts melt operations to invoices created by the same mint. Unlike the existing Square closed-loop (which validates against an external merchant API), the internal mode is self-contained — it tracks payment hashes from `create_incoming_payment_request` and rejects any bolt11 at melt time whose payment hash wasn't minted locally. Primary use case is FakeWallet backends for testing and development, but works with any backend. + +## Problem Statement / Motivation + +The existing closed-loop implementation (`ClosedLoopPayment` in cdk-agicash) is hardwired to Square validation — its struct holds `Arc` and `CancellationToken` directly. For testing, development, and simple closed-loop deployments, there's no way to enforce "mint-only" invoice restrictions without Square infrastructure. An internal closed-loop mode enables: + +- Testing closed-loop behavior without external services +- Simple deployments where the mint should only process its own invoices +- Development environments with FakeWallet that mirror production closed-loop behavior + +## Proposed Solution + +Refactor `ClosedLoopPayment` in cdk-agicash to support multiple validation strategies via an enum. The struct currently holds Square-specific fields — replace those with a `ClosedLoopValidator` enum that dispatches between Square and Internal validation modes. This keeps all closed-loop logic in one crate. + +### Architecture + +``` +┌──────────────────────────────────────────────────┐ +│ ClosedLoopPayment │ (refactored) +│ ┌────────────────────────────────────────────┐ │ +│ │ ClosedLoopValidator (enum) │ │ +│ │ ├─ Square { sync, name, cancel_token } │ │ ← existing behavior +│ │ └─ Internal { known_hashes, name } │ │ ← new +│ └────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ inner: DynMintPayment │──┼──→ Strike / FakeWallet / any backend +│ └────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ + +Square mode: + create_incoming_payment_request → pass through + get_payment_quote → validate via SquareSync → delegate or reject + +Internal mode: + create_incoming_payment_request → record payment hash → delegate to inner + get_payment_quote → check known_hashes → delegate or reject +``` + +## Technical Considerations + +### Refactoring `ClosedLoopPayment` + +**Before** (Square-only): +```rust +pub struct ClosedLoopPayment { + inner: Arc + Send + Sync>, + sync: Arc, + valid_destination_name: String, + cancel_token: CancellationToken, +} +``` + +**After** (multi-mode): +```rust +pub struct ClosedLoopPayment { + inner: Arc + Send + Sync>, + validator: ClosedLoopValidator, +} + +pub enum ClosedLoopValidator { + Square { + sync: Arc, + valid_destination_name: String, + cancel_token: CancellationToken, + }, + Internal { + known_hashes: Arc>, + destination_name: String, + }, +} +``` + +The `MintPayment` impl dispatches on `self.validator`: +- **`create_incoming_payment_request`**: Square passes through; Internal delegates then records hash +- **`get_payment_quote`**: Square runs `validate_invoice` (existing logic); Internal checks `known_hashes` +- **`stop`**: Square cancels token; Internal is a no-op +- **All other methods**: pass through to inner (unchanged) + +### Payment hash tracking (Internal mode) + +- **In-memory `DashSet<[u8; 32]>`**: Concurrent hash set. No persistence needed — FakeWallet invoices don't survive restarts, and a restart clears both invoices and the tracking set. +- **Bolt11 only**: Extract payment hash via `bolt11.payment_hash()`. Bolt12 offers don't have a payment hash at creation time — pass through without recording. +- **No cleanup needed for FakeWallet**: Invoices auto-pay quickly. The set won't grow unboundedly. + +### Interaction with `attempt_internal_settlement` + +The existing `attempt_internal_settlement` in the melt saga is an **optimization** (avoid external payment when the target invoice is local). The internal closed-loop is a **restriction** (reject non-local invoices). They're complementary and don't conflict: + +1. `get_payment_quote` → closed-loop validation → reject if hash unknown +2. `attempt_internal_settlement` → optimize if hash matches a local mint quote +3. `make_payment` → pay via inner backend if external payment needed + +### Configuration + +Extend the existing `ClosedLoopType` enum: + +```rust +pub enum ClosedLoopType { + Square, + Internal, // NEW +} +``` + +For Internal mode, only `agicash.closed_loop` is needed (no `agicash.square` settings). The `valid_destination_name` field is reused for the rejection error message (e.g., "this mint"). + +### Mint info advertisement + +The existing `AgicashInfo { closed_loop: bool }` in NUT06 already advertises closed-loop mode. Set to `true` for both Square and Internal — no changes needed to the schema. + +## Implementation Tasks + +### 1. Add `Internal` variant to `ClosedLoopType` + +**File:** `crates/cdk-mintd/src/config.rs:443-459` + +- [x] Add `Internal` variant to `ClosedLoopType` enum +- [x] Update `FromStr` impl to parse `"internal"` +- [x] Keep `Square` as the `#[default]` + +### 2. Add `dashmap` dependency to cdk-agicash + +**File:** `crates/cdk-agicash/Cargo.toml` + +- [x] ~~Add `dashmap` workspace dependency~~ Used `std::sync::RwLock` instead — no new deps + +### 3. Refactor `ClosedLoopPayment` to support multiple validators + +**File:** `crates/cdk-agicash/src/lib.rs` + +- [x] Add `ClosedLoopValidator` enum with `Square` and `Internal` variants +- [x] Replace `sync`, `valid_destination_name`, `cancel_token` fields with single `validator: ClosedLoopValidator` +- [x] Add `ClosedLoopPayment::new_square(inner, sync, name, cancel_token)` constructor (preserves existing API) +- [x] Add `ClosedLoopPayment::new_internal(inner, destination_name)` constructor +- [x] Move `validate_invoice` logic to dispatch on validator: + - `Square`: existing SquareSync validation (description check + timestamp lookup) + - `Internal`: check `known_hashes.contains(payment_hash)` +- [x] Update `create_incoming_payment_request`: + - `Square`: pass through (existing behavior) + - `Internal`: delegate to inner, parse bolt11 from response, record payment hash +- [x] Update `stop`: + - `Square`: cancel token + delegate + - `Internal`: just delegate +- [x] Keep `start_polling_sync` as-is (only used by Square wiring code) + +### 4. Update cdk-mintd wiring for Square + +**File:** `crates/cdk-mintd/src/lib.rs:625-637` + +- [x] Update `ClosedLoopPayment::new(...)` call to `ClosedLoopPayment::new_square(...)` (or adjust constructor) + +### 5. Add FakeWallet closed-loop wiring + +**File:** `crates/cdk-mintd/src/lib.rs:529-549` + +- [x] In the `FakeWallet` arm: check `settings.agicash.closed_loop` for `ClosedLoopType::Internal` +- [x] If Internal: wrap with `ClosedLoopPayment::new_internal(Arc::new(fake), destination_name)` +- [x] Set `AgicashInfo { closed_loop: true }` on mint builder (already handled by existing code at line 443-449) + +```rust +#[cfg(feature = "fakewallet")] +LnBackend::FakeWallet => { + for unit in fake_wallet.clone().supported_units { + let fake = fake_wallet.setup(...).await?; + + let backend: Arc + Send + Sync> = + if let Some(ref cl) = settings.agicash.as_ref() + .and_then(|a| a.closed_loop.as_ref()) + .filter(|cl| cl.closed_loop_type == ClosedLoopType::Internal) + { + Arc::new(cdk_agicash::ClosedLoopPayment::new_internal( + Arc::new(fake), + cl.valid_destination_name.clone(), + )) + } else { + Arc::new(fake) + }; + + mint_builder = configure_backend_for_unit(..., backend).await?; + } +} +``` + +### 6. Env var parsing (no changes needed) + +**File:** `crates/cdk-mintd/src/env_vars/agicash.rs` + +- [x] Verify: `from_env` already calls `loop_type_str.parse()` which works after `FromStr` update — no code change + +### 7. Update cdk-mintd Cargo.toml + +**File:** `crates/cdk-mintd/Cargo.toml` + +- [x] Ensure cdk-agicash dependency is available when FakeWallet + closed-loop is used. Split feature: `closed-loop = ["dep:cdk-agicash"]`, `agicash = ["closed-loop", "dep:tokio-util", "strike"]` +- [x] FakeWallet wiring uses `#[cfg(feature = "closed-loop")]` to conditionally wrap with `ClosedLoopPayment::new_internal` + +**Note:** The current feature gate `agicash = ["dep:cdk-agicash", "dep:tokio-util", "strike"]` forces Strike. For Internal mode with FakeWallet, we need cdk-agicash without Strike. Options: +- Split: `closed-loop = ["dep:cdk-agicash"]` and `agicash = ["closed-loop", "dep:tokio-util", "strike"]` +- Or: make cdk-agicash always available and only gate the Square-specific setup code + +### 8. Tests + +- [ ] Unit test in cdk-agicash: `ClosedLoopValidator::Internal` — create invoice records hash +- [ ] Unit test in cdk-agicash: `get_payment_quote` with known hash succeeds +- [ ] Unit test in cdk-agicash: `get_payment_quote` with unknown hash returns `PaymentNotAllowed` +- [x] Verify existing Square tests still pass (constructor change) +- [x] Integration consideration: existing FakeWallet integration tests pass (decorator is opt-in) + +## Acceptance Criteria + +- [ ] `ClosedLoopType::Internal` parses from config and env vars +- [ ] `ClosedLoopPayment` supports both Square and Internal validation modes +- [ ] Invoices created by the mint can be melted (happy path) +- [ ] External bolt11 invoices are rejected at `get_payment_quote` with `PaymentNotAllowed` +- [ ] Error message includes the configured `valid_destination_name` +- [ ] Mint info advertises `agicash.closed_loop: true` when internal mode is active +- [ ] Existing Square closed-loop behavior is preserved exactly +- [ ] Existing FakeWallet tests pass without closed-loop config +- [ ] No new feature flags beyond what's minimally needed for the dependency wiring + +## Dependencies & Risks + +- **Low risk**: Refactoring the decorator is straightforward — the MintPayment impl just adds dispatch +- **No breaking changes to Square flow**: `new_square()` constructor preserves existing behavior +- **`dashmap` dependency**: Already used in the workspace. Adds ~0 to compile time. +- **Feature gate adjustment**: The `agicash` feature currently requires `strike`. Need to ensure cdk-agicash is available for FakeWallet without pulling in Strike. This is the trickiest part — see Task 7. + +## References + +### Internal References + +- `ClosedLoopPayment` (refactor target): `crates/cdk-agicash/src/lib.rs:34-227` +- `ClosedLoopPayment::new`: `crates/cdk-agicash/src/lib.rs:49-63` +- `validate_invoice` (Square): `crates/cdk-agicash/src/lib.rs:94-147` +- `MintPayment` impl: `crates/cdk-agicash/src/lib.rs:150-227` +- cdk-agicash Cargo.toml: `crates/cdk-agicash/Cargo.toml` +- MintPayment trait: `crates/cdk-common/src/payment.rs:326-392` +- FakeWallet impl: `crates/cdk-fake-wallet/src/lib.rs:335-800` +- FakeWallet wiring: `crates/cdk-mintd/src/lib.rs:529-549` +- Square wiring: `crates/cdk-mintd/src/lib.rs:600-655` +- ClosedLoopType enum: `crates/cdk-mintd/src/config.rs:443-460` +- Agicash config: `crates/cdk-mintd/src/config.rs:506-512` +- cdk-mintd features: `crates/cdk-mintd/Cargo.toml:35` (`agicash = ["dep:cdk-agicash", ...]`) +- Env var parsing: `crates/cdk-mintd/src/env_vars/agicash.rs` +- Mint info agicash: `crates/cashu/src/nuts/nut06.rs:19-25` +- `PaymentNotAllowed` error: `crates/cdk-common/src/payment.rs:75` From 5f25bee1116532700293140668cee9dbd3738f09 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 17 Feb 2026 10:05:31 -0800 Subject: [PATCH 6/7] ci: add GHCR publish workflow for ghcr.io/makeprisms/cdk-mintd --- .github/workflows/ghcr-publish.yml | 63 ++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .github/workflows/ghcr-publish.yml diff --git a/.github/workflows/ghcr-publish.yml b/.github/workflows/ghcr-publish.yml new file mode 100644 index 0000000000..5371371e50 --- /dev/null +++ b/.github/workflows/ghcr-publish.yml @@ -0,0 +1,63 @@ +name: Publish Docker Image to GHCR + +on: + push: + branches: + - square-closed-loop + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Custom tag for the image' + required: false + default: '' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: makeprisms/cdk-mintd + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease && !contains(github.ref_name, 'rc') }} + type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }} + type=ref,event=branch + type=sha + type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max From 47cd2b560e8e93ee261586c00b383c2d9bb36d8e Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 17 Feb 2026 10:09:41 -0800 Subject: [PATCH 7/7] fix: resolve clippy warnings in cdk-strike and cdk-ldk-node Box StrikeApiError in Error::Api variant to fix result_large_err, and replace unwrap() with expect() in cdk-ldk-node test code. --- crates/cdk-ldk-node/src/web/templates/formatters.rs | 2 +- crates/cdk-strike/src/api/error.rs | 2 +- crates/cdk-strike/src/api/mod.rs | 4 ++-- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/cdk-ldk-node/src/web/templates/formatters.rs b/crates/cdk-ldk-node/src/web/templates/formatters.rs index 97630a1320..1ea44d5211 100644 --- a/crates/cdk-ldk-node/src/web/templates/formatters.rs +++ b/crates/cdk-ldk-node/src/web/templates/formatters.rs @@ -124,7 +124,7 @@ mod tests { let now = SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap() + .expect("system time before UNIX epoch") .as_secs(); // Test "Just now" (30 seconds ago) diff --git a/crates/cdk-strike/src/api/error.rs b/crates/cdk-strike/src/api/error.rs index b4c1655620..faab9d3c66 100644 --- a/crates/cdk-strike/src/api/error.rs +++ b/crates/cdk-strike/src/api/error.rs @@ -64,7 +64,7 @@ pub enum Error { /// Strike API returned an error response #[error("Strike API error: {0}")] - Api(#[from] StrikeApiError), + Api(#[from] Box), /// Webhook URL must use HTTPS #[error("Webhook URL must use HTTPS, got: {0}")] diff --git a/crates/cdk-strike/src/api/mod.rs b/crates/cdk-strike/src/api/mod.rs index 95b04296a1..ceeb451ffa 100644 --- a/crates/cdk-strike/src/api/mod.rs +++ b/crates/cdk-strike/src/api/mod.rs @@ -191,7 +191,7 @@ impl StrikeApi { } else { let text = response.text().await?; let error: StrikeApiError = serde_json::from_str(&text)?; - Err(Error::Api(error)) + Err(Error::Api(Box::new(error))) } } @@ -227,7 +227,7 @@ impl StrikeApi { error.trace_id ); } - Err(Error::Api(error)) + Err(Error::Api(Box::new(error))) } }