From 20a865be55b486a20187ffcf84741e03a85c2fb2 Mon Sep 17 00:00:00 2001 From: asmo Date: Wed, 24 Jun 2026 21:54:25 +0200 Subject: [PATCH 1/2] feat: nwc --- Cargo.lock | 16 + Cargo.toml | 2 + crates/cdk-ffi/Cargo.toml | 6 +- crates/cdk-ffi/src/lib.rs | 4 + crates/cdk-ffi/src/nwc.rs | 250 +++++++++++++++ crates/cdk-nwc/Cargo.toml | 25 ++ crates/cdk-nwc/src/error.rs | 38 +++ crates/cdk-nwc/src/handler.rs | 56 ++++ crates/cdk-nwc/src/lib.rs | 41 +++ crates/cdk-nwc/src/service.rs | 551 ++++++++++++++++++++++++++++++++++ crates/cdk/Cargo.toml | 2 + crates/cdk/src/wallet/mod.rs | 4 + crates/cdk/src/wallet/nwc.rs | 548 +++++++++++++++++++++++++++++++++ 13 files changed, 1542 insertions(+), 1 deletion(-) create mode 100644 crates/cdk-ffi/src/nwc.rs create mode 100644 crates/cdk-nwc/Cargo.toml create mode 100644 crates/cdk-nwc/src/error.rs create mode 100644 crates/cdk-nwc/src/handler.rs create mode 100644 crates/cdk-nwc/src/lib.rs create mode 100644 crates/cdk-nwc/src/service.rs create mode 100644 crates/cdk/src/wallet/nwc.rs diff --git a/Cargo.lock b/Cargo.lock index 5cf64feae2..a298e3d4e5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1162,6 +1162,7 @@ dependencies = [ "cdk-common", "cdk-fake-wallet", "cdk-npubcash", + "cdk-nwc", "cdk-prometheus", "cdk-signatory", "cdk-sqlite", @@ -1356,6 +1357,7 @@ dependencies = [ "cdk", "cdk-common", "cdk-npubcash", + "cdk-nwc", "cdk-postgres", "cdk-sql-common", "cdk-sqlite", @@ -1369,6 +1371,7 @@ dependencies = [ "serde_json", "thiserror 2.0.18", "tokio", + "tokio-util", "tracing", "tracing-subscriber", "uniffi", @@ -1635,6 +1638,19 @@ dependencies = [ "web-time", ] +[[package]] +name = "cdk-nwc" +version = "0.17.0" +dependencies = [ + "async-trait", + "nostr-sdk", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "cdk-payment-processor" version = "0.17.0" diff --git a/Cargo.toml b/Cargo.toml index c8cc320f8a..017694b42e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -72,6 +72,7 @@ cdk-mintd = { path = "./crates/cdk-mintd", version = "=0.17.0", default-features cdk-prometheus = { path = "./crates/cdk-prometheus", version = "=0.17.0", default-features = false } cdk-supabase = { path = "./crates/cdk-supabase", version = "=0.17.0", default-features = false } cdk-npubcash = { path = "./crates/cdk-npubcash", version = "=0.17.0" } +cdk-nwc = { path = "./crates/cdk-nwc", version = "=0.17.0" } cdk-bdk = { path = "./crates/cdk-bdk", version = "=0.17.0", default-features = false } clap = { version = "4.5.31", features = ["derive"] } ciborium = { version = "0.2.2", default-features = false, features = ["std"] } @@ -126,6 +127,7 @@ prometheus = { version = "0.13.4", features = ["process"], default-features = fa nostr-sdk = { version = "0.44.1", default-features = false, features = [ "nip04", "nip44", + "nip47", "nip59" ]} bitcoin-payment-instructions = { version = "0.7.0", default-features = false } diff --git a/crates/cdk-ffi/Cargo.toml b/crates/cdk-ffi/Cargo.toml index 9e777c5c14..3078269093 100644 --- a/crates/cdk-ffi/Cargo.toml +++ b/crates/cdk-ffi/Cargo.toml @@ -20,7 +20,9 @@ cdk-sqlite = { workspace = true } cdk-postgres = { workspace = true, optional = true } cdk-supabase = { workspace = true, optional = true, features = ["wallet"] } cdk-npubcash = { workspace = true, optional = true } +cdk-nwc = { workspace = true, optional = true } nostr-sdk = { workspace = true, optional = true } +tokio-util = { workspace = true, optional = true } futures = { workspace = true } once_cell = { workspace = true } rand = { workspace = true } @@ -42,7 +44,7 @@ log = "0.4" [features] -default = ["npubcash", "bip353"] +default = ["npubcash", "nwc", "bip353"] bip353 = ["cdk/bip353"] # Enable Postgres-backed wallet database support in FFI postgres = ["cdk-postgres"] @@ -50,6 +52,8 @@ postgres = ["cdk-postgres"] supabase = ["cdk-supabase"] # Enable NpubCash client bindings npubcash = ["cdk/npubcash", "cdk-npubcash", "nostr-sdk"] +# Enable Nostr Wallet Connect (NIP-47) wallet service bindings +nwc = ["cdk/nwc", "cdk-nwc", "nostr-sdk", "dep:tokio-util"] [dev-dependencies] diff --git a/crates/cdk-ffi/src/lib.rs b/crates/cdk-ffi/src/lib.rs index 0d65f8e178..eb92feab08 100644 --- a/crates/cdk-ffi/src/lib.rs +++ b/crates/cdk-ffi/src/lib.rs @@ -12,6 +12,8 @@ pub mod error; pub mod logging; #[cfg(feature = "npubcash")] pub mod npubcash; +#[cfg(feature = "nwc")] +pub mod nwc; #[cfg(feature = "postgres")] pub mod postgres; mod runtime; @@ -29,6 +31,8 @@ pub use error::*; pub use logging::*; #[cfg(feature = "npubcash")] pub use npubcash::*; +#[cfg(feature = "nwc")] +pub use nwc::*; pub use types::*; pub use wallet::*; pub use wallet_repository::*; diff --git a/crates/cdk-ffi/src/nwc.rs b/crates/cdk-ffi/src/nwc.rs new file mode 100644 index 0000000000..8d76f89d2d --- /dev/null +++ b/crates/cdk-ffi/src/nwc.rs @@ -0,0 +1,250 @@ +//! FFI bindings for the Nostr Wallet Connect (NIP-47) wallet service. +//! +//! Exposes an [`NwcService`] object that turns a CDK [`Wallet`] into a NIP-47 +//! wallet service: it generates a `nostr+walletconnect://` connection URI to +//! hand to a Nostr app, then listens on the configured relays and answers the +//! supported commands (`get_info`, `get_balance`, `make_invoice`, +//! `pay_invoice`, `lookup_invoice`, `list_transactions`) using the wallet. + +use std::sync::{Arc, Mutex}; + +use cdk::wallet::WalletNwcHandler; +use cdk_nwc::{NwcService as CdkNwcService, NwcServiceConfig}; +use nostr_sdk::{Keys, RelayUrl, SecretKey}; +use tokio::task::JoinHandle; +use tokio_util::sync::CancellationToken; + +use crate::error::FfiError; +use crate::wallet::Wallet; + +/// A NIP-47 Nostr Wallet Connect wallet service bound to a CDK wallet. +/// +/// Create one with [`NwcService::create`] (new connection) or +/// [`NwcService::restore`] (existing connection from a persisted client +/// secret), call [`NwcService::connection_uri`] to obtain the URI for the +/// Nostr app, then [`NwcService::start`] to begin servicing requests. +#[derive(uniffi::Object)] +pub struct NwcService { + service: CdkNwcService, + handler: Arc, + task: Mutex, CancellationToken)>>, +} + +impl NwcService { + /// Shared construction logic for [`Self::create`] and [`Self::restore`]. + fn build( + wallet: &Arc, + relays: Vec, + service_keys: Keys, + client_secret: SecretKey, + budget_msat: Option, + ) -> Result { + if relays.is_empty() { + return Err(FfiError::internal("at least one relay is required")); + } + + let relays = relays + .iter() + .map(|r| { + RelayUrl::parse(r).map_err(|e| FfiError::internal(format!("invalid relay {r}: {e}"))) + }) + .collect::, _>>()?; + + let cdk_wallet = wallet.inner().as_ref().clone(); + let handler = Arc::new(WalletNwcHandler::new(cdk_wallet, budget_msat)); + + let service = CdkNwcService::new(NwcServiceConfig { + service_keys, + client_secret, + relays, + lud16: None, + }) + .map_err(|e| FfiError::internal(e.to_string()))?; + + Ok(Self { + service, + handler, + task: Mutex::new(None), + }) + } +} + +#[uniffi::export(async_runtime = "tokio")] +impl NwcService { + /// Create a new wallet service with a freshly generated client connection. + /// + /// # Arguments + /// + /// * `wallet` - The CDK wallet that backs the service. + /// * `relays` - Relay URLs the service connects to and listens on. + /// * `service_secret_key` - Secret key of the wallet service (the signer). + /// Accepts hex or bech32 `nsec`. Derive a stable one from the wallet seed + /// with [`nwc_derive_service_secret_key_from_seed`]. + /// * `budget_msat` - Optional cap (in millisatoshis) on any single + /// `pay_invoice` request. + /// + /// # Errors + /// + /// Returns an error if a key or relay URL is invalid, or no relays are given. + #[uniffi::constructor] + pub fn create( + wallet: Arc, + relays: Vec, + service_secret_key: String, + budget_msat: Option, + ) -> Result { + let service_keys = parse_keys(&service_secret_key)?; + let client_secret = SecretKey::generate(); + Self::build(&wallet, relays, service_keys, client_secret, budget_msat) + } + + /// Restore a wallet service for an existing connection. + /// + /// Use this to rebuild a service after a restart from a persisted client + /// secret, so the previously issued connection URI keeps working. + /// + /// # Arguments + /// + /// * `client_secret_key` - The client secret from the original connection + /// URI (hex or `nsec`). + /// + /// See [`Self::create`] for the other arguments. + /// + /// # Errors + /// + /// Returns an error if a key or relay URL is invalid, or no relays are given. + #[uniffi::constructor] + pub fn restore( + wallet: Arc, + relays: Vec, + service_secret_key: String, + client_secret_key: String, + budget_msat: Option, + ) -> Result { + let service_keys = parse_keys(&service_secret_key)?; + let client_secret = parse_secret_key(&client_secret_key)?; + Self::build(&wallet, relays, service_keys, client_secret, budget_msat) + } + + /// The `nostr+walletconnect://` connection URI to hand to the Nostr app. + pub fn connection_uri(&self) -> String { + self.service.connection_uri().to_string() + } + + /// Hex-encoded public key of the wallet service (advertised in the URI). + pub fn service_pubkey(&self) -> String { + self.service.service_pubkey().to_hex() + } + + /// Hex-encoded public key of the authorized client. + pub fn client_pubkey(&self) -> String { + self.service.client_pubkey().to_hex() + } + + /// Start servicing requests in the background. + /// + /// Connects to the relays, publishes the info event, and begins answering + /// commands. Returns immediately; the service runs until [`Self::stop`] is + /// called. Per-request failures are answered with NIP-47 error responses + /// and logged rather than surfaced here. + /// + /// # Errors + /// + /// Returns an error if the service is already running. + // `async` is required so uniffi drives this on the tokio runtime, which + // `tokio::spawn` needs; the body itself does not await. + #[allow(clippy::unused_async)] + pub async fn start(&self) -> Result<(), FfiError> { + let mut guard = self + .task + .lock() + .map_err(|_| FfiError::internal("nwc service lock poisoned"))?; + + if guard.is_some() { + return Err(FfiError::internal("nwc service is already running")); + } + + let cancel = CancellationToken::new(); + let service = self.service.clone(); + let handler = self.handler.clone(); + let run_cancel = cancel.clone(); + + let handle = tokio::spawn(async move { + if let Err(e) = service.run(handler, run_cancel).await { + tracing::error!("NWC service stopped with error: {e}"); + } + }); + + *guard = Some((handle, cancel)); + Ok(()) + } + + /// Stop the background service if it is running. + pub async fn stop(&self) -> Result<(), FfiError> { + let task = { + let mut guard = self + .task + .lock() + .map_err(|_| FfiError::internal("nwc service lock poisoned"))?; + guard.take() + }; + + if let Some((handle, cancel)) = task { + cancel.cancel(); + handle.abort(); + let _ = handle.await; + } + + Ok(()) + } + + /// Whether the background service is currently running. + pub fn is_running(&self) -> bool { + self.task.lock().map(|g| g.is_some()).unwrap_or(false) + } +} + +/// Derive the NWC wallet-service secret key from a wallet seed. +/// +/// Returns a hex-encoded secret key for use as `service_secret_key`. Deriving +/// from the seed keeps the connection URI stable across restarts. Uses the +/// NIP-06 path `m/44'/1237'/1'/0/0`, distinct from the npub.cash key. +/// +/// # Errors +/// +/// Returns an error if the seed is shorter than 64 bytes or derivation fails. +#[uniffi::export] +pub fn nwc_derive_service_secret_key_from_seed(seed: Vec) -> Result { + if seed.len() < 64 { + return Err(FfiError::internal("Seed must be at least 64 bytes")); + } + + let seed: [u8; 64] = seed[..64] + .try_into() + .map_err(|_| FfiError::internal("Failed to read wallet seed bytes"))?; + + let secret_key = cdk::wallet::derive_nwc_secret_key_from_seed(&seed) + .map_err(|e| FfiError::internal(format!("Failed to derive secret key: {e}")))?; + + Ok(secret_key.to_secret_hex()) +} + +/// Get the hex-encoded public key for a Nostr secret key (hex or `nsec`). +/// +/// # Errors +/// +/// Returns an error if the secret key is invalid. +#[uniffi::export] +pub fn nwc_get_pubkey(nostr_secret_key: String) -> Result { + Ok(parse_keys(&nostr_secret_key)?.public_key().to_hex()) +} + +/// Parse a Nostr secret key (hex or bech32 `nsec`) into [`Keys`]. +fn parse_keys(key: &str) -> Result { + Ok(Keys::new(parse_secret_key(key)?)) +} + +/// Parse a Nostr secret key from either hex or bech32 `nsec`. +fn parse_secret_key(key: &str) -> Result { + SecretKey::parse(key).map_err(|e| FfiError::internal(format!("invalid secret key: {e}"))) +} diff --git a/crates/cdk-nwc/Cargo.toml b/crates/cdk-nwc/Cargo.toml new file mode 100644 index 0000000000..81e5e7bdc4 --- /dev/null +++ b/crates/cdk-nwc/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "cdk-nwc" +version.workspace = true +authors = ["CDK Developers"] +description = "Nostr Wallet Connect (NIP-47) wallet service for CDK" +edition.workspace = true +rust-version.workspace = true +license.workspace = true +homepage.workspace = true +repository.workspace = true + +[dependencies] +async-trait = { workspace = true } +nostr-sdk = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true } +tokio-util = { workspace = true } +tracing = { workspace = true } + +[dev-dependencies] +tokio = { workspace = true, features = ["macros", "rt", "test-util"] } + +[lints] +workspace = true diff --git a/crates/cdk-nwc/src/error.rs b/crates/cdk-nwc/src/error.rs new file mode 100644 index 0000000000..fd983e74af --- /dev/null +++ b/crates/cdk-nwc/src/error.rs @@ -0,0 +1,38 @@ +//! Error types for the Nostr Wallet Connect service + +use thiserror::Error; + +/// Result type for the NWC service +pub type Result = std::result::Result; + +/// Errors that can occur while running the NWC wallet service +#[derive(Debug, Error)] +pub enum Error { + /// No relays were configured for the service + #[error("at least one relay is required")] + NoRelays, + + /// Failed to add or connect to a relay + #[error("relay error: {0}")] + Relay(String), + + /// Failed to build, sign, or publish a Nostr event + #[error("nostr event error: {0}")] + Event(String), + + /// Failed to subscribe to the relay pool + #[error("subscription error: {0}")] + Subscription(String), + + /// NIP-47 protocol error (encoding/decoding) + #[error("nip47 protocol error: {0}")] + Protocol(String), + + /// Encryption or decryption of an event payload failed + #[error("encryption error: {0}")] + Encryption(String), + + /// JSON serialization/deserialization error + #[error("json error: {0}")] + Serde(#[from] serde_json::Error), +} diff --git a/crates/cdk-nwc/src/handler.rs b/crates/cdk-nwc/src/handler.rs new file mode 100644 index 0000000000..2edaed4560 --- /dev/null +++ b/crates/cdk-nwc/src/handler.rs @@ -0,0 +1,56 @@ +//! Handler trait implemented by wallet backends. +//! +//! A backend (e.g. a Cashu wallet) implements [`NwcRequestHandler`] to service +//! the NIP-47 commands. The handler is intentionally decoupled from any +//! relay/transport concerns: the [`crate::NwcService`] owns the Nostr relay +//! connection, decryption, authorization and response encoding, and only calls +//! into the handler with already-validated, decrypted requests. +//! +//! Every method returns either a typed NIP-47 response or a [`NIP47Error`], +//! which the service serializes into the encrypted response event. Handlers +//! must never panic: any internal failure should be surfaced as a +//! [`NIP47Error`] with an appropriate [`ErrorCode`](nostr_sdk::nips::nip47::ErrorCode). + +use async_trait::async_trait; +use nostr_sdk::nips::nip47::{ + GetBalanceResponse, GetInfoResponse, ListTransactionsRequest, LookupInvoiceRequest, + LookupInvoiceResponse, MakeInvoiceRequest, MakeInvoiceResponse, NIP47Error, PayInvoiceRequest, + PayInvoiceResponse, +}; + +/// Backend that services NIP-47 Nostr Wallet Connect requests. +/// +/// Implementations are expected to be cheap to clone/share (e.g. wrap an +/// `Arc`), since the service may dispatch requests concurrently. +#[async_trait] +pub trait NwcRequestHandler: Send + Sync { + /// Handle a `get_info` request. + async fn get_info(&self) -> Result; + + /// Handle a `get_balance` request. The returned balance is in millisatoshis. + async fn get_balance(&self) -> Result; + + /// Handle a `make_invoice` request. + async fn make_invoice( + &self, + request: MakeInvoiceRequest, + ) -> Result; + + /// Handle a `pay_invoice` request. + async fn pay_invoice( + &self, + request: PayInvoiceRequest, + ) -> Result; + + /// Handle a `lookup_invoice` request. + async fn lookup_invoice( + &self, + request: LookupInvoiceRequest, + ) -> Result; + + /// Handle a `list_transactions` request. + async fn list_transactions( + &self, + request: ListTransactionsRequest, + ) -> Result, NIP47Error>; +} diff --git a/crates/cdk-nwc/src/lib.rs b/crates/cdk-nwc/src/lib.rs new file mode 100644 index 0000000000..e579c6de0a --- /dev/null +++ b/crates/cdk-nwc/src/lib.rs @@ -0,0 +1,41 @@ +//! # CDK Nostr Wallet Connect (NIP-47) +//! +//! A [NIP-47 Nostr Wallet Connect](https://github.com/nostr-protocol/nips/blob/master/47.md) +//! **wallet service**: the side that holds funds and answers commands sent by a +//! connected Nostr client (Damus, Amethyst, a website, …). +//! +//! The protocol wire types (connection URI, requests, responses, error codes) +//! come from [`nostr_sdk::nips::nip47`] and are re-exported here for +//! convenience. This crate adds: +//! +//! - [`NwcRequestHandler`]: the trait a wallet backend implements to service +//! the supported commands. +//! - [`NwcService`]: owns the Nostr relay connection, advertises capabilities, +//! authorizes and decrypts requests, dispatches to the handler, and publishes +//! encrypted responses. +//! +//! The crate has no dependency on the `cdk` wallet crate; the Cashu-wallet +//! backed handler lives in `cdk::wallet::nwc`, keeping this layer reusable and +//! independently testable. +//! +//! ## Supported commands (first cut) +//! +//! `get_info`, `get_balance`, `make_invoice`, `pay_invoice`, `lookup_invoice`, +//! `list_transactions`. Any other command is answered with +//! [`ErrorCode::NotImplemented`](nostr_sdk::nips::nip47::ErrorCode::NotImplemented). +//! +//! All amounts in the NIP-47 protocol are denominated in **millisatoshis**. + +#![warn(missing_docs)] + +pub mod error; +pub mod handler; +pub mod service; + +pub use error::{Error, Result}; +pub use handler::NwcRequestHandler; +pub use service::{NwcService, NwcServiceConfig, SUPPORTED_METHODS}; + +// Re-export the NIP-47 protocol types so downstream crates depend on a single +// source of truth without pulling `nostr_sdk` paths into their signatures. +pub use nostr_sdk::nips::nip47; diff --git a/crates/cdk-nwc/src/service.rs b/crates/cdk-nwc/src/service.rs new file mode 100644 index 0000000000..6e7a0192be --- /dev/null +++ b/crates/cdk-nwc/src/service.rs @@ -0,0 +1,551 @@ +//! Nostr Wallet Connect (NIP-47) wallet **service**. +//! +//! This is the side that holds the funds and answers commands. It owns the +//! Nostr relay connection, publishes the kind `13194` info event advertising +//! capabilities, listens for kind `23194` requests from the authorized client, +//! decrypts and validates them, dispatches to a [`NwcRequestHandler`], and +//! publishes the encrypted kind `23195` response. +//! +//! Security properties enforced here (monetary software — defense in depth): +//! - **Authorization**: only events authored by the single client public key +//! issued in the connection URI are processed (enforced both by the relay +//! subscription filter and an explicit author check). +//! - **Replay / idempotency**: each request event id is processed at most once, +//! so relay re-delivery cannot trigger a duplicate `pay_invoice`. +//! - **Freshness**: requests carrying an expired NIP-40 `expiration` tag are +//! dropped, and only events created after the service started are considered. +//! - **No information leak / no panics**: every handler error is mapped to a +//! NIP-47 error response; the relay loop never aborts on a single bad request. + +use std::collections::{HashSet, VecDeque}; +use std::sync::Arc; + +use nostr_sdk::nips::nip47::{ + ErrorCode, NIP47Error, NostrWalletConnectURI, Request, RequestParams, Response, + ResponseResult, +}; +use nostr_sdk::nips::{nip04, nip44}; +use nostr_sdk::prelude::*; +use nostr_sdk::{Client as NostrClient, Keys, PublicKey, RelayUrl, SecretKey}; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use crate::error::{Error, Result}; +use crate::handler::NwcRequestHandler; + +/// Commands advertised in the info event and reported by `get_info`. +/// +/// Order is the canonical advertisement order; it is also the set of commands +/// this service will actually dispatch — anything else returns +/// [`ErrorCode::NotImplemented`]. +pub const SUPPORTED_METHODS: [&str; 6] = [ + "pay_invoice", + "make_invoice", + "lookup_invoice", + "list_transactions", + "get_balance", + "get_info", +]; + +/// Maximum number of recently-seen request event ids kept for replay +/// protection. Bounded to keep memory usage flat on long-running services. +const DEDUP_CAPACITY: usize = 10_000; + +/// Encryption scheme negotiated for a single request/response exchange. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Encryption { + /// NIP-44 v2 (preferred). + Nip44, + /// NIP-04 (legacy, still widely used by NWC clients). + Nip04, +} + +/// Configuration for an [`NwcService`]. +#[derive(Debug, Clone)] +pub struct NwcServiceConfig { + /// Keys of the wallet service (the "signer"). Its public key is the one + /// advertised in the connection URI. + pub service_keys: Keys, + /// The client secret embedded in the connection URI. The public key derived + /// from it is the only key authorized to send requests. + pub client_secret: SecretKey, + /// Relays the service connects to and listens on. Must be non-empty. + pub relays: Vec, + /// Optional lightning address advertised in the connection URI (`lud16`). + pub lud16: Option, +} + +/// A NIP-47 wallet service bound to a single client connection. +#[derive(Debug, Clone)] +pub struct NwcService { + service_keys: Keys, + client_secret: SecretKey, + client_pubkey: PublicKey, + relays: Vec, + lud16: Option, +} + +impl NwcService { + /// Create a new service from configuration. + /// + /// # Errors + /// + /// Returns [`Error::NoRelays`] if no relays are configured. + pub fn new(config: NwcServiceConfig) -> Result { + if config.relays.is_empty() { + return Err(Error::NoRelays); + } + + let client_pubkey = Keys::new(config.client_secret.clone()).public_key(); + + Ok(Self { + service_keys: config.service_keys, + client_secret: config.client_secret, + client_pubkey, + relays: config.relays, + lud16: config.lud16, + }) + } + + /// Public key of the wallet service (advertised in the connection URI). + pub fn service_pubkey(&self) -> PublicKey { + self.service_keys.public_key() + } + + /// Public key of the authorized client. + pub fn client_pubkey(&self) -> PublicKey { + self.client_pubkey + } + + /// Build the `nostr+walletconnect://` connection URI to hand to the client. + pub fn connection_uri(&self) -> NostrWalletConnectURI { + NostrWalletConnectURI::new( + self.service_keys.public_key(), + self.relays.clone(), + self.client_secret.clone(), + self.lud16.clone(), + ) + } + + /// Connect to the relays, publish the info event, and service requests + /// until `cancel` is triggered. + /// + /// This future runs the relay notification loop and only returns when the + /// loop ends (cancellation) or a fatal relay/connection error occurs. + /// + /// # Errors + /// + /// Returns an error if relays cannot be added, the info event cannot be + /// published, or the subscription cannot be created. Per-request failures + /// never bubble up here — they are answered with a NIP-47 error response. + pub async fn run(&self, handler: Arc, cancel: CancellationToken) -> Result<()> + where + H: NwcRequestHandler + 'static, + { + let client = NostrClient::new(self.service_keys.clone()); + + for relay in &self.relays { + client + .add_relay(relay.clone()) + .await + .map_err(|e| Error::Relay(format!("add relay {relay}: {e}")))?; + } + + client.connect().await; + + self.publish_info_event(&client).await?; + + // Only consider requests created from now on, addressed to us, authored + // by the authorized client. + let filter = Filter::new() + .kind(Kind::WalletConnectRequest) + .author(self.client_pubkey) + .pubkey(self.service_keys.public_key()) + .since(Timestamp::now()); + + client + .subscribe(filter, None) + .await + .map_err(|e| Error::Subscription(e.to_string()))?; + + let dedup: Arc> = Arc::new(Mutex::new(Dedup::new(DEDUP_CAPACITY))); + + let service_keys = self.service_keys.clone(); + let client_pubkey = self.client_pubkey; + let client_for_send = client.clone(); + + let res = client + .handle_notifications(move |notification| { + let handler = handler.clone(); + let dedup = dedup.clone(); + let service_keys = service_keys.clone(); + let client = client_for_send.clone(); + let cancel = cancel.clone(); + async move { + if cancel.is_cancelled() { + return Ok(true); + } + + let RelayPoolNotification::Event { event, .. } = notification else { + return Ok(false); + }; + + // Defense in depth: the filter already constrains the author, + // but never trust a relay to honor it. + if event.pubkey != client_pubkey || event.kind != Kind::WalletConnectRequest { + return Ok(false); + } + + if event.is_expired() { + tracing::debug!("Dropping expired NWC request {}", event.id); + return Ok(false); + } + + // Replay protection: process each request id at most once. + if !dedup.lock().await.insert(event.id) { + tracing::debug!("Dropping duplicate NWC request {}", event.id); + return Ok(false); + } + + handle_request(&service_keys, &client, handler.as_ref(), &event).await; + + Ok(false) + } + }) + .await; + + client.disconnect().await; + + res.map_err(|e| Error::Subscription(e.to_string())) + } + + /// Publish the kind `13194` info event advertising supported commands and + /// encryption schemes. + async fn publish_info_event(&self, client: &NostrClient) -> Result<()> { + let content = SUPPORTED_METHODS.join(" "); + let encryption_tag = Tag::custom( + TagKind::Custom(std::borrow::Cow::Borrowed("encryption")), + ["nip44_v2".to_string(), "nip04".to_string()], + ); + + let event = EventBuilder::new(Kind::WalletConnectInfo, content) + .tags([encryption_tag]) + .sign_with_keys(&self.service_keys) + .map_err(|e| Error::Event(e.to_string()))?; + + client + .send_event(&event) + .await + .map_err(|e| Error::Event(format!("publish info event: {e}")))?; + + Ok(()) + } +} + +/// Decrypt, dispatch, and respond to a single request event. +/// +/// Any failure is logged and (where possible) answered with a NIP-47 error +/// response. This function never panics and never returns an error to the +/// caller — keeping the relay loop alive is part of the security contract. +async fn handle_request( + service_keys: &Keys, + client: &NostrClient, + handler: &H, + event: &Event, +) where + H: NwcRequestHandler + ?Sized, +{ + let secret = service_keys.secret_key(); + + let (request, encryption) = + match decrypt_request(secret, &event.pubkey, &event.content) { + Ok(parsed) => parsed, + Err(e) => { + tracing::warn!("Failed to decode NWC request {}: {e}", event.id); + return; + } + }; + + let response = dispatch(handler, request).await; + + if let Err(e) = + send_response(service_keys, client, &event.pubkey, event.id, &response, encryption).await + { + tracing::warn!("Failed to send NWC response for {}: {e}", event.id); + } +} + +/// Try NIP-44 first, then fall back to NIP-04, and parse the request JSON. +fn decrypt_request( + secret: &SecretKey, + author: &PublicKey, + content: &str, +) -> Result<(Request, Encryption)> { + let (plaintext, encryption) = match nip44::decrypt(secret, author, content) { + Ok(plaintext) => (plaintext, Encryption::Nip44), + Err(_) => { + let plaintext = nip04::decrypt(secret, author, content) + .map_err(|e| Error::Encryption(e.to_string()))?; + (plaintext, Encryption::Nip04) + } + }; + + let request: Request = + serde_json::from_str(&plaintext).map_err(|e| Error::Protocol(e.to_string()))?; + + Ok((request, encryption)) +} + +/// Dispatch a decoded request to the handler and build the NIP-47 response. +async fn dispatch(handler: &H, request: Request) -> Response +where + H: NwcRequestHandler + ?Sized, +{ + let result_type = request.method; + + let result: std::result::Result = match request.params { + RequestParams::GetInfo => handler.get_info().await.map(ResponseResult::GetInfo), + RequestParams::GetBalance => handler.get_balance().await.map(ResponseResult::GetBalance), + RequestParams::MakeInvoice(params) => handler + .make_invoice(params) + .await + .map(ResponseResult::MakeInvoice), + RequestParams::PayInvoice(params) => handler + .pay_invoice(params) + .await + .map(ResponseResult::PayInvoice), + RequestParams::LookupInvoice(params) => handler + .lookup_invoice(params) + .await + .map(ResponseResult::LookupInvoice), + RequestParams::ListTransactions(params) => handler + .list_transactions(params) + .await + .map(ResponseResult::ListTransactions), + // Commands outside the supported set. + _ => Err(NIP47Error { + code: ErrorCode::NotImplemented, + message: format!("method {} is not implemented", result_type.as_str()), + }), + }; + + match result { + Ok(result) => Response { + result_type, + error: None, + result: Some(result), + }, + Err(error) => Response { + result_type, + error: Some(error), + result: None, + }, + } +} + +/// Encrypt and publish the response event (kind `23195`). +async fn send_response( + service_keys: &Keys, + client: &NostrClient, + client_pubkey: &PublicKey, + request_id: EventId, + response: &Response, + encryption: Encryption, +) -> Result<()> { + let payload = serde_json::to_string(response)?; + let secret = service_keys.secret_key(); + + let content = match encryption { + Encryption::Nip44 => nip44::encrypt(secret, client_pubkey, payload, nip44::Version::V2) + .map_err(|e| Error::Encryption(e.to_string()))?, + Encryption::Nip04 => nip04::encrypt(secret, client_pubkey, payload) + .map_err(|e| Error::Encryption(e.to_string()))?, + }; + + let event = EventBuilder::new(Kind::WalletConnectResponse, content) + .tags([Tag::public_key(*client_pubkey), Tag::event(request_id)]) + .sign_with_keys(service_keys) + .map_err(|e| Error::Event(e.to_string()))?; + + client + .send_event(&event) + .await + .map_err(|e| Error::Event(format!("publish response: {e}")))?; + + Ok(()) +} + +/// Bounded set of recently-seen request event ids for replay protection. +#[derive(Debug)] +struct Dedup { + seen: HashSet, + order: VecDeque, + capacity: usize, +} + +impl Dedup { + fn new(capacity: usize) -> Self { + Self { + seen: HashSet::new(), + order: VecDeque::new(), + capacity, + } + } + + /// Insert an id; returns `true` if it was newly inserted (i.e. should be + /// processed), `false` if it was already seen. + fn insert(&mut self, id: EventId) -> bool { + if !self.seen.insert(id) { + return false; + } + self.order.push_back(id); + if self.order.len() > self.capacity { + if let Some(oldest) = self.order.pop_front() { + self.seen.remove(&oldest); + } + } + true + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn dedup_rejects_repeat_and_evicts_oldest() { + let mut dedup = Dedup::new(2); + let keys = Keys::generate(); + let mk = |content: &str| { + EventBuilder::new(Kind::TextNote, content) + .sign_with_keys(&keys) + .expect("sign dummy event") + .id + }; + let a = mk("a"); + let b = mk("b"); + let c = mk("c"); + + assert!(dedup.insert(a)); + assert!(!dedup.insert(a)); // duplicate + assert!(dedup.insert(b)); + assert!(dedup.insert(c)); // evicts `a` + assert!(dedup.insert(a)); // `a` evicted, treated as new again + } + + use async_trait::async_trait; + use nostr_sdk::nips::nip47::{ + GetBalanceResponse, GetInfoResponse, ListTransactionsRequest, LookupInvoiceRequest, + LookupInvoiceResponse, MakeInvoiceRequest, MakeInvoiceResponse, PayInvoiceRequest, + PayInvoiceResponse, Request, + }; + + /// Canned handler: `get_balance` returns a fixed value, everything else errors. + struct MockHandler; + + #[async_trait] + impl crate::handler::NwcRequestHandler for MockHandler { + async fn get_info(&self) -> std::result::Result { + Err(NIP47Error { + code: ErrorCode::Internal, + message: "no".into(), + }) + } + async fn get_balance(&self) -> std::result::Result { + Ok(GetBalanceResponse { balance: 5000 }) + } + async fn make_invoice( + &self, + _: MakeInvoiceRequest, + ) -> std::result::Result { + unreachable!() + } + async fn pay_invoice( + &self, + _: PayInvoiceRequest, + ) -> std::result::Result { + unreachable!() + } + async fn lookup_invoice( + &self, + _: LookupInvoiceRequest, + ) -> std::result::Result { + unreachable!() + } + async fn list_transactions( + &self, + _: ListTransactionsRequest, + ) -> std::result::Result, NIP47Error> { + unreachable!() + } + } + + /// Encrypt a request the way a client would, with the chosen scheme. + fn encrypt_request( + client_secret: &SecretKey, + service_pubkey: &PublicKey, + request: &Request, + scheme: Encryption, + ) -> String { + let json = serde_json::to_string(request).expect("serialize request"); + match scheme { + Encryption::Nip44 => { + nip44::encrypt(client_secret, service_pubkey, json, nip44::Version::V2) + .expect("nip44 encrypt") + } + Encryption::Nip04 => { + nip04::encrypt(client_secret, service_pubkey, json).expect("nip04 encrypt") + } + } + } + + #[test] + fn decrypt_request_roundtrips_both_schemes() { + let service = Keys::generate(); + let client = Keys::generate(); + let request = Request::get_balance(); + + for scheme in [Encryption::Nip44, Encryption::Nip04] { + let content = encrypt_request( + client.secret_key(), + &service.public_key(), + &request, + scheme, + ); + + let (decoded, detected) = + decrypt_request(service.secret_key(), &client.public_key(), &content) + .expect("decrypt request"); + + assert_eq!(detected, scheme); + assert_eq!(decoded.method, request.method); + } + } + + #[tokio::test] + async fn dispatch_get_balance_ok() { + let response = dispatch(&MockHandler, Request::get_balance()).await; + assert!(response.error.is_none()); + match response.result { + Some(ResponseResult::GetBalance(b)) => assert_eq!(b.balance, 5000), + other => panic!("unexpected result: {other:?}"), + } + } + + #[tokio::test] + async fn dispatch_unsupported_method_is_not_implemented() { + // pay_keysend is outside the supported set. + let request = Request::pay_keysend(nostr_sdk::nips::nip47::PayKeysendRequest { + id: None, + amount: 1000, + pubkey: "00".repeat(32), + preimage: None, + tlv_records: Vec::new(), + }); + + let response = dispatch(&MockHandler, request).await; + let error = response.error.expect("unsupported method should error"); + assert_eq!(error.code, ErrorCode::NotImplemented); + assert!(response.result.is_none()); + } +} diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index 1113df0238..eec21d40ab 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -15,6 +15,7 @@ default = ["mint", "wallet", "nostr", "bip353"] wallet = ["dep:futures", "cdk-common/wallet", "cdk-common/http", "dep:rustls"] nostr = ["wallet", "dep:nostr-sdk", "cdk-common/nostr"] npubcash = ["wallet", "nostr", "dep:cdk-npubcash"] +nwc = ["wallet", "nostr", "dep:cdk-nwc"] mint = ["dep:futures", "cdk-common/mint", "cdk-common/http", "cdk-signatory"] bip353 = ["dep:hickory-resolver", "cdk-common/bip353"] bench = [] @@ -55,6 +56,7 @@ uuid.workspace = true jsonwebtoken.workspace = true nostr-sdk = { workspace = true, optional = true } cdk-npubcash = { workspace = true, optional = true } +cdk-nwc = { workspace = true, optional = true } cdk-prometheus = {workspace = true, optional = true} bitcoin-payment-instructions = { workspace = true } web-time.workspace = true diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index 3c6291e298..c9f7424b90 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -54,6 +54,8 @@ mod mint_connector; mod mint_metadata_cache; #[cfg(feature = "npubcash")] mod npubcash; +#[cfg(feature = "nwc")] +pub mod nwc; mod p2pk; pub mod payment_request; mod proofs; @@ -93,6 +95,8 @@ pub use mint_connector::{ pub use nostr_backup::{BackupOptions, BackupResult, RestoreOptions, RestoreResult}; #[cfg(feature = "npubcash")] pub use npubcash::derive_npubcash_secret_key_from_seed; +#[cfg(feature = "nwc")] +pub use nwc::{derive_nwc_secret_key_from_seed, WalletNwcHandler}; pub use payment_request::CreateRequestParams; #[cfg(feature = "nostr")] pub use payment_request::NostrWaitInfo; diff --git a/crates/cdk/src/wallet/nwc.rs b/crates/cdk/src/wallet/nwc.rs new file mode 100644 index 0000000000..b83b52b785 --- /dev/null +++ b/crates/cdk/src/wallet/nwc.rs @@ -0,0 +1,548 @@ +//! Nostr Wallet Connect (NIP-47) integration for the CDK wallet. +//! +//! This module bridges the transport-agnostic [`cdk_nwc`] wallet service to a +//! Cashu [`Wallet`]. [`WalletNwcHandler`] implements [`cdk_nwc::NwcRequestHandler`] +//! by mapping each NIP-47 command onto wallet operations: +//! +//! | NIP-47 command | Wallet operation | +//! |---------------------|----------------------------------------------------| +//! | `get_info` | static capability advertisement | +//! | `get_balance` | [`Wallet::total_balance`] | +//! | `make_invoice` | [`Wallet::mint_quote`] (bolt11) | +//! | `pay_invoice` | [`Wallet::melt_quote`] + [`Wallet::prepare_melt`] | +//! | `lookup_invoice` | transaction history + active mint quotes | +//! | `list_transactions` | [`Wallet::list_transactions`] | +//! +//! ## Units +//! +//! All NIP-47 amounts are **millisatoshis**. Cashu wallets denominated in `Sat` +//! are converted with a ×1000 factor; sub-satoshi millisat amounts that cannot +//! be represented exactly are rejected rather than silently rounded. Only `Sat` +//! and `Msat` wallets are supported. + +use std::collections::HashMap; +use std::str::FromStr; + +use async_trait::async_trait; +use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; +use bitcoin::Network; +use cdk_common::nut00::KnownMethod; +use cdk_common::wallet::{Transaction, TransactionDirection}; +use cdk_common::{PaymentMethod, SECP256K1}; +use cdk_nwc::nip47::{ + ErrorCode, GetBalanceResponse, GetInfoResponse, ListTransactionsRequest, LookupInvoiceRequest, + LookupInvoiceResponse, MakeInvoiceRequest, MakeInvoiceResponse, Method, NIP47Error, + PayInvoiceRequest, PayInvoiceResponse, TransactionType, +}; +use cdk_nwc::service::SUPPORTED_METHODS; +use lightning_invoice::Bolt11Invoice; +use nostr_sdk::Timestamp; +use tracing::instrument; + +use crate::error::Error; +use crate::nuts::{CurrencyUnit, SecretKey}; +use crate::Amount; +use crate::Wallet; + +/// Derive the NWC wallet-service secret key from a wallet seed. +/// +/// Uses NIP-06 BIP-32 derivation under account index `1` +/// (`m/44'/1237'/1'/0/0`), keeping it distinct from the npub.cash key +/// (`m/44'/1237'/0'/0/0`) so a single seed yields independent identities. The +/// derived key never equals raw seed material, so it cannot be used to recover +/// the seed. Deriving from the seed keeps the connection URI stable across +/// restarts. +/// +/// # Errors +/// +/// Returns an error if the key derivation fails. +pub fn derive_nwc_secret_key_from_seed(seed: &[u8; 64]) -> Result { + let path = DerivationPath::from(vec![ + ChildNumber::from_hardened_idx(44)?, + ChildNumber::from_hardened_idx(1237)?, + ChildNumber::from_hardened_idx(1)?, + ChildNumber::from_normal_idx(0)?, + ChildNumber::from_normal_idx(0)?, + ]); + + let xpriv = Xpriv::new_master(Network::Bitcoin, seed)?; + + Ok(SecretKey::from( + xpriv.derive_priv(&SECP256K1, &path)?.private_key, + )) +} + +impl Wallet { + /// Derive the NWC wallet-service secret key from this wallet's seed. + /// + /// See [`derive_nwc_secret_key_from_seed`] for the derivation path. + /// + /// # Errors + /// + /// Returns an error if the key derivation fails. + pub fn derive_nwc_secret_key(&self) -> Result { + derive_nwc_secret_key_from_seed(&self.seed) + } + + /// Build a [`WalletNwcHandler`] for this wallet. + /// + /// `budget_msat` optionally caps the amount of any single `pay_invoice` + /// request (in millisatoshis); pass `None` for no cap. + pub fn nwc_handler(&self, budget_msat: Option) -> WalletNwcHandler { + WalletNwcHandler::new(self.clone(), budget_msat) + } +} + +/// A [`cdk_nwc::NwcRequestHandler`] backed by a Cashu [`Wallet`]. +#[derive(Debug, Clone)] +pub struct WalletNwcHandler { + wallet: Wallet, + budget_msat: Option, +} + +impl WalletNwcHandler { + /// Create a new handler. + /// + /// `budget_msat` optionally caps the amount of any single `pay_invoice` + /// request (in millisatoshis). + pub fn new(wallet: Wallet, budget_msat: Option) -> Self { + Self { + wallet, + budget_msat, + } + } +} + +/// Build a NIP-47 error with the given code. +fn nip47_err(code: ErrorCode, message: impl Into) -> NIP47Error { + NIP47Error { + code, + message: message.into(), + } +} + +/// Convert a wallet [`Amount`] to millisatoshis for the given unit. +fn amount_to_msat(amount: Amount, unit: &CurrencyUnit) -> Result { + let value = u64::from(amount); + match unit { + CurrencyUnit::Sat => value + .checked_mul(1000) + .ok_or_else(|| nip47_err(ErrorCode::Internal, "amount overflow converting sat to msat")), + CurrencyUnit::Msat => Ok(value), + other => Err(nip47_err( + ErrorCode::Other, + format!("unsupported wallet unit: {other}"), + )), + } +} + +/// Convert millisatoshis to a wallet [`Amount`] for the given unit. +/// +/// For `Sat` wallets, millisat amounts that are not whole satoshis are rejected +/// rather than rounded. +fn msat_to_amount(msat: u64, unit: &CurrencyUnit) -> Result { + match unit { + CurrencyUnit::Sat => { + if msat % 1000 != 0 { + return Err(nip47_err( + ErrorCode::Other, + "sub-satoshi amounts are not supported by this wallet", + )); + } + Ok(Amount::from(msat / 1000)) + } + CurrencyUnit::Msat => Ok(Amount::from(msat)), + other => Err(nip47_err( + ErrorCode::Other, + format!("unsupported wallet unit: {other}"), + )), + } +} + +/// Extract the hex payment hash from a bolt11 invoice string. +fn payment_hash_of(invoice: &str) -> Option { + Bolt11Invoice::from_str(invoice) + .ok() + .map(|i| i.payment_hash().to_string()) +} + +/// Map a wallet [`Error`] from a melt operation to the appropriate NIP-47 code. +fn melt_error(err: &Error) -> NIP47Error { + match err { + Error::InsufficientFunds => nip47_err( + ErrorCode::InsufficientBalance, + "insufficient balance to pay invoice", + ), + Error::PaymentFailed => nip47_err(ErrorCode::PaymentFailed, "payment failed"), + other => nip47_err(ErrorCode::Internal, other.to_string()), + } +} + +/// Convert a wallet [`Transaction`] into a NIP-47 transaction object. +fn transaction_to_nip47( + tx: &Transaction, + unit: &CurrencyUnit, +) -> Result { + let transaction_type = match tx.direction { + TransactionDirection::Incoming => TransactionType::Incoming, + TransactionDirection::Outgoing => TransactionType::Outgoing, + }; + + let payment_hash = tx + .payment_request + .as_deref() + .and_then(payment_hash_of) + .unwrap_or_default(); + + Ok(LookupInvoiceResponse { + transaction_type: Some(transaction_type), + state: Some(cdk_nwc::nip47::TransactionState::Settled), + invoice: tx.payment_request.clone(), + description: tx.memo.clone(), + description_hash: None, + preimage: tx.payment_proof.clone(), + payment_hash, + amount: amount_to_msat(tx.amount, unit)?, + fees_paid: amount_to_msat(tx.fee, unit)?, + created_at: Timestamp::from(tx.timestamp), + expires_at: None, + settled_at: Some(Timestamp::from(tx.timestamp)), + metadata: None, + }) +} + +#[async_trait] +impl cdk_nwc::NwcRequestHandler for WalletNwcHandler { + #[instrument(skip(self))] + async fn get_info(&self) -> Result { + let methods = SUPPORTED_METHODS + .iter() + .filter_map(|m| Method::from_str(m).ok()) + .collect(); + + Ok(GetInfoResponse { + alias: Some("CDK Cashu Wallet".to_string()), + color: None, + pubkey: None, + network: Some("mainnet".to_string()), + block_height: None, + block_hash: None, + methods, + notifications: Vec::new(), + }) + } + + #[instrument(skip(self))] + async fn get_balance(&self) -> Result { + let balance = self + .wallet + .total_balance() + .await + .map_err(|e| nip47_err(ErrorCode::Internal, e.to_string()))?; + + Ok(GetBalanceResponse { + balance: amount_to_msat(balance, &self.wallet.unit)?, + }) + } + + #[instrument(skip(self))] + async fn make_invoice( + &self, + request: MakeInvoiceRequest, + ) -> Result { + let amount = msat_to_amount(request.amount, &self.wallet.unit)?; + + let quote = self + .wallet + .mint_quote( + PaymentMethod::Known(KnownMethod::Bolt11), + Some(amount), + request.description.clone(), + None, + ) + .await + .map_err(|e| nip47_err(ErrorCode::Internal, e.to_string()))?; + + let payment_hash = payment_hash_of("e.request); + + Ok(MakeInvoiceResponse { + invoice: quote.request, + payment_hash, + description: request.description, + description_hash: request.description_hash, + preimage: None, + amount: Some(request.amount), + created_at: None, + expires_at: Some(Timestamp::from(quote.expiry)), + }) + } + + #[instrument(skip(self))] + async fn pay_invoice( + &self, + request: PayInvoiceRequest, + ) -> Result { + let invoice = Bolt11Invoice::from_str(&request.invoice) + .map_err(|e| nip47_err(ErrorCode::Other, format!("invalid bolt11 invoice: {e}")))?; + + // The invoice must carry its own amount: paying amountless invoices + // would require an amount override, which is not supported here. + let invoice_msat = invoice.amount_milli_satoshis().ok_or_else(|| { + nip47_err( + ErrorCode::Other, + "amountless invoices are not supported; invoice must specify an amount", + ) + })?; + + // A redundant `amount` is accepted only when it matches the invoice; + // a mismatch is rejected rather than silently paying a different sum. + if let Some(requested) = request.amount { + if requested != invoice_msat { + return Err(nip47_err( + ErrorCode::Other, + "requested amount does not match the invoice amount", + )); + } + } + + // Budget enforcement (defense in depth, before any state changes). + if let Some(budget) = self.budget_msat { + if invoice_msat > budget { + return Err(nip47_err( + ErrorCode::QuotaExceeded, + "payment exceeds the connection budget", + )); + } + } + + let quote = self + .wallet + .melt_quote( + PaymentMethod::Known(KnownMethod::Bolt11), + request.invoice.clone(), + None, + None, + ) + .await + .map_err(|e| melt_error(&e))?; + + let prepared = self + .wallet + .prepare_melt("e.id, HashMap::new()) + .await + .map_err(|e| melt_error(&e))?; + + let finalized = prepared.confirm().await.map_err(|e| melt_error(&e))?; + + let preimage = finalized.payment_proof().unwrap_or_default().to_string(); + let fees_paid = amount_to_msat(finalized.fee_paid(), &self.wallet.unit)?; + + Ok(PayInvoiceResponse { + preimage, + fees_paid: Some(fees_paid), + }) + } + + #[instrument(skip(self))] + async fn lookup_invoice( + &self, + request: LookupInvoiceRequest, + ) -> Result { + let target_hash = request + .payment_hash + .clone() + .or_else(|| request.invoice.as_deref().and_then(payment_hash_of)) + .ok_or_else(|| { + nip47_err( + ErrorCode::Other, + "either payment_hash or invoice is required", + ) + })?; + + let unit = &self.wallet.unit; + + // Settled transactions (incoming and outgoing). + let transactions = self + .wallet + .list_transactions(None) + .await + .map_err(|e| nip47_err(ErrorCode::Internal, e.to_string()))?; + + for tx in &transactions { + if tx.payment_request.as_deref().and_then(payment_hash_of).as_deref() + == Some(target_hash.as_str()) + { + return transaction_to_nip47(tx, unit); + } + } + + // Outstanding (unpaid) invoices we issued. + let quotes = self + .wallet + .get_active_mint_quotes() + .await + .map_err(|e| nip47_err(ErrorCode::Internal, e.to_string()))?; + + for quote in quotes { + if payment_hash_of("e.request).as_deref() == Some(target_hash.as_str()) { + let amount = quote + .amount + .map(|a| amount_to_msat(a, unit)) + .transpose()? + .unwrap_or_default(); + + return Ok(LookupInvoiceResponse { + transaction_type: Some(TransactionType::Incoming), + state: Some(cdk_nwc::nip47::TransactionState::Pending), + invoice: Some(quote.request.clone()), + description: None, + description_hash: None, + preimage: None, + payment_hash: target_hash, + amount, + fees_paid: 0, + created_at: Timestamp::from(quote.expiry), + expires_at: Some(Timestamp::from(quote.expiry)), + settled_at: None, + metadata: None, + }); + } + } + + Err(nip47_err(ErrorCode::NotFound, "invoice not found")) + } + + #[instrument(skip(self))] + async fn list_transactions( + &self, + request: ListTransactionsRequest, + ) -> Result, NIP47Error> { + let direction = request.transaction_type.map(|t| match t { + TransactionType::Incoming => TransactionDirection::Incoming, + TransactionType::Outgoing => TransactionDirection::Outgoing, + }); + + let unit = &self.wallet.unit; + + let mut transactions = self + .wallet + .list_transactions(direction) + .await + .map_err(|e| nip47_err(ErrorCode::Internal, e.to_string()))?; + + // Newest first, consistent with most NWC clients' expectations. + transactions.reverse(); + + let from = request.from.map(|t| t.as_secs()); + let until = request.until.map(|t| t.as_secs()); + + let filtered = transactions.into_iter().filter(|tx| { + from.is_none_or(|f| tx.timestamp >= f) && until.is_none_or(|u| tx.timestamp <= u) + }); + + let offset = request.offset.unwrap_or(0) as usize; + let limit = request.limit.map(|l| l as usize); + + let mut out = Vec::new(); + for tx in filtered.skip(offset) { + if let Some(limit) = limit { + if out.len() >= limit { + break; + } + } + out.push(transaction_to_nip47(&tx, unit)?); + } + + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use std::str::FromStr; + + use cdk_common::mint_url::MintUrl; + + use super::*; + + #[test] + fn sat_amounts_convert_to_and_from_msat() { + assert_eq!( + amount_to_msat(Amount::from(5u64), &CurrencyUnit::Sat).expect("to msat"), + 5000 + ); + assert_eq!( + amount_to_msat(Amount::from(7u64), &CurrencyUnit::Msat).expect("msat passthrough"), + 7 + ); + assert_eq!( + msat_to_amount(5000, &CurrencyUnit::Sat).expect("from msat"), + Amount::from(5u64) + ); + assert_eq!( + msat_to_amount(9, &CurrencyUnit::Msat).expect("msat passthrough"), + Amount::from(9u64) + ); + } + + #[test] + fn sub_satoshi_msat_is_rejected_for_sat_wallet() { + let err = msat_to_amount(500, &CurrencyUnit::Sat).expect_err("sub-sat rejected"); + assert_eq!(err.code, ErrorCode::Other); + } + + #[test] + fn nwc_service_key_is_distinct_from_npubcash_key() { + let seed = [0x24u8; 64]; + let nwc = derive_nwc_secret_key_from_seed(&seed).expect("nwc key"); + + // npub.cash uses account index 0 (`m/44'/1237'/0'/0/0`); the NWC key + // must not collide with it. + let npub_path = DerivationPath::from_str("m/44'/1237'/0'/0/0").expect("npub path"); + let xpriv = Xpriv::new_master(Network::Bitcoin, &seed).expect("master key"); + let npub = xpriv + .derive_priv(&SECP256K1, &npub_path) + .expect("derive npub") + .private_key; + + assert_ne!(nwc.to_secret_bytes(), npub.secret_bytes()); + // Never raw seed material. + assert_ne!(&nwc.to_secret_bytes()[..], &seed[..32]); + } + + fn sample_transaction() -> Transaction { + Transaction { + mint_url: MintUrl::from_str("https://mint.example.com").expect("mint url"), + direction: TransactionDirection::Incoming, + amount: Amount::from(10u64), + fee: Amount::from(1u64), + unit: CurrencyUnit::Sat, + ys: Vec::new(), + timestamp: 1_700_000_000, + memo: Some("coffee".to_string()), + metadata: HashMap::new(), + quote_id: None, + payment_request: None, + payment_proof: None, + payment_method: None, + saga_id: None, + } + } + + #[test] + fn transaction_maps_to_settled_nip47_object_in_msat() { + let tx = sample_transaction(); + let mapped = transaction_to_nip47(&tx, &CurrencyUnit::Sat).expect("map tx"); + + assert_eq!(mapped.transaction_type, Some(TransactionType::Incoming)); + assert_eq!( + mapped.state, + Some(cdk_nwc::nip47::TransactionState::Settled) + ); + assert_eq!(mapped.amount, 10_000); + assert_eq!(mapped.fees_paid, 1_000); + assert_eq!(mapped.description.as_deref(), Some("coffee")); + assert!(mapped.settled_at.is_some()); + assert_eq!(mapped.payment_hash, ""); + } +} From 74d01e51480f0d6b4521f16babc853432b04522b Mon Sep 17 00:00:00 2001 From: asmo Date: Wed, 24 Jun 2026 23:11:24 +0200 Subject: [PATCH 2/2] fix: format --- bindings/swift/rust/Cargo.toml | 2 +- crates/cdk-ffi/src/nwc.rs | 3 ++- crates/cdk-nwc/src/lib.rs | 3 +-- crates/cdk-nwc/src/service.rs | 45 ++++++++++++++++------------------ crates/cdk/src/wallet/nwc.rs | 18 +++++++++----- 5 files changed, 37 insertions(+), 34 deletions(-) diff --git a/bindings/swift/rust/Cargo.toml b/bindings/swift/rust/Cargo.toml index 045f28fd75..687324aa90 100644 --- a/bindings/swift/rust/Cargo.toml +++ b/bindings/swift/rust/Cargo.toml @@ -18,7 +18,7 @@ name = "uniffi-bindgen-swift" path = "uniffi-bindgen-swift.rs" [dependencies] -cdk-ffi = { workspace = true, default-features = false, features = ["npubcash", "bip353"] } +cdk-ffi = { workspace = true, default-features = false, features = ["npubcash", "nwc", "bip353"] } uniffi = { workspace = true, features = ["cli"] } diff --git a/crates/cdk-ffi/src/nwc.rs b/crates/cdk-ffi/src/nwc.rs index 8d76f89d2d..0e9b595bd7 100644 --- a/crates/cdk-ffi/src/nwc.rs +++ b/crates/cdk-ffi/src/nwc.rs @@ -46,7 +46,8 @@ impl NwcService { let relays = relays .iter() .map(|r| { - RelayUrl::parse(r).map_err(|e| FfiError::internal(format!("invalid relay {r}: {e}"))) + RelayUrl::parse(r) + .map_err(|e| FfiError::internal(format!("invalid relay {r}: {e}"))) }) .collect::, _>>()?; diff --git a/crates/cdk-nwc/src/lib.rs b/crates/cdk-nwc/src/lib.rs index e579c6de0a..a7520a00f3 100644 --- a/crates/cdk-nwc/src/lib.rs +++ b/crates/cdk-nwc/src/lib.rs @@ -34,8 +34,7 @@ pub mod service; pub use error::{Error, Result}; pub use handler::NwcRequestHandler; -pub use service::{NwcService, NwcServiceConfig, SUPPORTED_METHODS}; - // Re-export the NIP-47 protocol types so downstream crates depend on a single // source of truth without pulling `nostr_sdk` paths into their signatures. pub use nostr_sdk::nips::nip47; +pub use service::{NwcService, NwcServiceConfig, SUPPORTED_METHODS}; diff --git a/crates/cdk-nwc/src/service.rs b/crates/cdk-nwc/src/service.rs index 6e7a0192be..2e795c22c2 100644 --- a/crates/cdk-nwc/src/service.rs +++ b/crates/cdk-nwc/src/service.rs @@ -21,8 +21,7 @@ use std::collections::{HashSet, VecDeque}; use std::sync::Arc; use nostr_sdk::nips::nip47::{ - ErrorCode, NIP47Error, NostrWalletConnectURI, Request, RequestParams, Response, - ResponseResult, + ErrorCode, NIP47Error, NostrWalletConnectURI, Request, RequestParams, Response, ResponseResult, }; use nostr_sdk::nips::{nip04, nip44}; use nostr_sdk::prelude::*; @@ -247,29 +246,31 @@ impl NwcService { /// Any failure is logged and (where possible) answered with a NIP-47 error /// response. This function never panics and never returns an error to the /// caller — keeping the relay loop alive is part of the security contract. -async fn handle_request( - service_keys: &Keys, - client: &NostrClient, - handler: &H, - event: &Event, -) where +async fn handle_request(service_keys: &Keys, client: &NostrClient, handler: &H, event: &Event) +where H: NwcRequestHandler + ?Sized, { let secret = service_keys.secret_key(); - let (request, encryption) = - match decrypt_request(secret, &event.pubkey, &event.content) { - Ok(parsed) => parsed, - Err(e) => { - tracing::warn!("Failed to decode NWC request {}: {e}", event.id); - return; - } - }; + let (request, encryption) = match decrypt_request(secret, &event.pubkey, &event.content) { + Ok(parsed) => parsed, + Err(e) => { + tracing::warn!("Failed to decode NWC request {}: {e}", event.id); + return; + } + }; let response = dispatch(handler, request).await; - if let Err(e) = - send_response(service_keys, client, &event.pubkey, event.id, &response, encryption).await + if let Err(e) = send_response( + service_keys, + client, + &event.pubkey, + event.id, + &response, + encryption, + ) + .await { tracing::warn!("Failed to send NWC response for {}: {e}", event.id); } @@ -506,12 +507,8 @@ mod tests { let request = Request::get_balance(); for scheme in [Encryption::Nip44, Encryption::Nip04] { - let content = encrypt_request( - client.secret_key(), - &service.public_key(), - &request, - scheme, - ); + let content = + encrypt_request(client.secret_key(), &service.public_key(), &request, scheme); let (decoded, detected) = decrypt_request(service.secret_key(), &client.public_key(), &content) diff --git a/crates/cdk/src/wallet/nwc.rs b/crates/cdk/src/wallet/nwc.rs index b83b52b785..99dfeb49c9 100644 --- a/crates/cdk/src/wallet/nwc.rs +++ b/crates/cdk/src/wallet/nwc.rs @@ -41,8 +41,7 @@ use tracing::instrument; use crate::error::Error; use crate::nuts::{CurrencyUnit, SecretKey}; -use crate::Amount; -use crate::Wallet; +use crate::{Amount, Wallet}; /// Derive the NWC wallet-service secret key from a wallet seed. /// @@ -125,9 +124,12 @@ fn nip47_err(code: ErrorCode, message: impl Into) -> NIP47Error { fn amount_to_msat(amount: Amount, unit: &CurrencyUnit) -> Result { let value = u64::from(amount); match unit { - CurrencyUnit::Sat => value - .checked_mul(1000) - .ok_or_else(|| nip47_err(ErrorCode::Internal, "amount overflow converting sat to msat")), + CurrencyUnit::Sat => value.checked_mul(1000).ok_or_else(|| { + nip47_err( + ErrorCode::Internal, + "amount overflow converting sat to msat", + ) + }), CurrencyUnit::Msat => Ok(value), other => Err(nip47_err( ErrorCode::Other, @@ -369,7 +371,11 @@ impl cdk_nwc::NwcRequestHandler for WalletNwcHandler { .map_err(|e| nip47_err(ErrorCode::Internal, e.to_string()))?; for tx in &transactions { - if tx.payment_request.as_deref().and_then(payment_hash_of).as_deref() + if tx + .payment_request + .as_deref() + .and_then(payment_hash_of) + .as_deref() == Some(target_hash.as_str()) { return transaction_to_nip47(tx, unit);