diff --git a/.github/workflows/ghcr-publish.yml b/.github/workflows/ghcr-publish.yml new file mode 100644 index 0000000000..ff6c284bff --- /dev/null +++ b/.github/workflows/ghcr-publish.yml @@ -0,0 +1,66 @@ +name: Publish Docker Image to GHCR + +on: + push: + branches: + - main + pull_request: + types: [opened, synchronize, reopened] + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: 'Custom tag for the image' + required: false + default: '' + +env: + REGISTRY: ghcr.io + IMAGE_NAME: makeprisms/cdk-mintd + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Extract metadata (tags, labels) for Docker + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' && github.event_name == 'push' }} + type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease && !contains(github.ref_name, 'rc') }} + type=semver,pattern={{version}},enable=${{ github.event_name == 'release' }} + type=semver,pattern={{major}}.{{minor}},enable=${{ github.event_name == 'release' }} + type=raw,value=${{ github.head_ref }},enable=${{ github.event_name == 'pull_request' }} + type=sha + type=raw,value=${{ inputs.tag }},enable=${{ inputs.tag != '' }} + + - name: Build and push Docker image + uses: docker/build-push-action@v5 + with: + context: . + push: true + platforms: linux/amd64 + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/Cargo.lock b/Cargo.lock index 76afe269fb..52cf39a7f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -75,6 +75,12 @@ dependencies = [ "alloc-no-stdlib", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "amplify" version = "4.9.0" @@ -488,6 +494,15 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic" version = "0.5.3" @@ -1225,6 +1240,27 @@ dependencies = [ "zeroize", ] +[[package]] +name = "cdk-agicash" +version = "0.14.0" +dependencies = [ + "async-trait", + "axum 0.8.8", + "base64 0.22.1", + "cdk-common", + "futures", + "lightning-invoice 0.34.0", + "reqwest", + "ring 0.17.14", + "serde", + "serde_json", + "sqlx", + "thiserror 2.0.17", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "cdk-axum" version = "0.14.0" @@ -1512,6 +1548,7 @@ dependencies = [ "bip39", "bitcoin 0.32.8", "cdk", + "cdk-agicash", "cdk-axum", "cdk-cln", "cdk-common", @@ -1525,6 +1562,7 @@ dependencies = [ "cdk-prometheus", "cdk-signatory", "cdk-sqlite", + "cdk-strike", "clap", "config", "futures", @@ -1532,6 +1570,7 @@ dependencies = [ "lightning-invoice 0.34.0", "serde", "tokio", + "tokio-util", "tower 0.5.3", "tower-http", "tracing", @@ -1721,6 +1760,30 @@ dependencies = [ "uuid", ] +[[package]] +name = "cdk-strike" +version = "0.14.0" +dependencies = [ + "anyhow", + "async-trait", + "axum 0.8.8", + "cdk-common", + "futures", + "hex", + "rand 0.9.2", + "reqwest", + "ring 0.17.14", + "serde", + "serde_json", + "thiserror 2.0.17", + "tokio", + "tokio-stream", + "tokio-util", + "tracing", + "url", + "uuid", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -2074,6 +2137,21 @@ dependencies = [ "libc", ] +[[package]] +name = "crc" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" +dependencies = [ + "crc-catalog", +] + +[[package]] +name = "crc-catalog" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19d374276b40fb8bbdee95aef7c7fa6b5316ec764510eb64b8dd0e2ed0d7e7f5" + [[package]] name = "crc32fast" version = "1.5.0" @@ -2535,6 +2613,12 @@ dependencies = [ "litrs", ] +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "downcast-rs" version = "1.2.1" @@ -2610,6 +2694,9 @@ name = "either" version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +dependencies = [ + "serde", +] [[package]] name = "electrum-client" @@ -2745,6 +2832,17 @@ dependencies = [ "tokio", ] +[[package]] +name = "etcetera" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "136d1b5283a1ab77bd9257427ffd09d8667ced0570b6f938942bc7568ed5b943" +dependencies = [ + "cfg-if", + "home", + "windows-sys 0.48.0", +] + [[package]] name = "event-listener" version = "5.4.1" @@ -2886,6 +2984,17 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "749cff877dc1af878a0b31a41dd221a753634401ea0ef2f87b62d3171522485a" +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin 0.9.8", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3011,6 +3120,17 @@ dependencies = [ "futures-util", ] +[[package]] +name = "futures-intrusive" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d930c203dd0b6ff06e0201a4a2fe9149b43c684fd4420555b26d21b1a02956f" +dependencies = [ + "futures-core", + "lock_api", + "parking_lot", +] + [[package]] name = "futures-io" version = "0.3.31" @@ -3227,6 +3347,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ "ahash", + "allocator-api2", "serde", ] @@ -6718,6 +6839,9 @@ name = "smallvec" version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +dependencies = [ + "serde", +] [[package]] name = "smawk" @@ -6756,6 +6880,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -6767,6 +6894,213 @@ dependencies = [ "der", ] +[[package]] +name = "sqlformat" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bba3a93db0cc4f7bdece8bb09e77e2e785c20bfebf79eb8340ed80708048790" +dependencies = [ + "nom", + "unicode_categories", +] + +[[package]] +name = "sqlx" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27144619c6e5802f1380337a209d2ac1c431002dd74c6e60aebff3c506dc4f0c" +dependencies = [ + "sqlx-core", + "sqlx-macros", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", +] + +[[package]] +name = "sqlx-core" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a999083c1af5b5d6c071d34a708a19ba3e02106ad82ef7bbd69f5e48266b613b" +dependencies = [ + "atoi", + "byteorder", + "bytes", + "crc", + "crossbeam-queue", + "either", + "event-listener", + "futures-channel", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.14.5", + "hashlink 0.9.1", + "hex", + "indexmap 2.13.0", + "log", + "memchr", + "once_cell", + "paste", + "percent-encoding", + "rustls 0.21.12", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlformat", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 0.25.4", +] + +[[package]] +name = "sqlx-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a23217eb7d86c584b8cbe0337b9eacf12ab76fe7673c513141ec42565698bb88" +dependencies = [ + "proc-macro2", + "quote", + "sqlx-core", + "sqlx-macros-core", + "syn 2.0.114", +] + +[[package]] +name = "sqlx-macros-core" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a099220ae541c5db479c6424bdf1b200987934033c2584f79a0e1693601e776" +dependencies = [ + "dotenvy", + "either", + "heck 0.5.0", + "hex", + "once_cell", + "proc-macro2", + "quote", + "serde", + "serde_json", + "sha2", + "sqlx-core", + "sqlx-mysql", + "sqlx-postgres", + "sqlx-sqlite", + "syn 2.0.114", + "tempfile", + "tokio", + "url", +] + +[[package]] +name = "sqlx-mysql" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5afe4c38a9b417b6a9a5eeffe7235d0a106716495536e7727d1c7f4b1ff3eba6" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "bytes", + "crc", + "digest", + "dotenvy", + "either", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "generic-array", + "hex", + "hkdf", + "hmac", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "percent-encoding", + "rand 0.8.5", + "rsa", + "serde", + "sha1", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-postgres" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1dbb157e65f10dbe01f729339c06d239120221c9ad9fa0ba8408c4cc18ecf21" +dependencies = [ + "atoi", + "base64 0.22.1", + "bitflags 2.10.0", + "byteorder", + "crc", + "dotenvy", + "etcetera", + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "hex", + "hkdf", + "hmac", + "home", + "itoa", + "log", + "md-5", + "memchr", + "once_cell", + "rand 0.8.5", + "serde", + "serde_json", + "sha2", + "smallvec", + "sqlx-core", + "stringprep", + "thiserror 1.0.69", + "tracing", + "whoami 1.6.1", +] + +[[package]] +name = "sqlx-sqlite" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b2cdd83c008a622d94499c0006d8ee5f821f36c89b7d625c900e5dc30b5c5ee" +dependencies = [ + "atoi", + "flume", + "futures-channel", + "futures-core", + "futures-executor", + "futures-intrusive", + "futures-util", + "libsqlite3-sys", + "log", + "percent-encoding", + "serde", + "serde_urlencoded", + "sqlx-core", + "tracing", + "url", +] + [[package]] name = "ssh-cipher" version = "0.2.0" @@ -7278,7 +7612,7 @@ dependencies = [ "socket2 0.6.1", "tokio", "tokio-util", - "whoami", + "whoami 2.0.2", ] [[package]] @@ -8565,6 +8899,12 @@ version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" +[[package]] +name = "unicode_categories" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" + [[package]] name = "uniffi" version = "0.29.5" @@ -8932,6 +9272,12 @@ dependencies = [ "wit-bindgen", ] +[[package]] +name = "wasite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" + [[package]] name = "wasite" version = "1.0.2" @@ -9129,6 +9475,16 @@ dependencies = [ "rustix 0.38.44", ] +[[package]] +name = "whoami" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4a4db5077702ca3015d3d02d74974948aba2ad9e12ab7df718ee64ccd7e97d" +dependencies = [ + "libredox", + "wasite 0.1.0", +] + [[package]] name = "whoami" version = "2.0.2" @@ -9136,7 +9492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ace4d5c7b5ab3d99629156d4e0997edbe98a4beb6d5ba99e2cae830207a81983" dependencies = [ "libredox", - "wasite", + "wasite 1.0.2", "web-sys", ] diff --git a/Cargo.toml b/Cargo.toml index 7fa5ebfb07..f97b7834a5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,6 +122,8 @@ nostr-sdk = { version = "0.44.1", default-features = false, features = [ "nip59" ]} +cdk-strike = { path = "./crates/cdk-strike", version = "=0.14.0" } +cdk-agicash = { path = "./crates/cdk-agicash", version = "=0.14.0" } diff --git a/Dockerfile b/Dockerfile index ed06de19f2..7e4c991de6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,7 @@ COPY Cargo.toml ./Cargo.toml COPY crates ./crates # Start the Nix daemon and develop the environment -RUN nix develop --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres --features prometheus +RUN nix develop --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres --features prometheus --features agicash # Create a runtime stage FROM debian:trixie-slim diff --git a/Dockerfile.arm b/Dockerfile.arm index f256cf73b5..a7c1505d51 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -13,7 +13,7 @@ COPY crates ./crates RUN echo 'filter-syscalls = false' > /etc/nix/nix.conf # Start the Nix daemon and develop the environment -RUN nix develop --extra-platforms aarch64-linux --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres +RUN nix develop --extra-platforms aarch64-linux --extra-experimental-features nix-command --extra-experimental-features flakes --command cargo build --release --bin cdk-mintd --features postgres --features agicash # Create a runtime stage FROM debian:trixie-slim diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index b7e362ac25..cd08a1ea87 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -58,7 +58,7 @@ pub use nut05::{ MeltMethodSettings, MeltQuoteCustomRequest, MeltQuoteCustomResponse, MeltRequest, QuoteState as MeltQuoteState, Settings as NUT05Settings, }; -pub use nut06::{ContactInfo, MintInfo, MintVersion, Nuts}; +pub use nut06::{AgicashInfo, ContactInfo, MintInfo, MintVersion, Nuts}; pub use nut07::{CheckStateRequest, CheckStateResponse, ProofState, State}; pub use nut09::{RestoreRequest, RestoreResponse}; pub use nut10::{Kind, Secret as Nut10Secret, SecretData, SpendingConditionVerification}; diff --git a/crates/cashu/src/nuts/nut06.rs b/crates/cashu/src/nuts/nut06.rs index 77c1ebc119..a25f26d7e0 100644 --- a/crates/cashu/src/nuts/nut06.rs +++ b/crates/cashu/src/nuts/nut06.rs @@ -16,6 +16,14 @@ use super::{nut04, nut05, nut15, nut19, MppMethodSettings}; use super::{AuthRequired, BlindAuthSettings, ClearAuthSettings, ProtectedEndpoint}; use crate::CurrencyUnit; +/// Agicash-specific mint information +#[derive(Default, Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct AgicashInfo { + /// Whether the mint operates in closed-loop mode + pub closed_loop: bool, +} + /// Mint Version #[derive(Debug, Clone, PartialEq, Eq, Hash)] #[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] @@ -105,6 +113,9 @@ pub struct MintInfo { /// terms of url service of the mint #[serde(skip_serializing_if = "Option::is_none")] pub tos_url: Option, + /// Agicash-specific mint information + #[serde(skip_serializing_if = "Option::is_none")] + pub agicash: Option, } impl MintInfo { @@ -219,6 +230,14 @@ impl MintInfo { } } + /// Set agicash info + pub fn agicash(self, agicash: AgicashInfo) -> Self { + Self { + agicash: Some(agicash), + ..self + } + } + /// Get protected endpoints #[cfg(feature = "auth")] pub fn protected_endpoints(&self) -> HashMap { diff --git a/crates/cdk-agicash/Cargo.toml b/crates/cdk-agicash/Cargo.toml new file mode 100644 index 0000000000..2dad9ce787 --- /dev/null +++ b/crates/cdk-agicash/Cargo.toml @@ -0,0 +1,35 @@ +[package] +name = "cdk-agicash" +version.workspace = true +edition.workspace = true +authors = ["Agicash Developers"] +license.workspace = true +homepage = "https://github.com/MakePrisms/cdk" +repository = "https://github.com/MakePrisms/cdk.git" +rust-version.workspace = true +description = "CDK closed-loop payment validation with Square" +readme = "README.md" + +[features] +default = [] +postgres = ["dep:sqlx"] + +[dependencies] +async-trait.workspace = true +cdk-common = { workspace = true, features = ["mint"] } +tokio.workspace = true +tracing.workspace = true +thiserror.workspace = true +serde_json.workspace = true +serde.workspace = true +axum.workspace = true +reqwest.workspace = true +ring = "0.17" +lightning-invoice.workspace = true +tokio-util.workspace = true +futures.workspace = true +sqlx = { version = "0.8", features = ["runtime-tokio-rustls", "postgres"], optional = true } +base64 = "0.22" + +[lints] +workspace = true diff --git a/crates/cdk-agicash/src/config.rs b/crates/cdk-agicash/src/config.rs new file mode 100644 index 0000000000..bd127b43e5 --- /dev/null +++ b/crates/cdk-agicash/src/config.rs @@ -0,0 +1,36 @@ +//! Square configuration types + +use serde::{Deserialize, Serialize}; + +/// Square environment +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum SquareEnvironment { + /// Sandbox environment for testing + Sandbox, + /// Production environment + #[default] + Production, +} + +impl SquareEnvironment { + /// Get the base URL for the Square API + pub fn base_url(&self) -> &str { + match self { + SquareEnvironment::Sandbox => "https://connect.squareupsandbox.com", + SquareEnvironment::Production => "https://connect.squareup.com", + } + } +} + +impl std::str::FromStr for SquareEnvironment { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "sandbox" => Ok(SquareEnvironment::Sandbox), + "production" => Ok(SquareEnvironment::Production), + _ => Err(format!("Unknown Square environment: {s}")), + } + } +} diff --git a/crates/cdk-agicash/src/credentials.rs b/crates/cdk-agicash/src/credentials.rs new file mode 100644 index 0000000000..092214d136 --- /dev/null +++ b/crates/cdk-agicash/src/credentials.rs @@ -0,0 +1,105 @@ +//! Square credential management + +use async_trait::async_trait; + +use crate::error::Error; + +/// Provides Square API access tokens +#[async_trait] +pub trait CredentialProvider: Send + Sync { + /// Get a valid access token + async fn get_access_token(&self) -> Result; + /// Invalidate cached credentials (e.g., on 401 response) + async fn invalidate(&self); +} + +#[cfg(feature = "postgres")] +use std::sync::Arc; +#[cfg(feature = "postgres")] +use tokio::sync::RwLock; + +/// Cached credential entry +#[cfg(feature = "postgres")] +struct CachedCred { + access_token: String, + fetched_at: u64, +} + +/// Cache TTL in seconds +#[cfg(feature = "postgres")] +const CACHE_TTL_SECS: u64 = 300; + +/// PostgreSQL-backed credential provider reading from square_credentials table +#[cfg(feature = "postgres")] +pub struct PgCredentialProvider { + pool: sqlx::PgPool, + cache: Arc>>, +} + +#[cfg(feature = "postgres")] +impl std::fmt::Debug for PgCredentialProvider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PgCredentialProvider").finish() + } +} + +#[cfg(feature = "postgres")] +impl PgCredentialProvider { + /// Create a new PgCredentialProvider + pub async fn new(database_url: &str) -> Result { + let pool = sqlx::PgPool::connect(database_url) + .await + .map_err(|e| Error::Credential(format!("Failed to connect to database: {e}")))?; + + Ok(Self { + pool, + cache: Arc::new(RwLock::new(None)), + }) + } + + async fn fetch_from_db(&self) -> Result { + let row: (String,) = sqlx::query_as("SELECT access_token FROM square_credentials LIMIT 1") + .fetch_one(&self.pool) + .await + .map_err(|e| Error::Credential(format!("Failed to fetch credentials: {e}")))?; + + Ok(row.0) + } +} + +#[cfg(feature = "postgres")] +#[async_trait] +impl CredentialProvider for PgCredentialProvider { + async fn get_access_token(&self) -> Result { + // Check cache + { + let cache = self.cache.read().await; + if let Some(cached) = cache.as_ref() { + let now = cdk_common::util::unix_time(); + if now - cached.fetched_at < CACHE_TTL_SECS { + return Ok(cached.access_token.clone()); + } + } + } + + // Cache miss or expired — fetch from DB + let access_token = self.fetch_from_db().await?; + + // Update cache + { + let mut cache = self.cache.write().await; + *cache = Some(CachedCred { + access_token: access_token.clone(), + fetched_at: cdk_common::util::unix_time(), + }); + } + + Ok(access_token) + } + + async fn invalidate(&self) { + let mut cache = self.cache.write().await; + *cache = None; + tracing::info!("Invalidated Square credential cache"); + } +} diff --git a/crates/cdk-agicash/src/error.rs b/crates/cdk-agicash/src/error.rs new file mode 100644 index 0000000000..b445879fbf --- /dev/null +++ b/crates/cdk-agicash/src/error.rs @@ -0,0 +1,26 @@ +//! Error types for the agicash closed-loop payment backend + +use thiserror::Error; + +/// Agicash Error +#[derive(Debug, Error)] +pub enum Error { + /// Square API error + #[error("Square API error: {0}")] + SquareApi(String), + /// Square API authentication error + #[error("Square API authentication error")] + SquareAuth, + /// KV store error + #[error("KV store error: {0}")] + KvStore(String), + /// Credential error + #[error("Credential error: {0}")] + Credential(String), +} + +impl From for cdk_common::payment::Error { + fn from(e: Error) -> Self { + Self::Lightning(Box::new(e)) + } +} diff --git a/crates/cdk-agicash/src/lib.rs b/crates/cdk-agicash/src/lib.rs new file mode 100644 index 0000000000..cd312d8c8b --- /dev/null +++ b/crates/cdk-agicash/src/lib.rs @@ -0,0 +1,334 @@ +//! CDK closed-loop payment validation +//! +//! Wraps a MintPayment backend to restrict melt operations to invoices +//! that pass closed-loop validation. Supports multiple validation modes: +//! - **Square**: validates against Square merchant payment timestamps +//! - **Internal**: only allows melting invoices created by the same mint + +#![warn(missing_docs)] +#![warn(rustdoc::bare_urls)] + +use std::collections::HashSet; +use std::pin::Pin; +use std::sync::{Arc, RwLock}; +use std::time::Duration; + +use async_trait::async_trait; +use cdk_common::bitcoin::hashes::Hash; +use cdk_common::payment::{ + self, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, MakePaymentResponse, + MintPayment, OutgoingPaymentOptions, PaymentIdentifier, PaymentQuoteResponse, SettingsResponse, + WaitPaymentResponse, +}; +use cdk_common::Bolt11Invoice; +use futures::Stream; +use tokio_util::sync::CancellationToken; +pub mod config; +pub mod credentials; +pub mod error; +pub mod square_api; +pub mod sync; +pub mod types; +pub mod webhook; + +use sync::SquareSync; + +/// Validation strategy for closed-loop payments +#[allow(missing_debug_implementations)] +pub enum ClosedLoopValidator { + /// Square merchant validation — checks invoice timestamps against Square payments + Square { + /// Square sync service for payment timestamp lookups + sync: Arc, + /// Merchant name shown in rejection error messages + valid_destination_name: String, + /// Token to cancel background Square polling + cancel_token: CancellationToken, + }, + /// Internal validation — only invoices created by this mint are allowed. + /// + /// **Note:** `known_hashes` is stored in memory and will not survive restarts. + /// This is acceptable for FakeWallet (the only backend currently using this mode), + /// but if internal mode is extended to a real Lightning backend, hashes should be + /// persisted to the KV store. + Internal { + /// Payment hashes from invoices created by this mint (in-memory only) + known_hashes: Arc>>, + /// Destination name shown in rejection error messages + destination_name: String, + }, +} + +/// Closed-loop payment wrapper that validates invoices before delegating +/// to the inner payment backend. +pub struct ClosedLoopPayment { + inner: Arc + Send + Sync>, + validator: ClosedLoopValidator, +} + +impl std::fmt::Debug for ClosedLoopPayment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match &self.validator { + ClosedLoopValidator::Square { + valid_destination_name, + .. + } => valid_destination_name, + ClosedLoopValidator::Internal { + destination_name, .. + } => destination_name, + }; + f.debug_struct("ClosedLoopPayment") + .field("destination_name", name) + .finish() + } +} + +impl ClosedLoopPayment { + /// Create a new ClosedLoopPayment with Square merchant validation + pub fn new_square( + inner: Arc + Send + Sync>, + sync: Arc, + valid_destination_name: String, + cancel_token: CancellationToken, + ) -> Self { + Self { + inner, + validator: ClosedLoopValidator::Square { + sync, + valid_destination_name, + cancel_token, + }, + } + } + + /// Create a new ClosedLoopPayment with internal validation (mint-only invoices) + pub fn new_internal( + inner: Arc + Send + Sync>, + destination_name: String, + ) -> Self { + Self { + inner, + validator: ClosedLoopValidator::Internal { + known_hashes: Arc::new(RwLock::new(HashSet::new())), + destination_name, + }, + } + } + + /// Start a background polling sync task (Square mode only) + pub fn start_polling_sync( + sync: Arc, + interval_secs: u64, + cancel_token: CancellationToken, + ) { + let interval = Duration::from_secs(interval_secs); + + tokio::spawn(async move { + loop { + tokio::select! { + _ = cancel_token.cancelled() => { + tracing::info!("Square polling sync cancelled"); + break; + } + _ = tokio::time::sleep(interval) => { + if let Err(e) = sync.sync_from_last().await { + tracing::warn!("Square polling sync error: {e}"); + } + if let Err(e) = sync.cleanup_expired().await { + tracing::warn!("Square cleanup error: {e}"); + } + } + } + } + }); + } + + /// Validate a bolt11 invoice against the configured closed-loop validator + async fn validate_invoice(&self, bolt11: &Bolt11Invoice) -> Result<(), payment::Error> { + match &self.validator { + ClosedLoopValidator::Square { + sync, + valid_destination_name, + .. + } => Self::validate_square(bolt11, sync, valid_destination_name).await, + ClosedLoopValidator::Internal { + known_hashes, + destination_name, + } => Self::validate_internal(bolt11, known_hashes, destination_name), + } + } + + /// Square validation: check invoice description and timestamp against Square payments + async fn validate_square( + bolt11: &Bolt11Invoice, + sync: &SquareSync, + valid_destination_name: &str, + ) -> Result<(), payment::Error> { + // Pre-filter: check description contains valid_destination_name + let description_matches = match bolt11.description() { + lightning_invoice::Bolt11InvoiceDescriptionRef::Direct(desc) => { + desc.to_string().contains(valid_destination_name) + } + lightning_invoice::Bolt11InvoiceDescriptionRef::Hash(_) => { + // Cannot verify description hash, allow through to timestamp check + true + } + }; + + if !description_matches { + tracing::debug!( + "Invoice description does not match '{}', rejecting", + valid_destination_name + ); + return Err(payment::Error::PaymentNotAllowed(format!( + "This ecash can only be spent at {}", + valid_destination_name + ))); + } + + // Check invoice timestamp against Square payment timestamps + let invoice_epoch = bolt11.duration_since_epoch().as_secs(); + + // KV lookup (check +/- 2 second window) + if sync.has_payment_near_timestamp(invoice_epoch).await? { + tracing::debug!("Square payment found near invoice timestamp {invoice_epoch}"); + return Ok(()); + } + + // On-demand sync then re-check + tracing::debug!( + "No Square payment found near timestamp {invoice_epoch}, triggering on-demand sync" + ); + if sync.on_demand_sync_for_timestamp(invoice_epoch).await? { + tracing::debug!( + "Square payment found after on-demand sync near timestamp {invoice_epoch}" + ); + return Ok(()); + } + + // Not found + tracing::info!("No Square payment found near invoice timestamp {invoice_epoch}"); + Err(payment::Error::PaymentNotAllowed(format!( + "This ecash can only be spent at {}", + valid_destination_name + ))) + } + + /// Internal validation: check if payment hash was created by this mint + fn validate_internal( + bolt11: &Bolt11Invoice, + known_hashes: &RwLock>, + destination_name: &str, + ) -> Result<(), payment::Error> { + let payment_hash: [u8; 32] = *bolt11.payment_hash().as_byte_array(); + let hashes = known_hashes.read().expect("known_hashes lock poisoned"); + if hashes.contains(&payment_hash) { + tracing::debug!("Internal closed-loop: payment hash found, allowing melt"); + Ok(()) + } else { + tracing::info!("Internal closed-loop: unknown payment hash, rejecting melt"); + Err(payment::Error::PaymentNotAllowed(format!( + "This ecash can only be spent at {}", + destination_name + ))) + } + } + + /// Record a payment hash from a newly created invoice (Internal mode) + fn record_payment_hash(&self, response: &CreateIncomingPaymentResponse) { + if let ClosedLoopValidator::Internal { + ref known_hashes, .. + } = self.validator + { + if let PaymentIdentifier::PaymentHash(hash) = &response.request_lookup_id { + let mut hashes = known_hashes.write().expect("known_hashes lock poisoned"); + hashes.insert(*hash); + tracing::debug!("Internal closed-loop: recorded payment hash"); + } + } + } +} + +#[async_trait] +impl MintPayment for ClosedLoopPayment { + type Err = payment::Error; + + async fn start(&self) -> Result<(), Self::Err> { + self.inner.start().await + } + + async fn stop(&self) -> Result<(), Self::Err> { + if let ClosedLoopValidator::Square { cancel_token, .. } = &self.validator { + cancel_token.cancel(); + } + self.inner.stop().await + } + + async fn get_settings(&self) -> Result { + self.inner.get_settings().await + } + + async fn create_incoming_payment_request( + &self, + unit: &cdk_common::nuts::CurrencyUnit, + options: IncomingPaymentOptions, + ) -> Result { + let response = self + .inner + .create_incoming_payment_request(unit, options) + .await?; + self.record_payment_hash(&response); + Ok(response) + } + + async fn get_payment_quote( + &self, + unit: &cdk_common::nuts::CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + // Validate the invoice before getting a quote + if let OutgoingPaymentOptions::Bolt11(ref opts) = options { + self.validate_invoice(&opts.bolt11).await?; + } + + self.inner.get_payment_quote(unit, options).await + } + + async fn make_payment( + &self, + unit: &cdk_common::nuts::CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + self.inner.make_payment(unit, options).await + } + + async fn wait_payment_event( + &self, + ) -> Result + Send>>, Self::Err> { + self.inner.wait_payment_event().await + } + + fn is_wait_invoice_active(&self) -> bool { + self.inner.is_wait_invoice_active() + } + + fn cancel_wait_invoice(&self) { + self.inner.cancel_wait_invoice(); + } + + async fn check_incoming_payment_status( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + self.inner + .check_incoming_payment_status(payment_identifier) + .await + } + + async fn check_outgoing_payment( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result { + self.inner.check_outgoing_payment(payment_identifier).await + } +} diff --git a/crates/cdk-agicash/src/square_api.rs b/crates/cdk-agicash/src/square_api.rs new file mode 100644 index 0000000000..813f926498 --- /dev/null +++ b/crates/cdk-agicash/src/square_api.rs @@ -0,0 +1,149 @@ +//! Square API client for listing payments +//! +//! Uses the Square Payments API (). +//! All requests are pinned to a specific API version via the `Square-Version` header. + +use std::sync::Arc; + +use reqwest::Client; +use tracing::warn; + +use crate::config::SquareEnvironment; +use crate::credentials::CredentialProvider; +use crate::error::Error; +use crate::types::ListPaymentsResponse; + +/// Square API version to pin requests to. +/// See +/// NOTE: This value is duplicated in https://github.com/MakePrisms/agicash-mints/blob/master/mint-provisioner/src/services/square_oauth.rs +const SQUARE_API_VERSION: &str = "2026-01-22"; + +/// Square API client +pub struct SquareApiClient { + client: Client, + base_url: String, + cred_provider: Arc, +} + +impl std::fmt::Debug for SquareApiClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SquareApiClient") + .field("base_url", &self.base_url) + .finish() + } +} + +impl SquareApiClient { + /// Create a new Square API client + pub fn new( + environment: &SquareEnvironment, + cred_provider: Arc, + ) -> Self { + Self { + client: Client::new(), + base_url: environment.base_url().to_string(), + cred_provider, + } + } + + /// List payments from Square API with time range and pagination. + /// + /// Calls `GET /v2/payments` with time-range filtering on `created_at`. + /// See + /// + /// # Parameters + /// - `begin_time` / `end_time`: RFC 3339 timestamps filtering on `created_at` + /// - `cursor`: pagination cursor from a previous response + /// - `limit`: max results per page (capped at 100 by Square) + pub async fn list_payments( + &self, + begin_time: &str, + end_time: &str, + cursor: Option<&str>, + limit: u32, + ) -> Result { + let url = format!("{}/v2/payments", self.base_url); + + let mut params = vec![ + ("begin_time", begin_time.to_string()), + ("end_time", end_time.to_string()), + ("sort_order", "ASC".to_string()), + ("limit", limit.to_string()), + ]; + + if let Some(c) = cursor { + params.push(("cursor", c.to_string())); + } + + self.request_with_retry(&url, ¶ms).await + } + + async fn request_with_retry( + &self, + url: &str, + params: &[(&str, String)], + ) -> Result { + let token = self.cred_provider.get_access_token().await?; + + let resp = self + .client + .get(url) + .header("Square-Version", SQUARE_API_VERSION) + .query(params) + .bearer_auth(&token) + .send() + .await + .map_err(|e| Error::SquareApi(format!("Request failed: {e}")))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + warn!("Square API returned 401, invalidating credentials and retrying"); + self.cred_provider.invalidate().await; + + let new_token = self.cred_provider.get_access_token().await?; + let resp = self + .client + .get(url) + .header("Square-Version", SQUARE_API_VERSION) + .query(params) + .bearer_auth(&new_token) + .send() + .await + .map_err(|e| Error::SquareApi(format!("Retry request failed: {e}")))?; + + if resp.status() == reqwest::StatusCode::UNAUTHORIZED { + return Err(Error::SquareAuth); + } + + return Self::parse_response(resp).await; + } + + Self::parse_response(resp).await + } + + async fn parse_response(resp: reqwest::Response) -> Result { + if !resp.status().is_success() { + let status = resp.status(); + let body = resp + .text() + .await + .unwrap_or_else(|_| "Failed to read body".to_string()); + return Err(Error::SquareApi(format!( + "Square API error {status}: {body}" + ))); + } + + let response: ListPaymentsResponse = resp + .json() + .await + .map_err(|e| Error::SquareApi(format!("Failed to parse response: {e}")))?; + + // Log any errors returned by Square alongside successful results + if let Some(errors) = &response.errors { + if !errors.is_empty() { + warn!("Square API returned errors: {:?}", errors); + } + } + + Ok(response) + } +} diff --git a/crates/cdk-agicash/src/sync.rs b/crates/cdk-agicash/src/sync.rs new file mode 100644 index 0000000000..979f2318d8 --- /dev/null +++ b/crates/cdk-agicash/src/sync.rs @@ -0,0 +1,369 @@ +//! Square payment sync logic using KV store + +use std::sync::Arc; + +use cdk_common::database::KVStore; +use tracing::{debug, info, warn}; + +use crate::error::Error; +use crate::square_api::SquareApiClient; + +const PRIMARY_NS: &str = "agicash"; +const PAYMENT_TIMES_NS: &str = "square_payment_times"; +const SYNC_NS: &str = "sync_state"; +const LAST_SYNC_KEY: &str = "last_sync_time"; +const PAGE_LIMIT: u32 = 100; + +/// Manages syncing Square payments to the KV store +pub struct SquareSync { + api_client: SquareApiClient, + kv_store: Arc + Send + Sync>, + invoice_expiry_secs: u64, +} + +impl std::fmt::Debug for SquareSync { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SquareSync") + .field("invoice_expiry_secs", &self.invoice_expiry_secs) + .finish() + } +} + +impl SquareSync { + /// Create a new SquareSync instance + pub fn new( + api_client: SquareApiClient, + kv_store: Arc + Send + Sync>, + invoice_expiry_secs: u64, + ) -> Self { + Self { + api_client, + kv_store, + invoice_expiry_secs, + } + } + + /// Sync payments from the last sync time to now + pub async fn sync_from_last(&self) -> Result { + let now = cdk_common::util::unix_time(); + + // Read last sync time from KV + let last_sync = self.read_last_sync_time().await?; + + // Calculate begin_time: max(last_sync, now - expiry) + let earliest_allowed = now.saturating_sub(self.invoice_expiry_secs); + let begin_epoch = last_sync.unwrap_or(earliest_allowed).max(earliest_allowed); + + let begin_time = epoch_to_rfc3339(begin_epoch); + let end_time = epoch_to_rfc3339(now); + + let mut total_synced: u64 = 0; + let mut cursor: Option = None; + + loop { + let response = self + .api_client + .list_payments(&begin_time, &end_time, cursor.as_deref(), PAGE_LIMIT) + .await?; + + if let Some(payments) = &response.payments { + for payment in payments { + if !is_lightning_payment(payment) { + continue; + } + if let Some(epoch) = parse_iso8601_epoch(&payment.created_at) { + self.store_payment_time(epoch, &payment.id).await?; + total_synced += 1; + } + } + } + + cursor = response.cursor; + if cursor.is_none() { + break; + } + } + + // Update last sync time + self.write_last_sync_time(now).await?; + + if total_synced > 0 { + info!("Synced {total_synced} Square payments"); + } + + Ok(total_synced) + } + + /// Clean up expired entries from the KV store + pub async fn cleanup_expired(&self) -> Result { + let now = cdk_common::util::unix_time(); + let expiry_threshold = now.saturating_sub(self.invoice_expiry_secs + 3600); // 1hr buffer + + let mut expired_keys: Vec = Vec::new(); + + // Collect expired timestamp keys + let time_keys = self + .kv_store + .kv_list(PRIMARY_NS, PAYMENT_TIMES_NS) + .await + .map_err(|e| Error::KvStore(format!("Failed to list KV time keys: {e}")))?; + + for key in &time_keys { + if let Ok(epoch) = key.parse::() { + if epoch < expiry_threshold { + expired_keys.push(key.clone()); + } + } + } + + if expired_keys.is_empty() { + return Ok(0); + } + + // Batch delete in a single transaction + let mut txn = self + .kv_store + .begin_transaction() + .await + .map_err(|e| Error::KvStore(format!("Failed to begin txn: {e}")))?; + + for key in &expired_keys { + txn.kv_remove(PRIMARY_NS, PAYMENT_TIMES_NS, key) + .await + .map_err(|e| Error::KvStore(format!("Failed to remove expired entry: {e}")))?; + } + + txn.commit() + .await + .map_err(|e| Error::KvStore(format!("Failed to commit txn: {e}")))?; + + let removed = expired_keys.len() as u64; + debug!("Cleaned up {removed} expired Square payment entries"); + + Ok(removed) + } + + /// Store a payment by its creation timestamp (epoch seconds) + pub async fn store_payment_time(&self, epoch_secs: u64, payment_id: &str) -> Result<(), Error> { + let key = epoch_secs.to_string(); + let value = payment_id.as_bytes().to_vec(); + + let mut txn = self + .kv_store + .begin_transaction() + .await + .map_err(|e| Error::KvStore(format!("Failed to begin txn: {e}")))?; + + txn.kv_write(PRIMARY_NS, PAYMENT_TIMES_NS, &key, &value) + .await + .map_err(|e| Error::KvStore(format!("Failed to write payment time: {e}")))?; + + txn.commit() + .await + .map_err(|e| Error::KvStore(format!("Failed to commit txn: {e}")))?; + + Ok(()) + } + + /// Check if a Square payment exists near the given timestamp (within +/- 2 seconds) + pub async fn has_payment_near_timestamp(&self, epoch_secs: u64) -> Result { + for ts in epoch_secs.saturating_sub(2)..=epoch_secs + 2 { + let key = ts.to_string(); + let result = self + .kv_store + .kv_read(PRIMARY_NS, PAYMENT_TIMES_NS, &key) + .await + .map_err(|e| Error::KvStore(format!("Failed to read KV store: {e}")))?; + + if result.is_some() { + return Ok(true); + } + } + + Ok(false) + } + + /// Perform an on-demand sync then check for a payment near the given timestamp + pub async fn on_demand_sync_for_timestamp(&self, epoch_secs: u64) -> Result { + self.sync_from_last().await?; + self.has_payment_near_timestamp(epoch_secs).await + } + + async fn read_last_sync_time(&self) -> Result, Error> { + let result = self + .kv_store + .kv_read(PRIMARY_NS, SYNC_NS, LAST_SYNC_KEY) + .await + .map_err(|e| Error::KvStore(format!("Failed to read last sync time: {e}")))?; + + match result { + Some(data) => { + let s = String::from_utf8(data) + .map_err(|e| Error::KvStore(format!("Invalid sync time data: {e}")))?; + let epoch: u64 = s + .parse() + .map_err(|e| Error::KvStore(format!("Invalid sync time: {e}")))?; + Ok(Some(epoch)) + } + None => Ok(None), + } + } + + async fn write_last_sync_time(&self, epoch: u64) -> Result<(), Error> { + let value = epoch.to_string().into_bytes(); + + let mut txn = self + .kv_store + .begin_transaction() + .await + .map_err(|e| Error::KvStore(format!("Failed to begin txn: {e}")))?; + + txn.kv_write(PRIMARY_NS, SYNC_NS, LAST_SYNC_KEY, &value) + .await + .map_err(|e| Error::KvStore(format!("Failed to write sync time: {e}")))?; + + txn.commit() + .await + .map_err(|e| Error::KvStore(format!("Failed to commit txn: {e}")))?; + + Ok(()) + } +} + +/// Check if a Square payment is a Lightning wallet payment +pub(crate) fn is_lightning_payment(payment: &crate::types::SquarePayment) -> bool { + matches!( + payment.source_type, + Some(crate::types::PaymentSourceType::Wallet) + ) && payment + .wallet_details + .as_ref() + .and_then(|wd| wd.brand) + .is_some_and(|b| b == crate::types::DigitalWalletBrand::Lightning) +} + +/// Convert epoch seconds to RFC 3339 UTC string +fn epoch_to_rfc3339(epoch: u64) -> String { + // Simple manual conversion — avoid pulling in chrono + let secs = epoch; + // Calculate date/time components from Unix timestamp + let days = secs / 86400; + let time_of_day = secs % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let seconds = time_of_day % 60; + + // Calculate year/month/day from days since epoch (1970-01-01) + let (year, month, day) = days_to_ymd(days); + + format!("{year:04}-{month:02}-{day:02}T{hours:02}:{minutes:02}:{seconds:02}Z") +} + +/// Convert days since Unix epoch to (year, month, day) +fn days_to_ymd(days: u64) -> (u64, u64, u64) { + // Algorithm based on Howard Hinnant's civil_from_days + let z = days as i64 + 719468; + let era = if z >= 0 { z } else { z - 146096 } / 146097; + let doe = (z - era * 146097) as u64; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + + (y as u64, m, d) +} + +/// Parse ISO 8601 timestamp to epoch seconds. +/// +/// Only accepts UTC timestamps ending in `Z` (e.g. `2024-01-15T12:00:00Z` +/// or `2024-01-15T12:00:00.000Z`). Returns `None` for non-UTC offsets. +pub(crate) fn parse_iso8601_epoch(s: &str) -> Option { + if !s.ends_with('Z') { + warn!("Rejecting non-UTC timestamp: {s}"); + return None; + } + let s = s.trim_end_matches('Z'); + let s = s.split('.').next()?; // Remove fractional seconds + + let parts: Vec<&str> = s.split('T').collect(); + if parts.len() != 2 { + return None; + } + + let date_parts: Vec = parts[0].split('-').filter_map(|p| p.parse().ok()).collect(); + let time_parts: Vec = parts[1].split(':').filter_map(|p| p.parse().ok()).collect(); + + if date_parts.len() != 3 || time_parts.len() != 3 { + return None; + } + + let (year, month, day) = (date_parts[0], date_parts[1], date_parts[2]); + let (hour, minute, second) = (time_parts[0], time_parts[1], time_parts[2]); + + // Convert to epoch using reverse of days_to_ymd + let days = ymd_to_days(year, month, day)?; + let epoch = days * 86400 + hour * 3600 + minute * 60 + second; + + Some(epoch) +} + +/// Convert (year, month, day) to days since Unix epoch +fn ymd_to_days(year: u64, month: u64, day: u64) -> Option { + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return None; + } + + // Howard Hinnant's days_from_civil + let y = if month <= 2 { + year as i64 - 1 + } else { + year as i64 + }; + let m = if month <= 2 { month + 9 } else { month - 3 }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = (y - era * 400) as u64; + let doy = (153 * m + 2) / 5 + day - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + let days = era * 146097 + doe as i64 - 719468; + + if days < 0 { + None + } else { + Some(days as u64) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_epoch_to_rfc3339() { + assert_eq!(epoch_to_rfc3339(0), "1970-01-01T00:00:00Z"); + assert_eq!(epoch_to_rfc3339(1705312800), "2024-01-15T10:00:00Z"); + } + + #[test] + fn test_parse_iso8601_epoch() { + assert_eq!(parse_iso8601_epoch("1970-01-01T00:00:00Z"), Some(0)); + assert_eq!( + parse_iso8601_epoch("2024-01-15T10:00:00Z"), + Some(1705312800) + ); + assert_eq!( + parse_iso8601_epoch("2024-01-15T10:00:00.000Z"), + Some(1705312800) + ); + } + + #[test] + fn test_roundtrip_epoch() { + let epoch = 1705312800u64; + let rfc3339 = epoch_to_rfc3339(epoch); + let parsed = parse_iso8601_epoch(&rfc3339).expect("Failed to parse"); + assert_eq!(epoch, parsed); + } +} diff --git a/crates/cdk-agicash/src/types.rs b/crates/cdk-agicash/src/types.rs new file mode 100644 index 0000000000..73de54b695 --- /dev/null +++ b/crates/cdk-agicash/src/types.rs @@ -0,0 +1,186 @@ +//! Square API response types +//! +//! See for the +//! official Payments API reference. + +use serde::Deserialize; + +/// Square List Payments API response. +/// +/// See +#[derive(Debug, Clone, Deserialize)] +pub struct ListPaymentsResponse { + /// List of payments (may be absent if no results) + pub payments: Option>, + /// Pagination cursor for the next page (absent on the final page) + pub cursor: Option, + /// Errors returned by Square (may accompany partial results) + pub errors: Option>, +} + +/// A Square API error object. +/// +/// See +#[derive(Debug, Clone, Deserialize)] +pub struct SquareApiError { + /// Error category (e.g., `API_ERROR`, `AUTHENTICATION_ERROR`) + pub category: Option, + /// Specific error code + pub code: Option, + /// Human-readable error detail + pub detail: Option, + /// The field that caused the error, if applicable + pub field: Option, +} + +/// Square payment source type. +/// +/// See +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum PaymentSourceType { + /// Card payment (`CARD`) + #[serde(rename = "CARD")] + Card, + /// Bank account payment (`BANK_ACCOUNT`) + #[serde(rename = "BANK_ACCOUNT")] + BankAccount, + /// Digital wallet payment (`WALLET`) + #[serde(rename = "WALLET")] + Wallet, + /// Buy now pay later (`BUY_NOW_PAY_LATER`) + #[serde(rename = "BUY_NOW_PAY_LATER")] + BuyNowPayLater, + /// Square account balance (`SQUARE_ACCOUNT`) + #[serde(rename = "SQUARE_ACCOUNT")] + SquareAccount, + /// Cash payment (`CASH`) + #[serde(rename = "CASH")] + Cash, + /// External payment (`EXTERNAL`) + #[serde(rename = "EXTERNAL")] + External, +} + +/// A Square payment object (subset of fields we use). +/// +/// See +#[derive(Debug, Clone, Deserialize)] +pub struct SquarePayment { + /// Square payment ID (max 192 chars) + pub id: String, + /// RFC 3339 timestamp when the payment was created + pub created_at: String, + /// The source type for this payment. + /// + /// `wallet_details` is only populated when this is [`PaymentSourceType::Wallet`]. + pub source_type: Option, + /// Digital wallet details, present when `source_type` is `WALLET`. + /// + /// See + pub wallet_details: Option, +} + +/// Square digital wallet brand. +/// +/// See +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +pub enum DigitalWalletBrand { + /// Cash App (`CASH_APP`) + #[serde(rename = "CASH_APP")] + CashApp, + /// PayPay (`PAYPAY`) + #[serde(rename = "PAYPAY")] + PayPay, + /// Alipay (`ALIPAY`) + #[serde(rename = "ALIPAY")] + Alipay, + /// Rakuten Pay (`RAKUTEN_PAY`) + #[serde(rename = "RAKUTEN_PAY")] + RakutenPay, + /// au PAY (`AU_PAY`) + #[serde(rename = "AU_PAY")] + AuPay, + /// d barai (`D_BARAI`) + #[serde(rename = "D_BARAI")] + DBarai, + /// Merpay (`MERPAY`) + #[serde(rename = "MERPAY")] + Merpay, + /// WeChat Pay (`WECHAT_PAY`) + #[serde(rename = "WECHAT_PAY")] + WechatPay, + /// Lightning Network (`LIGHTNING`) + #[serde(rename = "LIGHTNING")] + Lightning, + /// Unknown brand (`UNKNOWN`) + #[serde(rename = "UNKNOWN")] + Unknown, +} + +/// Digital wallet payment details (`DigitalWalletDetails` in Square docs). +/// +/// See +#[derive(Debug, Clone, Deserialize)] +pub struct WalletDetails { + /// Wallet brand. + /// + /// See [`DigitalWalletBrand`] for documented values. + pub brand: Option, + /// Lightning-specific details containing the bolt11 invoice. + /// + /// **NOTE:** This field is **not documented** in the public Square API reference + /// as of 2026-01-22. It is returned in practice for Lightning payments + /// (where `brand == "LIGHTNING"`) but could change without notice. + pub lightning_details: Option, +} + +/// Lightning payment details within a Square digital wallet payment. +/// +/// **NOTE:** This type corresponds to an **undocumented** Square API object. +/// It is returned in practice for Lightning payments but is not part of the +/// public API contract. +#[derive(Debug, Clone, Deserialize)] +pub struct LightningDetails { + /// The BOLT11 payment request string + pub payment_url: Option, +} + +/// Square webhook event payload. +/// +/// See +#[derive(Debug, Clone, Deserialize)] +pub struct SquareWebhookEvent { + /// Merchant ID + pub merchant_id: Option, + /// Event type (e.g., `payment.created`, `payment.updated`). + /// Serialized as `"type"` in JSON. + #[serde(rename = "type")] + pub event_type: String, + /// Unique event ID + pub event_id: Option, + /// Event data containing the affected object + pub data: Option, +} + +/// Square webhook event data (`EventData` in Square docs). +#[derive(Debug, Clone, Deserialize)] +pub struct SquareWebhookData { + /// Name of the affected object's type (e.g., `"payment"`). + /// Serialized as `"type"` in JSON. + #[serde(rename = "type")] + pub data_type: Option, + /// ID of the affected object + pub id: Option, + /// The affected object (absent if the object was deleted) + pub object: Option, +} + +/// Square webhook data object wrapper. +/// +/// For `payment.created` and `payment.updated` events, this contains the +/// full Payment object under the `payment` key. +#[derive(Debug, Clone, Deserialize)] +pub struct SquareWebhookObject { + /// Payment object (present for payment events) + pub payment: Option, +} diff --git a/crates/cdk-agicash/src/webhook.rs b/crates/cdk-agicash/src/webhook.rs new file mode 100644 index 0000000000..22bd944cdb --- /dev/null +++ b/crates/cdk-agicash/src/webhook.rs @@ -0,0 +1,162 @@ +//! Square webhook handler with HMAC-SHA256 signature verification + +use std::sync::Arc; + +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::post, + Router, +}; +use ring::hmac; +use tracing::{debug, warn}; + +use crate::sync::{is_lightning_payment, parse_iso8601_epoch, SquareSync}; +use crate::types::SquareWebhookEvent; + +/// State for Square webhook handlers +#[derive(Clone)] +struct SquareWebhookState { + sync: Arc, + signature_key: String, + notification_url: String, +} + +/// Verify Square webhook signature using HMAC-SHA256 +/// +/// Square computes: HMAC-SHA256(notification_url + body, signature_key) → base64 +fn verify_square_signature( + signature: &str, + body: &[u8], + signature_key: &str, + notification_url: &str, +) -> bool { + let key = hmac::Key::new(hmac::HMAC_SHA256, signature_key.as_bytes()); + + // Square signs: notification_url + raw body + let mut payload = notification_url.as_bytes().to_vec(); + payload.extend_from_slice(body); + + let computed = hmac::sign(&key, &payload); + let computed_b64 = base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + computed.as_ref(), + ); + + // Constant-time comparison + computed_b64.len() == signature.len() + && computed_b64 + .as_bytes() + .iter() + .zip(signature.as_bytes()) + .fold(0u8, |acc, (a, b)| acc | (a ^ b)) + == 0 +} + +/// Middleware to verify Square webhook signatures +async fn verify_request_body( + State(state): State, + request: Request, + next: Next, +) -> Result { + let signature = request + .headers() + .get("x-square-hmacsha256-signature") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + warn!("Missing x-square-hmacsha256-signature header"); + (StatusCode::UNAUTHORIZED, "Missing signature").into_response() + })? + .to_string(); + + let (parts, body) = request.into_parts(); + let bytes = axum::body::to_bytes(body, 1024 * 1024).await.map_err(|e| { + warn!("Failed to read request body: {}", e); + (StatusCode::BAD_REQUEST, "Invalid body").into_response() + })?; + + if !verify_square_signature( + &signature, + &bytes, + &state.signature_key, + &state.notification_url, + ) { + warn!("Square webhook signature verification failed"); + return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into_response()); + } + + debug!("Square webhook signature verified"); + + let request = Request::from_parts(parts, Body::from(bytes)); + Ok(next.run(request).await) +} + +/// Handle Square payment webhook events +async fn handle_payment_webhook( + State(state): State, + body: axum::body::Bytes, +) -> impl IntoResponse { + let event: SquareWebhookEvent = match serde_json::from_slice(&body) { + Ok(e) => e, + Err(e) => { + warn!("Failed to parse Square webhook event: {}", e); + return StatusCode::BAD_REQUEST; + } + }; + + match event.event_type.as_str() { + "payment.created" | "payment.updated" => { + debug!("Received Square payment event: {}", event.event_type); + + if let Some(data) = &event.data { + if let Some(object) = &data.object { + if let Some(payment) = &object.payment { + if !is_lightning_payment(payment) { + return StatusCode::OK; + } + + let epoch = parse_iso8601_epoch(&payment.created_at) + .unwrap_or_else(cdk_common::util::unix_time); + + if let Err(e) = state.sync.store_payment_time(epoch, &payment.id).await { + warn!("Failed to store payment time from webhook: {}", e); + } else { + debug!("Stored payment time from webhook: epoch={}", epoch); + } + } + } + } + + StatusCode::OK + } + _ => { + debug!("Ignoring Square webhook event: {}", event.event_type); + StatusCode::OK + } + } +} + +/// Create an Axum router for Square payment webhooks +pub fn create_square_webhook_router( + endpoint: &str, + sync: Arc, + signature_key: String, + notification_url: String, +) -> Router { + let state = SquareWebhookState { + sync, + signature_key, + notification_url, + }; + + Router::new() + .route(endpoint, post(handle_payment_webhook)) + .layer(middleware::from_fn_with_state( + state.clone(), + verify_request_body, + )) + .with_state(state) +} diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index 4f5846a9b9..46cc7ad43e 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -228,9 +228,9 @@ async fn cors_middleware( next: axum::middleware::Next, ) -> Response { #[cfg(feature = "auth")] - let allowed_headers = "Content-Type, Clear-auth, Blind-auth"; + let allowed_headers = "Content-Type, Clear-auth, Blind-auth, Prefer"; #[cfg(not(feature = "auth"))] - let allowed_headers = "Content-Type"; + let allowed_headers = "Content-Type, Prefer"; // Handle preflight requests if req.method() == axum::http::Method::OPTIONS { diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index f77f614fd4..a71e196e91 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -624,6 +624,11 @@ impl From for ErrorResponse { code: ErrorCode::PubkeyRequired, detail: err.to_string(), }, + #[cfg(feature = "mint")] + Error::Payment(crate::payment::Error::PaymentNotAllowed(msg)) => ErrorResponse { + code: ErrorCode::PaymentNotAllowed, + detail: msg, + }, Error::PaidQuote => ErrorResponse { code: ErrorCode::InvoiceAlreadyPaid, detail: err.to_string(), @@ -793,6 +798,7 @@ impl From for Error { ErrorCode::QuoteExpired => Self::ExpiredQuote(0, 0), ErrorCode::WitnessMissingOrInvalid => Self::SignatureMissingOrInvalid, ErrorCode::PubkeyRequired => Self::PubkeyRequired, + ErrorCode::PaymentNotAllowed => Self::UnknownErrorResponse(err.to_string()), // 30xxx - Clear auth errors ErrorCode::ClearAuthRequired => Self::ClearAuthRequired, ErrorCode::ClearAuthFailed => Self::ClearAuthFailed, @@ -867,6 +873,8 @@ pub enum ErrorCode { WitnessMissingOrInvalid, /// Pubkey required for mint quote (20009) PubkeyRequired, + /// Payment not allowed to this destination (20420) + PaymentNotAllowed, // 30xxx - Clear auth errors /// Endpoint requires clear auth (30001) @@ -921,6 +929,7 @@ impl ErrorCode { 20007 => Self::QuoteExpired, 20008 => Self::WitnessMissingOrInvalid, 20009 => Self::PubkeyRequired, + 20739 => Self::PaymentNotAllowed, // 30xxx - Clear auth errors 30001 => Self::ClearAuthRequired, 30002 => Self::ClearAuthFailed, @@ -965,6 +974,7 @@ impl ErrorCode { Self::QuoteExpired => 20007, Self::WitnessMissingOrInvalid => 20008, Self::PubkeyRequired => 20009, + Self::PaymentNotAllowed => 20739, // 30xxx - Clear auth errors Self::ClearAuthRequired => 30001, Self::ClearAuthFailed => 30002, diff --git a/crates/cdk-common/src/payment.rs b/crates/cdk-common/src/payment.rs index 26c0565add..bd9f3afc56 100644 --- a/crates/cdk-common/src/payment.rs +++ b/crates/cdk-common/src/payment.rs @@ -70,6 +70,9 @@ pub enum Error { /// Invalid hash #[error("Invalid hash")] InvalidHash, + /// Payment not allowed to this destination + #[error("{0}")] + PaymentNotAllowed(String), /// Custom #[error("`{0}`")] Custom(String), diff --git a/crates/cdk-ffi/src/types/mint.rs b/crates/cdk-ffi/src/types/mint.rs index 97abb2b527..263f9884f2 100644 --- a/crates/cdk-ffi/src/types/mint.rs +++ b/crates/cdk-ffi/src/types/mint.rs @@ -600,6 +600,29 @@ pub fn encode_nuts(nuts: Nuts) -> Result { Ok(serde_json::to_string(&nuts)?) } +/// FFI-compatible AgicashInfo +#[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] +pub struct AgicashInfo { + /// Whether the mint operates in closed-loop mode + pub closed_loop: bool, +} + +impl From for AgicashInfo { + fn from(info: cdk::nuts::AgicashInfo) -> Self { + Self { + closed_loop: info.closed_loop, + } + } +} + +impl From for cdk::nuts::AgicashInfo { + fn from(info: AgicashInfo) -> Self { + Self { + closed_loop: info.closed_loop, + } + } +} + /// FFI-compatible MintInfo #[derive(Debug, Clone, Serialize, Deserialize, uniffi::Record)] pub struct MintInfo { @@ -627,6 +650,8 @@ pub struct MintInfo { pub time: Option, /// terms of url service of the mint pub tos_url: Option, + /// Agicash-specific mint information + pub agicash: Option, } impl From for MintInfo { @@ -646,6 +671,7 @@ impl From for MintInfo { motd: info.motd, time: info.time, tos_url: info.tos_url, + agicash: info.agicash.map(Into::into), } } } @@ -669,6 +695,7 @@ impl From for cdk::nuts::MintInfo { motd: info.motd, time: info.time, tos_url: info.tos_url, + agicash: info.agicash.map(Into::into), } } } diff --git a/crates/cdk-integration-tests/Cargo.toml b/crates/cdk-integration-tests/Cargo.toml index 0141f15abc..2b93ee39c5 100644 --- a/crates/cdk-integration-tests/Cargo.toml +++ b/crates/cdk-integration-tests/Cargo.toml @@ -29,7 +29,7 @@ cdk-sqlite = { workspace = true } cdk-redb = { workspace = true } cdk-fake-wallet = { workspace = true } cdk-common = { workspace = true, features = ["mint", "wallet", "auth"] } -cdk-mintd = { workspace = true, features = ["cln", "lnd", "fakewallet", "grpc-processor", "auth", "lnbits", "management-rpc", "sqlite", "postgres", "ldk-node", "prometheus"] } +cdk-mintd = { workspace = true, features = ["cln", "lnd", "fakewallet", "grpc-processor", "auth", "lnbits", "management-rpc", "sqlite", "postgres", "ldk-node", "prometheus", "strike"] } futures = { workspace = true, default-features = false, features = [ "executor", ] } diff --git a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs index 62d9698b98..64efd92229 100644 --- a/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs +++ b/crates/cdk-integration-tests/src/bin/start_regtest_mints.rs @@ -299,6 +299,8 @@ fn create_ldk_settings( mint_management_rpc: None, prometheus: None, auth: None, + strike: None, + agicash: None, } } diff --git a/crates/cdk-integration-tests/src/shared.rs b/crates/cdk-integration-tests/src/shared.rs index 0c14f43776..f670bc6a67 100644 --- a/crates/cdk-integration-tests/src/shared.rs +++ b/crates/cdk-integration-tests/src/shared.rs @@ -238,6 +238,8 @@ pub fn create_fake_wallet_settings( mint_management_rpc: None, auth: None, prometheus: Some(Default::default()), + strike: None, + agicash: None, } } @@ -288,6 +290,8 @@ pub fn create_cln_settings( mint_management_rpc: None, auth: None, prometheus: Some(Default::default()), + strike: None, + agicash: None, } } @@ -336,5 +340,7 @@ pub fn create_lnd_settings( mint_management_rpc: None, auth: None, prometheus: Some(Default::default()), + strike: None, + agicash: None, } } diff --git a/crates/cdk-ldk-node/src/web/templates/formatters.rs b/crates/cdk-ldk-node/src/web/templates/formatters.rs index 97630a1320..1ea44d5211 100644 --- a/crates/cdk-ldk-node/src/web/templates/formatters.rs +++ b/crates/cdk-ldk-node/src/web/templates/formatters.rs @@ -124,7 +124,7 @@ mod tests { let now = SystemTime::now() .duration_since(UNIX_EPOCH) - .unwrap() + .expect("system time before UNIX epoch") .as_secs(); // Test "Just now" (30 seconds ago) diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index afa81a7b7a..a564cb79bb 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -11,10 +11,10 @@ rust-version.workspace = true readme = "README.md" [features] -default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "sqlite"] +default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "sqlite", "strike"] # Database features - at least one must be enabled sqlite = ["dep:cdk-sqlite"] -postgres = ["dep:cdk-postgres"] +postgres = ["dep:cdk-postgres", "cdk-agicash/postgres"] # Ensure at least one lightning backend is enabled management-rpc = ["cdk-mint-rpc"] cln = ["dep:cdk-cln"] @@ -30,6 +30,10 @@ redis = ["cdk-axum/redis"] auth = ["cdk/auth", "cdk-axum/auth", "cdk-sqlite?/auth", "cdk-postgres?/auth"] prometheus = ["cdk/prometheus", "dep:cdk-prometheus", "cdk-sqlite?/prometheus", "cdk-axum/prometheus"] +# Agicash Fork additions +strike = ["dep:cdk-strike"] +agicash = ["dep:tokio-util", "strike"] + [dependencies] anyhow.workspace = true async-trait.workspace = true @@ -69,6 +73,11 @@ home.workspace = true utoipa = { workspace = true, optional = true } utoipa-swagger-ui = { version = "9.0.0", features = ["axum"], optional = true } +# Agicash Fork additions +cdk-strike = { workspace = true, optional = true } +cdk-agicash.workspace = true +tokio-util = { workspace = true, optional = true } + [lints] workspace = true diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 723fb5ad6a..25d93b8db4 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -87,7 +87,7 @@ max_connections = 20 connection_timeout_seconds = 10 [ln] -# Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits', 'ldknode' +# Required ln backend `cln`, `lnd`, `fakewallet`, 'lnbits', 'ldknode', 'strike' ln_backend = "fakewallet" # min_mint=1 # max_mint=500000 @@ -175,6 +175,30 @@ max_delay_time = 3 # the get_settings() response. The mint will automatically create routes # for these methods (e.g., /v1/mint/quote/paypal, /v1/mint/paypal, etc.) +# [strike] +# api_key = "your_strike_api_key_here" +# supported_units = ["sat"] +# Optional: Override webhook URL when mint is behind NAT or has different internal/external URLs +# webhook_url = "https://your-public-domain.com" + +# [agicash.closed_loop] +# Closed loop payment configuration +# When configured, only allows melting to invoices that match the specified criteria +# +# closed_loop_type = "square" +# valid_destination_name = "this mint" + +# [agicash.square] +# Square payment validation settings (requires agicash + postgres features) +# When configured with closed_loop, validates melt invoices against Square payments +# +# environment = "production" # "sandbox" or "production" +# webhook_enabled = false +# webhook_url = "https://your-public-domain.com/webhook/square/payment" +# webhook_signature_key = "your_square_webhook_signature_key" +# sync_interval_secs = 5 # Polling sync interval in secs (0 = disabled, only used when webhook_enabled = false) +# invoice_expiry_secs = 3600 # How long to keep payment hashes in KV store + # [auth] # Set to true to enable authentication features (defaults to false) # auth_enabled = false diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 1d9fbffa3d..e3d209a8c8 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -138,6 +138,8 @@ pub enum LnBackend { LdkNode, #[cfg(feature = "grpc-processor")] GrpcProcessor, + #[cfg(feature = "strike")] + Strike, } impl std::str::FromStr for LnBackend { @@ -157,6 +159,8 @@ impl std::str::FromStr for LnBackend { "ldk-node" | "ldknode" => Ok(LnBackend::LdkNode), #[cfg(feature = "grpc-processor")] "grpcprocessor" => Ok(LnBackend::GrpcProcessor), + #[cfg(feature = "strike")] + "strike" => Ok(LnBackend::Strike), _ => Err(format!("Unknown Lightning backend: {s}")), } } @@ -424,6 +428,92 @@ fn default_grpc_port() -> u16 { 50051 } +#[cfg(feature = "strike")] +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Strike { + pub api_key: String, + pub supported_units: Vec, + /// Optional webhook URL base. If not set, uses the mint's info.url. + /// Set this when your mint runs behind NAT or has different internal/external URLs. + #[serde(skip_serializing_if = "Option::is_none")] + pub webhook_url: Option, +} + +/// Type of closed loop validation +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum ClosedLoopType { + /// Square payments - validates against Square payment system + #[default] + Square, + /// Internal - only invoices created by the same mint can be melted + Internal, +} + +impl std::str::FromStr for ClosedLoopType { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_lowercase().as_str() { + "square" => Ok(ClosedLoopType::Square), + "internal" => Ok(ClosedLoopType::Internal), + _ => Err(format!("Unknown closed loop type: {s}")), + } + } +} + +/// Closed loop payment configuration +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ClosedLoop { + /// Type of closed loop validation + #[serde(default)] + pub closed_loop_type: ClosedLoopType, + /// Valid destination name to display in error messages + pub valid_destination_name: String, +} + +/// Square payment validation settings +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SquareSettings { + /// Square environment: "sandbox" or "production" + #[serde(default = "default_square_environment")] + pub environment: String, + /// Whether to enable the Square webhook endpoint + #[serde(default)] + pub webhook_enabled: bool, + /// URL for webhook signature verification + pub webhook_url: Option, + /// HMAC-SHA256 signature key for verifying Square webhooks + pub webhook_signature_key: Option, + /// Polling sync interval in seconds (0 = disabled) + #[serde(default = "default_sync_interval")] + pub sync_interval_secs: u64, + /// Invoice expiry in seconds + #[serde(default = "default_invoice_expiry")] + pub invoice_expiry_secs: u64, +} + +fn default_square_environment() -> String { + "production".to_string() +} + +fn default_sync_interval() -> u64 { + 5 +} + +fn default_invoice_expiry() -> u64 { + 3600 +} + +/// Agicash configuration +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct Agicash { + /// Closed loop payment configuration (None = disabled) + pub closed_loop: Option, + /// Square payment validation settings (None = disabled) + pub square: Option, +} + #[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)] #[serde(rename_all = "lowercase")] pub enum DatabaseEngine { @@ -574,6 +664,9 @@ pub struct Settings { pub auth: Option, #[cfg(feature = "prometheus")] pub prometheus: Option, + #[cfg(feature = "strike")] + pub strike: Option, + pub agicash: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/cdk-mintd/src/env_vars/agicash.rs b/crates/cdk-mintd/src/env_vars/agicash.rs new file mode 100644 index 0000000000..3a35bda510 --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/agicash.rs @@ -0,0 +1,89 @@ +//! Agicash environment variables + +use std::env; + +use crate::config::{Agicash, ClosedLoop, ClosedLoopType, SquareSettings}; + +pub const ENV_AGICASH_CLOSED_LOOP_TYPE: &str = "CDK_MINTD_AGICASH_CLOSED_LOOP_TYPE"; +pub const ENV_AGICASH_CLOSED_LOOP_VALID_DESTINATION: &str = + "CDK_MINTD_AGICASH_CLOSED_LOOP_VALID_DESTINATION"; + +pub const ENV_AGICASH_SQUARE_ENVIRONMENT: &str = "CDK_MINTD_AGICASH_SQUARE_ENVIRONMENT"; +pub const ENV_AGICASH_SQUARE_WEBHOOK_ENABLED: &str = "CDK_MINTD_AGICASH_SQUARE_WEBHOOK_ENABLED"; +pub const ENV_AGICASH_SQUARE_WEBHOOK_URL: &str = "CDK_MINTD_AGICASH_SQUARE_WEBHOOK_URL"; +pub const ENV_AGICASH_SQUARE_WEBHOOK_SIGNATURE_KEY: &str = + "CDK_MINTD_AGICASH_SQUARE_WEBHOOK_SIGNATURE_KEY"; +pub const ENV_AGICASH_SQUARE_SYNC_INTERVAL: &str = "CDK_MINTD_AGICASH_SQUARE_SYNC_INTERVAL_SECS"; +pub const ENV_AGICASH_SQUARE_INVOICE_EXPIRY: &str = "CDK_MINTD_AGICASH_SQUARE_INVOICE_EXPIRY_SECS"; + +impl Agicash { + pub fn from_env(mut self) -> Self { + let has_closed_loop_env = env::var(ENV_AGICASH_CLOSED_LOOP_TYPE).is_ok() + || env::var(ENV_AGICASH_CLOSED_LOOP_VALID_DESTINATION).is_ok(); + + if has_closed_loop_env { + let mut closed_loop = self.closed_loop.unwrap_or_else(|| ClosedLoop { + closed_loop_type: ClosedLoopType::Square, + valid_destination_name: String::new(), + }); + + if let Ok(loop_type_str) = env::var(ENV_AGICASH_CLOSED_LOOP_TYPE) { + if let Ok(loop_type) = loop_type_str.parse() { + closed_loop.closed_loop_type = loop_type; + } + } + + if let Ok(valid_dest) = env::var(ENV_AGICASH_CLOSED_LOOP_VALID_DESTINATION) { + closed_loop.valid_destination_name = valid_dest; + } + + self.closed_loop = Some(closed_loop); + } + + // Square settings + let has_square_env = env::var(ENV_AGICASH_SQUARE_ENVIRONMENT).is_ok(); + + if has_square_env { + let mut square = self.square.unwrap_or_else(|| SquareSettings { + environment: "production".to_string(), + webhook_enabled: false, + webhook_url: None, + webhook_signature_key: None, + sync_interval_secs: 300, + invoice_expiry_secs: 3600, + }); + + if let Ok(env_str) = env::var(ENV_AGICASH_SQUARE_ENVIRONMENT) { + square.environment = env_str; + } + + if let Ok(webhook_enabled) = env::var(ENV_AGICASH_SQUARE_WEBHOOK_ENABLED) { + square.webhook_enabled = webhook_enabled == "true" || webhook_enabled == "1"; + } + + if let Ok(webhook_url) = env::var(ENV_AGICASH_SQUARE_WEBHOOK_URL) { + square.webhook_url = Some(webhook_url); + } + + if let Ok(sig_key) = env::var(ENV_AGICASH_SQUARE_WEBHOOK_SIGNATURE_KEY) { + square.webhook_signature_key = Some(sig_key); + } + + if let Ok(interval) = env::var(ENV_AGICASH_SQUARE_SYNC_INTERVAL) { + if let Ok(secs) = interval.parse() { + square.sync_interval_secs = secs; + } + } + + if let Ok(expiry) = env::var(ENV_AGICASH_SQUARE_INVOICE_EXPIRY) { + if let Ok(secs) = expiry.parse() { + square.invoice_expiry_secs = secs; + } + } + + self.square = Some(square); + } + + self + } +} diff --git a/crates/cdk-mintd/src/env_vars/mod.rs b/crates/cdk-mintd/src/env_vars/mod.rs index a3e32f8682..9a85354a5e 100644 --- a/crates/cdk-mintd/src/env_vars/mod.rs +++ b/crates/cdk-mintd/src/env_vars/mod.rs @@ -4,6 +4,7 @@ //! This module contains all environment variable definitions and parsing logic //! organized by component. +mod agicash; mod common; mod database; mod info; @@ -28,10 +29,13 @@ mod lnd; mod management_rpc; #[cfg(feature = "prometheus")] mod prometheus; +#[cfg(feature = "strike")] +mod strike; use std::env; use std::str::FromStr; +pub use agicash::*; use anyhow::{anyhow, bail, Result}; #[cfg(feature = "auth")] pub use auth::*; @@ -55,6 +59,8 @@ pub use management_rpc::*; pub use mint_info::*; #[cfg(feature = "prometheus")] pub use prometheus::*; +#[cfg(feature = "strike")] +pub use strike::*; use crate::config::{DatabaseEngine, LnBackend, Settings}; @@ -95,6 +101,13 @@ impl Settings { self.mint_info = self.mint_info.clone().from_env(); self.ln = self.ln.clone().from_env(); + // Always process agicash env vars (even if no [agicash] section in config) + // so that agicash can be configured entirely via environment variables + let agicash = self.agicash.clone().unwrap_or_default().from_env(); + if agicash.closed_loop.is_some() || agicash.square.is_some() { + self.agicash = Some(agicash); + } + #[cfg(feature = "auth")] { // Check env vars for auth config even if None @@ -149,6 +162,10 @@ impl Settings { self.grpc_processor = Some(self.grpc_processor.clone().unwrap_or_default().from_env()); } + #[cfg(feature = "strike")] + LnBackend::Strike => { + self.strike = Some(self.strike.clone().unwrap_or_default().from_env()); + } LnBackend::None => bail!("Ln backend must be set"), #[allow(unreachable_patterns)] _ => bail!("Selected Ln backend is not enabled in this build"), diff --git a/crates/cdk-mintd/src/env_vars/strike.rs b/crates/cdk-mintd/src/env_vars/strike.rs new file mode 100644 index 0000000000..99b73a8399 --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/strike.rs @@ -0,0 +1,35 @@ +//! Strike environment variables + +use std::env; + +use cdk::nuts::CurrencyUnit; + +use crate::config::Strike; + +pub const ENV_STRIKE_API_KEY: &str = "CDK_MINTD_STRIKE_API_KEY"; +pub const ENV_STRIKE_SUPPORTED_UNITS: &str = "CDK_MINTD_STRIKE_SUPPORTED_UNITS"; +pub const ENV_STRIKE_WEBHOOK_URL: &str = "CDK_MINTD_STRIKE_WEBHOOK_URL"; + +impl Strike { + pub fn from_env(mut self) -> Self { + if let Ok(api_key) = env::var(ENV_STRIKE_API_KEY) { + self.api_key = api_key; + } + + if let Ok(units_str) = env::var(ENV_STRIKE_SUPPORTED_UNITS) { + let units: Vec = units_str + .split(',') + .filter_map(|unit| unit.trim().parse().ok()) + .collect(); + if !units.is_empty() { + self.supported_units = units; + } + } + + if let Ok(webhook_url) = env::var(ENV_STRIKE_WEBHOOK_URL) { + self.webhook_url = Some(webhook_url); + } + + self + } +} diff --git a/crates/cdk-mintd/src/lib.rs b/crates/cdk-mintd/src/lib.rs index 67c661e0d4..10d2e4d1bc 100644 --- a/crates/cdk-mintd/src/lib.rs +++ b/crates/cdk-mintd/src/lib.rs @@ -23,7 +23,8 @@ use cdk::nuts::nut00::KnownMethod; feature = "lnd", feature = "ldk-node", feature = "fakewallet", - feature = "grpc-processor" + feature = "grpc-processor", + feature = "strike" ))] use cdk::nuts::nut17::SupportedMethods; use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path}; @@ -31,7 +32,8 @@ use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path} feature = "cln", feature = "lnbits", feature = "lnd", - feature = "ldk-node" + feature = "ldk-node", + feature = "strike" ))] use cdk::nuts::CurrencyUnit; #[cfg(feature = "auth")] @@ -347,12 +349,12 @@ async fn configure_mint_builder( runtime: Option>, work_dir: &Path, kv_store: Option + Send + Sync>>, -) -> Result { +) -> Result<(MintBuilder, Vec)> { // Configure basic mint information let mint_builder = configure_basic_info(settings, mint_builder); // Configure lightning backend - let mint_builder = + let (mint_builder, additional_routers) = configure_lightning_backend(settings, mint_builder, runtime, work_dir, kv_store).await?; // Extract configured payment methods from mint_builder @@ -368,7 +370,7 @@ async fn configure_mint_builder( // Configure caching with payment methods let mint_builder = configure_cache(settings, mint_builder, &payment_methods); - Ok(mint_builder) + Ok((mint_builder, additional_routers)) } /// Configures basic mint information (name, contact info, descriptions, etc.) @@ -438,6 +440,15 @@ fn configure_basic_info(settings: &config::Settings, mint_builder: MintBuilder) } } + // Set agicash info if closed loop is configured + if settings + .agicash + .as_ref() + .is_some_and(|a| a.closed_loop.is_some()) + { + builder = builder.with_agicash(cdk::nuts::AgicashInfo { closed_loop: true }); + } + builder } /// Configures Lightning Network backend based on the specified backend type @@ -447,7 +458,7 @@ async fn configure_lightning_backend( _runtime: Option>, work_dir: &Path, _kv_store: Option + Send + Sync>>, -) -> Result { +) -> Result<(MintBuilder, Vec)> { let mint_melt_limits = MintMeltLimits { mint_min: settings.ln.min_mint, mint_max: settings.ln.max_mint, @@ -520,6 +531,12 @@ async fn configure_lightning_backend( let fake_wallet = settings.clone().fake_wallet.expect("Fake wallet defined"); tracing::info!("Using fake wallet: {:?}", fake_wallet); + let internal_closed_loop = settings + .agicash + .as_ref() + .and_then(|a| a.closed_loop.as_ref()) + .filter(|cl| cl.closed_loop_type == config::ClosedLoopType::Internal); + for unit in fake_wallet.clone().supported_units { let fake = fake_wallet .setup(settings, unit.clone(), None, work_dir, _kv_store.clone()) @@ -527,12 +544,27 @@ async fn configure_lightning_backend( #[cfg(feature = "prometheus")] let fake = MetricsMintPayment::new(fake); + let backend: Arc< + dyn MintPayment + Send + Sync, + > = if let Some(cl) = internal_closed_loop { + tracing::info!( + "Wrapping FakeWallet with internal closed-loop (destination: {})", + cl.valid_destination_name + ); + Arc::new(cdk_agicash::ClosedLoopPayment::new_internal( + Arc::new(fake), + cl.valid_destination_name.clone(), + )) + } else { + Arc::new(fake) + }; + mint_builder = configure_backend_for_unit( settings, mint_builder, unit.clone(), mint_melt_limits, - Arc::new(fake), + backend, ) .await?; } @@ -586,6 +618,62 @@ async fn configure_lightning_backend( ) .await?; } + #[cfg(feature = "strike")] + LnBackend::Strike => { + let strike_settings = settings.clone().strike.expect("Checked at config load"); + tracing::info!( + "Using Strike backend with supported units: {:?}", + strike_settings.supported_units + ); + + let mut webhook_routers = Vec::new(); + + // Check if agicash closed-loop with Square validation is configured + #[cfg(feature = "agicash")] + let agicash_sync = + setup_agicash_square(settings, &_kv_store, &mut webhook_routers).await?; + + for unit in &strike_settings.supported_units { + let (strike, webhook_router) = + strike_settings.setup(settings, unit.clone()).await?; + + webhook_routers.push(webhook_router); + + #[cfg(feature = "prometheus")] + let strike = MetricsMintPayment::new(strike); + + // Wrap with ClosedLoopPayment if agicash is configured + #[cfg(feature = "agicash")] + let backend: Arc< + dyn MintPayment + Send + Sync, + > = if let Some((ref sync, ref name, ref cancel_token)) = agicash_sync { + Arc::new(cdk_agicash::ClosedLoopPayment::new_square( + Arc::new(strike), + sync.clone(), + name.clone(), + cancel_token.clone(), + )) + } else { + Arc::new(strike) + }; + + #[cfg(not(feature = "agicash"))] + let backend: Arc< + dyn MintPayment + Send + Sync, + > = Arc::new(strike); + + mint_builder = configure_backend_for_unit( + settings, + mint_builder, + unit.clone(), + mint_melt_limits, + backend, + ) + .await?; + } + + return Ok((mint_builder, webhook_routers)); + } LnBackend::None => { tracing::error!( "Payment backend was not set or feature disabled. {:?}", @@ -595,7 +683,120 @@ async fn configure_lightning_backend( } }; - Ok(mint_builder) + Ok((mint_builder, vec![])) +} + +/// Set up agicash Square closed-loop validation if configured +#[cfg(feature = "agicash")] +#[allow(unused_variables)] +async fn setup_agicash_square( + settings: &config::Settings, + kv_store: &Option + Send + Sync>>, + webhook_routers: &mut Vec, +) -> Result< + Option<( + Arc, + String, + tokio_util::sync::CancellationToken, + )>, +> { + let agicash_config = match settings.agicash.as_ref() { + Some(c) => c, + None => return Ok(None), + }; + let square = match agicash_config.square.as_ref() { + Some(s) => s, + None => return Ok(None), + }; + let closed_loop = match agicash_config.closed_loop.as_ref() { + Some(cl) => cl, + None => return Ok(None), + }; + + let kv = kv_store + .as_ref() + .ok_or_else(|| anyhow!("Agicash Square configured but no KV store available"))?; + + let square_env: cdk_agicash::config::SquareEnvironment = square + .environment + .parse() + .unwrap_or(cdk_agicash::config::SquareEnvironment::Production); + + let pg_url = settings + .database + .postgres + .as_ref() + .map(|p| p.url.clone()) + .unwrap_or_default(); + + if pg_url.is_empty() { + bail!("Agicash Square configured but no postgres URL for credentials"); + } + + #[cfg(not(feature = "postgres"))] + { + bail!("Agicash Square requires postgres feature for credential lookup"); + } + + #[cfg(feature = "postgres")] + { + let provider = cdk_agicash::credentials::PgCredentialProvider::new(&pg_url) + .await + .map_err(|e| anyhow!("Failed to create Square credential provider: {e}"))?; + + let api_client = + cdk_agicash::square_api::SquareApiClient::new(&square_env, Arc::new(provider)); + let sync = Arc::new(cdk_agicash::sync::SquareSync::new( + api_client, + kv.clone(), + square.invoice_expiry_secs, + )); + + let cancel_token = tokio_util::sync::CancellationToken::new(); + + if square.webhook_enabled { + // Webhook mode: require signature key and URL + let sig_key = square.webhook_signature_key.as_ref().ok_or_else(|| { + anyhow!("Square webhook_enabled but webhook_signature_key is missing") + })?; + let wh_url = square + .webhook_url + .as_ref() + .ok_or_else(|| anyhow!("Square webhook_enabled but webhook_url is missing"))?; + + let square_webhook_router = cdk_agicash::webhook::create_square_webhook_router( + "/webhook/square/payment", + sync.clone(), + sig_key.clone(), + wh_url.clone(), + ); + webhook_routers.push(square_webhook_router); + tracing::info!("Square webhook enabled at /webhook/square/payment"); + } else if square.sync_interval_secs > 0 { + // Polling mode: only when webhook is not configured + cdk_agicash::ClosedLoopPayment::start_polling_sync( + sync.clone(), + square.sync_interval_secs, + cancel_token.clone(), + ); + tracing::info!( + "Square polling sync started with interval {}s", + square.sync_interval_secs + ); + } + + // Initial sync on startup + if let Err(e) = sync.sync_from_last().await { + tracing::warn!("Initial Square sync failed: {e}"); + } + + tracing::info!("Agicash Square closed-loop validation enabled"); + Ok(Some(( + sync, + closed_loop.valid_destination_name.clone(), + cancel_token, + ))) + } } /// Helper function to configure a mint builder with a lightning backend for a specific currency unit @@ -1370,7 +1571,7 @@ pub async fn run_mintd_with_shutdown( } }; - let mint_builder = + let (mint_builder, additional_routers) = configure_mint_builder(settings, maybe_mint_builder, runtime, work_dir, Some(kv)).await?; #[cfg(feature = "auth")] let (mint_builder, auth_localstore) = @@ -1384,6 +1585,9 @@ pub async fn run_mintd_with_shutdown( let mint = Arc::new(mint); + let mut routers = routers; + routers.extend(additional_routers); + start_services_with_shutdown( mint.clone(), settings, diff --git a/crates/cdk-mintd/src/setup.rs b/crates/cdk-mintd/src/setup.rs index a5de3eb0c7..af98dd3b32 100644 --- a/crates/cdk-mintd/src/setup.rs +++ b/crates/cdk-mintd/src/setup.rs @@ -364,3 +364,35 @@ impl LnBackendSetup for config::LdkNode { Ok(ldk_node) } } + +#[cfg(feature = "strike")] +impl config::Strike { + pub async fn setup( + &self, + settings: &Settings, + unit: CurrencyUnit, + ) -> anyhow::Result<(cdk_strike::Strike, axum::Router)> { + use cdk::mint_url::MintUrl; + + let webhook_endpoint = format!("/webhook/strike/{}/invoice", unit); + + // Use explicit webhook_url if provided, otherwise fall back to mint's info.url + let webhook_url = match &self.webhook_url { + Some(base_url) => { + let base: MintUrl = base_url.parse()?; + base.join(&webhook_endpoint)? + } + None => { + let mint_url: MintUrl = settings.info.url.parse()?; + mint_url.join(&webhook_endpoint)? + } + }; + + let strike = + cdk_strike::Strike::new(self.api_key.clone(), unit, webhook_url.to_string()).await?; + + let webhook_router = strike.create_invoice_webhook(&webhook_endpoint)?; + + Ok((strike, webhook_router)) + } +} diff --git a/crates/cdk-sql-common/src/wallet/mod.rs b/crates/cdk-sql-common/src/wallet/mod.rs index 3ab67f7d8e..f522781a4b 100644 --- a/crates/cdk-sql-common/src/wallet/mod.rs +++ b/crates/cdk-sql-common/src/wallet/mod.rs @@ -1011,6 +1011,7 @@ where motd, time, tos_url, + .. } = mint_info; ( @@ -1349,6 +1350,7 @@ fn sql_row_to_mint_info(row: Vec) -> Result { motd: column_as_nullable_string!(motd), time: column_as_nullable_number!(mint_time).map(|t| t), tos_url: column_as_nullable_string!(tos_url), + agicash: None, }) } diff --git a/crates/cdk-strike/Cargo.toml b/crates/cdk-strike/Cargo.toml new file mode 100644 index 0000000000..bfbd3ac6b0 --- /dev/null +++ b/crates/cdk-strike/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "cdk-strike" +version.workspace = true +edition.workspace = true +authors = ["CDK Developers"] +license.workspace = true +homepage = "https://github.com/cashubtc/cdk" +repository = "https://github.com/cashubtc/cdk.git" +rust-version.workspace = true # MSRV +description = "CDK ln backend for Strike" +readme = "README.md" + +[dependencies] +async-trait.workspace = true +anyhow.workspace = true +cdk-common = { workspace = true, features = ["mint"] } +futures.workspace = true +tokio.workspace = true +tokio-stream = { workspace = true, features = ["sync"] } +tokio-util.workspace = true +tracing.workspace = true +thiserror.workspace = true +serde_json.workspace = true +uuid.workspace = true +axum.workspace = true +reqwest.workspace = true +serde.workspace = true +url.workspace = true +rand.workspace = true +ring = "0.17" +hex = "0.4" \ No newline at end of file diff --git a/crates/cdk-strike/README.md b/crates/cdk-strike/README.md new file mode 100644 index 0000000000..75932b8b30 --- /dev/null +++ b/crates/cdk-strike/README.md @@ -0,0 +1,22 @@ +# CDK Strike + +[![crates.io](https://img.shields.io/crates/v/cdk-strike.svg)](https://crates.io/crates/cdk-strike) +[![Documentation](https://docs.rs/cdk-strike/badge.svg)](https://docs.rs/cdk-strike) +[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://github.com/cashubtc/cdk/blob/main/LICENSE) + +**ALPHA** This library is in early development, the API will change and should be used with caution. + +Strike backend implementation for the Cashu Development Kit (CDK). + +## Installation + +Add this to your `Cargo.toml`: + +```toml +[dependencies] +cdk-strike = "*" +``` + +## License + +This project is licensed under the [MIT License](../../LICENSE). \ No newline at end of file diff --git a/crates/cdk-strike/src/api/error.rs b/crates/cdk-strike/src/api/error.rs new file mode 100644 index 0000000000..ed28dde395 --- /dev/null +++ b/crates/cdk-strike/src/api/error.rs @@ -0,0 +1,343 @@ +//! Strike API error types +//! +//! See for the complete error reference. +//! +//! # Error Handling +//! +//! The Strike API uses standard HTTP response codes: +//! - 2xx: Success +//! - 4xx: Client errors (invalid request, insufficient permissions, etc.) +//! - 5xx: Server errors +//! +//! Error responses follow this JSON structure: +//! +//! ```json +//! { +//! "traceId": "optional-trace-id", +//! "data": { +//! "status": 400, +//! "code": "INVALID_DATA", +//! "message": "Human-readable error message", +//! "validationErrors": {} +//! } +//! } +//! ``` +//! +//! Note: Error messages are for developer reference only and may change without +//! notice. Do not display them to end users. +//! +//! # Error Codes +//! +//! [`StrikeErrorCode`] covers all known Strike API error codes. Key codes include: +//! +//! - `RATE_LIMIT_EXCEEDED` / `TOO_MANY_ATTEMPTS` - Retry with exponential backoff +//! - `BALANCE_TOO_LOW` - Insufficient account balance +//! - `PAYMENT_QUOTE_EXPIRED` - Quote expired, create a new one +//! - `LN_ROUTE_NOT_FOUND` - Lightning payment routing failed +//! - `INVALID_LN_INVOICE` - Malformed BOLT11 invoice +//! - `DUPLICATE_PAYMENT_QUOTE` - Use idempotency-key header to prevent duplicates +//! +//! Use [`StrikeErrorCode::is_retryable()`] to check if an error is transient. + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use thiserror::Error; + +/// Strike API error +#[derive(Debug, Error)] +pub enum Error { + /// Resource not found (404) + #[error("Not found")] + NotFound, + + /// Invalid URL format + #[error("Invalid URL: {0}")] + InvalidUrl(#[from] url::ParseError), + + /// HTTP request error + #[error("HTTP error: {0}")] + Reqwest(#[from] reqwest::Error), + + /// JSON serialization/deserialization error + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + + /// Strike API returned an error response + #[error("Strike API error: {0}")] + Api(Box), + + /// Webhook URL must use HTTPS + #[error("Webhook URL must use HTTPS, got: {0}")] + WebhookUrlNotHttps(String), +} + +impl From for Error { + fn from(e: StrikeApiError) -> Self { + Self::Api(Box::new(e)) + } +} + +/// Detailed Strike API error response +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize, Error)] +#[serde(rename_all = "camelCase")] +#[error("{}", self.data.message)] +pub struct StrikeApiError { + /// Optional trace ID for debugging + #[serde(default)] + pub trace_id: Option, + /// Error details + pub data: StrikeApiErrorData, +} + +impl StrikeApiError { + /// Get the HTTP status code + pub fn status(&self) -> u16 { + self.data.status + } + + /// Get the error code + pub fn code(&self) -> &StrikeErrorCode { + &self.data.code + } + + /// Get the error message + pub fn message(&self) -> &str { + &self.data.message + } + + /// Check if this error matches a specific code + pub fn is_error_code(&self, code: &StrikeErrorCode) -> bool { + &self.data.code == code + } + + /// Check if this is a rate limit error + pub fn is_rate_limit_error(&self) -> bool { + matches!( + self.data.code, + StrikeErrorCode::RateLimitExceeded | StrikeErrorCode::TooManyAttempts + ) + } + + /// Check if this is a server error (5xx) + pub fn is_server_error(&self) -> bool { + self.data.status >= 500 + } + + /// Check if this is a client error (4xx) + pub fn is_client_error(&self) -> bool { + self.data.status >= 400 && self.data.status < 500 + } + + /// Check if this error is retryable + pub fn is_retryable(&self) -> bool { + self.data.code.is_retryable() + } +} + +/// Strike API error data payload +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StrikeApiErrorData { + /// HTTP status code + pub status: u16, + /// Error code enum + pub code: StrikeErrorCode, + /// Human-readable error message + pub message: String, + /// Additional context values + #[serde(default)] + pub values: HashMap, + /// Field-specific validation errors + #[serde(default)] + pub validation_errors: HashMap>, + /// Debug information (only in non-production) + #[serde(default)] + pub debug: Option, +} + +/// Validation error for a specific field +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct ValidationError { + /// Validation error code + pub code: ValidationErrorCode, + /// Human-readable validation message + pub message: String, + /// Additional context values + #[serde(default)] + pub values: HashMap, +} + +/// Debug information included in error responses +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +pub struct DebugInfo { + /// Full debug output + #[serde(default)] + pub full: Option, + /// Request body that caused the error + #[serde(default)] + pub body: Option, +} + +/// Strike API error codes +/// +/// These codes are returned in the `code` field of error responses. +/// Use [`is_retryable`](StrikeErrorCode::is_retryable) to check if an error +/// should be retried with backoff. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum StrikeErrorCode { + /// Resource not found + NotFound, + /// Internal server error + InternalServerError, + /// Bad gateway + BadGateway, + /// Service in maintenance mode + MaintenanceMode, + /// Service temporarily unavailable + ServiceUnavailable, + /// Gateway timeout + GatewayTimeout, + /// Rate limit exceeded + RateLimitExceeded, + /// Too many attempts + TooManyAttempts, + /// Concurrent processing conflict + ProcessingConflict, + /// Authentication required + Unauthorized, + /// Permission denied + Forbidden, + /// Invalid request data + InvalidData, + /// Invalid query parameters + InvalidDataQuery, + /// Request could not be processed + UnprocessableEntity, + /// Account setup incomplete + AccountNotReady, + /// Invoice already paid + InvalidStateForInvoicePaid, + /// Invoice already reversed + InvalidStateForInvoiceReversed, + /// Invoice already cancelled + InvalidStateForInvoiceCancelled, + /// Invalid payment recipient + InvalidRecipient, + /// Payment currently processing + ProcessingPayment, + /// Duplicate invoice + DuplicateInvoice, + /// Cannot pay to self + SelfPaymentNotAllowed, + /// Currency not available for user + UserCurrencyUnavailable, + /// Lightning Network unavailable + LnUnavailable, + /// Exchange rate not available + ExchangeRateNotAvailable, + /// Insufficient balance + BalanceTooLow, + /// Invalid amount + InvalidAmount, + /// Amount exceeds maximum + AmountTooHigh, + /// Amount below minimum + AmountTooLow, + /// Payment method not supported + UnsupportedPaymentMethod, + /// Invalid payment method + InvalidPaymentMethod, + /// Currency not supported + CurrencyUnsupported, + /// Plaid linking failed + PlaidLinkingFailed, + /// Payment method not ready + PaymentMethodNotReady, + /// Payout originator not approved + PayoutOriginatorNotApproved, + /// Payout already initiated + PayoutAlreadyInitiated, + /// Invoice has expired + InvalidStateForInvoiceExpired, + /// Payment already processed + PaymentProcessed, + /// Invalid Lightning invoice + InvalidLnInvoice, + /// Lightning invoice already processed + LnInvoiceProcessed, + /// Invalid Bitcoin address + InvalidBitcoinAddress, + /// Lightning route not found + LnRouteNotFound, + /// Payment quote has expired + PaymentQuoteExpired, + /// Duplicate payment quote + DuplicatePaymentQuote, + /// Too many transactions + TooManyTransactions, + /// Duplicate currency exchange quote + DuplicateCurrencyExchangeQuote, + /// Currency exchange quote already processed + CurrencyExchangeQuoteProcessed, + /// Currency exchange quote expired + CurrencyExchangeQuoteExpired, + /// Currency exchange pair not supported + CurrencyExchangePairNotSupported, + /// Currency exchange amount too low + CurrencyExchangeAmountTooLow, + /// Deposit limit exceeded + DepositLimitExceeded, + /// Duplicate deposit + DuplicateDeposit, + /// Unknown error code + #[serde(other)] + Unknown, +} + +impl StrikeErrorCode { + /// Check if this error is retryable + /// + /// Returns `true` for transient errors that may succeed on retry: + /// - Server errors (5xx) + /// - Rate limiting + /// - Processing conflicts + /// - Lightning Network unavailable + pub fn is_retryable(&self) -> bool { + matches!( + self, + StrikeErrorCode::InternalServerError + | StrikeErrorCode::BadGateway + | StrikeErrorCode::MaintenanceMode + | StrikeErrorCode::ServiceUnavailable + | StrikeErrorCode::GatewayTimeout + | StrikeErrorCode::RateLimitExceeded + | StrikeErrorCode::TooManyAttempts + | StrikeErrorCode::ProcessingConflict + | StrikeErrorCode::LnUnavailable + ) + } +} + +/// Validation error codes for field-level errors +#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum ValidationErrorCode { + /// Generic invalid data + InvalidData, + /// Required field missing + InvalidDataRequired, + /// Invalid field length + InvalidDataLength, + /// Field too short + InvalidDataMinlength, + /// Field too long + InvalidDataMaxlength, + /// Invalid field value + InvalidDataValue, + /// Invalid currency + InvalidDataCurrency, + /// Unknown validation error + #[serde(other)] + Unknown, +} diff --git a/crates/cdk-strike/src/api/mod.rs b/crates/cdk-strike/src/api/mod.rs new file mode 100644 index 0000000000..57cf87a654 --- /dev/null +++ b/crates/cdk-strike/src/api/mod.rs @@ -0,0 +1,372 @@ +//! Strike API client +//! +//! This module implements the Strike API v1 for Lightning Network payments. +//! See for the complete API reference. +//! +//! # Overview +//! +//! Strike enables receiving and sending payments via the Bitcoin Lightning Network. +//! This client supports: +//! - Creating invoices to receive payments +//! - Generating payment quotes and executing payments +//! - Webhook subscriptions for real-time notifications +//! +//! # Endpoints +//! +//! ## Invoices (Receiving Payments) +//! +//! | Method | Endpoint | Description | +//! |--------|----------|-------------| +//! | POST | `/v1/invoices` | Create an invoice for a specific amount | +//! | GET | `/v1/invoices/{id}` | Get invoice by ID | +//! | POST | `/v1/invoices/{id}/quote` | Generate BOLT11 Lightning invoice | +//! +//! **Invoice Flow:** +//! 1. Create invoice (state: `UNPAID`) +//! 2. Generate quote to get BOLT11 (expires in 30s cross-currency, 1hr same-currency) +//! 3. Payer pays the BOLT11 +//! 4. Invoice transitions to `PAID` +//! +//! ## Payment Quotes (Sending Payments) +//! +//! | Method | Endpoint | Description | +//! |--------|----------|-------------| +//! | POST | `/v1/payment-quotes/lightning` | Quote for paying a BOLT11 invoice | +//! | PATCH | `/v1/payment-quotes/{id}/execute` | Execute the payment | +//! | GET | `/v1/payments/{id}` | Get payment status | +//! +//! **Payment States:** `PENDING` (awaiting confirmation), `COMPLETED`, `FAILED` +//! +//! ## Webhooks +//! +//! | Method | Endpoint | Description | +//! |--------|----------|-------------| +//! | POST | `/v1/subscriptions` | Create webhook subscription | +//! | GET | `/v1/subscriptions` | List subscriptions | +//! | DELETE | `/v1/subscriptions/{id}` | Delete subscription | +//! +//! **Event Types:** `invoice.updated` +//! +//! # Authentication +//! +//! All requests require Bearer token authentication via the `Authorization` header. +//! API keys are generated in the Strike Dashboard at . + +pub mod error; +pub mod types; +pub mod webhook; + +use error::{Error, StrikeApiError}; +use rand::Rng; +use reqwest::Client; +use serde::Serialize; +use std::time::Duration; +use tracing::{debug, warn}; +use types::*; +use url::Url; + +/// Strike API client +#[derive(Debug, Clone)] +pub struct StrikeApi { + api_key: String, + base_url: Url, + client: Client, + webhook_secret: String, +} + +impl StrikeApi { + /// Create a new Strike API client + pub fn new(api_key: &str, api_url: Option<&str>, timeout_ms: u64) -> anyhow::Result { + let base_url = api_url.unwrap_or("https://api.strike.me"); + let base_url = Url::parse(base_url)?; + + let client = Client::builder() + .timeout(Duration::from_millis(timeout_ms)) + .build()?; + + // Generate a random webhook secret + let mut rng = rand::rng(); + let webhook_secret: String = (0..15) + .map(|_| { + let idx = rng.random_range(0..62); + let chars = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; + chars[idx] as char + }) + .collect(); + + Ok(Self { + api_key: api_key.to_string(), + base_url, + client, + webhook_secret, + }) + } + + /// Get the webhook secret for signature verification + pub fn webhook_secret(&self) -> &str { + &self.webhook_secret + } + + /// Make a GET request + async fn get(&self, path: &str) -> Result { + let url = self.base_url.join(path)?; + debug!("GET {}", url); + + let response = self + .client + .get(url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Accept", "application/json") + .send() + .await?; + + self.handle_response(response).await + } + + /// Make a POST request + async fn post(&self, path: &str, body: &T) -> Result { + let url = self.base_url.join(path)?; + debug!("POST {}", url); + + let response = self + .client + .post(url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .header("Accept", "application/json") + .json(body) + .send() + .await?; + + self.handle_response(response).await + } + + /// Make a PATCH request (no body) + async fn patch(&self, path: &str) -> Result { + let url = self.base_url.join(path)?; + debug!("PATCH {}", url); + + let response = self + .client + .patch(url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Accept", "application/json") + .send() + .await?; + + self.handle_response(response).await + } + + /// Make a DELETE request + async fn delete(&self, path: &str) -> Result<(), Error> { + let url = self.base_url.join(path)?; + debug!("DELETE {}", url); + + let response = self + .client + .delete(url) + .header("Authorization", format!("Bearer {}", self.api_key)) + .send() + .await?; + + if response.status().is_success() { + Ok(()) + } else { + let text = response.text().await?; + let error: StrikeApiError = serde_json::from_str(&text)?; + Err(Error::Api(Box::new(error))) + } + } + + /// Handle API response + async fn handle_response( + &self, + response: reqwest::Response, + ) -> Result { + let status = response.status(); + let text = response.text().await?; + + if status.is_success() { + let json: serde_json::Value = serde_json::from_str(&text)?; + Ok(json) + } else if status == reqwest::StatusCode::NOT_FOUND { + Err(Error::NotFound) + } else { + let error: StrikeApiError = serde_json::from_str(&text)?; + if error.is_retryable() { + warn!( + "Strike: API error (retryable): {} - {} [code: {:?}, trace: {:?}]", + status, + error.message(), + error.code(), + error.trace_id + ); + } else { + warn!( + "Strike: API error: {} - {} [code: {:?}, trace: {:?}]", + status, + error.message(), + error.code(), + error.trace_id + ); + } + Err(Error::Api(Box::new(error))) + } + } + + // ==================== Invoice Endpoints ==================== + + /// Create an invoice + pub async fn create_invoice(&self, request: InvoiceRequest) -> Result { + let json = self.post("/v1/invoices", &request).await?; + Ok(serde_json::from_value(json)?) + } + + /// Get an invoice by ID + pub async fn get_incoming_invoice(&self, invoice_id: &str) -> Result { + let json = self.get(&format!("/v1/invoices/{}", invoice_id)).await?; + Ok(serde_json::from_value(json)?) + } + + /// Get a quote for an invoice (returns BOLT11) + /// + /// See + pub async fn invoice_quote(&self, invoice_id: &str) -> Result { + self.invoice_quote_with_options(invoice_id, InvoiceQuoteRequest::default()) + .await + } + + /// Get a quote for an invoice with options (returns BOLT11) + /// + /// Use this method when you need to specify a description hash for BOLT11 compliance. + /// See + pub async fn invoice_quote_with_options( + &self, + invoice_id: &str, + request: InvoiceQuoteRequest, + ) -> Result { + let json = self + .post(&format!("/v1/invoices/{}/quote", invoice_id), &request) + .await?; + Ok(serde_json::from_value(json)?) + } + + // ==================== Payment Endpoints ==================== + + /// Get a quote for paying a Lightning invoice + pub async fn payment_quote( + &self, + request: PayInvoiceQuoteRequest, + ) -> Result { + let json = self.post("/v1/payment-quotes/lightning", &request).await?; + Ok(serde_json::from_value(json)?) + } + + /// Execute a payment quote + pub async fn pay_quote(&self, quote_id: &str) -> Result { + let json = self + .patch(&format!("/v1/payment-quotes/{}/execute", quote_id)) + .await?; + Ok(serde_json::from_value(json)?) + } + + /// Get an outgoing payment by ID + pub async fn get_outgoing_payment( + &self, + payment_id: &str, + ) -> Result { + let json = self.get(&format!("/v1/payments/{}", payment_id)).await?; + Ok(serde_json::from_value(json)?) + } + + // ==================== Webhook Endpoints ==================== + + /// Validate that a webhook URL uses HTTPS + fn validate_webhook_url(url: &str) -> Result<(), Error> { + if url.starts_with("http://") { + return Err(Error::WebhookUrlNotHttps(url.to_string())); + } + Ok(()) + } + + /// Get current webhook subscriptions + pub async fn get_current_subscriptions( + &self, + ) -> Result, Error> { + let json = self.get("/v1/subscriptions").await?; + Ok(serde_json::from_value(json)?) + } + + /// Delete a webhook subscription + pub async fn delete_subscription(&self, webhook_id: &str) -> Result<(), Error> { + self.delete(&format!("/v1/subscriptions/{}", webhook_id)) + .await + } + + /// Rotate webhook subscription: delete existing ones for this URL, create fresh one + /// + /// This method ensures only one subscription exists for the given webhook URL by: + /// 1. Fetching all current subscriptions + /// 2. Deleting any that match the webhook URL + /// 3. Creating a fresh subscription with the current secret + /// + /// This prevents accumulating orphaned subscriptions (Strike has a 50 subscription limit). + pub async fn rotate_webhook_subscription( + &self, + webhook_url: &str, + event_types: Vec, + ) -> Result<(), Error> { + Self::validate_webhook_url(webhook_url)?; + + // Get current subscriptions + let subscriptions = match self.get_current_subscriptions().await { + Ok(subs) => subs, + Err(e) => { + warn!( + "Failed to get current subscriptions, will attempt to create new: {}", + e + ); + vec![] + } + }; + + // Delete any matching our webhook URL + for sub in subscriptions { + if sub.webhook_url == webhook_url { + tracing::info!( + "Deleting existing webhook subscription {} for {}", + sub.id, + webhook_url + ); + if let Err(e) = self.delete_subscription(&sub.id).await { + warn!("Failed to delete subscription {}: {}", sub.id, e); + } + } + } + + // Create fresh subscription + let request = webhook::WebhookRequest { + webhook_url: webhook_url.to_string(), + webhook_version: "v1".to_string(), + secret: self.webhook_secret.clone(), + enabled: true, + event_types, + }; + + self.post("/v1/subscriptions", &request).await?; + tracing::info!("Created new webhook subscription for {}", webhook_url); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_new_client() { + let client = StrikeApi::new("test_key", None, 30000).unwrap(); + assert_eq!(client.base_url.as_str(), "https://api.strike.me/"); + assert_eq!(client.webhook_secret.len(), 15); + } +} diff --git a/crates/cdk-strike/src/api/types.rs b/crates/cdk-strike/src/api/types.rs new file mode 100644 index 0000000000..94759e1ac7 --- /dev/null +++ b/crates/cdk-strike/src/api/types.rs @@ -0,0 +1,426 @@ +//! Strike API type definitions +//! +//! This module contains request and response types for the Strike API v1. +//! See for the complete API reference. +//! +//! # Type Definitions +//! +//! All types use `camelCase` serialization to match the API's JSON format. +//! +//! ## Amount Handling +//! +//! Strike API returns amounts as strings (e.g., `"0.00001234"` for BTC). +//! The [`Amount`] type handles this with custom deserialization via +//! [`parse_f64_from_string`]. +//! +//! ## Invoice States +//! +//! [`InvoiceState`] covers both invoice and payment states: +//! - Invoice states: `UNPAID`, `PENDING`, `PAID`, `CANCELLED` +//! - Payment states: `PENDING`, `COMPLETED`, `FAILED` +//! +//! ## Supported Currencies +//! +//! `BTC`, `USD`, `EUR`, `USDT`, `GBP`, `AUD` + +use serde::{Deserialize, Deserializer, Serialize}; +use std::fmt; + +/// Parse f64 from a string (Strike API returns amounts as strings) +pub fn parse_f64_from_string<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + #[derive(Deserialize)] + #[serde(untagged)] + enum StringOrFloat { + String(String), + Float(f64), + } + + match StringOrFloat::deserialize(deserializer)? { + StringOrFloat::String(s) => s.parse().map_err(serde::de::Error::custom), + StringOrFloat::Float(f) => Ok(f), + } +} + +/// Supported currencies in the Strike API +/// +/// See +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum Currency { + /// US Dollar - ideal for e-commerce transactions + USD, + /// Euro + EUR, + /// Bitcoin - used for Lightning Network operations + BTC, + /// Tether USD (stablecoin) + USDT, + /// British Pound + GBP, + /// Australian Dollar + AUD, +} + +impl fmt::Display for Currency { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Currency::USD => write!(f, "USD"), + Currency::EUR => write!(f, "EUR"), + Currency::BTC => write!(f, "BTC"), + Currency::USDT => write!(f, "USDT"), + Currency::GBP => write!(f, "GBP"), + Currency::AUD => write!(f, "AUD"), + } + } +} + +impl std::str::FromStr for Currency { + type Err = String; + + fn from_str(s: &str) -> Result { + match s.to_uppercase().as_str() { + "USD" => Ok(Currency::USD), + "EUR" => Ok(Currency::EUR), + "BTC" => Ok(Currency::BTC), + "USDT" => Ok(Currency::USDT), + "GBP" => Ok(Currency::GBP), + "AUD" => Ok(Currency::AUD), + // Also support lowercase unit names + "SAT" | "SATS" => Ok(Currency::BTC), + "MSAT" | "MSATS" => Ok(Currency::BTC), + _ => Err(format!("Unknown currency: {}", s)), + } + } +} + +/// Amount with currency +/// +/// Strike API returns amounts as decimal strings (e.g., "0.00001234" for BTC). +/// This type handles both string and float deserialization. +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +pub struct Amount { + /// The currency type (BTC, USD, EUR, USDT, GBP, AUD) + pub currency: Currency, + /// The amount value as a decimal + #[serde(deserialize_with = "parse_f64_from_string")] + pub amount: f64, +} + +impl Amount { + /// Create an amount from satoshis + pub fn from_sats(sats: u64) -> Self { + Self { + currency: Currency::BTC, + amount: sats as f64 / 100_000_000.0, + } + } + + /// Convert to satoshis (only valid for BTC) + pub fn to_sats(&self) -> anyhow::Result { + if self.currency != Currency::BTC { + anyhow::bail!("Cannot convert {} to sats", self.currency); + } + Ok((self.amount * 100_000_000.0).round() as u64) + } +} + +/// Fee policy for payments +/// +/// Determines how fees are handled relative to the payment amount. +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize, Serialize)] +pub enum FeePolicy { + /// Fee is included in the amount - reduces the amount sent to recipient + Inclusive, + /// Fee is added on top of the amount - recipient receives full amount (default) + Exclusive, +} + +/// Payment amount with optional fee policy +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PaymentAmount { + /// The amount value + #[serde(deserialize_with = "parse_f64_from_string")] + pub amount: f64, + /// The currency type + pub currency: Currency, + /// Optional fee policy + #[serde(skip_serializing_if = "Option::is_none")] + pub fee_policy: Option, +} + +/// Invoice and payment state +/// +/// This enum covers states for both invoices and payments: +/// +/// **Invoice states:** +/// - `UNPAID` - Invoice created, awaiting payment +/// - `PENDING` - Payment in progress +/// - `PAID` - Payment received successfully +/// - `CANCELLED` - Invoice was cancelled +/// +/// **Payment states:** +/// - `PENDING` - Payment in progress, awaiting blockchain confirmation +/// - `COMPLETED` - Payment completed successfully +/// - `FAILED` - Payment failed +/// +/// See +#[derive(Debug, Clone, Copy, Hash, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub enum InvoiceState { + /// Payment completed successfully (payment state) + Completed, + /// Invoice/payment has been paid (invoice state) + Paid, + /// Invoice is unpaid, awaiting payment (invoice state) + Unpaid, + /// Payment in progress, awaiting confirmation + Pending, + /// Payment failed (payment state) + Failed, + /// Invoice was cancelled (invoice state) + Cancelled, +} + +impl fmt::Display for InvoiceState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + InvoiceState::Completed => write!(f, "COMPLETED"), + InvoiceState::Paid => write!(f, "PAID"), + InvoiceState::Unpaid => write!(f, "UNPAID"), + InvoiceState::Pending => write!(f, "PENDING"), + InvoiceState::Failed => write!(f, "FAILED"), + InvoiceState::Cancelled => write!(f, "CANCELLED"), + } + } +} + +/// Request to create an invoice +/// +/// Invoices are used to receive payments for a specific amount. They begin in the +/// UNPAID state and transition to PAID upon successful payment delivery. +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceRequest { + /// Universally unique identifier for invoice tracking. + /// Use this to correlate invoices with your internal systems. + #[serde(skip_serializing_if = "Option::is_none")] + pub correlation_id: Option, + /// Optional invoice description that will be included in the Lightning invoice. + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// The invoice amount with currency. USD invoices are ideal for e-commerce + /// since the received amount is guaranteed regardless of BTC price fluctuations. + pub amount: Amount, +} + +/// Response from creating an invoice +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceResponse { + /// Unique invoice identifier (UUID) + pub invoice_id: String, + /// The invoice amount with currency + pub amount: Amount, + /// Current invoice state: UNPAID, PENDING, PAID, or CANCELLED + pub state: InvoiceState, + /// Creation timestamp (ISO 8601) + pub created: String, + /// Universally unique identifier for invoice tracking, provided in the create request + #[serde(default)] + pub correlation_id: Option, + /// Optional invoice description + #[serde(default)] + pub description: Option, + /// ID of the invoice issuer (UUID) + pub issuer_id: String, + /// ID of the payment receiver (UUID) + pub receiver_id: String, +} + +/// Request for an invoice quote +/// +/// See +#[derive(Clone, Debug, Default, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceQuoteRequest { + /// Optional description hash for BOLT11 spec compliance. + /// When provided, the resulting Lightning invoice will include this hash + /// instead of the plain text description. + #[serde(skip_serializing_if = "Option::is_none")] + pub description_hash: Option, +} + +/// Response from getting an invoice quote (includes BOLT11) +/// +/// After creating an invoice, generate a quote to get the BOLT11 Lightning invoice. +/// Quotes have an expiration time: 30 seconds for cross-currency, 3600 seconds (1 hour) +/// for same-currency invoices. +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoiceQuoteResponse { + /// Unique quote identifier (UUID) + pub quote_id: String, + /// Optional description forwarded from the invoice + #[serde(default)] + pub description: Option, + /// BOLT11 Lightning invoice string. This alphanumeric code contains the + /// payment amount and destination, and can be presented as a QR code. + pub ln_invoice: String, + /// Optional on-chain Bitcoin address for fallback payment + #[serde(default)] + pub onchain_address: Option, + /// Quote expiration timestamp (ISO 8601) + pub expiration: String, + /// Seconds until quote expiration. 30 sec for cross-currency, 3600 sec for same-currency. + pub expiration_in_sec: u64, + /// Source amount - what the payer sends in BTC + pub source_amount: Amount, + /// Target amount - what the receiver gets in their chosen currency + pub target_amount: Amount, + /// Currency conversion rate. Value is 1 for BTC-to-BTC invoices. + pub conversion_rate: ConversionRate, +} + +/// Conversion rate between currencies +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConversionRate { + /// The conversion rate value + #[serde(deserialize_with = "parse_f64_from_string")] + pub amount: f64, + /// Source currency + pub source_currency: Currency, + /// Target currency + pub target_currency: Currency, +} + +/// Request to get a Lightning payment quote +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PayInvoiceQuoteRequest { + /// BOLT11 Lightning invoice to pay (min length: 1) + pub ln_invoice: String, + /// Currency to send from. Defaults to the user's default currency if not specified. + pub source_currency: Currency, + /// Amount to pay. Required only for zero-amount invoices; omit otherwise. + #[serde(skip_serializing_if = "Option::is_none")] + pub amount: Option, +} + +/// Response from getting a Lightning payment quote +/// +/// Contains the cost breakdown for paying a Lightning invoice, including +/// the amount, fees, and conversion rate for cross-currency payments. +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PayInvoiceQuoteResponse { + /// Unique payment quote identifier (UUID) + pub payment_quote_id: String, + /// Optional description forwarded from the Lightning invoice + #[serde(default)] + pub description: Option, + /// Quote expiration timestamp (ISO 8601). Execute before this time. + #[serde(default)] + pub valid_until: Option, + /// Currency conversion rate for cross-currency payments. + /// Shows how much of source currency equals 1 unit of target currency (BTC). + #[serde(default)] + pub conversion_rate: Option, + /// The payment amount in the source currency + pub amount: Amount, + /// Lightning Network routing fee charged by the network + #[serde(default)] + pub lightning_network_fee: Option, + /// Total fee including all applicable fees + #[serde(default)] + pub total_fee: Option, + /// Total amount the sender will spend (amount + all fees) + pub total_amount: Amount, + /// Optional reward amount (e.g., cashback) + #[serde(default)] + pub reward: Option, +} + +/// Response from executing a payment +/// +/// Returned after executing a payment quote. The payment state indicates +/// whether the payment is PENDING (awaiting blockchain confirmation) or +/// COMPLETED (successfully delivered). +/// +/// See +#[derive(Clone, Debug, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct InvoicePaymentResponse { + /// Unique payment identifier (UUID) + pub payment_id: String, + /// Current payment state: PENDING or COMPLETED + pub state: InvoiceState, + /// Completion timestamp (ISO 8601), present when state is COMPLETED + #[serde(default)] + pub completed: Option, + /// Currency conversion rate used for cross-currency payments + #[serde(default)] + pub conversion_rate: Option, + /// The payment amount in the source currency + pub amount: Amount, + /// Total fee charged for the payment + #[serde(default)] + pub total_fee: Option, + /// Lightning Network routing fee + #[serde(default)] + pub lightning_network_fee: Option, + /// Total amount spent (amount + all fees) + pub total_amount: Amount, + /// Optional reward earned (e.g., cashback) + #[serde(default)] + pub reward: Option, + /// Lightning-specific payment details + #[serde(default)] + pub lightning: Option, + /// On-chain payment details (for on-chain payments) + #[serde(default)] + pub onchain: Option, +} + +/// Lightning-specific payment details +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LightningPaymentDetails { + /// Optional network fee + #[serde(default)] + pub network_fee: Option, +} + +/// On-chain payment details +#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct OnchainPaymentDetails { + /// Optional transaction ID + #[serde(default)] + pub txn_id: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_amount_from_sats() { + let amount = Amount::from_sats(100_000_000); + assert_eq!(amount.to_sats().unwrap(), 100_000_000); + } +} diff --git a/crates/cdk-strike/src/api/webhook.rs b/crates/cdk-strike/src/api/webhook.rs new file mode 100644 index 0000000000..2ec1111389 --- /dev/null +++ b/crates/cdk-strike/src/api/webhook.rs @@ -0,0 +1,251 @@ +//! Strike webhook handling +//! +//! Webhooks are HTTP-based notifications that Strike sends when specific events +//! occur, such as invoice state changes or payment completions. +//! +//! See for the official documentation. +//! +//! # Signature Verification +//! +//! All webhooks are signed using HMAC-SHA256 for authentication: +//! +//! 1. Extract signature from `X-Webhook-Signature` header (hex-encoded) +//! 2. Compute HMAC-SHA256 of the raw request body using your webhook secret +//! 3. Compare signatures using timing-safe comparison to prevent timing attacks +//! +//! See for details. +//! +//! # Event Types +//! +//! This module handles subscriptions for: +//! - `invoice.updated` - Invoice state changes (UNPAID → PAID) +//! +//! Other available event types (not currently used): +//! - `payment.created`, `payment.updated` +//! - `receive-request.receive-pending`, `receive-request.receive-completed` +//! +//! # Webhook Payload +//! +//! ```json +//! { +//! "id": "event-uuid", +//! "eventType": "invoice.updated", +//! "webhookVersion": "v1", +//! "data": { +//! "entityId": "invoice-or-quote-uuid", +//! "changes": ["state"] +//! }, +//! "created": "2024-01-15T12:00:00Z", +//! "deliverySuccess": true +//! } +//! ``` +//! +//! **Important:** The webhook payload does NOT contain the new state value. +//! You must fetch the entity via the API to get the current state. + +use axum::{ + body::Body, + extract::State, + http::{Request, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::post, + Router, +}; +use ring::hmac; +use serde::{Deserialize, Serialize}; +use tokio::sync::mpsc; +use tracing::{debug, warn}; + +/// Webhook subscription request sent to Strike API +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookRequest { + /// URL to receive webhook notifications + pub webhook_url: String, + /// Webhook API version (currently "v1") + pub webhook_version: String, + /// Secret for HMAC signature verification + pub secret: String, + /// Whether the webhook is enabled + pub enabled: bool, + /// Event types to subscribe to + pub event_types: Vec, +} + +/// Webhook subscription info response from Strike API +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookInfoResponse { + /// Unique subscription identifier + pub id: String, + /// URL receiving webhook notifications + pub webhook_url: String, + /// Webhook API version + pub webhook_version: String, + /// Whether the webhook is enabled + pub enabled: bool, + /// Subscribed event types + pub event_types: Vec, +} + +/// Webhook event payload received from Strike +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookEvent { + /// Unique event identifier + pub id: String, + /// Event type (e.g., "invoice.updated") + pub event_type: String, + /// Webhook API version + pub webhook_version: String, + /// Event data payload + pub data: WebhookData, + /// Event creation timestamp (ISO 8601) + pub created: String, + /// Whether delivery was successful (for retries) + #[serde(default)] + pub delivery_success: Option, +} + +/// Webhook event data payload +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WebhookData { + /// ID of the affected entity (invoice or quote) + pub entity_id: String, + /// List of changed fields + #[serde(default)] + pub changes: Vec, +} + +/// State for webhook handlers +#[derive(Clone)] +pub struct WebhookState { + /// Channel sender for forwarding entity IDs + pub sender: mpsc::Sender, + /// Secret for signature verification + pub secret: String, +} + +/// Verify webhook request signature using HMAC-SHA256 +fn verify_signature(signature: &str, body: &[u8], secret: &[u8]) -> anyhow::Result<()> { + let key = hmac::Key::new(hmac::HMAC_SHA256, secret); + + let signature_bytes = + hex::decode(signature).map_err(|_| anyhow::anyhow!("Invalid signature hex"))?; + + hmac::verify(&key, body, &signature_bytes).map_err(|_| anyhow::anyhow!("Invalid signature"))?; + + Ok(()) +} + +/// Middleware to verify webhook signatures +async fn verify_request_body( + State(state): State, + request: Request, + next: Next, +) -> Result { + // Extract signature header + let signature = request + .headers() + .get("X-Webhook-Signature") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + warn!("Missing X-Webhook-Signature header"); + (StatusCode::UNAUTHORIZED, "Missing signature").into_response() + })? + .to_string(); + + // Collect body bytes + let (parts, body) = request.into_parts(); + let bytes = axum::body::to_bytes(body, 1024 * 1024).await.map_err(|e| { + warn!("Failed to read request body: {}", e); + (StatusCode::BAD_REQUEST, "Invalid body").into_response() + })?; + + // Verify signature + if let Err(e) = verify_signature(&signature, &bytes, state.secret.as_bytes()) { + warn!("Webhook signature verification failed: {}", e); + return Err((StatusCode::UNAUTHORIZED, "Invalid signature").into_response()); + } + + debug!("Webhook signature verified"); + + // Reconstruct request with body + let request = Request::from_parts(parts, Body::from(bytes)); + Ok(next.run(request).await) +} + +/// Handle invoice webhook events +async fn handle_invoice_webhook( + State(state): State, + body: axum::body::Bytes, +) -> impl IntoResponse { + let event: WebhookEvent = match serde_json::from_slice(&body) { + Ok(e) => e, + Err(e) => { + warn!("Failed to parse webhook event: {}", e); + return StatusCode::BAD_REQUEST; + } + }; + + if event.event_type != "invoice.updated" { + debug!("Ignoring non-invoice webhook event: {}", event.event_type); + return StatusCode::OK; + } + + debug!( + "Received invoice webhook: {} - {}", + event.event_type, event.data.entity_id + ); + + // Send entity ID to channel + if let Err(e) = state.sender.send(event.data.entity_id).await { + warn!("Failed to send webhook event to channel: {}", e); + return StatusCode::INTERNAL_SERVER_ERROR; + } + + StatusCode::OK +} + +/// Create an Axum router for invoice webhooks +/// +/// The router handles POST requests to the specified endpoint, verifies +/// the webhook signature, and forwards the entity ID to the provided channel. +pub fn create_invoice_webhook_router( + endpoint: &str, + sender: mpsc::Sender, + secret: String, +) -> Router { + let state = WebhookState { sender, secret }; + + Router::new() + .route(endpoint, post(handle_invoice_webhook)) + .layer(middleware::from_fn_with_state( + state.clone(), + verify_request_body, + )) + .with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_verify_signature() { + // Generate a test signature + let secret = b"test_secret"; + let body = b"test body"; + let key = hmac::Key::new(hmac::HMAC_SHA256, secret); + let tag = hmac::sign(&key, body); + let signature = hex::encode(tag.as_ref()); + + // Verify should succeed + assert!(verify_signature(&signature, body, secret).is_ok()); + + // Wrong body should fail + assert!(verify_signature(&signature, b"wrong body", secret).is_err()); + } +} diff --git a/crates/cdk-strike/src/error.rs b/crates/cdk-strike/src/error.rs new file mode 100644 index 0000000000..ed2d0875be --- /dev/null +++ b/crates/cdk-strike/src/error.rs @@ -0,0 +1,21 @@ +//! Error for Strike ln backend + +use crate::api::error::Error as StrikeApiError; +use thiserror::Error; + +/// Strike Error +#[derive(Debug, Error)] +pub enum Error { + /// Strike API error + #[error(transparent)] + StrikeApi(#[from] StrikeApiError), + /// Anyhow error + #[error(transparent)] + Anyhow(#[from] anyhow::Error), +} + +impl From for cdk_common::payment::Error { + fn from(e: Error) -> Self { + Self::Lightning(Box::new(e)) + } +} diff --git a/crates/cdk-strike/src/lib.rs b/crates/cdk-strike/src/lib.rs new file mode 100644 index 0000000000..e9b86ee1ff --- /dev/null +++ b/crates/cdk-strike/src/lib.rs @@ -0,0 +1,831 @@ +//! CDK lightning backend for Strike + +#![warn(missing_docs)] +#![warn(rustdoc::bare_urls)] + +use std::collections::HashMap; +use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::bail; +use api::{ + types::{ + Amount as StrikeAmount, Currency as StrikeCurrencyUnit, InvoiceRequest, InvoiceState, + PayInvoiceQuoteRequest, + }, + StrikeApi, +}; +use async_trait::async_trait; +use axum::Router; +use cdk_common::amount::Amount; +use cdk_common::nuts::{CurrencyUnit, MeltQuoteState}; +use cdk_common::payment::{ + self, Bolt11Settings, CreateIncomingPaymentResponse, Event, IncomingPaymentOptions, + MakePaymentResponse, MintPayment, OutgoingPaymentOptions, PaymentIdentifier, + PaymentQuoteResponse, SettingsResponse, WaitPaymentResponse, +}; +use cdk_common::util::unix_time; +use cdk_common::Bolt11Invoice; +use error::Error; +use futures::stream::StreamExt; +use futures::Stream; +use tokio::sync::Mutex; +use tokio_stream::wrappers::BroadcastStream; +use tokio_util::sync::CancellationToken; +use uuid::Uuid; + +pub mod api; +pub mod error; + +const POLLING_INTERVAL: Duration = Duration::from_secs(3); +const INVOICE_EXPIRY_HOURS: u64 = 24; + +/// Convert CurrencyUnit to Strike's currency format +fn to_strike_currency(unit: &CurrencyUnit) -> Result { + match unit { + CurrencyUnit::Sat | CurrencyUnit::Msat => Ok(StrikeCurrencyUnit::BTC), + _ => Err(payment::Error::UnsupportedUnit), + } +} + +/// Strike lightning backend implementation +#[derive(Clone)] +pub struct Strike { + strike_api: StrikeApi, + unit: CurrencyUnit, + webhook_url: String, + sender: tokio::sync::broadcast::Sender, + wait_invoice_cancel_token: CancellationToken, + wait_invoice_is_active: Arc, + pending_invoices: Arc>>, + webhook_mode_active: Arc, + webhook_subscribed: Arc, +} + +impl std::fmt::Debug for Strike { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Strike") + .field("unit", &self.unit) + .field("webhook_url", &self.webhook_url) + .field( + "wait_invoice_is_active", + &self.wait_invoice_is_active.load(Ordering::SeqCst), + ) + .field( + "webhook_mode_active", + &self.webhook_mode_active.load(Ordering::SeqCst), + ) + .field( + "webhook_subscribed", + &self.webhook_subscribed.load(Ordering::SeqCst), + ) + .field( + "pending_invoices_count", + &self + .pending_invoices + .try_lock() + .map(|m| m.len()) + .unwrap_or(0), + ) + .finish() + } +} + +impl Strike { + /// Create new [`Strike`] wallet + pub async fn new( + api_key: String, + unit: CurrencyUnit, + webhook_url: String, + ) -> Result { + let strike_api = StrikeApi::new(&api_key, None, 30_000).map_err(Error::from)?; + + // Create broadcast channel for payment events (webhook notifications) + let (sender, _receiver) = tokio::sync::broadcast::channel::(1000); + + Ok(Self { + strike_api, + sender, + unit, + webhook_url, + wait_invoice_cancel_token: CancellationToken::new(), + wait_invoice_is_active: Arc::new(AtomicBool::new(false)), + pending_invoices: Arc::new(Mutex::new(HashMap::new())), + webhook_mode_active: Arc::new(AtomicBool::new(false)), + webhook_subscribed: Arc::new(AtomicBool::new(false)), + }) + } + + /// Get a sender for webhook notifications + pub fn sender(&self) -> tokio::sync::broadcast::Sender { + self.sender.clone() + } + + fn create_webhook_stream( + &self, + receiver: tokio::sync::broadcast::Receiver, + cancel_token: CancellationToken, + is_active: Arc, + strike_api: StrikeApi, + unit: CurrencyUnit, + ) -> Pin + Send>> { + let response_stream = BroadcastStream::new(receiver) + .filter_map(move |result| { + let unit = unit.clone(); + let strike_api = strike_api.clone(); + let is_active = is_active.clone(); + let cancel_token = cancel_token.clone(); + async move { + tokio::select! { + _ = cancel_token.cancelled() => { + is_active.store(false, Ordering::SeqCst); + None + } + invoice_result = async { + match result { + Ok(invoice_id) if !invoice_id.is_empty() => { + match strike_api.get_incoming_invoice(&invoice_id).await { + Ok(invoice) if invoice.state == InvoiceState::Paid || invoice.state == InvoiceState::Completed => { + match Strike::from_strike_amount(invoice.amount, &unit) { + Ok(amount) => { + is_active.store(false, Ordering::SeqCst); + Some(Event::PaymentReceived(WaitPaymentResponse { + payment_identifier: PaymentIdentifier::CustomId(invoice_id.clone()), + payment_amount: Amount::new(amount, unit.clone()), + payment_id: invoice_id, + })) + } + Err(_) => None, + } + } + _ => None, + } + } + Err(err) => { + tracing::warn!("Error in webhook broadcast stream: {}", err); + None + } + _ => None, + } + } => invoice_result + } + } + }); + + Box::pin(response_stream) + } + + fn create_polling_stream( + &self, + receiver: tokio::sync::broadcast::Receiver, + cancel_token: CancellationToken, + is_active: Arc, + strike_api: StrikeApi, + pending_invoices: Arc>>, + unit: CurrencyUnit, + ) -> Pin + Send>> { + // Clone for separate branches to avoid move issues + let strike_api_broadcast = strike_api.clone(); + let pending_invoices_broadcast = pending_invoices.clone(); + let unit_broadcast = unit.clone(); + + let broadcast_stream = BroadcastStream::new(receiver) + .filter_map(move |result| { + let strike_api = strike_api_broadcast.clone(); + let pending_invoices = pending_invoices_broadcast.clone(); + let unit = unit_broadcast.clone(); + let cancel_token = cancel_token.clone(); + async move { + tokio::select! { + _ = cancel_token.cancelled() => None, + event = async { + match result { + Ok(invoice_id) => { + Self::process_invoice_message(&strike_api, &invoice_id, &unit, &pending_invoices).await + } + Err(err) => { + tracing::warn!("Error in polling broadcast stream: {}", err); + None + } + } + } => event + } + } + }); + + // Combine broadcast stream with periodic polling + let polling_stream = futures::stream::unfold( + (strike_api, pending_invoices, unit), + |(strike_api, pending_invoices, unit)| async move { + tokio::time::sleep(POLLING_INTERVAL).await; + let event = + Self::poll_pending_invoices(&strike_api, &pending_invoices, &unit).await; + + Self::cleanup_expired_invoices(&pending_invoices).await; + + Some((event, (strike_api, pending_invoices, unit))) + }, + ) + .filter_map(|event| async move { event }); + + let combined_stream = + futures::stream::select(broadcast_stream, polling_stream).inspect(move |_| { + is_active.store(false, Ordering::SeqCst); + }); + + Box::pin(combined_stream) + } + + async fn process_invoice_message( + strike_api: &StrikeApi, + invoice_id: &str, + unit: &CurrencyUnit, + pending_invoices: &Arc>>, + ) -> Option { + match strike_api.get_incoming_invoice(invoice_id).await { + Ok(invoice) + if invoice.state == InvoiceState::Paid + || invoice.state == InvoiceState::Completed => + { + { + let mut pending = pending_invoices.lock().await; + pending.remove(invoice_id); + } + + if let Ok(amount) = Strike::from_strike_amount(invoice.amount, unit) { + Some(Event::PaymentReceived(WaitPaymentResponse { + payment_identifier: PaymentIdentifier::CustomId(invoice_id.to_string()), + payment_amount: Amount::new(amount, unit.clone()), + payment_id: invoice_id.to_string(), + })) + } else { + None + } + } + _ => None, + } + } + + async fn poll_pending_invoices( + strike_api: &StrikeApi, + pending_invoices: &Arc>>, + unit: &CurrencyUnit, + ) -> Option { + let invoices_to_check: Vec = { + let pending = pending_invoices.lock().await; + pending.keys().cloned().collect() + }; + + for invoice_id in invoices_to_check { + if let Some(event) = + Self::process_invoice_message(strike_api, &invoice_id, unit, pending_invoices).await + { + return Some(event); + } + } + None + } + + async fn cleanup_expired_invoices(pending_invoices: &Arc>>) { + let current_time = unix_time(); + let expiry_seconds = INVOICE_EXPIRY_HOURS * 60 * 60; + + let mut pending = pending_invoices.lock().await; + pending.retain(|_, creation_time| current_time - *creation_time < expiry_seconds); + } +} + +#[async_trait] +impl MintPayment for Strike { + type Err = payment::Error; + + async fn get_settings(&self) -> Result { + Ok(SettingsResponse { + unit: self.unit.to_string(), + bolt11: Some(Bolt11Settings { + mpp: false, + amountless: false, + invoice_description: true, + }), + bolt12: None, + custom: std::collections::HashMap::new(), + }) + } + + fn is_wait_invoice_active(&self) -> bool { + self.wait_invoice_is_active.load(Ordering::SeqCst) + } + + fn cancel_wait_invoice(&self) { + self.wait_invoice_cancel_token.cancel(); + self.webhook_mode_active.store(false, Ordering::SeqCst); + self.webhook_subscribed.store(false, Ordering::SeqCst); + } + + #[allow(clippy::incompatible_msrv)] + async fn wait_payment_event( + &self, + ) -> Result + Send>>, Self::Err> { + tracing::info!("Starting Strike payment event stream"); + + let receiver = self.sender.subscribe(); + + let strike_api = self.strike_api.clone(); + let cancel_token = self.wait_invoice_cancel_token.clone(); + let pending_invoices = Arc::clone(&self.pending_invoices); + let is_active = Arc::clone(&self.wait_invoice_is_active); + let unit = self.unit.clone(); + + self.wait_invoice_is_active.store(true, Ordering::SeqCst); + + // If already subscribed this session, reuse webhook mode + if self.webhook_subscribed.load(Ordering::SeqCst) { + tracing::debug!("Reusing existing webhook subscription"); + self.webhook_mode_active.store(true, Ordering::SeqCst); + return Ok(self.create_webhook_stream( + receiver, + cancel_token, + is_active, + strike_api, + unit, + )); + } + + // Try to rotate/create subscription (deletes old ones for this URL first) + match self + .strike_api + .rotate_webhook_subscription(&self.webhook_url, vec!["invoice.updated".to_string()]) + .await + { + Ok(_) => { + tracing::info!("Using webhook mode for payment events"); + self.webhook_subscribed.store(true, Ordering::SeqCst); + self.webhook_mode_active.store(true, Ordering::SeqCst); + Ok(self.create_webhook_stream(receiver, cancel_token, is_active, strike_api, unit)) + } + Err(err) => { + match &err { + api::error::Error::Api(api_err) if api_err.is_rate_limit_error() => { + tracing::warn!( + "Strike: Webhook subscription rate limited, using polling mode [trace: {:?}]", + api_err.trace_id + ); + } + api::error::Error::Api(api_err) if api_err.is_retryable() => { + tracing::warn!( + "Strike: Webhook subscription failed (transient), using polling mode: {} [trace: {:?}]", + api_err.message(), + api_err.trace_id + ); + } + _ => { + tracing::warn!( + "Strike: Webhook subscription failed, using polling mode: {}", + err + ); + } + } + self.webhook_mode_active.store(false, Ordering::SeqCst); + Ok(self.create_polling_stream( + receiver, + cancel_token, + is_active, + strike_api, + pending_invoices, + unit, + )) + } + } + } + + async fn get_payment_quote( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + let bolt11 = match options { + OutgoingPaymentOptions::Bolt11(opts) => opts.bolt11, + OutgoingPaymentOptions::Bolt12(_) | OutgoingPaymentOptions::Custom(_) => { + return Err(Self::Err::UnsupportedPaymentOption); + } + }; + + if unit != &self.unit { + return Err(Self::Err::UnsupportedUnit); + } + + let source_currency = to_strike_currency(unit)?; + + let payment_quote_request = PayInvoiceQuoteRequest { + ln_invoice: bolt11.to_string(), + source_currency, + amount: None, + }; + + let quote = self + .strike_api + .payment_quote(payment_quote_request) + .await + .map_err(Error::from)?; + + let fee = quote + .lightning_network_fee + .map(|fee| Strike::from_strike_amount(fee, unit)) + .transpose()? + .unwrap_or(0); + + let amount = Strike::from_strike_amount(quote.amount, unit)?; + + Ok(PaymentQuoteResponse { + request_lookup_id: Some(PaymentIdentifier::CustomId(format!( + "payment:{}", + quote.payment_quote_id + ))), + amount: Amount::new(amount, self.unit.clone()), + fee: Amount::new(fee, self.unit.clone()), + state: MeltQuoteState::Unpaid, + }) + } + + async fn make_payment( + &self, + unit: &CurrencyUnit, + options: OutgoingPaymentOptions, + ) -> Result { + let bolt11 = match options { + OutgoingPaymentOptions::Bolt11(opts) => opts.bolt11, + OutgoingPaymentOptions::Bolt12(_) | OutgoingPaymentOptions::Custom(_) => { + return Err(Self::Err::UnsupportedPaymentOption); + } + }; + + if unit != &self.unit { + return Err(Self::Err::UnsupportedUnit); + } + + let source_currency = to_strike_currency(unit)?; + + let payment_quote_request = PayInvoiceQuoteRequest { + ln_invoice: bolt11.to_string(), + source_currency, + amount: None, + }; + + let quote = self + .strike_api + .payment_quote(payment_quote_request) + .await + .map_err(Error::from)?; + + let pay_response = self + .strike_api + .pay_quote("e.payment_quote_id) + .await + .map_err(Error::from)?; + + let total_spent = Strike::from_strike_amount(pay_response.total_amount, unit)?; + + Ok(MakePaymentResponse { + payment_lookup_id: PaymentIdentifier::CustomId(format!( + "payment:{}", + pay_response.payment_id + )), + payment_proof: None, + status: Strike::to_melt_quote_state(pay_response.state), + total_spent: Amount::new(total_spent, unit.clone()), + }) + } + + async fn create_incoming_payment_request( + &self, + unit: &CurrencyUnit, + options: IncomingPaymentOptions, + ) -> Result { + let (amount, description, unix_expiry) = match options { + IncomingPaymentOptions::Bolt11(opts) => ( + opts.amount, + opts.description.unwrap_or_default(), + opts.unix_expiry, + ), + IncomingPaymentOptions::Bolt12(_) | IncomingPaymentOptions::Custom(_) => { + return Err(Self::Err::UnsupportedPaymentOption); + } + }; + + let time_now = unix_time(); + if let Some(expiry) = unix_expiry { + if expiry <= time_now { + return Err(cdk_common::payment::Error::Custom( + "Payment request has expired".to_string(), + )); + } + } + + let correlation_id = Uuid::new_v4(); + let strike_amount = Strike::to_strike_unit(amount, unit)?; + + let invoice_request = InvoiceRequest { + correlation_id: Some(correlation_id.to_string()), + amount: strike_amount, + description: if description.is_empty() { + None + } else { + Some(description) + }, + }; + + let create_invoice_response = self + .strike_api + .create_invoice(invoice_request) + .await + .map_err(Error::from)?; + + let quote = self + .strike_api + .invoice_quote(&create_invoice_response.invoice_id) + .await + .map_err(Error::from)?; + + let request: Bolt11Invoice = quote.ln_invoice.parse()?; + let expiry = request.expires_at().map(|t| t.as_secs()); + + // Store the invoice ID for polling only if not in webhook mode + if !self.webhook_mode_active.load(Ordering::SeqCst) { + let mut pending_invoices = self.pending_invoices.lock().await; + pending_invoices.insert(create_invoice_response.invoice_id.clone(), time_now); + } + + Ok(CreateIncomingPaymentResponse { + request_lookup_id: PaymentIdentifier::CustomId(create_invoice_response.invoice_id), + request: quote.ln_invoice, + expiry, + extra_json: None, + }) + } + + async fn check_incoming_payment_status( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result, Self::Err> { + let request_lookup_id = payment_identifier.to_string(); + let invoice = self + .strike_api + .get_incoming_invoice(&request_lookup_id) + .await + .map_err(Error::from)?; + + match invoice.state { + InvoiceState::Paid | InvoiceState::Completed => { + let amount = Strike::from_strike_amount(invoice.amount, &self.unit)?; + Ok(vec![WaitPaymentResponse { + payment_identifier: payment_identifier.clone(), + payment_amount: Amount::new(amount, self.unit.clone()), + payment_id: invoice.invoice_id, + }]) + } + InvoiceState::Unpaid + | InvoiceState::Pending + | InvoiceState::Failed + | InvoiceState::Cancelled => Ok(vec![]), + } + } + + async fn check_outgoing_payment( + &self, + payment_identifier: &PaymentIdentifier, + ) -> Result { + let payment_lookup_id = payment_identifier.to_string(); + let payment_id = payment_lookup_id + .strip_prefix("payment:") + .unwrap_or(&payment_lookup_id); + + match self.strike_api.get_outgoing_payment(payment_id).await { + Ok(invoice) => { + let total_spent = Strike::from_strike_amount(invoice.total_amount, &self.unit)?; + + Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: None, + status: Strike::to_melt_quote_state(invoice.state), + total_spent: Amount::new(total_spent, self.unit.clone()), + }) + } + Err(api::error::Error::NotFound) => Ok(MakePaymentResponse { + payment_lookup_id: payment_identifier.clone(), + payment_proof: None, + status: MeltQuoteState::Unknown, + total_spent: Amount::new(0, self.unit.clone()), + }), + Err(err) => Err(Error::from(err).into()), + } + } +} + +impl Strike { + /// Create invoice webhook router + pub fn create_invoice_webhook(&self, webhook_endpoint: &str) -> anyhow::Result { + // Create an adapter channel to bridge mpsc -> broadcast + let (mpsc_sender, mut mpsc_receiver) = tokio::sync::mpsc::channel::(1000); + let broadcast_sender = self.sender(); + + // Spawn a task to forward messages from mpsc to broadcast + tokio::spawn(async move { + while let Some(invoice_id) = mpsc_receiver.recv().await { + if let Err(err) = broadcast_sender.send(invoice_id) { + tracing::warn!( + "Failed to forward webhook message to broadcast channel: {}", + err + ); + } + } + }); + + Ok(api::webhook::create_invoice_webhook_router( + webhook_endpoint, + mpsc_sender, + self.strike_api.webhook_secret().to_string(), + )) + } +} + +impl Strike { + fn to_melt_quote_state(state: InvoiceState) -> MeltQuoteState { + match state { + InvoiceState::Paid | InvoiceState::Completed => MeltQuoteState::Paid, + InvoiceState::Unpaid => MeltQuoteState::Unpaid, + InvoiceState::Pending => MeltQuoteState::Pending, + InvoiceState::Failed | InvoiceState::Cancelled => MeltQuoteState::Failed, + } + } + + fn from_strike_amount( + strike_amount: StrikeAmount, + target_unit: &CurrencyUnit, + ) -> anyhow::Result { + match target_unit { + CurrencyUnit::Sat => { + if strike_amount.currency == StrikeCurrencyUnit::BTC { + strike_amount.to_sats() + } else { + bail!("Cannot convert Strike amount: expected BTC currency for Sat unit, got {:?} currency with amount {}", + strike_amount.currency, strike_amount.amount); + } + } + CurrencyUnit::Msat => { + if strike_amount.currency == StrikeCurrencyUnit::BTC { + Ok(strike_amount.to_sats()? * 1000) + } else { + bail!("Cannot convert Strike amount: expected BTC currency for Msat unit, got {:?} currency with amount {}", + strike_amount.currency, strike_amount.amount); + } + } + _ => bail!("Unsupported unit: {:?}", target_unit), + } + } + + fn to_strike_unit>( + amount: T, + current_unit: &CurrencyUnit, + ) -> anyhow::Result { + let amount = amount.into(); + match current_unit { + CurrencyUnit::Sat => Ok(StrikeAmount::from_sats(amount)), + CurrencyUnit::Msat => Ok(StrikeAmount::from_sats(amount / 1000)), + _ => bail!("Unsupported unit"), + } + } +} + +#[cfg(test)] +mod tests { + use crate::api::types::{Amount as StrikeAmount, Currency as StrikeCurrencyUnit}; + use cdk_common::nuts::CurrencyUnit; + + use super::*; + + #[test] + fn test_to_strike_currency() { + assert_eq!( + to_strike_currency(&CurrencyUnit::Sat).unwrap(), + StrikeCurrencyUnit::BTC + ); + assert_eq!( + to_strike_currency(&CurrencyUnit::Msat).unwrap(), + StrikeCurrencyUnit::BTC + ); + // Fiat currencies are no longer supported + assert!(to_strike_currency(&CurrencyUnit::Usd).is_err()); + assert!(to_strike_currency(&CurrencyUnit::Eur).is_err()); + } + + // Amount conversion tests - core functionality + #[test] + fn test_from_strike_amount_btc() { + // BTC to sats + let amount = StrikeAmount { + currency: StrikeCurrencyUnit::BTC, + amount: 1.0, + }; + assert_eq!( + Strike::from_strike_amount(amount, &CurrencyUnit::Sat).unwrap(), + 100_000_000 + ); + + // BTC to msats + let amount = StrikeAmount { + currency: StrikeCurrencyUnit::BTC, + amount: 0.001, + }; + assert_eq!( + Strike::from_strike_amount(amount, &CurrencyUnit::Msat).unwrap(), + 100_000_000 + ); + } + + #[test] + fn test_from_strike_amount_currency_mismatch() { + let amount = StrikeAmount { + currency: StrikeCurrencyUnit::USD, + amount: 10.0, + }; + // USD to BTC should fail + assert!(Strike::from_strike_amount(amount, &CurrencyUnit::Sat).is_err()); + } + + #[test] + fn test_to_strike_unit() { + // Sats to BTC + let result = Strike::to_strike_unit(100_000_000u64, &CurrencyUnit::Sat).unwrap(); + assert_eq!(result.currency, StrikeCurrencyUnit::BTC); + assert_eq!(result.amount, 1.0); + + // Msats to BTC + let result = Strike::to_strike_unit(100_000_000_000u64, &CurrencyUnit::Msat).unwrap(); + assert_eq!(result.currency, StrikeCurrencyUnit::BTC); + assert_eq!(result.amount, 1.0); + + // Fiat currencies are no longer supported + assert!(Strike::to_strike_unit(1050u64, &CurrencyUnit::Usd).is_err()); + } + + #[test] + fn test_roundtrip_conversions() { + // Test that conversions are lossless + let original_sats = 12345678u64; + let strike_amount = Strike::to_strike_unit(original_sats, &CurrencyUnit::Sat).unwrap(); + let converted_back = Strike::from_strike_amount(strike_amount, &CurrencyUnit::Sat).unwrap(); + assert_eq!(original_sats, converted_back); + } + + #[tokio::test] + async fn test_strike_creation() { + let strike = Strike::new( + "test_api_key".to_string(), + CurrencyUnit::Sat, + "http://localhost:3000/webhook".to_string(), + ) + .await; + + assert!(strike.is_ok()); + let strike = strike.unwrap(); + assert_eq!(strike.unit, CurrencyUnit::Sat); + assert!(!strike.is_wait_invoice_active()); + } + + #[tokio::test] + async fn test_wait_payment_event_multiple_calls() { + let strike = Strike::new( + "test_api_key".to_string(), + CurrencyUnit::Sat, + "http://localhost:3000/webhook".to_string(), + ) + .await + .unwrap(); + + // Multiple calls should succeed + let result1 = strike.wait_payment_event().await; + assert!(result1.is_ok()); + + strike.cancel_wait_invoice(); + + let result2 = strike.wait_payment_event().await; + assert!(result2.is_ok()); + } + + #[test] + fn test_zero_amounts() { + let zero_btc = StrikeAmount { + currency: StrikeCurrencyUnit::BTC, + amount: 0.0, + }; + assert_eq!( + Strike::from_strike_amount(zero_btc, &CurrencyUnit::Sat).unwrap(), + 0 + ); + + let result = Strike::to_strike_unit(0u64, &CurrencyUnit::Sat).unwrap(); + assert_eq!(result.amount, 0.0); + } +} diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index 94c89dc9df..456843edb8 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -178,6 +178,12 @@ impl MintBuilder { self } + /// Set agicash info + pub fn with_agicash(mut self, agicash: crate::nuts::AgicashInfo) -> Self { + self.mint_info.agicash = Some(agicash); + self + } + /// Set description pub fn with_description(mut self, description: String) -> Self { self.mint_info.description = Some(description); diff --git a/docs/plans/2026-02-16-feat-internal-closed-loop-fakewallet-plan.md b/docs/plans/2026-02-16-feat-internal-closed-loop-fakewallet-plan.md new file mode 100644 index 0000000000..200b259da5 --- /dev/null +++ b/docs/plans/2026-02-16-feat-internal-closed-loop-fakewallet-plan.md @@ -0,0 +1,256 @@ +--- +title: "feat: Add internal closed-loop payment validation for FakeWallet" +type: feat +status: completed +date: 2026-02-16 +--- + +# feat: Add internal closed-loop payment validation for FakeWallet + +## Overview + +Add a new "internal" closed-loop payment type that restricts melt operations to invoices created by the same mint. Unlike the existing Square closed-loop (which validates against an external merchant API), the internal mode is self-contained — it tracks payment hashes from `create_incoming_payment_request` and rejects any bolt11 at melt time whose payment hash wasn't minted locally. Primary use case is FakeWallet backends for testing and development, but works with any backend. + +## Problem Statement / Motivation + +The existing closed-loop implementation (`ClosedLoopPayment` in cdk-agicash) is hardwired to Square validation — its struct holds `Arc` and `CancellationToken` directly. For testing, development, and simple closed-loop deployments, there's no way to enforce "mint-only" invoice restrictions without Square infrastructure. An internal closed-loop mode enables: + +- Testing closed-loop behavior without external services +- Simple deployments where the mint should only process its own invoices +- Development environments with FakeWallet that mirror production closed-loop behavior + +## Proposed Solution + +Refactor `ClosedLoopPayment` in cdk-agicash to support multiple validation strategies via an enum. The struct currently holds Square-specific fields — replace those with a `ClosedLoopValidator` enum that dispatches between Square and Internal validation modes. This keeps all closed-loop logic in one crate. + +### Architecture + +``` +┌──────────────────────────────────────────────────┐ +│ ClosedLoopPayment │ (refactored) +│ ┌────────────────────────────────────────────┐ │ +│ │ ClosedLoopValidator (enum) │ │ +│ │ ├─ Square { sync, name, cancel_token } │ │ ← existing behavior +│ │ └─ Internal { known_hashes, name } │ │ ← new +│ └────────────────────────────────────────────┘ │ +│ ┌────────────────────────────────────────────┐ │ +│ │ inner: DynMintPayment │──┼──→ Strike / FakeWallet / any backend +│ └────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────┘ + +Square mode: + create_incoming_payment_request → pass through + get_payment_quote → validate via SquareSync → delegate or reject + +Internal mode: + create_incoming_payment_request → record payment hash → delegate to inner + get_payment_quote → check known_hashes → delegate or reject +``` + +## Technical Considerations + +### Refactoring `ClosedLoopPayment` + +**Before** (Square-only): +```rust +pub struct ClosedLoopPayment { + inner: Arc + Send + Sync>, + sync: Arc, + valid_destination_name: String, + cancel_token: CancellationToken, +} +``` + +**After** (multi-mode): +```rust +pub struct ClosedLoopPayment { + inner: Arc + Send + Sync>, + validator: ClosedLoopValidator, +} + +pub enum ClosedLoopValidator { + Square { + sync: Arc, + valid_destination_name: String, + cancel_token: CancellationToken, + }, + Internal { + known_hashes: Arc>, + destination_name: String, + }, +} +``` + +The `MintPayment` impl dispatches on `self.validator`: +- **`create_incoming_payment_request`**: Square passes through; Internal delegates then records hash +- **`get_payment_quote`**: Square runs `validate_invoice` (existing logic); Internal checks `known_hashes` +- **`stop`**: Square cancels token; Internal is a no-op +- **All other methods**: pass through to inner (unchanged) + +### Payment hash tracking (Internal mode) + +- **In-memory `DashSet<[u8; 32]>`**: Concurrent hash set. No persistence needed — FakeWallet invoices don't survive restarts, and a restart clears both invoices and the tracking set. +- **Bolt11 only**: Extract payment hash via `bolt11.payment_hash()`. Bolt12 offers don't have a payment hash at creation time — pass through without recording. +- **No cleanup needed for FakeWallet**: Invoices auto-pay quickly. The set won't grow unboundedly. + +### Interaction with `attempt_internal_settlement` + +The existing `attempt_internal_settlement` in the melt saga is an **optimization** (avoid external payment when the target invoice is local). The internal closed-loop is a **restriction** (reject non-local invoices). They're complementary and don't conflict: + +1. `get_payment_quote` → closed-loop validation → reject if hash unknown +2. `attempt_internal_settlement` → optimize if hash matches a local mint quote +3. `make_payment` → pay via inner backend if external payment needed + +### Configuration + +Extend the existing `ClosedLoopType` enum: + +```rust +pub enum ClosedLoopType { + Square, + Internal, // NEW +} +``` + +For Internal mode, only `agicash.closed_loop` is needed (no `agicash.square` settings). The `valid_destination_name` field is reused for the rejection error message (e.g., "this mint"). + +### Mint info advertisement + +The existing `AgicashInfo { closed_loop: bool }` in NUT06 already advertises closed-loop mode. Set to `true` for both Square and Internal — no changes needed to the schema. + +## Implementation Tasks + +### 1. Add `Internal` variant to `ClosedLoopType` + +**File:** `crates/cdk-mintd/src/config.rs:443-459` + +- [x] Add `Internal` variant to `ClosedLoopType` enum +- [x] Update `FromStr` impl to parse `"internal"` +- [x] Keep `Square` as the `#[default]` + +### 2. Add `dashmap` dependency to cdk-agicash + +**File:** `crates/cdk-agicash/Cargo.toml` + +- [x] ~~Add `dashmap` workspace dependency~~ Used `std::sync::RwLock` instead — no new deps + +### 3. Refactor `ClosedLoopPayment` to support multiple validators + +**File:** `crates/cdk-agicash/src/lib.rs` + +- [x] Add `ClosedLoopValidator` enum with `Square` and `Internal` variants +- [x] Replace `sync`, `valid_destination_name`, `cancel_token` fields with single `validator: ClosedLoopValidator` +- [x] Add `ClosedLoopPayment::new_square(inner, sync, name, cancel_token)` constructor (preserves existing API) +- [x] Add `ClosedLoopPayment::new_internal(inner, destination_name)` constructor +- [x] Move `validate_invoice` logic to dispatch on validator: + - `Square`: existing SquareSync validation (description check + timestamp lookup) + - `Internal`: check `known_hashes.contains(payment_hash)` +- [x] Update `create_incoming_payment_request`: + - `Square`: pass through (existing behavior) + - `Internal`: delegate to inner, parse bolt11 from response, record payment hash +- [x] Update `stop`: + - `Square`: cancel token + delegate + - `Internal`: just delegate +- [x] Keep `start_polling_sync` as-is (only used by Square wiring code) + +### 4. Update cdk-mintd wiring for Square + +**File:** `crates/cdk-mintd/src/lib.rs:625-637` + +- [x] Update `ClosedLoopPayment::new(...)` call to `ClosedLoopPayment::new_square(...)` (or adjust constructor) + +### 5. Add FakeWallet closed-loop wiring + +**File:** `crates/cdk-mintd/src/lib.rs:529-549` + +- [x] In the `FakeWallet` arm: check `settings.agicash.closed_loop` for `ClosedLoopType::Internal` +- [x] If Internal: wrap with `ClosedLoopPayment::new_internal(Arc::new(fake), destination_name)` +- [x] Set `AgicashInfo { closed_loop: true }` on mint builder (already handled by existing code at line 443-449) + +```rust +#[cfg(feature = "fakewallet")] +LnBackend::FakeWallet => { + for unit in fake_wallet.clone().supported_units { + let fake = fake_wallet.setup(...).await?; + + let backend: Arc + Send + Sync> = + if let Some(ref cl) = settings.agicash.as_ref() + .and_then(|a| a.closed_loop.as_ref()) + .filter(|cl| cl.closed_loop_type == ClosedLoopType::Internal) + { + Arc::new(cdk_agicash::ClosedLoopPayment::new_internal( + Arc::new(fake), + cl.valid_destination_name.clone(), + )) + } else { + Arc::new(fake) + }; + + mint_builder = configure_backend_for_unit(..., backend).await?; + } +} +``` + +### 6. Env var parsing (no changes needed) + +**File:** `crates/cdk-mintd/src/env_vars/agicash.rs` + +- [x] Verify: `from_env` already calls `loop_type_str.parse()` which works after `FromStr` update — no code change + +### 7. Update cdk-mintd Cargo.toml + +**File:** `crates/cdk-mintd/Cargo.toml` + +- [x] Ensure cdk-agicash dependency is available when FakeWallet + closed-loop is used. Split feature: `closed-loop = ["dep:cdk-agicash"]`, `agicash = ["closed-loop", "dep:tokio-util", "strike"]` +- [x] FakeWallet wiring uses `#[cfg(feature = "closed-loop")]` to conditionally wrap with `ClosedLoopPayment::new_internal` + +**Note:** The current feature gate `agicash = ["dep:cdk-agicash", "dep:tokio-util", "strike"]` forces Strike. For Internal mode with FakeWallet, we need cdk-agicash without Strike. Options: +- Split: `closed-loop = ["dep:cdk-agicash"]` and `agicash = ["closed-loop", "dep:tokio-util", "strike"]` +- Or: make cdk-agicash always available and only gate the Square-specific setup code + +### 8. Tests + +- [ ] Unit test in cdk-agicash: `ClosedLoopValidator::Internal` — create invoice records hash +- [ ] Unit test in cdk-agicash: `get_payment_quote` with known hash succeeds +- [ ] Unit test in cdk-agicash: `get_payment_quote` with unknown hash returns `PaymentNotAllowed` +- [x] Verify existing Square tests still pass (constructor change) +- [x] Integration consideration: existing FakeWallet integration tests pass (decorator is opt-in) + +## Acceptance Criteria + +- [ ] `ClosedLoopType::Internal` parses from config and env vars +- [ ] `ClosedLoopPayment` supports both Square and Internal validation modes +- [ ] Invoices created by the mint can be melted (happy path) +- [ ] External bolt11 invoices are rejected at `get_payment_quote` with `PaymentNotAllowed` +- [ ] Error message includes the configured `valid_destination_name` +- [ ] Mint info advertises `agicash.closed_loop: true` when internal mode is active +- [ ] Existing Square closed-loop behavior is preserved exactly +- [ ] Existing FakeWallet tests pass without closed-loop config +- [ ] No new feature flags beyond what's minimally needed for the dependency wiring + +## Dependencies & Risks + +- **Low risk**: Refactoring the decorator is straightforward — the MintPayment impl just adds dispatch +- **No breaking changes to Square flow**: `new_square()` constructor preserves existing behavior +- **`dashmap` dependency**: Already used in the workspace. Adds ~0 to compile time. +- **Feature gate adjustment**: The `agicash` feature currently requires `strike`. Need to ensure cdk-agicash is available for FakeWallet without pulling in Strike. This is the trickiest part — see Task 7. + +## References + +### Internal References + +- `ClosedLoopPayment` (refactor target): `crates/cdk-agicash/src/lib.rs:34-227` +- `ClosedLoopPayment::new`: `crates/cdk-agicash/src/lib.rs:49-63` +- `validate_invoice` (Square): `crates/cdk-agicash/src/lib.rs:94-147` +- `MintPayment` impl: `crates/cdk-agicash/src/lib.rs:150-227` +- cdk-agicash Cargo.toml: `crates/cdk-agicash/Cargo.toml` +- MintPayment trait: `crates/cdk-common/src/payment.rs:326-392` +- FakeWallet impl: `crates/cdk-fake-wallet/src/lib.rs:335-800` +- FakeWallet wiring: `crates/cdk-mintd/src/lib.rs:529-549` +- Square wiring: `crates/cdk-mintd/src/lib.rs:600-655` +- ClosedLoopType enum: `crates/cdk-mintd/src/config.rs:443-460` +- Agicash config: `crates/cdk-mintd/src/config.rs:506-512` +- cdk-mintd features: `crates/cdk-mintd/Cargo.toml:35` (`agicash = ["dep:cdk-agicash", ...]`) +- Env var parsing: `crates/cdk-mintd/src/env_vars/agicash.rs` +- Mint info agicash: `crates/cashu/src/nuts/nut06.rs:19-25` +- `PaymentNotAllowed` error: `crates/cdk-common/src/payment.rs:75` diff --git a/justfile b/justfile index facaa3858d..9f225e0a17 100644 --- a/justfile +++ b/justfile @@ -511,6 +511,7 @@ release m="": "-p cdk-payment-processor" "-p cdk-cli" "-p cdk-mintd" + "-p cdk-strike" ) for arg in "${args[@]}"; @@ -552,6 +553,7 @@ check-docs: "-p cdk-cli" "-p cdk-mintd" "-p cdk-ffi" + "-p cdk-strike" ) for arg in "${args[@]}"; do @@ -586,6 +588,7 @@ docs-strict: "-p cdk-cli" "-p cdk-mintd" "-p cdk-ffi" + "-p cdk-strike" ) for arg in "${args[@]}"; do