From 08e2c1dbf17b60dfa3c875863575763fc9f3e7fe Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 15 Oct 2025 15:28:06 -0700 Subject: [PATCH 01/18] feat: add kv_remove_older_than method --- crates/cdk-common/src/database/mint/mod.rs | 8 ++++++ crates/cdk-sql-common/src/mint/mod.rs | 31 +++++++++++++++++++++- 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/cdk-common/src/database/mint/mod.rs b/crates/cdk-common/src/database/mint/mod.rs index 922c9d29de..a7adbc8f05 100644 --- a/crates/cdk-common/src/database/mint/mod.rs +++ b/crates/cdk-common/src/database/mint/mod.rs @@ -390,6 +390,14 @@ pub trait KVStoreTransaction<'a, Error>: DbTransactionFinalizer { primary_namespace: &str, secondary_namespace: &str, ) -> Result, Error>; + + /// Remove values from key-value store where the value was updated before a given time + async fn kv_remove_older_than( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + expiry_time: u64, + ) -> Result<(), Error>; } /// Base database writer diff --git a/crates/cdk-sql-common/src/mint/mod.rs b/crates/cdk-sql-common/src/mint/mod.rs index 3bd0c4bfca..789d981ec2 100644 --- a/crates/cdk-sql-common/src/mint/mod.rs +++ b/crates/cdk-sql-common/src/mint/mod.rs @@ -15,7 +15,7 @@ use std::sync::Arc; use async_trait::async_trait; use bitcoin::bip32::DerivationPath; -use cdk_common::database::mint::validate_kvstore_params; +use cdk_common::database::mint::{validate_kvstore_params, validate_kvstore_string}; use cdk_common::database::{ self, ConversionError, Error, MintDatabase, MintDbWriterFinalizer, MintKeyDatabaseTransaction, MintKeysDatabase, MintProofsDatabase, MintQuotesDatabase, MintQuotesTransaction, @@ -1893,6 +1893,35 @@ where .map(|row| Ok(column_as_string!(&row[0]))) .collect::, Error>>()?) } + + async fn kv_remove_older_than( + &mut self, + primary_namespace: &str, + secondary_namespace: &str, + expiry_time: u64, + ) -> Result<(), Error> { + validate_kvstore_string(primary_namespace)?; + validate_kvstore_string(secondary_namespace)?; + + let current_time = unix_time(); + let cutoff_time = current_time.saturating_sub(expiry_time); + + query( + r#" + DELETE FROM kv_store + WHERE primary_namespace = :primary_namespace + AND secondary_namespace = :secondary_namespace + AND updated_time < :cutoff_time + "#, + )? + .bind("primary_namespace", primary_namespace.to_owned()) + .bind("secondary_namespace", secondary_namespace.to_owned()) + .bind("cutoff_time", cutoff_time as i64) + .execute(&self.inner) + .await?; + + Ok(()) + } } #[async_trait] From 931641387b642a7f96111fbc78568abdb198962b Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 15 Oct 2025 20:45:42 -0700 Subject: [PATCH 02/18] feat: square payment tracker --- Cargo.toml | 2 + crates/cdk-square/Cargo.toml | 30 +++ crates/cdk-square/README.md | 87 ++++++ crates/cdk-square/src/client.rs | 373 ++++++++++++++++++++++++++ crates/cdk-square/src/config.rs | 28 ++ crates/cdk-square/src/error.rs | 23 ++ crates/cdk-square/src/lib.rs | 62 +++++ crates/cdk-square/src/sync.rs | 159 +++++++++++ crates/cdk-square/src/types.rs | 166 ++++++++++++ crates/cdk-square/src/util.rs | 99 +++++++ crates/cdk-square/src/webhook.rs | 439 +++++++++++++++++++++++++++++++ 11 files changed, 1468 insertions(+) create mode 100644 crates/cdk-square/Cargo.toml create mode 100644 crates/cdk-square/README.md create mode 100644 crates/cdk-square/src/client.rs create mode 100644 crates/cdk-square/src/config.rs create mode 100644 crates/cdk-square/src/error.rs create mode 100644 crates/cdk-square/src/lib.rs create mode 100644 crates/cdk-square/src/sync.rs create mode 100644 crates/cdk-square/src/types.rs create mode 100644 crates/cdk-square/src/util.rs create mode 100644 crates/cdk-square/src/webhook.rs diff --git a/Cargo.toml b/Cargo.toml index ff390895af..b3fb656c2c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -114,6 +114,8 @@ strum_macros = "0.27.1" rustls = { version = "0.23.27", default-features = false, features = ["ring"] } prometheus = { version = "0.13.4", features = ["process"], default-features = false } +cdk-square = { path = "./crates/cdk-square", version = "=0.13.0" } + diff --git a/crates/cdk-square/Cargo.toml b/crates/cdk-square/Cargo.toml new file mode 100644 index 0000000000..e2c5214f5d --- /dev/null +++ b/crates/cdk-square/Cargo.toml @@ -0,0 +1,30 @@ +[package] +name = "cdk-square" +version.workspace = true +edition.workspace = true +authors = ["CDK Developers"] +license.workspace = true +homepage = "https://github.com/cashubtc/cdk" +repository = "https://github.com/cashubtc/cdk.git" +rust-version.workspace = true # MSRV +description = "CDK Square payment backend for internal payment tracking" +readme = "README.md" + +[dependencies] +async-trait.workspace = true +anyhow.workspace = true +cdk-common = { workspace = true, features = ["mint"] } +futures.workspace = true +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +serde_json.workspace = true +serde.workspace = true +uuid.workspace = true +axum.workspace = true + +# Square payment integration +squareup = "2.13.2" +reqwest = { workspace = true } +bitcoin = { workspace = true } + diff --git a/crates/cdk-square/README.md b/crates/cdk-square/README.md new file mode 100644 index 0000000000..a1a60ac635 --- /dev/null +++ b/crates/cdk-square/README.md @@ -0,0 +1,87 @@ +# CDK Square + +Square payment backend integration for Cashu Development Kit (CDK). + +## Overview + +`cdk-square` provides Square Lightning payment tracking and webhook handling for payment detection. It monitors Lightning invoices paid through Square's payment system and stores payment hash mappings in a persistent KV store. + +## Features + +- **Webhook Mode**: Real-time payment notifications with HMAC signature verification +- **Polling Mode**: Automatic fallback to polling every 5 seconds when webhooks unavailable +- **Payment Hash Tracking**: Maps Lightning payment hashes to Square payment IDs +- **KV Store Integration**: Persistent storage for invoice hashes and configuration + +## Usage + +```rust +use cdk_square::{Square, SquareConfig}; + +let config = SquareConfig { + api_token: "your-square-api-token".to_string(), + environment: "SANDBOX".to_string(), // or "PRODUCTION" + webhook_enabled: true, + payment_expiry: 300, // seconds - how far back to sync payments +}; + +// Initialize with optional webhook URL +let webhook_url = Some("https://your-mint.com/webhook/square/payment".to_string()); + +let square = Square::from_config(config, webhook_url, kv_store)?; + +// Start payment tracking (sets up webhooks or starts polling) +square.start().await?; + +// Check if an invoice has been paid +let invoice = Bolt11Invoice::from_str(&bolt11_string)?; +let paid = square.check_invoice_exists(&invoice).await?; + +// Create webhook router (if using webhook mode) +if let Some(router) = square.create_webhook_router() { + // Merge into your Axum app + app = app.merge(router); +} +``` + +## Webhook vs Polling Mode + +**Webhook Mode (Recommended)** +- Real-time notifications when payments are received +- Lower latency and resource usage +- Requires HTTPS endpoint accessible by Square +- Automatic signature verification + +**Polling Mode** +- Polls Square API every 5 seconds for new payments + +Enable webhook mode by setting `webhook_enabled: true` and providing a `webhook_url`. Otherwise, polling mode is used automatically. + +## Configuration + +```rust +pub struct SquareConfig { + /// Square API token + pub api_token: String, + /// Square environment (SANDBOX or PRODUCTION) + pub environment: String, + /// Enable webhook mode (if false, uses polling) + pub webhook_enabled: bool, + /// Payment expiry time in seconds (default: 300) + pub payment_expiry: u64, +} +``` + +## Security + +Webhook requests are verified using HMAC-SHA256 signatures: +1. Square provides a `signature_key` when creating webhook subscriptions +2. Each webhook includes an `x-square-hmacsha256-signature` header +3. Signature verified as: `HMAC-SHA256(webhook_url + request_body, signature_key)` +4. Invalid signatures are rejected with 401 Unauthorized + +See [Square Webhook Documentation](https://developer.squareup.com/docs/webhooks/step3validate). + +## License + +MIT diff --git a/crates/cdk-square/src/client.rs b/crates/cdk-square/src/client.rs new file mode 100644 index 0000000000..2577fabeab --- /dev/null +++ b/crates/cdk-square/src/client.rs @@ -0,0 +1,373 @@ +//! Square API client wrapper and core functionality + +use std::sync::Arc; + +use cdk_common::database::mint::DynMintKVStore; +use cdk_common::lightning_invoice::Bolt11Invoice; +use cdk_common::util::hex; +use squareup::config::{Configuration as SquareConfiguration, Environment as SquareEnvironment}; +use squareup::SquareClient; +use tokio::sync::RwLock; + +use crate::config::SquareConfig; +use crate::error::Error; +use crate::types::{ListMerchantsResponse, ListPaymentsParams, ListPaymentsResponse}; +use crate::util::{ + INVOICE_HASH_PREFIX, SQUARE_KV_PRIMARY_NAMESPACE, SQUARE_KV_SECONDARY_NAMESPACE, +}; + +const SYNC_POLLING_INTERVAL: u64 = 5; + +/// Square payment backend for tracking Lightning payments +#[derive(Clone)] +pub struct Square { + /// Square API client + pub(crate) client: Arc, + /// Square API token for direct API calls + pub(crate) api_token: String, + /// Square environment (sandbox or production) + pub(crate) environment: SquareEnvironment, + /// Square webhook notification URL (optional - if None, will use polling) + pub(crate) webhook_url: Option, + /// Enable webhook mode (if false, uses polling mode even if webhook_url is provided) + pub(crate) webhook_enabled: bool, + /// Payment expiry time in seconds (how far back to sync payments) + pub(crate) payment_expiry: u64, + /// KV store for persistent data (invoice hashes, signature key, etc.) + pub(crate) kv_store: DynMintKVStore, + /// Cached merchant business names for invoice description matching + pub(crate) merchant_names: Arc>>, +} + +impl Square { + /// Initialize Square backend from configuration + /// + /// Returns `Err` if configuration is invalid. + pub fn from_config( + square_config: SquareConfig, + webhook_url: Option, + kv_store: DynMintKVStore, + ) -> Result { + let environment = match square_config.environment.to_uppercase().as_str() { + "PRODUCTION" => SquareEnvironment::Production, + "SANDBOX" | _ => SquareEnvironment::Sandbox, + }; + + let webhook_enabled = square_config.webhook_enabled; + + let config = SquareConfiguration { + environment: environment.clone(), + http_client_config: squareup::http::client::HttpClientConfiguration::default(), + base_uri: squareup::config::BaseUri::default(), + }; + + let square_client = SquareClient::try_new(config) + .map_err(|e| Error::SquareConfig(format!("Failed to create Square client: {}", e)))?; + + let square = Self { + client: Arc::new(square_client), + api_token: square_config.api_token, + environment, + webhook_url, + webhook_enabled, + payment_expiry: square_config.payment_expiry, + kv_store, + merchant_names: Arc::new(RwLock::new(Vec::new())), + }; + + Ok(square) + } + + /// Start Square backend (setup webhook and sync payments) + /// + /// If webhook_enabled is true and webhook_url is configured, sets up webhook subscription. + /// Otherwise, starts a background task that polls every 5 seconds. + /// + /// This method blocks until the initial payment sync completes successfully. + /// If the initial sync fails, an error is returned and the mint will not start. + pub async fn start(&self) -> Result<(), Error> { + self.refresh_merchant_names().await?; + + if self.webhook_enabled && self.webhook_url.is_some() { + self.setup_webhook_subscription().await?; + + self.sync_payments().await?; + } else { + self.sync_payments().await?; + + let square = self.clone(); + tokio::spawn(async move { + let mut interval = + tokio::time::interval(tokio::time::Duration::from_secs(SYNC_POLLING_INTERVAL)); + loop { + interval.tick().await; + if let Err(e) = square.sync_payments().await { + tracing::warn!("Square payment sync failed: {}", e); + } + } + }); + } + tracing::debug!("Square payment sync completed successfully"); + + Ok(()) + } + + /// Check if a Square invoice exists by Bolt11 invoice + /// + /// Invoices created by Square merchants are assumed to have the merchant name in the description. + /// + /// This method first checks if the invoice description contains any cached merchant names. + /// If no merchant name is found in the description, returns None without checking the KV store. + /// This optimization avoids unnecessary database lookups for invoices that are not from Square merchants. + /// + /// If a merchant name is found but the payment hash is not in the KV store, it re-syncs the payments and checks again. + pub async fn check_invoice_exists(&self, invoice: &Bolt11Invoice) -> Result { + let description = invoice.description().to_string(); + let description_lower = description.to_lowercase(); + + let merchant_names = self.merchant_names.read().await; + + // Check if any merchant name appears in the description (case-insensitive) + let merchant_found = merchant_names.iter().any(|merchant_name| { + let merchant_name_lower = merchant_name.to_lowercase(); + description_lower.contains(&merchant_name_lower) + }); + + drop(merchant_names); + + if !merchant_found { + tracing::debug!( + "No Square merchant name found in invoice description: '{}'. Skipping KV store check.", + description + ); + return Ok(false); + } + + let payment_hash: &[u8] = invoice.payment_hash().as_ref(); + let key = format!("{}{}", INVOICE_HASH_PREFIX, hex::encode(payment_hash)); + + let result = self + .kv_store + .kv_read( + SQUARE_KV_PRIMARY_NAMESPACE, + SQUARE_KV_SECONDARY_NAMESPACE, + &key, + ) + .await?; + + // If found, return immediately + if result.is_some() { + return Ok(true); + } + + // Not found in KV store - trigger re-sync to get latest payments + tracing::debug!( + "Payment hash not found in KV store, triggering re-sync for invoice with description: {}", + description + ); + + self.sync_payments().await?; + + // Check KV store again after sync + let result_after_sync = self + .kv_store + .kv_read( + SQUARE_KV_PRIMARY_NAMESPACE, + SQUARE_KV_SECONDARY_NAMESPACE, + &key, + ) + .await?; + + if let Some(bytes) = result_after_sync { + tracing::debug!( + "Found Square payment in KV store after re-sync: {} (hash: {})", + String::from_utf8_lossy(&bytes).to_string(), + hex::encode(payment_hash) + ); + Ok(true) + } else { + tracing::debug!( + "Payment not found in Square even after re-sync (hash: {}, description: {})", + hex::encode(payment_hash), + description + ); + Ok(false) + } + } + + /// Store Square invoice payment hash to payment ID mapping in KV store + pub(crate) async fn store_invoice_hash( + &self, + payment_hash: &[u8; 32], + payment_id: &str, + ) -> Result<(), Error> { + let key = format!("{}{}", INVOICE_HASH_PREFIX, hex::encode(payment_hash)); + let value = payment_id.as_bytes(); + + let mut tx = self.kv_store.begin_transaction().await?; + tx.kv_write( + SQUARE_KV_PRIMARY_NAMESPACE, + SQUARE_KV_SECONDARY_NAMESPACE, + &key, + value, + ) + .await?; + tx.commit().await?; + + Ok(()) + } + + pub(crate) async fn remove_expired_payments(&self) -> Result<(), Error> { + let mut tx = self.kv_store.begin_transaction().await?; + tx.kv_remove_older_than( + SQUARE_KV_PRIMARY_NAMESPACE, + SQUARE_KV_SECONDARY_NAMESPACE, + self.payment_expiry, + ) + .await?; + tx.commit().await?; + Ok(()) + } +} + +/// These methods directly call the Square API via HTTP, +/// as the squareup SDK v2.13.2 does not expose the functionality we need. +impl Square { + /// List payments from Square API + /// + /// Applies client-side filtering based on `params.brand` filter. + pub async fn list_payments( + &self, + params: ListPaymentsParams, + ) -> Result { + use crate::types::PaymentBrand; + + let base_url = self.get_base_url(); + + let url = format!("{}/v2/payments", base_url); + let client = reqwest::Client::new(); + + tracing::debug!( + "Listing Square payments (begin_time: {:?}, cursor: {:?}, limit: {}, brand: {:?})", + params.begin_time, + params.cursor, + params.limit, + params.brand + ); + + let mut request = client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Square-Version", "2025-09-24") + .query(&[("limit", params.limit.to_string())]); + + if let Some(ref begin_time) = params.begin_time { + request = request.query(&[("begin_time", begin_time.as_str())]); + } + + if let Some(ref cursor) = params.cursor { + request = request.query(&[("cursor", cursor.as_str())]); + } + + let response = request + .send() + .await + .map_err(|e| Error::SquareHttp(format!("Failed to list payments: {}", e)))?; + + if !response.status().is_success() { + let error_body = response + .text() + .await + .unwrap_or_else(|_| "unknown".to_string()); + tracing::error!("Failed to list Square payments: {}", error_body); + return Err(Error::SquareHttp(format!( + "List payments failed: {}", + error_body + ))); + } + + let mut response_body: ListPaymentsResponse = response + .json() + .await + .map_err(|e| Error::SquareHttp(format!("Failed to parse payments response: {}", e)))?; + + if params.brand != PaymentBrand::All { + response_body.payments.retain(|payment| { + let brand = payment + .wallet_details + .as_ref() + .and_then(|details| details.brand.as_deref()); + params.brand.matches(brand) + }); + } + + Ok(response_body) + } + + /// List merchants associated with the access token + /// + /// According to Square's API, the access token is associated with a single merchant, + /// so this typically returns a list with one merchant object. + pub async fn list_merchants(&self) -> Result { + let base_url = self.get_base_url(); + + let url = format!("{}/v2/merchants", base_url); + let client = reqwest::Client::new(); + + let response = client + .get(&url) + .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Square-Version", "2025-09-24") + .header("Content-Type", "application/json") + .send() + .await + .map_err(|e| Error::SquareHttp(format!("Failed to list merchants: {}", e)))?; + + if !response.status().is_success() { + let error_body = response + .text() + .await + .unwrap_or_else(|_| "unknown".to_string()); + tracing::error!("Failed to list Square merchants: {}", error_body); + return Err(Error::SquareHttp(format!( + "List merchants failed: {}", + error_body + ))); + } + + let response_body: ListMerchantsResponse = response + .json() + .await + .map_err(|e| Error::SquareHttp(format!("Failed to parse merchants response: {}", e)))?; + + Ok(response_body) + } + + /// Refresh merchant names cache by listing merchants + /// + /// This method fetches all merchants and stores their business names in memory. + pub async fn refresh_merchant_names(&self) -> Result<(), Error> { + let merchants_response = self.list_merchants().await?; + + let mut merchant_names = self.merchant_names.write().await; + merchant_names.clear(); + + for merchant in merchants_response.merchant { + let business_name = merchant + .business_name + .unwrap_or_else(|| merchant.id.clone()); + merchant_names.push(business_name); + } + + tracing::debug!("Cached merchant names: {}", merchant_names.join(", ")); + + Ok(()) + } + + pub(crate) fn get_base_url(&self) -> String { + match self.environment { + SquareEnvironment::Production => "https://connect.squareup.com".to_string(), + SquareEnvironment::Sandbox => "https://connect.squareupsandbox.com".to_string(), + } + } +} diff --git a/crates/cdk-square/src/config.rs b/crates/cdk-square/src/config.rs new file mode 100644 index 0000000000..cca16e605f --- /dev/null +++ b/crates/cdk-square/src/config.rs @@ -0,0 +1,28 @@ +//! Configuration types for Square integration + +use serde::{Deserialize, Serialize}; + +/// Square configuration for payment backends +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SquareConfig { + /// Square API token + pub api_token: String, + /// Square environment (SANDBOX or PRODUCTION) + pub environment: String, + /// Enable webhook mode (if false, uses polling mode even if webhook_url is provided) + /// Default is true + #[serde(default = "default_webhook_enabled")] + pub webhook_enabled: bool, + /// Payment expiry time in seconds (how far back to sync payments) + /// Default is 300 seconds (5 minutes) + #[serde(default = "default_payment_expiry")] + pub payment_expiry: u64, +} + +fn default_webhook_enabled() -> bool { + true +} + +fn default_payment_expiry() -> u64 { + 300 // 5 minutes in seconds +} diff --git a/crates/cdk-square/src/error.rs b/crates/cdk-square/src/error.rs new file mode 100644 index 0000000000..ba7bacb7f5 --- /dev/null +++ b/crates/cdk-square/src/error.rs @@ -0,0 +1,23 @@ +//! Error types for Square integration + +use thiserror::Error; + +/// Square error type +#[derive(Debug, Error)] +pub enum Error { + /// Square HTTP error + #[error("Square HTTP error: {0}")] + SquareHttp(String), + + /// Square configuration error + #[error("Square configuration error: {0}")] + SquareConfig(String), + + /// Database error + #[error("Database error: {0}")] + Database(#[from] cdk_common::database::Error), + + /// Bolt11 parsing error + #[error("Bolt11 parsing error: {0}")] + Bolt11Parse(String), +} diff --git a/crates/cdk-square/src/lib.rs b/crates/cdk-square/src/lib.rs new file mode 100644 index 0000000000..6d5e3aaa43 --- /dev/null +++ b/crates/cdk-square/src/lib.rs @@ -0,0 +1,62 @@ +//! CDK Square payment backend integration +//! +//! This crate provides Square Lightning payment tracking and webhook handling +//! for internal payment detection in payment processor backends. +//! +//! # Features +//! +//! - **Webhook Mode**: Real-time payment notifications with signature verification +//! - **Polling Mode**: Automatic fallback to polling every 5 seconds +//! - **KV Store Integration**: Persistent storage of payment data +//! - **Payment Hash Tracking**: Maps Lightning payment hashes to Square payment IDs +//! +//! # Usage +//! +//! ```rust,no_run +//! use cdk_square::{Square, SquareConfig}; +//! +//! # async fn example() -> Result<(), Box> { +//! # let kv_store = todo!(); +//! let config = SquareConfig { +//! api_token: "your-api-token".to_string(), +//! environment: "SANDBOX".to_string(), +//! webhook_enabled: true, +//! payment_expiry: 300, // 5 minutes +//! }; +//! +//! let square = Square::from_config( +//! Some(config), +//! Some("https://your-mint.com/webhook".to_string()), +//! kv_store, +//! ) +//! .await?; +//! +//! if let Some(square) = square { +//! square.start().await?; +//! } +//! # Ok(()) +//! # } +//! ``` + +#![warn(missing_docs)] +#![warn(rustdoc::bare_urls)] + +pub mod client; +pub mod config; +pub mod error; +pub mod sync; +pub mod types; +pub mod util; +pub mod webhook; + +// Re-export main types +pub use client::Square; +pub use config::SquareConfig; +pub use error::Error; +pub use types::{ + LightningDetails, ListMerchantsResponse, ListPaymentsParams, ListPaymentsResponse, Merchant, + Money, Payment, PaymentBrand, WalletDetails, +}; + +/// Default payment expiry time in seconds (5 minutes) +pub const DEFAULT_SQUARE_PAYMENT_EXPIRY: u64 = 300; diff --git a/crates/cdk-square/src/sync.rs b/crates/cdk-square/src/sync.rs new file mode 100644 index 0000000000..04117875a4 --- /dev/null +++ b/crates/cdk-square/src/sync.rs @@ -0,0 +1,159 @@ +//! Payment synchronization logic for Square + +use std::str::FromStr; +use std::time::{SystemTime, UNIX_EPOCH}; + +use crate::error::Error; +use crate::types::{ListPaymentsParams, PaymentBrand}; +use crate::util::{ + unix_to_rfc3339, LAST_SYNC_TIME_KEY, SQUARE_KV_CONFIG_NAMESPACE, SQUARE_KV_PRIMARY_NAMESPACE, +}; + +/// Payment synchronization functionality +impl crate::client::Square { + /// Sync all Square LIGHTNING payments to KV store + /// + /// Only syncs payments created after the last sync time to avoid re-processing all payments. + pub async fn sync_payments(&self) -> Result<(), Error> { + use cdk_common::Bolt11Invoice; + + let last_sync_time = self.get_last_sync_time().await?; + + let current_time_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|e| Error::SquareHttp(format!("System time error: {}", e)))? + .as_secs(); + + let current_time_rfc3339 = unix_to_rfc3339(current_time_secs); + + let mut cursor: Option = None; + let mut synced_count = 0; + let mut total_processed = 0; + + // Paginate through all payments + loop { + let mut params = ListPaymentsParams::new().with_brand(PaymentBrand::Lightning); + + if let Some(ref last_sync) = last_sync_time { + params = params.with_begin_time(last_sync.clone()); + } + + if let Some(ref cursor_value) = cursor { + params = params.with_cursor(cursor_value.clone()); + } + + let response = self.list_payments(params).await?; + + let payments = &response.payments; + + if payments.is_empty() { + break; + } + + total_processed += payments.len(); + + // Process each LIGHTNING payment + for payment in payments { + // Skip CANCELED and FAILED payments - only track viable payments + if payment.status == "CANCELED" || payment.status == "FAILED" { + continue; + } + + // Extract wallet details (should exist since we filtered for LIGHTNING) + let wallet_details = match &payment.wallet_details { + Some(details) => details, + None => { + return Err(Error::SquareHttp(format!( + "LIGHTNING payment {} missing wallet_details", + payment.id + ))); + } + }; + + let lightning_details = match &wallet_details.lightning_details { + Some(details) => details, + None => { + return Err(Error::SquareHttp(format!( + "LIGHTNING payment {} missing lightning_details", + payment.id + ))); + } + }; + + let payment_url = &lightning_details.payment_url; + + let bolt11_str = payment_url + .strip_prefix("lightning:") + .unwrap_or(payment_url) + .to_uppercase(); + + match Bolt11Invoice::from_str(&bolt11_str) { + Ok(invoice) => { + let payment_hash = invoice.payment_hash().as_ref(); + self.store_invoice_hash(payment_hash, &payment.id).await?; + synced_count += 1; + } + Err(e) => { + return Err(Error::SquareHttp(format!( + "Failed to parse bolt11 invoice for payment {}: {}", + payment.id, e + ))); + } + } + } + + cursor = response.cursor; + if cursor.is_none() { + break; // No more pages + } + } + + if synced_count > 0 { + tracing::info!( + "Square payment sync complete: {} LIGHTNING payments synced out of {} total payments processed", + synced_count, + total_processed + ); + } + + self.store_last_sync_time(¤t_time_rfc3339).await?; + + tracing::debug!( + "Updated last Square payment sync time to: {}", + current_time_rfc3339 + ); + + self.remove_expired_payments().await?; + + Ok(()) + } + + /// Store last payment sync timestamp in KV store + pub(crate) async fn store_last_sync_time(&self, timestamp: &str) -> Result<(), Error> { + let mut tx = self.kv_store.begin_transaction().await?; + tx.kv_write( + SQUARE_KV_PRIMARY_NAMESPACE, + SQUARE_KV_CONFIG_NAMESPACE, + LAST_SYNC_TIME_KEY, + timestamp.as_bytes(), + ) + .await?; + tx.commit().await?; + + Ok(()) + } + + /// Retrieve last payment sync timestamp from KV store + pub(crate) async fn get_last_sync_time(&self) -> Result, Error> { + let result = self + .kv_store + .kv_read( + SQUARE_KV_PRIMARY_NAMESPACE, + SQUARE_KV_CONFIG_NAMESPACE, + LAST_SYNC_TIME_KEY, + ) + .await?; + + Ok(result.map(|bytes| String::from_utf8_lossy(&bytes).to_string())) + } +} diff --git a/crates/cdk-square/src/types.rs b/crates/cdk-square/src/types.rs new file mode 100644 index 0000000000..4acb2cf054 --- /dev/null +++ b/crates/cdk-square/src/types.rs @@ -0,0 +1,166 @@ +//! Square API types for extended square client functionality + +use serde::{Deserialize, Serialize}; + +/// Payment brand filter +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PaymentBrand { + /// No brand filtering - return all payments + All, + /// Filter for LIGHTNING payments only + Lightning, +} + +impl PaymentBrand { + /// Check if a payment matches this brand filter + pub fn matches(&self, brand: Option<&str>) -> bool { + match self { + PaymentBrand::All => true, + PaymentBrand::Lightning => brand == Some("LIGHTNING"), + } + } +} + +/// Response from Square List Payments API +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListPaymentsResponse { + /// Array of payment objects + #[serde(default)] + pub payments: Vec, + /// Cursor for pagination (if more results available) + pub cursor: Option, +} + +/// Square payment object +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Payment { + /// Unique identifier for the payment + pub id: String, + /// RFC 3339 timestamp of when the payment was created + pub created_at: String, + /// RFC 3339 timestamp of when the payment was last updated + pub updated_at: String, + /// Money amount for the payment + pub amount_money: Option, + /// Current status of the payment (e.g., "COMPLETED", "FAILED", "PENDING", "CANCELED", "APPROVED") + pub status: String, + /// Source type of the payment (e.g., "WALLET") + pub source_type: Option, + /// Wallet-specific payment details (contains Lightning info) + pub wallet_details: Option, +} + +/// Money amount representation +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Money { + /// Amount in the smallest currency unit (e.g., cents for USD) + pub amount: i64, + /// 3-letter ISO 4217 currency code (e.g., "USD", "BTC") + pub currency: String, +} + +/// Wallet details for a payment +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct WalletDetails { + /// Payment brand (e.g., "LIGHTNING") + pub brand: Option, + /// Lightning-specific payment details + pub lightning_details: Option, +} + +/// Lightning payment details +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct LightningDetails { + /// Lightning payment URL (lightning:bolt11...) + pub payment_url: String, + /// RFC 3339 timestamp of when the invoice expires + pub expires_at: Option, + /// Payment amount in millisatoshis (as string to avoid precision loss) + pub amount_milli_sats: Option, +} + +/// Parameters for listing payments +#[derive(Debug, Clone)] +pub struct ListPaymentsParams { + /// RFC 3339 timestamp to filter payments from + pub begin_time: Option, + /// Cursor for pagination + pub cursor: Option, + /// Maximum number of results per page (default: 100) + pub limit: u32, + /// Payment brand filter (client-side filtering) + pub brand: PaymentBrand, +} + +impl Default for ListPaymentsParams { + fn default() -> Self { + Self::new() + } +} + +impl ListPaymentsParams { + /// Create new params with default limit of 100 and no brand filtering + pub fn new() -> Self { + Self { + begin_time: None, + cursor: None, + limit: 100, + brand: PaymentBrand::All, + } + } + + /// Set begin_time filter + pub fn with_begin_time(mut self, begin_time: String) -> Self { + self.begin_time = Some(begin_time); + self + } + + /// Set cursor for pagination + pub fn with_cursor(mut self, cursor: String) -> Self { + self.cursor = Some(cursor); + self + } + + /// Set limit for results per page + pub fn with_limit(mut self, limit: u32) -> Self { + self.limit = limit; + self + } + + /// Set payment brand filter + pub fn with_brand(mut self, brand: PaymentBrand) -> Self { + self.brand = brand; + self + } +} + +/// Response from Square List Merchants API +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListMerchantsResponse { + /// Array of merchant objects + #[serde(default)] + pub merchant: Vec, + /// Cursor for pagination (if more results available) + pub cursor: Option, +} + +/// Square merchant object +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Merchant { + /// Unique identifier for the merchant + pub id: String, + /// Business name of the merchant + pub business_name: Option, + /// Country code (e.g., "US") + pub country: String, + /// Language code (e.g., "en-US") + pub language_code: Option, + /// Currency code (e.g., "USD") + pub currency: Option, + /// Status of the merchant (e.g., "ACTIVE") + pub status: Option, + /// Main location ID for the merchant + pub main_location_id: Option, + /// RFC 3339 timestamp of when the merchant was created + pub created_at: Option, +} diff --git a/crates/cdk-square/src/util.rs b/crates/cdk-square/src/util.rs new file mode 100644 index 0000000000..bd33718564 --- /dev/null +++ b/crates/cdk-square/src/util.rs @@ -0,0 +1,99 @@ +//! Utility functions and constants for Square integration + +/// KV Store namespace for Square backend data +pub const SQUARE_KV_PRIMARY_NAMESPACE: &str = "cdk_square_backend"; +/// KV Store secondary namespace for lightning invoice tracking +pub const SQUARE_KV_SECONDARY_NAMESPACE: &str = "lightning_invoices"; +/// KV Store secondary namespace for Square webhook configuration +pub const SQUARE_KV_CONFIG_NAMESPACE: &str = "config"; +/// Prefix for invoice hash keys in KV store +pub const INVOICE_HASH_PREFIX: &str = "invoice_hash_"; +/// Key for storing Square webhook signature key +pub const SIGNATURE_KEY_STORAGE_KEY: &str = "signature_key"; +/// Key for storing last payment sync timestamp +pub const LAST_SYNC_TIME_KEY: &str = "last_sync_time"; + +/// Convert unix timestamp (seconds) to RFC 3339 format +/// +/// Returns a string in the format: YYYY-MM-DDTHH:MM:SSZ +pub fn unix_to_rfc3339(unix_secs: u64) -> String { + const SECONDS_PER_DAY: u64 = 86400; + const SECONDS_PER_HOUR: u64 = 3600; + const SECONDS_PER_MINUTE: u64 = 60; + + // Days since Unix epoch (1970-01-01) + let mut days = unix_secs / SECONDS_PER_DAY; + let remainder = unix_secs % SECONDS_PER_DAY; + + let hours = remainder / SECONDS_PER_HOUR; + let minutes = (remainder % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE; + let seconds = remainder % SECONDS_PER_MINUTE; + + // Calculate year, month, day from days since epoch + // Start from 1970 + let mut year = 1970; + + loop { + let days_in_year = if is_leap_year(year) { 366 } else { 365 }; + if days < days_in_year { + break; + } + days -= days_in_year; + year += 1; + } + + // Now days is the day of year (0-indexed) + let days_in_months = if is_leap_year(year) { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + }; + + let mut month = 1; + for (m, &days_in_month) in days_in_months.iter().enumerate() { + if days < days_in_month as u64 { + month = m + 1; + break; + } + days -= days_in_month as u64; + } + + let day = days + 1; // Convert from 0-indexed to 1-indexed + + format!( + "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", + year, month, day, hours, minutes, seconds + ) +} + +/// Check if a year is a leap year +pub fn is_leap_year(year: u64) -> bool { + (year % 4 == 0 && year % 100 != 0) || (year % 400 == 0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_is_leap_year() { + assert!(is_leap_year(2000)); // Divisible by 400 + assert!(is_leap_year(2024)); // Divisible by 4, not by 100 + assert!(!is_leap_year(1900)); // Divisible by 100, not by 400 + assert!(!is_leap_year(2023)); // Not divisible by 4 + } + + #[test] + fn test_unix_to_rfc3339() { + // Test epoch + assert_eq!(unix_to_rfc3339(0), "1970-01-01T00:00:00Z"); + + // Test specific known dates + // 2024-01-01 00:00:00 UTC = 1704067200 + assert_eq!(unix_to_rfc3339(1704067200), "2024-01-01T00:00:00Z"); + + // Test with time components + // 2024-06-15 14:40:45 UTC = 1718462445 + assert_eq!(unix_to_rfc3339(1718462445), "2024-06-15T14:40:45Z"); + } +} diff --git a/crates/cdk-square/src/webhook.rs b/crates/cdk-square/src/webhook.rs new file mode 100644 index 0000000000..3e60dd1397 --- /dev/null +++ b/crates/cdk-square/src/webhook.rs @@ -0,0 +1,439 @@ +//! Webhook handling for Square payment notifications + +use axum::body::Bytes; +use axum::http::{HeaderMap, StatusCode}; +use axum::response::IntoResponse; +use axum::routing::post; +use axum::Router; +use bitcoin::base64::Engine as _; +use bitcoin::secp256k1::hashes::{hmac, sha256, Hash, HashEngine, HmacEngine}; +use serde_json::Value; +use squareup::api::WebhookSubscriptionsApi; +use uuid::Uuid; + +use crate::error::Error; +use crate::util::{ + SIGNATURE_KEY_STORAGE_KEY, SQUARE_KV_CONFIG_NAMESPACE, SQUARE_KV_PRIMARY_NAMESPACE, +}; + +/// Webhook functionality for Square +impl crate::client::Square { + /// Set up Square webhook subscription (idempotent) + pub(crate) async fn setup_webhook_subscription(&self) -> Result<(), Error> { + // If no webhook URL is configured, skip webhook setup + let webhook_url = match &self.webhook_url { + Some(url) => url, + None => { + return Err(Error::SquareConfig( + "Webhook URL not configured".to_string(), + )) + } + }; + + let webhooks_api = WebhookSubscriptionsApi::new(self.client.as_ref().clone()); + + // List existing subscriptions to check if we already have one + let list_params = squareup::models::ListWebhookSubscriptionsParams::default(); + match webhooks_api.list_webhook_subscriptions(&list_params).await { + Ok(response) => { + if let Some(subscriptions) = response.subscriptions { + // Check if we already have a subscription with our webhook URL and payment.created event + for subscription in subscriptions { + if subscription.notification_url.as_ref() == Some(webhook_url) + && subscription + .event_types + .as_ref() + .map(|events| { + events.iter().any(|e| { + // Check if any event type string contains "payment" and "created" + let event_str = format!("{:?}", e).to_lowercase(); + event_str.contains("payment") + && event_str.contains("created") + }) + }) + .unwrap_or(false) + { + tracing::info!( + "Using existing Square webhook subscription: {}", + subscription.id.as_deref().unwrap_or("unknown") + ); + return Ok(()); + } + } + } + } + Err(e) => { + tracing::warn!("Failed to list webhook subscriptions: {}", e); + // Continue to try creating a new one + } + } + + // No existing subscription found, create a new one + let idempotency_key = Uuid::new_v4().to_string(); + + // Build the webhook subscription request using raw JSON to specify event types + // since the SDK may not expose all event type enums + let subscription_json = serde_json::json!({ + "idempotency_key": idempotency_key, + "subscription": { + "name": "CDK Payment Notifications", + "enabled": true, + "event_types": ["payment.created"], + "notification_url": webhook_url, + "api_version": "2025-09-24" + } + }); + + let base_url = self.get_base_url(); + + let client = reqwest::Client::new(); + let response = client + .post(format!("{}/v2/webhooks/subscriptions", base_url)) + .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Content-Type", "application/json") + .json(&subscription_json) + .send() + .await + .map_err(|e| { + Error::SquareHttp(format!("Failed to create webhook subscription: {}", e)) + })?; + + if response.status().is_success() { + let response_body: Value = response.json().await.map_err(|e| { + Error::SquareHttp(format!("Failed to parse webhook response: {}", e)) + })?; + + let subscription_id = response_body + .get("subscription") + .and_then(|s| s.get("id")) + .and_then(|id| id.as_str()) + .unwrap_or("unknown"); + let signature_key = response_body + .get("subscription") + .and_then(|s| s.get("signature_key")) + .and_then(|key| key.as_str()); + + tracing::info!("Created Square webhook subscription: {}", subscription_id); + + // Store the signature key for webhook verification + if let Some(key) = signature_key { + self.store_signature_key(key).await?; + } else { + tracing::warn!("Square webhook response did not include signature_key"); + } + + Ok(()) + } else { + let error_body = response + .text() + .await + .unwrap_or_else(|_| "unknown".to_string()); + tracing::error!( + "Failed to create Square webhook subscription: {}", + error_body + ); + Err(Error::SquareHttp(format!( + "Webhook creation failed: {}", + error_body + ))) + } + } + + /// Store Square webhook signature key in KV store + pub(crate) async fn store_signature_key(&self, signature_key: &str) -> Result<(), Error> { + let mut tx = self.kv_store.begin_transaction().await?; + tx.kv_write( + SQUARE_KV_PRIMARY_NAMESPACE, + SQUARE_KV_CONFIG_NAMESPACE, + SIGNATURE_KEY_STORAGE_KEY, + signature_key.as_bytes(), + ) + .await?; + tx.commit().await?; + + Ok(()) + } + + /// Retrieve Square webhook signature key from KV store + async fn get_signature_key(&self) -> Result, Error> { + let result = self + .kv_store + .kv_read( + SQUARE_KV_PRIMARY_NAMESPACE, + SQUARE_KV_CONFIG_NAMESPACE, + SIGNATURE_KEY_STORAGE_KEY, + ) + .await?; + + Ok(result.map(|bytes| String::from_utf8_lossy(&bytes).to_string())) + } + + /// Verify Square webhook signature + /// + /// This validates that the webhook request came from Square by verifying + /// the HMAC-SHA256 signature in the request header. + /// + /// # Arguments + /// * `webhook_url` - The full URL of the webhook endpoint + /// * `raw_body` - The raw request body bytes + /// * `signature_header` - The signature from the x-square-hmacsha256-signature header + /// + /// # Returns + /// * `Ok(true)` if signature is valid + /// * `Ok(false)` if signature is invalid or signature key not found + /// * `Err` if there's a database error + /// + /// See: + async fn verify_webhook_signature( + &self, + webhook_url: &str, + raw_body: &[u8], + signature_header: &str, + ) -> Result { + let signature_key = match self.get_signature_key().await? { + Some(key) => key, + None => { + tracing::warn!("Square webhook signature key not found in KV store"); + return Ok(false); + } + }; + + // Compute the expected signature + // According to Square docs: HMAC-SHA256(notification_url + request_body, signature_key) + let mut engine = HmacEngine::::new(signature_key.as_bytes()); + + // Concatenate URL + body + engine.input(webhook_url.as_bytes()); + engine.input(raw_body); + + // Finalize and encode to base64 + let hmac_result = hmac::Hmac::::from_engine(engine); + let expected_signature = + bitcoin::base64::engine::general_purpose::STANDARD.encode(hmac_result.to_byte_array()); + + // Constant-time comparison to prevent timing attacks + let signatures_match = expected_signature.as_bytes().len() + == signature_header.as_bytes().len() + && expected_signature + .as_bytes() + .iter() + .zip(signature_header.as_bytes().iter()) + .all(|(a, b)| a == b); + + if !signatures_match { + tracing::warn!( + "Square webhook signature mismatch. Expected: {}, Got: {}", + expected_signature, + signature_header + ); + } + + Ok(signatures_match) + } + + /// Create Square webhook router for payment notifications + /// This router can be merged into the mint's main router + /// + /// Returns `None` if webhook mode is disabled or no webhook URL is configured (polling mode). + pub fn create_webhook_router(&self) -> Option { + // If webhook mode is disabled, return None + if !self.webhook_enabled { + tracing::info!( + "Webhook mode disabled in configuration, skipping webhook router creation" + ); + return None; + } + + // If no webhook URL is configured, return None and error if webhook mode is enabled + let webhook_url = match &self.webhook_url { + Some(url) => url.clone(), + None => { + return None; + } + }; + + let square = self.clone(); + let webhook_url_owned = webhook_url.clone(); + + // Create the webhook handler + let handler = move |headers: HeaderMap, body: Bytes| { + let square = square.clone(); + let webhook_url = webhook_url_owned.clone(); + + async move { + let signature = match headers.get("x-square-hmacsha256-signature") { + Some(sig) => match sig.to_str() { + Ok(s) => s, + Err(e) => { + tracing::warn!("Invalid signature header format: {}", e); + return StatusCode::BAD_REQUEST.into_response(); + } + }, + None => { + tracing::warn!("Missing x-square-hmacsha256-signature header"); + return StatusCode::UNAUTHORIZED.into_response(); + } + }; + + // Verify the signature + match square + .verify_webhook_signature(&webhook_url, &body, signature) + .await + { + Ok(true) => { + tracing::debug!("Square webhook signature verified"); + } + Ok(false) => { + tracing::warn!("Square webhook signature verification failed"); + return StatusCode::UNAUTHORIZED.into_response(); + } + Err(e) => { + tracing::error!("Error verifying Square webhook signature: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR.into_response(); + } + } + + // Parse the JSON payload + let payload: Value = match serde_json::from_slice(&body) { + Ok(p) => p, + Err(e) => { + tracing::warn!("Failed to parse webhook JSON: {}", e); + return StatusCode::BAD_REQUEST.into_response(); + } + }; + + // Process the webhook + match square.process_payment_webhook(&payload).await { + Ok(_) => { + tracing::debug!("Successfully processed Square webhook"); + StatusCode::OK.into_response() + } + Err(e) => { + tracing::error!("Failed to process Square webhook: {}", e); + StatusCode::INTERNAL_SERVER_ERROR.into_response() + } + } + } + }; + + // Extract just the path from the URL for the route + let webhook_path = webhook_url + .split("://") + .nth(1) + .and_then(|s| s.split_once('/')) + .map(|(_, path)| format!("/{}", path)) + .unwrap_or_else(|| "/webhook/square/payment".to_string()); + + // Create the router with the webhook endpoint + let router = Router::new().route(&webhook_path, post(handler)); + + tracing::info!( + "Created Square webhook router at path: {} (full URL: {})", + webhook_path, + webhook_url + ); + + Some(router) + } + + /// Process a Square payment webhook from raw JSON payload + /// + /// This method parses the raw webhook JSON to extract Lightning payment details + /// that are not exposed in the squareup SDK type system. + pub async fn process_payment_webhook(&self, webhook_payload: &Value) -> Result<(), Error> { + use std::str::FromStr; + + use cdk_common::util::hex; + use cdk_common::Bolt11Invoice; + + // Extract payment data from webhook JSON + // Square webhook structure: data.object.payment + let payment = match webhook_payload + .get("data") + .and_then(|d| d.get("object")) + .and_then(|obj| obj.get("payment")) + { + Some(payment_obj) => payment_obj, + None => { + tracing::warn!("Webhook payload missing data.object.payment"); + return Ok(()); + } + }; + + let payment_id = match payment.get("id").and_then(|id| id.as_str()) { + Some(id) => id, + None => { + tracing::warn!("Payment missing id field"); + return Ok(()); + } + }; + + // Check if it's a LIGHTNING payment + let wallet_details = match payment.get("wallet_details") { + Some(details) => details, + None => { + tracing::debug!("Payment {} has no wallet_details", payment_id); + return Ok(()); + } + }; + + let _brand = match wallet_details.get("brand").and_then(|b| b.as_str()) { + Some("LIGHTNING") => "LIGHTNING", + _ => { + tracing::debug!("Payment {} is not a LIGHTNING payment", payment_id); + return Ok(()); + } + }; + + // Extract lightning details + let lightning_details = match wallet_details.get("lightning_details") { + Some(details) => details, + None => { + tracing::warn!("LIGHTNING payment {} missing lightning_details", payment_id); + return Ok(()); + } + }; + + let payment_url = match lightning_details + .get("payment_url") + .and_then(|url| url.as_str()) + { + Some(url) => url, + None => { + tracing::warn!("LIGHTNING payment {} missing payment_url", payment_id); + return Ok(()); + } + }; + + // Parse bolt11 from payment_url + let bolt11_str = payment_url + .strip_prefix("lightning:") + .unwrap_or(payment_url) + .to_uppercase(); + + // Parse the invoice and extract payment hash + match Bolt11Invoice::from_str(&bolt11_str) { + Ok(invoice) => { + let payment_hash = invoice.payment_hash().as_ref(); + + // Store in KV store + self.store_invoice_hash(payment_hash, payment_id).await?; + + tracing::info!( + "Synced Square LIGHTNING payment from webhook: {} (hash: {})", + payment_id, + hex::encode(payment_hash) + ); + Ok(()) + } + Err(e) => { + let err_msg = format!( + "Failed to parse bolt11 invoice for webhook payment {}: {}", + payment_id, e + ); + tracing::warn!("{}", err_msg); + Err(Error::Bolt11Parse(err_msg)) + } + } + } +} From ff175b4ad721aff2188b55def5826ee56e6dd731 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Wed, 15 Oct 2025 20:48:35 -0700 Subject: [PATCH 03/18] feat: is_internal_payment MintPayment method --- crates/cdk-common/src/payment.rs | 29 +++++++++++++++++++++++++++ crates/cdk/src/mint/melt.rs | 34 +++++++++++++++++++++----------- 2 files changed, 52 insertions(+), 11 deletions(-) diff --git a/crates/cdk-common/src/payment.rs b/crates/cdk-common/src/payment.rs index 8ee371d698..33b67647c0 100644 --- a/crates/cdk-common/src/payment.rs +++ b/crates/cdk-common/src/payment.rs @@ -270,6 +270,19 @@ pub trait MintPayment { Ok(()) } + /// Check if a payment is internal to this payment processor + /// Used for internal settlement validation + /// Returns: + /// - `Ok(Some(true))` if payment is internal + /// - `Ok(Some(false))` if payment is definitely not internal + /// - `Ok(None)` if processor doesn't implement this check + async fn is_internal_payment( + &self, + _request: &Bolt11Invoice, + ) -> Result, Self::Err> { + Ok(None) + } + /// Base Settings async fn get_settings(&self) -> Result; @@ -611,6 +624,22 @@ where result } + + async fn is_internal_payment( + &self, + request: &Bolt11Invoice, + ) -> Result, Self::Err> { + let start = std::time::Instant::now(); + METRICS.inc_in_flight_requests("is_internal_payment"); + + let result = self.inner.is_internal_payment(request).await; + + let duration = start.elapsed().as_secs_f64(); + METRICS.record_mint_operation_histogram("is_internal_payment", result.is_ok(), duration); + METRICS.dec_in_flight_requests("is_internal_payment"); + + result + } } /// Type alias for Mint Payment trait diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index 141aff8f44..3c635b383f 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -161,17 +161,6 @@ impl Mint { ) }); - if internal_melts_only { - let mut tx = self.localstore.begin_transaction().await?; - let matching_mint_quote = tx.get_mint_quote_by_request(&request.to_string()).await?; - tx.commit().await?; - - if matching_mint_quote.is_none() { - let mint_name = mint_info.name.unwrap_or_else(|| "this mint".to_string()); - return Err(Error::InternalSettlementOnly(mint_name)); - } - } - let ln = self .payment_processors .get(&PaymentProcessorKey::new( @@ -184,6 +173,29 @@ impl Mint { Error::UnsupportedUnit })?; + if internal_melts_only { + match ln.is_internal_payment(&request).await? { + Some(true) => {} + Some(false) => { + let mint_name = mint_info.name.unwrap_or_else(|| "this mint".to_string()); + return Err(Error::InternalSettlementOnly(mint_name)); + } + None => { + // is_internal_payment is not implemented for this payment processor + // so we fall back to checking if there is a matching mint quote + let mut tx = self.localstore.begin_transaction().await?; + let matching_mint_quote = + tx.get_mint_quote_by_request(&request.to_string()).await?; + tx.commit().await?; + + if matching_mint_quote.is_none() { + let mint_name = mint_info.name.unwrap_or_else(|| "this mint".to_string()); + return Err(Error::InternalSettlementOnly(mint_name)); + } + } + } + } + let bolt11 = Bolt11OutgoingPaymentOptions { bolt11: melt_request.request.clone(), max_fee_amount: None, From 72778a5aa0c89963917e00119fdb4677dc7f2f90 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 16 Oct 2025 09:37:46 -0700 Subject: [PATCH 04/18] ci: docker on PR and disable some ci --- .github/workflows/ci.yml | 4 +--- .github/workflows/daily-flake-check.yml | 6 +++--- .github/workflows/docker-publish-arm.yml | 2 ++ .github/workflows/docker-publish.yml | 2 ++ 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 05d2416ffe..0cf4c93c8f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -294,9 +294,7 @@ jobs: matrix: ln: [ - FAKEWALLET, - CLN, - LND + FAKEWALLET ] steps: - name: checkout diff --git a/.github/workflows/daily-flake-check.yml b/.github/workflows/daily-flake-check.yml index 659ca6c465..6e22ac33d6 100644 --- a/.github/workflows/daily-flake-check.yml +++ b/.github/workflows/daily-flake-check.yml @@ -1,9 +1,9 @@ name: Daily Flake Check on: - schedule: - # Run daily at 6 AM UTC - - cron: '0 6 * * *' + # schedule: + # # Run daily at 6 AM UTC + # - cron: '0 6 * * *' # Allow manual trigger workflow_dispatch: diff --git a/.github/workflows/docker-publish-arm.yml b/.github/workflows/docker-publish-arm.yml index f1eed20979..40acba5c9a 100644 --- a/.github/workflows/docker-publish-arm.yml +++ b/.github/workflows/docker-publish-arm.yml @@ -3,6 +3,8 @@ name: Publish Docker Image ARM64 on: push: branches: [main] + pull_request: + branches: [main] release: types: [published] workflow_dispatch: diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index c313e4b1ae..f7545acc9f 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -3,6 +3,8 @@ name: Publish Docker Image AMD64 on: push: branches: [main] + pull_request: + branches: [main] release: types: [published] workflow_dispatch: From 0fb5b1369bc9c99c55ac3369e78e320beaa8cf93 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 16 Oct 2025 10:52:24 -0700 Subject: [PATCH 05/18] fix: improve square syncing --- crates/cdk-square/src/client.rs | 10 +-- crates/cdk-square/src/lib.rs | 4 +- crates/cdk-square/src/sync.rs | 105 +++++++++++++++++++++++++------ crates/cdk-square/src/util.rs | 81 ++++++++++++++++++++++++ crates/cdk-square/src/webhook.rs | 3 +- 5 files changed, 172 insertions(+), 31 deletions(-) diff --git a/crates/cdk-square/src/client.rs b/crates/cdk-square/src/client.rs index 2577fabeab..5e7ce7c085 100644 --- a/crates/cdk-square/src/client.rs +++ b/crates/cdk-square/src/client.rs @@ -50,7 +50,7 @@ impl Square { ) -> Result { let environment = match square_config.environment.to_uppercase().as_str() { "PRODUCTION" => SquareEnvironment::Production, - "SANDBOX" | _ => SquareEnvironment::Sandbox, + _ => SquareEnvironment::Sandbox, }; let webhook_enabled = square_config.webhook_enabled; @@ -247,14 +247,6 @@ impl Square { let url = format!("{}/v2/payments", base_url); let client = reqwest::Client::new(); - tracing::debug!( - "Listing Square payments (begin_time: {:?}, cursor: {:?}, limit: {}, brand: {:?})", - params.begin_time, - params.cursor, - params.limit, - params.brand - ); - let mut request = client .get(&url) .header("Authorization", format!("Bearer {}", self.api_token)) diff --git a/crates/cdk-square/src/lib.rs b/crates/cdk-square/src/lib.rs index 6d5e3aaa43..c9e3cb6f3c 100644 --- a/crates/cdk-square/src/lib.rs +++ b/crates/cdk-square/src/lib.rs @@ -58,5 +58,5 @@ pub use types::{ Money, Payment, PaymentBrand, WalletDetails, }; -/// Default payment expiry time in seconds (5 minutes) -pub const DEFAULT_SQUARE_PAYMENT_EXPIRY: u64 = 300; +/// Default payment expiry time in seconds +pub const DEFAULT_SQUARE_PAYMENT_EXPIRY: u64 = 500; diff --git a/crates/cdk-square/src/sync.rs b/crates/cdk-square/src/sync.rs index 04117875a4..bbdae5c590 100644 --- a/crates/cdk-square/src/sync.rs +++ b/crates/cdk-square/src/sync.rs @@ -3,10 +3,13 @@ use std::str::FromStr; use std::time::{SystemTime, UNIX_EPOCH}; +use cdk_common::util::hex; + use crate::error::Error; use crate::types::{ListPaymentsParams, PaymentBrand}; use crate::util::{ - unix_to_rfc3339, LAST_SYNC_TIME_KEY, SQUARE_KV_CONFIG_NAMESPACE, SQUARE_KV_PRIMARY_NAMESPACE, + rfc3339_to_unix, unix_to_rfc3339, LAST_SYNC_TIME_KEY, SQUARE_KV_CONFIG_NAMESPACE, + SQUARE_KV_PRIMARY_NAMESPACE, }; /// Payment synchronization functionality @@ -14,28 +17,51 @@ impl crate::client::Square { /// Sync all Square LIGHTNING payments to KV store /// /// Only syncs payments created after the last sync time to avoid re-processing all payments. + /// + /// This function ensures canonical syncing by: + /// 1. Capturing the sync start time BEFORE querying the API + /// 2. Using a 2-second overlap buffer to handle boundary cases + /// 3. Batching all payment hash writes in a single transaction per page + /// 4. Only updating the last sync time after ALL payments are successfully processed pub async fn sync_payments(&self) -> Result<(), Error> { use cdk_common::Bolt11Invoice; - let last_sync_time = self.get_last_sync_time().await?; - - let current_time_secs = SystemTime::now() + // Capture sync start time BEFORE any API calls to prevent race conditions + let sync_start_time_secs = SystemTime::now() .duration_since(UNIX_EPOCH) .map_err(|e| Error::SquareHttp(format!("System time error: {}", e)))? .as_secs(); - let current_time_rfc3339 = unix_to_rfc3339(current_time_secs); + let sync_start_time_rfc3339 = unix_to_rfc3339(sync_start_time_secs); + + let last_sync_time = self.get_last_sync_time().await?; + + // Apply a 2-second overlap buffer to prevent missing payments at boundaries + let query_begin_time = if let Some(ref last_sync) = last_sync_time { + // Parse the last sync time and subtract 2 seconds for overlap + // This ensures we don't miss payments due to timing precision or clock skew + if let Some(last_sync_unix) = rfc3339_to_unix(last_sync) { + let buffered_time = last_sync_unix.saturating_sub(2); + Some(unix_to_rfc3339(buffered_time)) + } else { + tracing::warn!("Failed to parse last sync time, syncing all payments"); + None + } + } else { + None + }; let mut cursor: Option = None; let mut synced_count = 0; let mut total_processed = 0; + let mut skipped_duplicates = 0; // Paginate through all payments loop { let mut params = ListPaymentsParams::new().with_brand(PaymentBrand::Lightning); - if let Some(ref last_sync) = last_sync_time { - params = params.with_begin_time(last_sync.clone()); + if let Some(ref begin_time) = query_begin_time { + params = params.with_begin_time(begin_time.clone()); } if let Some(ref cursor_value) = cursor { @@ -52,6 +78,10 @@ impl crate::client::Square { total_processed += payments.len(); + // Collect all payment hash writes for this page in a single transaction + // This ensures atomicity - either all payments in the page are stored or none + let mut payment_hashes_to_store: Vec<([u8; 32], String)> = Vec::new(); + // Process each LIGHTNING payment for payment in payments { // Skip CANCELED and FAILED payments - only track viable payments @@ -89,9 +119,29 @@ impl crate::client::Square { match Bolt11Invoice::from_str(&bolt11_str) { Ok(invoice) => { - let payment_hash = invoice.payment_hash().as_ref(); - self.store_invoice_hash(payment_hash, &payment.id).await?; - synced_count += 1; + let payment_hash = *invoice.payment_hash().as_ref(); + + // Check if we've already stored this payment (for overlap handling) + let key = format!( + "{}{}", + crate::util::INVOICE_HASH_PREFIX, + hex::encode(payment_hash) + ); + let existing = self + .kv_store + .kv_read( + crate::util::SQUARE_KV_PRIMARY_NAMESPACE, + crate::util::SQUARE_KV_SECONDARY_NAMESPACE, + &key, + ) + .await?; + + if existing.is_some() { + skipped_duplicates += 1; + continue; + } + + payment_hashes_to_store.push((payment_hash, payment.id.clone())); } Err(e) => { return Err(Error::SquareHttp(format!( @@ -102,26 +152,45 @@ impl crate::client::Square { } } + // Store all payment hashes from this page in a single transaction + if !payment_hashes_to_store.is_empty() { + let mut tx = self.kv_store.begin_transaction().await?; + + for (payment_hash, payment_id) in &payment_hashes_to_store { + let key = format!( + "{}{}", + crate::util::INVOICE_HASH_PREFIX, + hex::encode(payment_hash) + ); + tx.kv_write( + crate::util::SQUARE_KV_PRIMARY_NAMESPACE, + crate::util::SQUARE_KV_SECONDARY_NAMESPACE, + &key, + payment_id.as_bytes(), + ) + .await?; + } + + tx.commit().await?; + synced_count += payment_hashes_to_store.len(); + } + cursor = response.cursor; if cursor.is_none() { break; // No more pages } } - if synced_count > 0 { + if synced_count > 0 || skipped_duplicates > 0 { tracing::info!( - "Square payment sync complete: {} LIGHTNING payments synced out of {} total payments processed", + "Square payment sync complete: {} new payments synced, {} duplicates skipped, {} total processed", synced_count, + skipped_duplicates, total_processed ); } - self.store_last_sync_time(¤t_time_rfc3339).await?; - - tracing::debug!( - "Updated last Square payment sync time to: {}", - current_time_rfc3339 - ); + self.store_last_sync_time(&sync_start_time_rfc3339).await?; self.remove_expired_payments().await?; diff --git a/crates/cdk-square/src/util.rs b/crates/cdk-square/src/util.rs index bd33718564..da03a44fa4 100644 --- a/crates/cdk-square/src/util.rs +++ b/crates/cdk-square/src/util.rs @@ -13,6 +13,60 @@ pub const SIGNATURE_KEY_STORAGE_KEY: &str = "signature_key"; /// Key for storing last payment sync timestamp pub const LAST_SYNC_TIME_KEY: &str = "last_sync_time"; +/// Convert RFC 3339 format to unix timestamp (seconds) +/// +/// Returns None if the string is not a valid RFC 3339 timestamp +pub fn rfc3339_to_unix(rfc3339: &str) -> Option { + // Expected format: YYYY-MM-DDTHH:MM:SSZ + let parts: Vec<&str> = rfc3339.split('T').collect(); + if parts.len() != 2 { + return None; + } + + let date_parts: Vec<&str> = parts[0].split('-').collect(); + if date_parts.len() != 3 { + return None; + } + + let year: u64 = date_parts[0].parse().ok()?; + let month: u64 = date_parts[1].parse().ok()?; + let day: u64 = date_parts[2].parse().ok()?; + + let time_str = parts[1].trim_end_matches('Z'); + let time_parts: Vec<&str> = time_str.split(':').collect(); + if time_parts.len() != 3 { + return None; + } + + let hours: u64 = time_parts[0].parse().ok()?; + let minutes: u64 = time_parts[1].parse().ok()?; + let seconds: u64 = time_parts[2].parse().ok()?; + + // Calculate days since Unix epoch + let mut days = 0u64; + for y in 1970..year { + days += if is_leap_year(y) { 366 } else { 365 }; + } + + let days_in_months = if is_leap_year(year) { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + }; + + for &days_in_month in days_in_months.iter().take((month - 1) as usize) { + days += days_in_month as u64; + } + + days += day - 1; // day is 1-indexed + + const SECONDS_PER_DAY: u64 = 86400; + const SECONDS_PER_HOUR: u64 = 3600; + const SECONDS_PER_MINUTE: u64 = 60; + + Some(days * SECONDS_PER_DAY + hours * SECONDS_PER_HOUR + minutes * SECONDS_PER_MINUTE + seconds) +} + /// Convert unix timestamp (seconds) to RFC 3339 format /// /// Returns a string in the format: YYYY-MM-DDTHH:MM:SSZ @@ -96,4 +150,31 @@ mod tests { // 2024-06-15 14:40:45 UTC = 1718462445 assert_eq!(unix_to_rfc3339(1718462445), "2024-06-15T14:40:45Z"); } + + #[test] + fn test_rfc3339_to_unix() { + // Test epoch + assert_eq!(rfc3339_to_unix("1970-01-01T00:00:00Z"), Some(0)); + + // Test specific known dates + assert_eq!(rfc3339_to_unix("2024-01-01T00:00:00Z"), Some(1704067200)); + + // Test with time components + assert_eq!(rfc3339_to_unix("2024-06-15T14:40:45Z"), Some(1718462445)); + + // Test invalid formats + assert_eq!(rfc3339_to_unix("invalid"), None); + assert_eq!(rfc3339_to_unix("2024-01-01"), None); + } + + #[test] + fn test_rfc3339_roundtrip() { + // Test roundtrip conversion + let timestamps = vec![0, 1704067200, 1718462445]; + for ts in timestamps { + let rfc = unix_to_rfc3339(ts); + let back = rfc3339_to_unix(&rfc); + assert_eq!(back, Some(ts), "Roundtrip failed for timestamp {}", ts); + } + } } diff --git a/crates/cdk-square/src/webhook.rs b/crates/cdk-square/src/webhook.rs index 3e60dd1397..9b0977c1f0 100644 --- a/crates/cdk-square/src/webhook.rs +++ b/crates/cdk-square/src/webhook.rs @@ -212,8 +212,7 @@ impl crate::client::Square { bitcoin::base64::engine::general_purpose::STANDARD.encode(hmac_result.to_byte_array()); // Constant-time comparison to prevent timing attacks - let signatures_match = expected_signature.as_bytes().len() - == signature_header.as_bytes().len() + let signatures_match = expected_signature.len() == signature_header.len() && expected_signature .as_bytes() .iter() From 1f2b02082de60a5179de6e265cec5210120e01d4 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 16 Oct 2025 10:52:58 -0700 Subject: [PATCH 06/18] feat: wip square in fake and nwc --- crates/cdk-fake-wallet/Cargo.toml | 6 + crates/cdk-fake-wallet/src/error.rs | 4 + crates/cdk-fake-wallet/src/lib.rs | 108 +++++++++++ .../src/bin/start_fake_auth_mint.rs | 1 + .../src/bin/start_fake_mint.rs | 1 + crates/cdk-mintd/Cargo.toml | 6 +- crates/cdk-mintd/example.config.toml | 12 ++ crates/cdk-mintd/src/config.rs | 27 +++ crates/cdk-mintd/src/env_vars/fake_wallet.rs | 25 ++- crates/cdk-mintd/src/env_vars/nwc.rs | 28 +++ crates/cdk-mintd/src/lib.rs | 139 +++++++++++--- crates/cdk-mintd/src/setup.rs | 176 +++++++++++++++--- crates/cdk-nwc/Cargo.toml | 8 +- crates/cdk-nwc/src/error.rs | 7 + crates/cdk-nwc/src/lib.rs | 70 +++++++ crates/cdk-strike/src/lib.rs | 9 + crates/cdk/src/mint/melt.rs | 2 +- justfile | 7 + 18 files changed, 574 insertions(+), 62 deletions(-) diff --git a/crates/cdk-fake-wallet/Cargo.toml b/crates/cdk-fake-wallet/Cargo.toml index a9b365a8e5..b719ec0a18 100644 --- a/crates/cdk-fake-wallet/Cargo.toml +++ b/crates/cdk-fake-wallet/Cargo.toml @@ -10,6 +10,10 @@ rust-version.workspace = true # MSRV description = "CDK fake ln backend" readme = "README.md" +[features] +default = ["square"] +square = ["dep:cdk-square"] + [dependencies] async-trait.workspace = true bitcoin.workspace = true @@ -26,3 +30,5 @@ lightning.workspace = true tokio-stream.workspace = true reqwest.workspace = true uuid.workspace = true +axum = { workspace = true } +cdk-square = { workspace = true, optional = true } diff --git a/crates/cdk-fake-wallet/src/error.rs b/crates/cdk-fake-wallet/src/error.rs index f52dbc6e5e..3de8f11d1a 100644 --- a/crates/cdk-fake-wallet/src/error.rs +++ b/crates/cdk-fake-wallet/src/error.rs @@ -14,6 +14,10 @@ pub enum Error { /// Unknown invoice #[error("No channel receiver")] NoReceiver, + /// Square error + #[cfg(feature = "square")] + #[error(transparent)] + Square(#[from] cdk_square::error::Error), } impl From for cdk_common::payment::Error { diff --git a/crates/cdk-fake-wallet/src/lib.rs b/crates/cdk-fake-wallet/src/lib.rs index 80e221ea47..96561ea384 100644 --- a/crates/cdk-fake-wallet/src/lib.rs +++ b/crates/cdk-fake-wallet/src/lib.rs @@ -21,10 +21,14 @@ use std::sync::Arc; use std::time::{Duration, Instant}; use async_trait::async_trait; +#[cfg(feature = "square")] +use axum::Router; use bitcoin::hashes::{sha256, Hash}; use bitcoin::secp256k1::{Secp256k1, SecretKey}; use cdk_common::amount::{to_unit, Amount}; use cdk_common::common::FeeReserve; +#[cfg(feature = "square")] +use cdk_common::database::mint::DynMintKVStore; use cdk_common::ensure_cdk; use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; use cdk_common::payment::{ @@ -48,6 +52,10 @@ use uuid::Uuid; pub mod error; +// Re-export Square types for convenience +#[cfg(feature = "square")] +pub use cdk_square::{Square, SquareConfig}; + /// Default maximum size for the secondary repayment queue const DEFAULT_REPAY_QUEUE_MAX_SIZE: usize = 100; @@ -336,6 +344,9 @@ pub struct FakeWallet { unit: CurrencyUnit, secondary_repayment_queue: SecondaryRepaymentQueue, exchange_rate_cache: ExchangeRateCache, + /// Optional Square payment backend + #[cfg(feature = "square")] + square: Option>, } impl FakeWallet { @@ -357,6 +368,60 @@ impl FakeWallet { ) } + /// Create new [`FakeWallet`] with Square integration + #[cfg(feature = "square")] + #[allow(clippy::too_many_arguments)] + pub async fn new_with_square( + fee_reserve: FeeReserve, + payment_states: HashMap, + fail_payment_check: HashSet, + payment_delay: u64, + unit: CurrencyUnit, + kv_store: DynMintKVStore, + square_config: Option, + square_webhook_url: Option, + ) -> Result { + let (sender, receiver) = tokio::sync::mpsc::channel(8); + let incoming_payments = Arc::new(RwLock::new(HashMap::new())); + + let secondary_repayment_queue = SecondaryRepaymentQueue::new( + DEFAULT_REPAY_QUEUE_MAX_SIZE, + sender.clone(), + unit.clone(), + ); + + let square = match square_config { + Some(config) => { + match cdk_square::Square::from_config(config, square_webhook_url, kv_store.clone()) + { + Ok(square_backend) => { + let square_arc = Arc::new(square_backend); + square_arc.start().await?; + Some(square_arc) + } + Err(e) => return Err(e.into()), + } + } + None => None, + }; + + Ok(Self { + fee_reserve, + sender, + receiver: Arc::new(Mutex::new(Some(receiver))), + payment_states: Arc::new(Mutex::new(payment_states)), + failed_payment_check: Arc::new(Mutex::new(fail_payment_check)), + payment_delay, + wait_invoice_cancel_token: CancellationToken::new(), + wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + incoming_payments, + unit, + secondary_repayment_queue, + exchange_rate_cache: ExchangeRateCache::new(), + square, + }) + } + /// Create new [`FakeWallet`] with custom secondary repayment queue size pub fn new_with_repay_queue_size( fee_reserve: FeeReserve, @@ -385,8 +450,22 @@ impl FakeWallet { unit, secondary_repayment_queue, exchange_rate_cache: ExchangeRateCache::new(), + #[cfg(feature = "square")] + square: None, } } + + /// Create Square webhook router for payment notifications + /// + /// # Returns + /// * `Some(Router)` if Square backend is configured with a webhook URL + /// * `None` if Square backend is not configured or is using polling mode + #[cfg(feature = "square")] + pub fn create_square_webhook_router(&self) -> Option { + self.square + .as_ref() + .and_then(|square| square.create_webhook_router()) + } } /// Struct for signaling what methods should respond via invoice description @@ -428,6 +507,35 @@ impl MintPayment for FakeWallet { })?) } + #[instrument(skip_all)] + async fn is_internal_payment( + &self, + request: &Bolt11Invoice, + ) -> Result, Self::Err> { + #[cfg(feature = "square")] + { + let Some(square) = self.square.as_ref() else { + return Ok(None); + }; + + // Check if this invoice exists in our Square tracking + square + .check_invoice_exists(request) + .await + .map_err(|e| { + tracing::error!("Error checking Square invoice: {}", e); + cdk_common::payment::Error::Custom(format!("Square error: {}", e)) + }) + .map(Some) + } + + #[cfg(not(feature = "square"))] + { + let _ = request; // Avoid unused variable warning + Ok(None) + } + } + #[instrument(skip_all)] fn is_wait_invoice_active(&self) -> bool { self.wait_invoice_is_active.load(Ordering::SeqCst) diff --git a/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs b/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs index 7d7dc8e1ad..3747982edf 100644 --- a/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs +++ b/crates/cdk-integration-tests/src/bin/start_fake_auth_mint.rs @@ -60,6 +60,7 @@ async fn start_fake_auth_mint( reserve_fee_min: cdk::Amount::from(1), min_delay_time: 1, max_delay_time: 3, + square: None, }; let mut settings = shared::create_fake_wallet_settings( diff --git a/crates/cdk-integration-tests/src/bin/start_fake_mint.rs b/crates/cdk-integration-tests/src/bin/start_fake_mint.rs index 01aab141c5..bf41706a77 100644 --- a/crates/cdk-integration-tests/src/bin/start_fake_mint.rs +++ b/crates/cdk-integration-tests/src/bin/start_fake_mint.rs @@ -76,6 +76,7 @@ async fn start_fake_mint( reserve_fee_min: 1.into(), min_delay_time: 1, max_delay_time: 3, + square: None, }); // Create settings struct for fake mint using shared function diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index c99a61a2b7..63502ca70c 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -11,7 +11,7 @@ rust-version.workspace = true readme = "README.md" [features] -default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "sqlite", "strike", "nwc"] +default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "sqlite", "strike", "nwc", "square"] # Database features - at least one must be enabled sqlite = ["dep:cdk-sqlite"] postgres = ["dep:cdk-postgres"] @@ -31,6 +31,8 @@ auth = ["cdk/auth", "cdk-axum/auth", "cdk-sqlite?/auth", "cdk-postgres?/auth"] prometheus = ["cdk/prometheus", "dep:cdk-prometheus", "cdk-sqlite?/prometheus", "cdk-axum/prometheus"] strike = ["dep:cdk-strike"] +square = ["dep:cdk-square", "cdk-fake-wallet?/square", "cdk-nwc?/square"] + [dependencies] anyhow.workspace = true async-trait.workspace = true @@ -72,4 +74,6 @@ home.workspace = true utoipa = { workspace = true, optional = true } utoipa-swagger-ui = { version = "9.0.0", features = ["axum"], optional = true } +cdk-square = { workspace = true, optional = true } + [build-dependencies] diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 4320eb5815..13ea3b91d1 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -159,10 +159,22 @@ reserve_fee_min = 1 min_delay_time = 1 max_delay_time = 3 +# Optional Square integration for fake wallet (for testing Square merchants) +# [fake_wallet.square] +# api_token = "your_square_api_token_here" +# environment = "SANDBOX" # or "PRODUCTION" +# webhook_enabled = true # If false, uses polling mode (syncs every 5 seconds) instead of webhooks + # [nwc] # nwc_uri = "nostr+walletconnect://..." # fee_percent = 0.02 # reserve_fee_min = 1 +# +# # Optional Square integration for NWC backend +# [nwc.square] +# api_token = "your_square_api_token_here" +# environment = "SANDBOX" # or "PRODUCTION" +# webhook_enabled = true # If false, uses polling mode (syncs every 5 seconds) instead of webhooks # [grpc_processor] # gRPC Payment Processor configuration diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index bd4bbda4f5..a2978408d3 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -315,6 +315,31 @@ pub struct Nwc { pub nwc_uri: String, pub fee_percent: f32, pub reserve_fee_min: Amount, + pub square: Option, +} + +#[cfg(feature = "nwc")] +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SquareConfig { + pub api_token: String, + pub environment: String, + #[serde(default = "default_webhook_enabled")] + pub webhook_enabled: bool, +} + +#[cfg(feature = "nwc")] +impl Default for SquareConfig { + fn default() -> Self { + Self { + api_token: String::new(), + environment: String::from("SANDBOX"), + webhook_enabled: true, + } + } +} + +fn default_webhook_enabled() -> bool { + true } #[cfg(feature = "fakewallet")] @@ -327,6 +352,7 @@ pub struct FakeWallet { pub min_delay_time: u64, #[serde(default = "default_max_delay_time")] pub max_delay_time: u64, + pub square: Option, } #[cfg(feature = "fakewallet")] @@ -338,6 +364,7 @@ impl Default for FakeWallet { reserve_fee_min: 2.into(), min_delay_time: 1, max_delay_time: 3, + square: None, } } } diff --git a/crates/cdk-mintd/src/env_vars/fake_wallet.rs b/crates/cdk-mintd/src/env_vars/fake_wallet.rs index f196891544..165dcd3a3c 100644 --- a/crates/cdk-mintd/src/env_vars/fake_wallet.rs +++ b/crates/cdk-mintd/src/env_vars/fake_wallet.rs @@ -4,7 +4,7 @@ use std::env; use cdk::nuts::CurrencyUnit; -use crate::config::FakeWallet; +use crate::config::{FakeWallet, SquareConfig}; // Fake Wallet environment variables pub const ENV_FAKE_WALLET_SUPPORTED_UNITS: &str = "CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS"; @@ -13,6 +13,12 @@ pub const ENV_FAKE_WALLET_RESERVE_FEE_MIN: &str = "CDK_MINTD_FAKE_WALLET_RESERVE pub const ENV_FAKE_WALLET_MIN_DELAY: &str = "CDK_MINTD_FAKE_WALLET_MIN_DELAY"; pub const ENV_FAKE_WALLET_MAX_DELAY: &str = "CDK_MINTD_FAKE_WALLET_MAX_DELAY"; +// Square environment variables (optional integration with Fake Wallet) +pub const ENV_FAKE_WALLET_SQUARE_API_TOKEN: &str = "CDK_MINTD_FAKE_WALLET_SQUARE_API_TOKEN"; +pub const ENV_FAKE_WALLET_SQUARE_ENVIRONMENT: &str = "CDK_MINTD_FAKE_WALLET_SQUARE_ENVIRONMENT"; +pub const ENV_FAKE_WALLET_SQUARE_WEBHOOK_ENABLED: &str = + "CDK_MINTD_FAKE_WALLET_SQUARE_WEBHOOK_ENABLED"; + impl FakeWallet { pub fn from_env(mut self) -> Self { // Supported Units - expects comma-separated list @@ -50,6 +56,23 @@ impl FakeWallet { } } + // Square integration (optional) + if let Ok(api_token) = env::var(ENV_FAKE_WALLET_SQUARE_API_TOKEN) { + let environment = env::var(ENV_FAKE_WALLET_SQUARE_ENVIRONMENT) + .unwrap_or_else(|_| "SANDBOX".to_string()); + + let webhook_enabled = env::var(ENV_FAKE_WALLET_SQUARE_WEBHOOK_ENABLED) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(true); + + self.square = Some(SquareConfig { + api_token, + environment, + webhook_enabled, + }); + } + self } } diff --git a/crates/cdk-mintd/src/env_vars/nwc.rs b/crates/cdk-mintd/src/env_vars/nwc.rs index d516b26329..65f185313d 100644 --- a/crates/cdk-mintd/src/env_vars/nwc.rs +++ b/crates/cdk-mintd/src/env_vars/nwc.rs @@ -3,12 +3,22 @@ use std::env; use crate::config::Nwc; +#[cfg(feature = "square")] +use crate::config::SquareConfig; // NWC environment variables pub const ENV_NWC_URI: &str = "CDK_MINTD_NWC_URI"; pub const ENV_NWC_FEE_PERCENT: &str = "CDK_MINTD_NWC_FEE_PERCENT"; pub const ENV_NWC_RESERVE_FEE_MIN: &str = "CDK_MINTD_NWC_RESERVE_FEE_MIN"; +// Square environment variables (optional integration with NWC) +#[cfg(feature = "square")] +pub const ENV_SQUARE_API_TOKEN: &str = "CDK_MINTD_SQUARE_API_TOKEN"; +#[cfg(feature = "square")] +pub const ENV_SQUARE_ENVIRONMENT: &str = "CDK_MINTD_SQUARE_ENVIRONMENT"; +#[cfg(feature = "square")] +pub const ENV_SQUARE_WEBHOOK_ENABLED: &str = "CDK_MINTD_SQUARE_WEBHOOK_ENABLED"; + impl Nwc { pub fn from_env(mut self) -> Self { if let Ok(nwc_uri) = env::var(ENV_NWC_URI) { @@ -27,6 +37,24 @@ impl Nwc { } } + // Square integration (optional) + #[cfg(feature = "square")] + if let Ok(api_token) = env::var(ENV_SQUARE_API_TOKEN) { + let environment = + env::var(ENV_SQUARE_ENVIRONMENT).unwrap_or_else(|_| "SANDBOX".to_string()); + + let webhook_enabled = env::var(ENV_SQUARE_WEBHOOK_ENABLED) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(true); + + self.square = Some(SquareConfig { + api_token, + environment, + webhook_enabled, + }); + } + self } } diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 04d91c7d91..676740c553 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -484,21 +484,61 @@ async fn configure_lightning_backend( let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined"); tracing::info!("Using fake wallet: {:?}", fake_wallet); - for unit in fake_wallet.clone().supported_units { - let fake = fake_wallet - .setup(settings, unit.clone(), None, work_dir, _kv_store.clone()) - .await?; - #[cfg(feature = "prometheus")] - let fake = MetricsMintPayment::new(fake); + #[cfg(feature = "square")] + let square_enabled = fake_wallet.square.is_some(); + #[cfg(not(feature = "square"))] + let square_enabled = false; - mint_builder = configure_backend_for_unit( - settings, - mint_builder, - unit.clone(), - mint_melt_limits, - Arc::new(fake), - ) - .await?; + if square_enabled { + #[cfg(feature = "square")] + { + let mut webhook_routers = Vec::new(); + + for unit in fake_wallet.clone().supported_units { + let kv_store = _kv_store.clone().ok_or_else(|| { + anyhow!("KV store is required for FakeWallet with Square backend") + })?; + + let (fake, square_webhook_router) = fake_wallet + .setup_with_square(settings, unit.clone(), kv_store) + .await?; + + if let Some(router) = square_webhook_router { + webhook_routers.push(router); + } + + #[cfg(feature = "prometheus")] + let fake = MetricsMintPayment::new(fake); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + unit.clone(), + mint_melt_limits, + Arc::new(fake), + ) + .await?; + } + + return Ok((mint_builder, webhook_routers)); + } + } else { + for unit in fake_wallet.clone().supported_units { + let fake = fake_wallet + .setup(settings, unit.clone(), None, work_dir, _kv_store.clone()) + .await?; + #[cfg(feature = "prometheus")] + let fake = MetricsMintPayment::new(fake); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + unit.clone(), + mint_melt_limits, + Arc::new(fake), + ) + .await?; + } } } #[cfg(feature = "grpc-processor")] @@ -561,12 +601,11 @@ async fn configure_lightning_backend( let mut webhook_routers = Vec::new(); for unit in &strike_settings.supported_units { - // Create Strike backend with webhook router for this unit let kv_store = _kv_store .clone() .ok_or_else(|| anyhow!("KV store is required for Strike backend"))?; let (strike, webhook_router) = strike_settings - .setup_with_webhook(settings, unit.clone(), kv_store) + .setup_with_router(settings, unit.clone(), kv_store) .await?; webhook_routers.push(webhook_router); @@ -589,18 +628,64 @@ async fn configure_lightning_backend( #[cfg(feature = "nwc")] LnBackend::Nwc => { let nwc_settings = settings.clone().nwc.expect("NWC config defined"); - let nwc = nwc_settings - .setup(settings, CurrencyUnit::Sat, None, work_dir, None) - .await?; - mint_builder = configure_backend_for_unit( - settings, - mint_builder, - CurrencyUnit::Sat, - mint_melt_limits, - Arc::new(nwc), - ) - .await?; + #[cfg(feature = "square")] + let square_enabled = nwc_settings.square.is_some(); + #[cfg(not(feature = "square"))] + let square_enabled = false; + + if square_enabled { + #[cfg(feature = "square")] + { + let mut webhook_routers = Vec::new(); + let kv_store = _kv_store.clone().ok_or_else(|| { + anyhow!("KV store is required for NWC with Square backend") + })?; + let (nwc, square_webhook_router) = nwc_settings + .setup_with_square(settings, CurrencyUnit::Sat, kv_store) + .await?; + + if let Some(router) = square_webhook_router { + webhook_routers.push(router); + } + + #[cfg(feature = "prometheus")] + let nwc = MetricsMintPayment::new(nwc); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + CurrencyUnit::Sat, + mint_melt_limits, + Arc::new(nwc), + ) + .await?; + + return Ok((mint_builder, webhook_routers)); + } + } else { + let nwc = nwc_settings + .setup( + settings, + CurrencyUnit::Sat, + None, + work_dir, + _kv_store.clone(), + ) + .await?; + + #[cfg(feature = "prometheus")] + let nwc = MetricsMintPayment::new(nwc); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + CurrencyUnit::Sat, + mint_melt_limits, + Arc::new(nwc), + ) + .await?; + } } LnBackend::None => { tracing::error!( diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index 0138d3a5ac..b244812d24 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -9,7 +9,7 @@ use std::sync::Arc; use anyhow::anyhow; use async_trait::async_trait; #[cfg(feature = "fakewallet")] -use bip39::rand::{thread_rng, Rng}; +use bip39::rand::{rngs::OsRng, Rng}; use cdk::cdk_database::MintKVStore; use cdk::cdk_payment::MintPayment; use cdk::nuts::CurrencyUnit; @@ -140,6 +140,59 @@ impl LnBackendSetup for config::Lnd { } } +#[cfg(all(feature = "fakewallet", feature = "square"))] +impl config::FakeWallet { + pub async fn setup_with_square( + &self, + settings: &Settings, + unit: CurrencyUnit, + kv_store: cdk_common::database::mint::DynMintKVStore, + ) -> anyhow::Result<(cdk_fake_wallet::FakeWallet, Option)> { + use cdk::mint_url::MintUrl; + + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; + + let mut rng = OsRng; + let delay_time = rng.gen_range(self.min_delay_time..=self.max_delay_time); + + let (square_config, square_webhook_url) = if let Some(ref square_cfg) = self.square { + let webhook_endpoint = format!("/webhook/square/{}/payment", unit); + let mint_url: MintUrl = settings.info.url.parse()?; + let webhook_url = mint_url.join(&webhook_endpoint)?; + + let square_config = cdk_square::SquareConfig { + api_token: square_cfg.api_token.clone(), + environment: square_cfg.environment.clone(), + webhook_enabled: square_cfg.webhook_enabled, + payment_expiry: cdk_square::DEFAULT_SQUARE_PAYMENT_EXPIRY, + }; + + (Some(square_config), Some(webhook_url.to_string())) + } else { + (None, None) + }; + + let fake_wallet = cdk_fake_wallet::FakeWallet::new_with_square( + fee_reserve, + HashMap::default(), + HashSet::default(), + delay_time, + unit.clone(), + kv_store, + square_config, + square_webhook_url, + ) + .await?; + + let square_webhook_router = fake_wallet.create_square_webhook_router(); + + Ok((fake_wallet, square_webhook_router)) + } +} + #[cfg(feature = "fakewallet")] #[async_trait] impl LnBackendSetup for config::FakeWallet { @@ -156,19 +209,16 @@ impl LnBackendSetup for config::FakeWallet { percent_fee_reserve: self.fee_percent, }; - // calculate random delay time - let mut rng = thread_rng(); + let mut rng = OsRng; let delay_time = rng.gen_range(self.min_delay_time..=self.max_delay_time); - let fake_wallet = cdk_fake_wallet::FakeWallet::new( + Ok(cdk_fake_wallet::FakeWallet::new( fee_reserve, HashMap::default(), HashSet::default(), delay_time, unit, - ); - - Ok(fake_wallet) + )) } } @@ -329,6 +379,36 @@ impl LnBackendSetup for config::LdkNode { } } +#[cfg(feature = "strike")] +impl config::Strike { + /// Setup Strike backend with webhook router + /// Strike requires webhook endpoints for invoice notifications + pub async fn setup_with_router( + &self, + settings: &Settings, + unit: CurrencyUnit, + kv_store: cdk_common::database::mint::DynMintKVStore, + ) -> anyhow::Result<(cdk_strike::Strike, axum::Router)> { + use cdk::mint_url::MintUrl; + + let webhook_endpoint = format!("/webhook/strike/{}/invoice", unit); + let mint_url: MintUrl = settings.info.url.parse()?; + let webhook_url = mint_url.join(&webhook_endpoint)?; + + let strike = cdk_strike::Strike::new( + self.api_key.clone(), + unit, + webhook_url.to_string(), + kv_store, + ) + .await?; + + let webhook_router = strike.create_invoice_webhook(&webhook_endpoint).await?; + + Ok((strike, webhook_router)) + } +} + #[cfg(feature = "strike")] #[async_trait] impl LnBackendSetup for config::Strike { @@ -340,40 +420,66 @@ impl LnBackendSetup for config::Strike { _work_dir: &Path, _kv_store: Option + Send + Sync>>, ) -> anyhow::Result { - anyhow::bail!( - "Strike backend cannot use the standard setup() method due to webhook routing requirements. \ - Use setup_with_webhook() instead, or configure Strike through the mint builder." - ) + anyhow::bail!("Strike backend requires webhook routing. Use setup_with_router() instead.") } } -#[cfg(feature = "strike")] -impl config::Strike { - /// Special setup for Strike that includes webhook router - /// This is necessary because Strike requires webhook endpoints on the same server - pub async fn setup_with_webhook( +#[cfg(all(feature = "nwc", feature = "square"))] +impl config::Nwc { + /// Special setup for NWC with Square integration + pub async fn setup_with_square( &self, settings: &Settings, unit: CurrencyUnit, kv_store: cdk_common::database::mint::DynMintKVStore, - ) -> anyhow::Result<(cdk_strike::Strike, axum::Router)> { + ) -> anyhow::Result<(cdk_nwc::NWCWallet, Option)> { use cdk::mint_url::MintUrl; - let webhook_endpoint = format!("/webhook/strike/{}/invoice", unit); - let mint_url: MintUrl = settings.info.url.parse()?; - let webhook_url = mint_url.join(&webhook_endpoint)?; + let fee_reserve = FeeReserve { + min_fee_reserve: self.reserve_fee_min, + percent_fee_reserve: self.fee_percent, + }; - let strike = cdk_strike::Strike::new( - self.api_key.clone(), - unit, - webhook_url.to_string(), + let (square_config, square_webhook_url) = if let Some(ref square_cfg) = self.square { + let webhook_endpoint = format!("/webhook/square/{}/payment", unit); + let mint_url: MintUrl = settings.info.url.parse()?; + let webhook_url = mint_url.join(&webhook_endpoint)?; + + let square_config = cdk_square::SquareConfig { + api_token: square_cfg.api_token.clone(), + environment: square_cfg.environment.clone(), + webhook_enabled: square_cfg.webhook_enabled, + payment_expiry: cdk_square::DEFAULT_SQUARE_PAYMENT_EXPIRY, + }; + + (Some(square_config), Some(webhook_url.to_string())) + } else { + (None, None) + }; + + let internal_melts_only = if square_config.is_some() { + // TODO: handle this better. Right now the mint will be configured with internal melts only, + // but because we are using Square to determine if the payment is internal, we need to set this to false + // so that the mint will still be able to pay external invoices, but only those tracked by Square. + false + } else { + settings.ln.internal_melts_only + }; + + let nwc = cdk_nwc::NWCWallet::new( + &self.nwc_uri, + fee_reserve, + unit.clone(), + internal_melts_only, kv_store, + square_config, + square_webhook_url, ) .await?; - let webhook_router = strike.create_invoice_webhook(&webhook_endpoint).await?; + let square_webhook_router = nwc.create_square_webhook_router(); - Ok((strike, webhook_router)) + Ok((nwc, square_webhook_router)) } } @@ -386,17 +492,25 @@ impl LnBackendSetup for config::Nwc { unit: CurrencyUnit, _runtime: Option>, _work_dir: &Path, - _kv_store: Option + Send + Sync>>, + kv_store: Option + Send + Sync>>, ) -> anyhow::Result { let fee_reserve = FeeReserve { min_fee_reserve: self.reserve_fee_min, percent_fee_reserve: self.fee_percent, }; - let internal_melts_only = settings.ln.internal_melts_only; + let kv_store_arc = + kv_store.ok_or_else(|| anyhow::anyhow!("KV store is required for NWC backend"))?; - let nwc = - cdk_nwc::NWCWallet::new(&self.nwc_uri, fee_reserve, unit, internal_melts_only).await?; - Ok(nwc) + Ok(cdk_nwc::NWCWallet::new( + &self.nwc_uri, + fee_reserve, + unit, + settings.ln.internal_melts_only, + kv_store_arc, + None, + None, + ) + .await?) } } diff --git a/crates/cdk-nwc/Cargo.toml b/crates/cdk-nwc/Cargo.toml index 91f408f0d2..1e73b91f4c 100644 --- a/crates/cdk-nwc/Cargo.toml +++ b/crates/cdk-nwc/Cargo.toml @@ -10,6 +10,10 @@ rust-version.workspace = true # MSRV description = "CDK nwc backend" readme = "README.md" +[features] +default = ["square"] +square = ["dep:cdk-square"] + [dependencies] nwc = "0.43.0" async-trait.workspace = true @@ -22,4 +26,6 @@ tracing.workspace = true thiserror.workspace = true serde_json.workspace = true tokio-stream = { workspace = true, features = ["sync"] } -rustls.workspace = true \ No newline at end of file +rustls.workspace = true +axum.workspace = true +cdk-square = { workspace = true, optional = true } \ No newline at end of file diff --git a/crates/cdk-nwc/src/error.rs b/crates/cdk-nwc/src/error.rs index a138769086..c097baeee4 100644 --- a/crates/cdk-nwc/src/error.rs +++ b/crates/cdk-nwc/src/error.rs @@ -32,6 +32,11 @@ pub enum Error { /// Connection error #[error("Connection error: {0}")] Connection(String), + + /// Square error + #[cfg(feature = "square")] + #[error(transparent)] + Square(#[from] cdk_square::error::Error), } impl From for cdk_common::payment::Error { @@ -58,6 +63,8 @@ impl From for cdk_common::payment::Error { Error::Connection(msg) => { cdk_common::payment::Error::Custom(format!("Connection error: {}", msg)) } + #[cfg(feature = "square")] + Error::Square(e) => cdk_common::payment::Error::Custom(format!("Square error: {}", e)), } } } diff --git a/crates/cdk-nwc/src/lib.rs b/crates/cdk-nwc/src/lib.rs index 393db9800c..7c15ada2a3 100644 --- a/crates/cdk-nwc/src/lib.rs +++ b/crates/cdk-nwc/src/lib.rs @@ -17,15 +17,18 @@ use std::sync::Arc; use std::time::Duration; use async_trait::async_trait; +use axum::Router; use bitcoin::hashes::sha256::Hash; use cdk_common::amount::{to_unit, Amount}; use cdk_common::common::FeeReserve; +use cdk_common::database::mint::DynMintKVStore; use cdk_common::nuts::{CurrencyUnit, MeltOptions, MeltQuoteState}; use cdk_common::payment::{ self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, WaitPaymentResponse, }; +#[cfg(feature = "square")] use cdk_common::util::hex; use cdk_common::Bolt11Invoice; use error::Error; @@ -62,6 +65,9 @@ pub struct NWCWallet { unit: CurrencyUnit, /// Notification handler task handle notification_handle: Arc>>>, + /// Optional Square payment backend + #[cfg(feature = "square")] + square: Option>, } impl NWCWallet { @@ -71,6 +77,9 @@ impl NWCWallet { fee_reserve: FeeReserve, unit: CurrencyUnit, internal_melts_only: bool, + kv_store: DynMintKVStore, + #[cfg(feature = "square")] square_config: Option, + #[cfg(feature = "square")] square_webhook_url: Option, ) -> Result { // NWC requires TLS for talking to the relay if rustls::crypto::CryptoProvider::get_default().is_none() { @@ -105,6 +114,24 @@ impl NWCWallet { let (sender, receiver) = tokio::sync::broadcast::channel(100); + // Initialize Square backend if configuration is provided + // If webhook_url is None, Square will use polling mode (sync every 5 seconds) + #[cfg(feature = "square")] + let square = match square_config { + Some(config) => { + match cdk_square::Square::from_config(config, square_webhook_url, kv_store.clone()) + { + Ok(square_backend) => { + let square_arc = Arc::new(square_backend); + square_arc.start().await?; + Some(square_arc) + } + Err(e) => return Err(e.into()), + } + } + None => None, + }; + let wallet = Self { nwc_client, fee_reserve, @@ -114,6 +141,8 @@ impl NWCWallet { wait_invoice_is_active: Arc::new(AtomicBool::new(false)), unit, notification_handle: Arc::new(Mutex::new(None)), + #[cfg(feature = "square")] + square, }; // Start notification handler @@ -122,6 +151,18 @@ impl NWCWallet { Ok(wallet) } + /// Create Square webhook router for payment notifications + /// + /// # Returns + /// * `Some(Router)` if Square backend is configured with a webhook URL + /// * `None` if Square backend is not configured or is using polling mode + #[cfg(feature = "square")] + pub fn create_square_webhook_router(&self) -> Option { + self.square + .as_ref() + .and_then(|square| square.create_webhook_router()) + } + /// Start the notification handler for payment updates async fn start_notification_handler(&self) -> Result<(), Error> { let nwc_client = self.nwc_client.clone(); @@ -246,6 +287,35 @@ impl MintPayment for NWCWallet { })?) } + #[instrument(skip_all)] + async fn is_internal_payment( + &self, + request: &Bolt11Invoice, + ) -> Result, Self::Err> { + #[cfg(feature = "square")] + { + let Some(square) = self.square.as_ref() else { + return Ok(None); + }; + + // Check if this invoice exists in our Square tracking + return square + .check_invoice_exists(request) + .await + .map_err(|e| { + tracing::error!("Error checking Square invoice: {}", e); + Error::Square(e).into() + }) + .map(Some); + } + + #[cfg(not(feature = "square"))] + { + let _ = request; // Avoid unused variable warning + Ok(None) + } + } + #[instrument(skip_all)] fn is_wait_invoice_active(&self) -> bool { self.wait_invoice_is_active.load(Ordering::SeqCst) diff --git a/crates/cdk-strike/src/lib.rs b/crates/cdk-strike/src/lib.rs index 1d26193236..b9b861fe71 100644 --- a/crates/cdk-strike/src/lib.rs +++ b/crates/cdk-strike/src/lib.rs @@ -1228,6 +1228,15 @@ mod tests { ) -> Result, DatabaseError> { Ok(vec![]) } + + async fn kv_remove_older_than( + &mut self, + _primary_namespace: &str, + _secondary_namespace: &str, + _expiry_time: u64, + ) -> Result<(), DatabaseError> { + Ok(()) + } } #[async_trait] diff --git a/crates/cdk/src/mint/melt.rs b/crates/cdk/src/mint/melt.rs index 3c635b383f..4ed1770b99 100644 --- a/crates/cdk/src/mint/melt.rs +++ b/crates/cdk/src/mint/melt.rs @@ -174,7 +174,7 @@ impl Mint { })?; if internal_melts_only { - match ln.is_internal_payment(&request).await? { + match ln.is_internal_payment(request).await? { Some(true) => {} Some(false) => { let mint_name = mint_info.name.unwrap_or_else(|| "this mint".to_string()); diff --git a/justfile b/justfile index 4b9eff1e7e..68cf34cb4e 100644 --- a/justfile +++ b/justfile @@ -341,6 +341,9 @@ release m="": "-p cdk-payment-processor" "-p cdk-cli" "-p cdk-mintd" + "-p cdk-square" + "-p cdk-nwc" + "-p cdk-strike" ) for arg in "${args[@]}"; @@ -377,6 +380,9 @@ check-docs: "-p cdk-signatory" "-p cdk-cli" "-p cdk-mintd" + "-p cdk-square" + "-p cdk-nwc" + "-p cdk-strike" ) for arg in "${args[@]}"; do @@ -408,6 +414,7 @@ docs-strict: "-p cdk-signatory" "-p cdk-cli" "-p cdk-mintd" + "-p cdk-square" ) for arg in "${args[@]}"; do From 124eec3588f3748735ebfa8fe546a385c13f327b Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 16 Oct 2025 11:58:46 -0700 Subject: [PATCH 07/18] fix: enable tls for square and better error logging --- crates/cdk-square/Cargo.toml | 1 + crates/cdk-square/src/client.rs | 215 +++++++++++++++++++++++++++++++- 2 files changed, 212 insertions(+), 4 deletions(-) diff --git a/crates/cdk-square/Cargo.toml b/crates/cdk-square/Cargo.toml index e2c5214f5d..df1ad1c654 100644 --- a/crates/cdk-square/Cargo.toml +++ b/crates/cdk-square/Cargo.toml @@ -27,4 +27,5 @@ axum.workspace = true squareup = "2.13.2" reqwest = { workspace = true } bitcoin = { workspace = true } +rustls = { workspace = true } diff --git a/crates/cdk-square/src/client.rs b/crates/cdk-square/src/client.rs index 5e7ce7c085..013369786c 100644 --- a/crates/cdk-square/src/client.rs +++ b/crates/cdk-square/src/client.rs @@ -1,5 +1,6 @@ //! Square API client wrapper and core functionality +use std::error::Error as StdError; use std::sync::Arc; use cdk_common::database::mint::DynMintKVStore; @@ -48,6 +49,13 @@ impl Square { webhook_url: Option, kv_store: DynMintKVStore, ) -> Result { + // Square requires TLS for HTTPS requests + // Install rustls crypto provider if not already set + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + tracing::debug!("Installed rustls crypto provider for Square"); + } + let environment = match square_config.environment.to_uppercase().as_str() { "PRODUCTION" => SquareEnvironment::Production, _ => SquareEnvironment::Sandbox, @@ -245,7 +253,27 @@ impl Square { let base_url = self.get_base_url(); let url = format!("{}/v2/payments", base_url); - let client = reqwest::Client::new(); + + tracing::debug!( + "Listing Square payments - URL: {}, Environment: {:?}, begin_time: {:?}", + url, + self.environment, + params.begin_time + ); + + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + { + Ok(c) => c, + Err(e) => { + tracing::error!("Failed to build reqwest client for payments: {:?}", e); + return Err(Error::SquareHttp(format!( + "Failed to build HTTP client: {}. This may indicate TLS backend issues.", + e + ))); + } + }; let mut request = client .get(&url) @@ -261,10 +289,76 @@ impl Square { request = request.query(&[("cursor", cursor.as_str())]); } + tracing::debug!("Sending GET request to {}", url); let response = request .send() .await - .map_err(|e| Error::SquareHttp(format!("Failed to list payments: {}", e)))?; + .map_err(|e| { + // Provide detailed error diagnostics + let mut error_details = format!("Failed to send request to {}", url); + let mut hints = Vec::new(); + + if e.is_timeout() { + error_details.push_str(" - Timeout after 30s"); + hints.push("Increase timeout or check network latency"); + } else if e.is_connect() { + error_details.push_str(" - Connection failed"); + hints.push("Check firewall rules, network connectivity, or proxy settings"); + } else if e.is_request() { + error_details.push_str(" - Request building failed"); + hints.push("This is likely a bug in the request construction"); + } else if e.is_redirect() { + error_details.push_str(" - Too many redirects"); + } else if e.is_builder() { + error_details.push_str(" - Client builder error"); + hints.push("TLS backend configuration issue"); + } + + // Include underlying error chain + if let Some(source) = e.source() { + error_details.push_str(&format!(" | Root cause: {}", source)); + + // Check for specific error types + let source_str = source.to_string().to_lowercase(); + if source_str.contains("certificate") || source_str.contains("tls") || source_str.contains("ssl") { + hints.push("TLS/SSL issue - check root certificates or try native-tls feature"); + } else if source_str.contains("dns") || source_str.contains("resolve") { + hints.push("DNS resolution failed"); + } else if source_str.contains("tcp") || source_str.contains("connection refused") { + hints.push("TCP connection refused - check if port 443 is accessible"); + } else if source_str.contains("crypto") || source_str.contains("provider") { + hints.push("Crypto provider error - rustls crypto provider may have failed to initialize"); + } + + // Walk the full error chain + let mut current_source = source.source(); + let mut depth = 0; + while let Some(src) = current_source { + depth += 1; + if depth < 3 { // Limit depth to avoid spam + error_details.push_str(&format!(" -> {}", src)); + } + current_source = src.source(); + } + } + + let hints_str = if !hints.is_empty() { + format!(" | Hints: {}", hints.join("; ")) + } else { + String::new() + }; + + tracing::error!( + "Square API request failed: {}{}", + error_details, + hints_str + ); + tracing::error!("Full error debug: {:?}", e); + + Error::SquareHttp(format!("{}{}", error_details, hints_str)) + })?; + + tracing::debug!("Received response with status: {}", response.status()); if !response.status().is_success() { let error_body = response @@ -304,8 +398,56 @@ impl Square { let base_url = self.get_base_url(); let url = format!("{}/v2/merchants", base_url); - let client = reqwest::Client::new(); + tracing::info!( + "Listing Square merchants - URL: {}, Environment: {:?}", + url, + self.environment + ); + + // Test DNS resolution before making the request + let host = match self.environment { + SquareEnvironment::Production => "connect.squareup.com", + SquareEnvironment::Sandbox => "connect.squareupsandbox.com", + }; + + tracing::debug!("Testing DNS resolution for {}", host); + match tokio::net::lookup_host(format!("{}:443", host)).await { + Ok(mut addrs) => { + if let Some(addr) = addrs.next() { + tracing::info!("DNS resolution successful: {} -> {}", host, addr); + } else { + tracing::error!("DNS resolution returned no addresses for {}", host); + } + } + Err(e) => { + tracing::error!("DNS resolution failed for {}: {}", host, e); + return Err(Error::SquareHttp(format!( + "DNS resolution failed for {}: {}. Check network connectivity and DNS configuration.", + host, e + ))); + } + } + + tracing::debug!("Building reqwest client"); + let client = match reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + { + Ok(c) => { + tracing::debug!("reqwest client built successfully"); + c + } + Err(e) => { + tracing::error!("Failed to build reqwest client: {:?}", e); + return Err(Error::SquareHttp(format!( + "Failed to build HTTP client: {}. This may indicate TLS backend issues.", + e + ))); + } + }; + + tracing::debug!("Sending GET request to {}", url); let response = client .get(&url) .header("Authorization", format!("Bearer {}", self.api_token)) @@ -313,7 +455,72 @@ impl Square { .header("Content-Type", "application/json") .send() .await - .map_err(|e| Error::SquareHttp(format!("Failed to list merchants: {}", e)))?; + .map_err(|e| { + // Provide detailed error diagnostics + let mut error_details = format!("Failed to send request to {}", url); + let mut hints = Vec::new(); + + if e.is_timeout() { + error_details.push_str(" - Timeout after 30s"); + hints.push("Increase timeout or check network latency"); + } else if e.is_connect() { + error_details.push_str(" - Connection failed"); + hints.push("Check firewall rules, network connectivity, or proxy settings"); + } else if e.is_request() { + error_details.push_str(" - Request building failed"); + hints.push("This is likely a bug in the request construction"); + } else if e.is_redirect() { + error_details.push_str(" - Too many redirects"); + } else if e.is_builder() { + error_details.push_str(" - Client builder error"); + hints.push("TLS backend configuration issue"); + } + + // Include underlying error chain + if let Some(source) = e.source() { + error_details.push_str(&format!(" | Root cause: {}", source)); + + // Check for specific error types + let source_str = source.to_string().to_lowercase(); + if source_str.contains("certificate") || source_str.contains("tls") || source_str.contains("ssl") { + hints.push("TLS/SSL issue - check root certificates or try native-tls feature"); + } else if source_str.contains("dns") || source_str.contains("resolve") { + hints.push("DNS resolution failed after initial check - possible race condition"); + } else if source_str.contains("tcp") || source_str.contains("connection refused") { + hints.push("TCP connection refused - check if port 443 is accessible"); + } else if source_str.contains("crypto") || source_str.contains("provider") { + hints.push("Crypto provider error - rustls crypto provider may have failed to initialize"); + } + + // Walk the full error chain + let mut current_source = source.source(); + let mut depth = 0; + while let Some(src) = current_source { + depth += 1; + if depth < 3 { // Limit depth to avoid spam + error_details.push_str(&format!(" -> {}", src)); + } + current_source = src.source(); + } + } + + let hints_str = if !hints.is_empty() { + format!(" | Hints: {}", hints.join("; ")) + } else { + String::new() + }; + + tracing::error!( + "Square API request failed: {}{}", + error_details, + hints_str + ); + tracing::error!("Full error debug: {:?}", e); + + Error::SquareHttp(format!("{}{}", error_details, hints_str)) + })?; + + tracing::debug!("Received response with status: {}", response.status()); if !response.status().is_success() { let error_body = response From 4d89c9ce7f5011ecc9f1265b86341f9c39a22369 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Thu, 16 Oct 2025 12:25:07 -0700 Subject: [PATCH 08/18] fix: use rusttls --- crates/cdk-square/Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/cdk-square/Cargo.toml b/crates/cdk-square/Cargo.toml index df1ad1c654..14a50539ff 100644 --- a/crates/cdk-square/Cargo.toml +++ b/crates/cdk-square/Cargo.toml @@ -24,7 +24,7 @@ uuid.workspace = true axum.workspace = true # Square payment integration -squareup = "2.13.2" +squareup = { version = "2.13.2", default-features = false, features = ["rustls-tls"] } reqwest = { workspace = true } bitcoin = { workspace = true } rustls = { workspace = true } From d0ed38ec4e24fca3d0295f53e386808f7de7f3a1 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Mon, 27 Oct 2025 18:48:45 -0700 Subject: [PATCH 09/18] feat: square backend with oauth suppor --- crates/cdk-mintd/example.config.toml | 2 + crates/cdk-mintd/src/config.rs | 6 +- crates/cdk-mintd/src/env_vars/fake_wallet.rs | 4 + crates/cdk-mintd/src/env_vars/nwc.rs | 7 +- crates/cdk-mintd/src/setup.rs | 2 + crates/cdk-square/Cargo.toml | 6 ++ crates/cdk-square/src/client.rs | 68 +++++++++++- crates/cdk-square/src/config.rs | 5 +- crates/cdk-square/src/db.rs | 103 +++++++++++++++++++ crates/cdk-square/src/error.rs | 14 +++ crates/cdk-square/src/lib.rs | 12 +-- 11 files changed, 215 insertions(+), 14 deletions(-) create mode 100644 crates/cdk-square/src/db.rs diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 13ea3b91d1..3a1e303744 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -164,6 +164,7 @@ max_delay_time = 3 # api_token = "your_square_api_token_here" # environment = "SANDBOX" # or "PRODUCTION" # webhook_enabled = true # If false, uses polling mode (syncs every 5 seconds) instead of webhooks +# database_url = "postgresql://user:password@host:port/database?sslmode=require" # PostgreSQL connection for OAuth credentials # [nwc] # nwc_uri = "nostr+walletconnect://..." @@ -175,6 +176,7 @@ max_delay_time = 3 # api_token = "your_square_api_token_here" # environment = "SANDBOX" # or "PRODUCTION" # webhook_enabled = true # If false, uses polling mode (syncs every 5 seconds) instead of webhooks +# database_url = "postgresql://user:password@host:port/database?sslmode=require" # PostgreSQL connection for OAuth credentials # [grpc_processor] # gRPC Payment Processor configuration diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index a2978408d3..ebcf43ab16 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -318,22 +318,24 @@ pub struct Nwc { pub square: Option, } -#[cfg(feature = "nwc")] +#[cfg(any(feature = "nwc", feature = "fakewallet"))] #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SquareConfig { pub api_token: String, pub environment: String, #[serde(default = "default_webhook_enabled")] pub webhook_enabled: bool, + pub database_url: String, } -#[cfg(feature = "nwc")] +#[cfg(any(feature = "nwc", feature = "fakewallet"))] impl Default for SquareConfig { fn default() -> Self { Self { api_token: String::new(), environment: String::from("SANDBOX"), webhook_enabled: true, + database_url: String::new(), } } } diff --git a/crates/cdk-mintd/src/env_vars/fake_wallet.rs b/crates/cdk-mintd/src/env_vars/fake_wallet.rs index 165dcd3a3c..b8e8e7a963 100644 --- a/crates/cdk-mintd/src/env_vars/fake_wallet.rs +++ b/crates/cdk-mintd/src/env_vars/fake_wallet.rs @@ -18,6 +18,7 @@ pub const ENV_FAKE_WALLET_SQUARE_API_TOKEN: &str = "CDK_MINTD_FAKE_WALLET_SQUARE pub const ENV_FAKE_WALLET_SQUARE_ENVIRONMENT: &str = "CDK_MINTD_FAKE_WALLET_SQUARE_ENVIRONMENT"; pub const ENV_FAKE_WALLET_SQUARE_WEBHOOK_ENABLED: &str = "CDK_MINTD_FAKE_WALLET_SQUARE_WEBHOOK_ENABLED"; +pub const ENV_FAKE_WALLET_SQUARE_DATABASE_URL: &str = "CDK_MINTD_FAKE_WALLET_SQUARE_DATABASE_URL"; impl FakeWallet { pub fn from_env(mut self) -> Self { @@ -66,10 +67,13 @@ impl FakeWallet { .and_then(|v| v.parse::().ok()) .unwrap_or(true); + let database_url = env::var(ENV_FAKE_WALLET_SQUARE_DATABASE_URL).unwrap_or_default(); + self.square = Some(SquareConfig { api_token, environment, webhook_enabled, + database_url, }); } diff --git a/crates/cdk-mintd/src/env_vars/nwc.rs b/crates/cdk-mintd/src/env_vars/nwc.rs index 65f185313d..c802fecccc 100644 --- a/crates/cdk-mintd/src/env_vars/nwc.rs +++ b/crates/cdk-mintd/src/env_vars/nwc.rs @@ -3,7 +3,7 @@ use std::env; use crate::config::Nwc; -#[cfg(feature = "square")] +#[cfg(any(feature = "square", feature = "fakewallet"))] use crate::config::SquareConfig; // NWC environment variables @@ -18,6 +18,8 @@ pub const ENV_SQUARE_API_TOKEN: &str = "CDK_MINTD_SQUARE_API_TOKEN"; pub const ENV_SQUARE_ENVIRONMENT: &str = "CDK_MINTD_SQUARE_ENVIRONMENT"; #[cfg(feature = "square")] pub const ENV_SQUARE_WEBHOOK_ENABLED: &str = "CDK_MINTD_SQUARE_WEBHOOK_ENABLED"; +#[cfg(feature = "square")] +pub const ENV_SQUARE_DATABASE_URL: &str = "CDK_MINTD_SQUARE_DATABASE_URL"; impl Nwc { pub fn from_env(mut self) -> Self { @@ -48,10 +50,13 @@ impl Nwc { .and_then(|v| v.parse::().ok()) .unwrap_or(true); + let database_url = env::var(ENV_SQUARE_DATABASE_URL).unwrap_or_default(); + self.square = Some(SquareConfig { api_token, environment, webhook_enabled, + database_url, }); } diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index b244812d24..e8f4b8248f 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -168,6 +168,7 @@ impl config::FakeWallet { environment: square_cfg.environment.clone(), webhook_enabled: square_cfg.webhook_enabled, payment_expiry: cdk_square::DEFAULT_SQUARE_PAYMENT_EXPIRY, + database_url: square_cfg.database_url.clone(), }; (Some(square_config), Some(webhook_url.to_string())) @@ -450,6 +451,7 @@ impl config::Nwc { environment: square_cfg.environment.clone(), webhook_enabled: square_cfg.webhook_enabled, payment_expiry: cdk_square::DEFAULT_SQUARE_PAYMENT_EXPIRY, + database_url: square_cfg.database_url.clone(), }; (Some(square_config), Some(webhook_url.to_string())) diff --git a/crates/cdk-square/Cargo.toml b/crates/cdk-square/Cargo.toml index 14a50539ff..e23353beab 100644 --- a/crates/cdk-square/Cargo.toml +++ b/crates/cdk-square/Cargo.toml @@ -29,3 +29,9 @@ reqwest = { workspace = true } bitcoin = { workspace = true } rustls = { workspace = true } +# PostgreSQL database for OAuth credentials +tokio-postgres = "0.7.13" +tokio-postgres-rustls = "0.13" +rustls-native-certs = "0.8" +webpki-roots = "0.26" + diff --git a/crates/cdk-square/src/client.rs b/crates/cdk-square/src/client.rs index 013369786c..1c521eb01d 100644 --- a/crates/cdk-square/src/client.rs +++ b/crates/cdk-square/src/client.rs @@ -11,6 +11,7 @@ use squareup::SquareClient; use tokio::sync::RwLock; use crate::config::SquareConfig; +use crate::db::SquareDatabase; use crate::error::Error; use crate::types::{ListMerchantsResponse, ListPaymentsParams, ListPaymentsResponse}; use crate::util::{ @@ -24,7 +25,7 @@ const SYNC_POLLING_INTERVAL: u64 = 5; pub struct Square { /// Square API client pub(crate) client: Arc, - /// Square API token for direct API calls + /// Square API token for direct API calls (used for webhook management) pub(crate) api_token: String, /// Square environment (sandbox or production) pub(crate) environment: SquareEnvironment, @@ -38,12 +39,19 @@ pub struct Square { pub(crate) kv_store: DynMintKVStore, /// Cached merchant business names for invoice description matching pub(crate) merchant_names: Arc>>, + /// PostgreSQL database URL for OAuth credentials + pub(crate) database_url: String, + /// PostgreSQL database connection for OAuth credentials (initialized in start()) + pub(crate) db: Arc>>, + /// Cached OAuth access token from database + pub(crate) oauth_token: Arc>, } impl Square { /// Initialize Square backend from configuration /// /// Returns `Err` if configuration is invalid. + /// Database initialization happens in `start()`. pub fn from_config( square_config: SquareConfig, webhook_url: Option, @@ -81,6 +89,9 @@ impl Square { payment_expiry: square_config.payment_expiry, kv_store, merchant_names: Arc::new(RwLock::new(Vec::new())), + database_url: square_config.database_url, + db: Arc::new(RwLock::new(None)), + oauth_token: Arc::new(RwLock::new(String::new())), }; Ok(square) @@ -91,9 +102,30 @@ impl Square { /// If webhook_enabled is true and webhook_url is configured, sets up webhook subscription. /// Otherwise, starts a background task that polls every 5 seconds. /// - /// This method blocks until the initial payment sync completes successfully. + /// This method initializes the database connection and blocks until the initial payment sync completes successfully. /// If the initial sync fails, an error is returned and the mint will not start. pub async fn start(&self) -> Result<(), Error> { + // Initialize database connection for OAuth credentials + let db = SquareDatabase::new(&self.database_url).await?; + + // Fetch initial OAuth credentials from database + let credentials = db.read_credentials().await.map_err(|e| { + Error::SquareConfig(format!( + "Failed to read OAuth credentials from database: {}. Ensure credentials are set up in PostgreSQL.", + e + )) + })?; + + // Store the database connection and OAuth token + { + let mut db_lock = self.db.write().await; + *db_lock = Some(db); + } + { + let mut token_lock = self.oauth_token.write().await; + *token_lock = credentials.access_token; + } + self.refresh_merchant_names().await?; if self.webhook_enabled && self.webhook_url.is_some() { @@ -115,6 +147,30 @@ impl Square { } }); } + + // Start background task to periodically refresh OAuth token from database + let square_for_token_refresh = self.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(tokio::time::Duration::from_secs(60)); + loop { + interval.tick().await; + let db_opt = square_for_token_refresh.db.read().await; + if let Some(ref db) = *db_opt { + match db.read_credentials().await { + Ok(credentials) => { + drop(db_opt); // Release read lock before acquiring write lock + let mut token = square_for_token_refresh.oauth_token.write().await; + *token = credentials.access_token; + tracing::debug!("Refreshed OAuth access token from database"); + } + Err(e) => { + tracing::warn!("Failed to refresh OAuth token from database: {}", e); + } + } + } + } + }); + tracing::debug!("Square payment sync completed successfully"); Ok(()) @@ -275,9 +331,11 @@ impl Square { } }; + let oauth_token = self.oauth_token.read().await.clone(); + let mut request = client .get(&url) - .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Authorization", format!("Bearer {}", oauth_token)) .header("Square-Version", "2025-09-24") .query(&[("limit", params.limit.to_string())]); @@ -447,10 +505,12 @@ impl Square { } }; + let oauth_token = self.oauth_token.read().await.clone(); + tracing::debug!("Sending GET request to {}", url); let response = client .get(&url) - .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Authorization", format!("Bearer {}", oauth_token)) .header("Square-Version", "2025-09-24") .header("Content-Type", "application/json") .send() diff --git a/crates/cdk-square/src/config.rs b/crates/cdk-square/src/config.rs index cca16e605f..c68586889a 100644 --- a/crates/cdk-square/src/config.rs +++ b/crates/cdk-square/src/config.rs @@ -5,10 +5,13 @@ use serde::{Deserialize, Serialize}; /// Square configuration for payment backends #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SquareConfig { - /// Square API token + /// Square API token (for webhook management) pub api_token: String, /// Square environment (SANDBOX or PRODUCTION) pub environment: String, + /// PostgreSQL database URL for OAuth credentials + /// Format: postgresql://user:password@host:port/database?sslmode=require + pub database_url: String, /// Enable webhook mode (if false, uses polling mode even if webhook_url is provided) /// Default is true #[serde(default = "default_webhook_enabled")] diff --git a/crates/cdk-square/src/db.rs b/crates/cdk-square/src/db.rs new file mode 100644 index 0000000000..f180fa1a92 --- /dev/null +++ b/crates/cdk-square/src/db.rs @@ -0,0 +1,103 @@ +//! PostgreSQL database integration for Square OAuth credentials + +use std::sync::Arc; + +use tokio_postgres::Client; + +use crate::error::Error; + +/// OAuth credentials from Square +#[derive(Debug, Clone)] +pub struct OAuthCredentials { + /// Square OAuth access token + pub access_token: String, + /// Square OAuth refresh token + pub refresh_token: String, + /// Token expiration timestamp (RFC 3339 format) + pub expires_at: String, +} + +/// PostgreSQL database connection for Square OAuth credentials +#[derive(Clone)] +pub struct SquareDatabase { + client: Arc, +} + +impl SquareDatabase { + /// Create a new database connection + pub async fn new(database_url: &str) -> Result { + // Ensure rustls crypto provider is installed + if rustls::crypto::CryptoProvider::get_default().is_none() { + let _ = rustls::crypto::ring::default_provider().install_default(); + } + + // Create TLS connector + let root_cert_store = rustls::RootCertStore { + roots: webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(), + }; + let config = rustls::ClientConfig::builder() + .with_root_certificates(root_cert_store) + .with_no_client_auth(); + let tls = tokio_postgres_rustls::MakeRustlsConnect::new(config); + + let (client, connection) = + tokio_postgres::connect(database_url, tls) + .await + .map_err(|e| { + Error::DatabaseConnection(format!("Failed to connect to database: {}", e)) + })?; + + tokio::spawn(async move { + if let Err(e) = connection.await { + tracing::error!("PostgreSQL connection error: {}", e); + } + }); + + Ok(Self { + client: Arc::new(client), + }) + } + + /// Read OAuth credentials from the database + /// + /// Queries the `mints.square_merchant_credentials` table. + pub async fn read_credentials(&self) -> Result { + let row = self + .client + .query_one( + "SELECT access_token, refresh_token, expires_at FROM mints.square_merchant_credentials", + &[], + ) + .await + .map_err(|e| { + Error::DatabaseQuery(format!("Failed to read OAuth credentials: {}", e)) + })?; + + Ok(OAuthCredentials { + access_token: row.get(0), + refresh_token: row.get(1), + expires_at: row.get(2), + }) + } + + /// Update OAuth credentials in the database + /// + /// Updates the `mints.square_merchant_credentials` table with new tokens + /// and sets `updated_at` to the current timestamp. + pub async fn update_credentials( + &self, + access_token: &str, + refresh_token: &str, + expires_at: &str, + ) -> Result { + let rows_affected = self + .client + .execute( + "UPDATE mints.square_merchant_credentials SET access_token = $1, refresh_token = $2, expires_at = $3, updated_at = now()", + &[&access_token, &refresh_token, &expires_at], + ) + .await?; + + Ok(rows_affected) + } +} diff --git a/crates/cdk-square/src/error.rs b/crates/cdk-square/src/error.rs index ba7bacb7f5..af415bdadc 100644 --- a/crates/cdk-square/src/error.rs +++ b/crates/cdk-square/src/error.rs @@ -17,7 +17,21 @@ pub enum Error { #[error("Database error: {0}")] Database(#[from] cdk_common::database::Error), + /// Database connection error + #[error("Database connection error: {0}")] + DatabaseConnection(String), + + /// Database query error + #[error("Database query error: {0}")] + DatabaseQuery(String), + /// Bolt11 parsing error #[error("Bolt11 parsing error: {0}")] Bolt11Parse(String), } + +impl From for Error { + fn from(err: tokio_postgres::Error) -> Self { + Error::DatabaseQuery(err.to_string()) + } +} diff --git a/crates/cdk-square/src/lib.rs b/crates/cdk-square/src/lib.rs index c9e3cb6f3c..9bad656f7a 100644 --- a/crates/cdk-square/src/lib.rs +++ b/crates/cdk-square/src/lib.rs @@ -20,20 +20,18 @@ //! let config = SquareConfig { //! api_token: "your-api-token".to_string(), //! environment: "SANDBOX".to_string(), +//! database_url: "postgresql://user:pass@host:5432/db?sslmode=require".to_string(), //! webhook_enabled: true, //! payment_expiry: 300, // 5 minutes //! }; //! //! let square = Square::from_config( -//! Some(config), +//! config, //! Some("https://your-mint.com/webhook".to_string()), //! kv_store, -//! ) -//! .await?; +//! )?; //! -//! if let Some(square) = square { -//! square.start().await?; -//! } +//! square.start().await?; //! # Ok(()) //! # } //! ``` @@ -43,6 +41,7 @@ pub mod client; pub mod config; +pub mod db; pub mod error; pub mod sync; pub mod types; @@ -52,6 +51,7 @@ pub mod webhook; // Re-export main types pub use client::Square; pub use config::SquareConfig; +pub use db::OAuthCredentials; pub use error::Error; pub use types::{ LightningDetails, ListMerchantsResponse, ListPaymentsParams, ListPaymentsResponse, Merchant, From 8b896fdbb33a683444df2abf55f4be2d720c96c9 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Oct 2025 09:38:35 -0700 Subject: [PATCH 10/18] chore: fixes and clippy --- crates/cdk-postgres/src/lib.rs | 5 ++--- crates/cdk-square/Cargo.toml | 5 ++--- crates/cdk-square/src/db.rs | 20 ++++++++------------ crates/cdk-strike/src/lib.rs | 2 +- 4 files changed, 13 insertions(+), 19 deletions(-) diff --git a/crates/cdk-postgres/src/lib.rs b/crates/cdk-postgres/src/lib.rs index d0a60c4263..0d89c06c62 100644 --- a/crates/cdk-postgres/src/lib.rs +++ b/crates/cdk-postgres/src/lib.rs @@ -335,10 +335,9 @@ mod test { let db_url = format!("{db_url} schema={test_id}"); - let db = MintPgDatabase::new(db_url.as_str()) + MintPgDatabase::new(db_url.as_str()) .await - .expect("database"); - db + .expect("database") } mint_db_test!(provide_db); diff --git a/crates/cdk-square/Cargo.toml b/crates/cdk-square/Cargo.toml index e23353beab..425c511f46 100644 --- a/crates/cdk-square/Cargo.toml +++ b/crates/cdk-square/Cargo.toml @@ -31,7 +31,6 @@ rustls = { workspace = true } # PostgreSQL database for OAuth credentials tokio-postgres = "0.7.13" -tokio-postgres-rustls = "0.13" -rustls-native-certs = "0.8" -webpki-roots = "0.26" +postgres-native-tls = "0.5.1" +native-tls = "0.2" diff --git a/crates/cdk-square/src/db.rs b/crates/cdk-square/src/db.rs index f180fa1a92..9d5f373489 100644 --- a/crates/cdk-square/src/db.rs +++ b/crates/cdk-square/src/db.rs @@ -2,6 +2,8 @@ use std::sync::Arc; +use native_tls::TlsConnector; +use postgres_native_tls::MakeTlsConnector; use tokio_postgres::Client; use crate::error::Error; @@ -26,19 +28,13 @@ pub struct SquareDatabase { impl SquareDatabase { /// Create a new database connection pub async fn new(database_url: &str) -> Result { - // Ensure rustls crypto provider is installed - if rustls::crypto::CryptoProvider::get_default().is_none() { - let _ = rustls::crypto::ring::default_provider().install_default(); - } + // Create TLS connector with lenient settings for development + let builder = TlsConnector::builder(); - // Create TLS connector - let root_cert_store = rustls::RootCertStore { - roots: webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect(), - }; - let config = rustls::ClientConfig::builder() - .with_root_certificates(root_cert_store) - .with_no_client_auth(); - let tls = tokio_postgres_rustls::MakeRustlsConnect::new(config); + let connector = builder.build().map_err(|e| { + Error::DatabaseConnection(format!("Failed to build TLS connector: {}", e)) + })?; + let tls = MakeTlsConnector::new(connector); let (client, connection) = tokio_postgres::connect(database_url, tls) diff --git a/crates/cdk-strike/src/lib.rs b/crates/cdk-strike/src/lib.rs index b9b861fe71..60841d91df 100644 --- a/crates/cdk-strike/src/lib.rs +++ b/crates/cdk-strike/src/lib.rs @@ -1186,7 +1186,7 @@ mod tests { } #[async_trait] - impl<'a> KVStoreTransaction<'a, DatabaseError> for MockKVTransaction { + impl KVStoreTransaction<'_, DatabaseError> for MockKVTransaction { async fn kv_read( &mut self, primary_namespace: &str, From f4ee14b49c58c584e9665c6793e70202a6da26a8 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Oct 2025 10:09:08 -0700 Subject: [PATCH 11/18] chore: bump rustc --- Cargo.toml | 2 +- flake.nix | 4 ++-- rust-toolchain.toml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b3fb656c2c..d3766a56f6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,7 @@ bare_urls = "warn" [workspace.package] edition = "2021" -rust-version = "1.85.0" +rust-version = "1.88.0" license = "MIT" homepage = "https://github.com/cashubtc/cdk" repository = "https://github.com/cashubtc/cdk.git" diff --git a/flake.nix b/flake.nix index fbcc431a04..4bfec1913b 100644 --- a/flake.nix +++ b/flake.nix @@ -56,7 +56,7 @@ # Toolchains # latest stable - stable_toolchain = pkgs.rust-bin.stable."1.86.0".default.override { + stable_toolchain = pkgs.rust-bin.stable."1.88.0".default.override { targets = [ "wasm32-unknown-unknown" ]; # wasm extensions = [ "rustfmt" @@ -66,7 +66,7 @@ }; # MSRV stable - msrv_toolchain = pkgs.rust-bin.stable."1.85.0".default.override { + msrv_toolchain = pkgs.rust-bin.stable."1.88.0".default.override { targets = [ "wasm32-unknown-unknown" ]; # wasm extensions = [ "rustfmt" diff --git a/rust-toolchain.toml b/rust-toolchain.toml index b557f5a45c..d3bfb8a964 100644 --- a/rust-toolchain.toml +++ b/rust-toolchain.toml @@ -1,4 +1,4 @@ [toolchain] -channel="1.86.0" +channel="1.88.0" components = ["rustfmt", "clippy", "rust-analyzer"] From d3309d101bcab6b11106b9b5ab0d83f663527ff4 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Oct 2025 10:54:46 -0700 Subject: [PATCH 12/18] feat: add features to docker file --- Dockerfile | 2 +- Dockerfile.arm | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index ed06de19f2..60749aba2d 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 fakewallet --features square # Create a runtime stage FROM debian:trixie-slim diff --git a/Dockerfile.arm b/Dockerfile.arm index f256cf73b5..ee05c4d671 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 prometheus --features fakewallet --features square # Create a runtime stage FROM debian:trixie-slim From 8500c33856d4980200077c879c1d15ab762b0efb Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Oct 2025 12:46:20 -0700 Subject: [PATCH 13/18] feat: add ca-certificates to Dockerfile --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 60749aba2d..540ea619fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ WORKDIR /usr/src/app # Install needed runtime dependencies (if any) RUN apt-get update && \ - apt-get install -y --no-install-recommends patchelf && \ + apt-get install -y --no-install-recommends patchelf ca-certificates && \ rm -rf /var/lib/apt/lists/* # Copy the built application from the build stage From 915975aa7fa11c5036aa3b7ff1f4beb42cddc767 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Oct 2025 14:00:19 -0700 Subject: [PATCH 14/18] fix: try no tls --- crates/cdk-square/src/db.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cdk-square/src/db.rs b/crates/cdk-square/src/db.rs index 9d5f373489..a4b52a0427 100644 --- a/crates/cdk-square/src/db.rs +++ b/crates/cdk-square/src/db.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use native_tls::TlsConnector; use postgres_native_tls::MakeTlsConnector; -use tokio_postgres::Client; +use tokio_postgres::{Client, NoTls}; use crate::error::Error; @@ -37,7 +37,7 @@ impl SquareDatabase { let tls = MakeTlsConnector::new(connector); let (client, connection) = - tokio_postgres::connect(database_url, tls) + tokio_postgres::connect(database_url, NoTls) .await .map_err(|e| { Error::DatabaseConnection(format!("Failed to connect to database: {}", e)) From 87c2ef651c403369e96effb0ccdd67046ac04eb0 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Oct 2025 16:43:20 -0700 Subject: [PATCH 15/18] feat: auth webhooks and no polling --- crates/cdk-square/src/client.rs | 36 +++++++++----- crates/cdk-square/src/webhook.rs | 82 +++++++++++++++++++------------- 2 files changed, 74 insertions(+), 44 deletions(-) diff --git a/crates/cdk-square/src/client.rs b/crates/cdk-square/src/client.rs index 1c521eb01d..ad849e31a2 100644 --- a/crates/cdk-square/src/client.rs +++ b/crates/cdk-square/src/client.rs @@ -129,23 +129,35 @@ impl Square { self.refresh_merchant_names().await?; if self.webhook_enabled && self.webhook_url.is_some() { - self.setup_webhook_subscription().await?; + // Attempt to set up webhook subscription, but don't fail if it doesn't work + // Common reasons: subscription limit reached, authentication issues + if let Err(e) = self.setup_webhook_subscription().await { + tracing::warn!( + "Failed to set up Square webhook subscription: {}. Continuing without webhooks.", + e + ); + } self.sync_payments().await?; } else { self.sync_payments().await?; - let square = self.clone(); - tokio::spawn(async move { - let mut interval = - tokio::time::interval(tokio::time::Duration::from_secs(SYNC_POLLING_INTERVAL)); - loop { - interval.tick().await; - if let Err(e) = square.sync_payments().await { - tracing::warn!("Square payment sync failed: {}", e); - } - } - }); + // TODO: I commented this out because we can only have 3 webhook subscriptions on Square which means we + // will have to disable webhook mode for production. This is more efficient than polling. + // Most invoices will be paid within 30 seconds of creation, so polling every 5 seconds seems unnecessary, + // when we can just resync the payments per payment request + + // let square = self.clone(); + // tokio::spawn(async move { + // let mut interval = + // tokio::time::interval(tokio::time::Duration::from_secs(SYNC_POLLING_INTERVAL)); + // loop { + // interval.tick().await; + // if let Err(e) = square.sync_payments().await { + // tracing::warn!("Square payment sync failed: {}", e); + // } + // } + // }); } // Start background task to periodically refresh OAuth token from database diff --git a/crates/cdk-square/src/webhook.rs b/crates/cdk-square/src/webhook.rs index 9b0977c1f0..03aae73a56 100644 --- a/crates/cdk-square/src/webhook.rs +++ b/crates/cdk-square/src/webhook.rs @@ -8,7 +8,6 @@ use axum::Router; use bitcoin::base64::Engine as _; use bitcoin::secp256k1::hashes::{hmac, sha256, Hash, HashEngine, HmacEngine}; use serde_json::Value; -use squareup::api::WebhookSubscriptionsApi; use uuid::Uuid; use crate::error::Error; @@ -30,42 +29,61 @@ impl crate::client::Square { } }; - let webhooks_api = WebhookSubscriptionsApi::new(self.client.as_ref().clone()); + let base_url = self.get_base_url(); // List existing subscriptions to check if we already have one - let list_params = squareup::models::ListWebhookSubscriptionsParams::default(); - match webhooks_api.list_webhook_subscriptions(&list_params).await { - Ok(response) => { - if let Some(subscriptions) = response.subscriptions { - // Check if we already have a subscription with our webhook URL and payment.created event - for subscription in subscriptions { - if subscription.notification_url.as_ref() == Some(webhook_url) - && subscription - .event_types - .as_ref() - .map(|events| { - events.iter().any(|e| { - // Check if any event type string contains "payment" and "created" - let event_str = format!("{:?}", e).to_lowercase(); - event_str.contains("payment") - && event_str.contains("created") - }) - }) - .unwrap_or(false) - { - tracing::info!( - "Using existing Square webhook subscription: {}", - subscription.id.as_deref().unwrap_or("unknown") - ); - return Ok(()); - } + let client = reqwest::Client::new(); + let list_response = client + .get(format!("{}/v2/webhooks/subscriptions", base_url)) + .header("Authorization", format!("Bearer {}", self.api_token)) + .header("Square-Version", "2025-09-24") + .header("Content-Type", "application/json") + .send() + .await + .map_err(|e| { + Error::SquareHttp(format!("Failed to list webhook subscriptions: {}", e)) + })?; + + if list_response.status().is_success() { + let list_body: Value = list_response.json().await.map_err(|e| { + Error::SquareHttp(format!("Failed to parse list webhook response: {}", e)) + })?; + + // Check if we already have a subscription with our webhook URL and payment.created event + if let Some(subscriptions) = list_body.get("subscriptions").and_then(|s| s.as_array()) { + for subscription in subscriptions { + let notification_url = subscription + .get("notification_url") + .and_then(|u| u.as_str()); + let event_types = subscription + .get("event_types") + .and_then(|e| e.as_array()) + .map(|events| { + events.iter().any(|e| { + e.as_str().map(|s| s == "payment.created").unwrap_or(false) + }) + }) + .unwrap_or(false); + + if notification_url == Some(webhook_url.as_str()) && event_types { + let subscription_id = subscription + .get("id") + .and_then(|id| id.as_str()) + .unwrap_or("unknown"); + tracing::info!( + "Using existing Square webhook subscription: {}", + subscription_id + ); + return Ok(()); } } } - Err(e) => { - tracing::warn!("Failed to list webhook subscriptions: {}", e); - // Continue to try creating a new one - } + } else { + tracing::warn!( + "Failed to list webhook subscriptions: {}", + list_response.status() + ); + // Continue to try creating a new one } // No existing subscription found, create a new one From 9eefa555e00da91d6f8c8ca74dc5b1ab4b0cddcc Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Oct 2025 17:00:59 -0700 Subject: [PATCH 16/18] Revert "feat: add ca-certificates to Dockerfile" This reverts commit 8500c33856d4980200077c879c1d15ab762b0efb. --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 540ea619fd..60749aba2d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -20,7 +20,7 @@ WORKDIR /usr/src/app # Install needed runtime dependencies (if any) RUN apt-get update && \ - apt-get install -y --no-install-recommends patchelf ca-certificates && \ + apt-get install -y --no-install-recommends patchelf && \ rm -rf /var/lib/apt/lists/* # Copy the built application from the build stage From 14c48fa80f2452f2d25c7f08e4310e7231abe96d Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Oct 2025 17:01:38 -0700 Subject: [PATCH 17/18] Revert "fix: try no tls" This reverts commit 915975aa7fa11c5036aa3b7ff1f4beb42cddc767. --- crates/cdk-square/src/db.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/cdk-square/src/db.rs b/crates/cdk-square/src/db.rs index a4b52a0427..9d5f373489 100644 --- a/crates/cdk-square/src/db.rs +++ b/crates/cdk-square/src/db.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use native_tls::TlsConnector; use postgres_native_tls::MakeTlsConnector; -use tokio_postgres::{Client, NoTls}; +use tokio_postgres::Client; use crate::error::Error; @@ -37,7 +37,7 @@ impl SquareDatabase { let tls = MakeTlsConnector::new(connector); let (client, connection) = - tokio_postgres::connect(database_url, NoTls) + tokio_postgres::connect(database_url, tls) .await .map_err(|e| { Error::DatabaseConnection(format!("Failed to connect to database: {}", e)) From 9455695abb4c45753d93e0cbdb5824b67e0743d5 Mon Sep 17 00:00:00 2001 From: gudnuf Date: Tue, 28 Oct 2025 17:02:28 -0700 Subject: [PATCH 18/18] Revert "feat: add features to docker file" This reverts commit d3309d101bcab6b11106b9b5ab0d83f663527ff4. --- Dockerfile | 2 +- Dockerfile.arm | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index 60749aba2d..ed06de19f2 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 --features fakewallet --features square +RUN nix develop --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres --features prometheus # Create a runtime stage FROM debian:trixie-slim diff --git a/Dockerfile.arm b/Dockerfile.arm index ee05c4d671..f256cf73b5 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 --features prometheus --features fakewallet --features square +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 # Create a runtime stage FROM debian:trixie-slim