From 408a37f749e7c1503b7828b27f4746b9839edae5 Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Wed, 30 Sep 2026 20:28:27 +0530 Subject: [PATCH 01/11] feat(payments): NIP-47 client core and mock wallet for the NWC rail First of the three NWC sub-PRs in #116. Rail infrastructure only: no processor, no handler, and no change to the Phase A traits, middlewares or registration entry points. Behind the off-by-default `nwc` feature, which only switches on `nostr-sdk/nip47` and adds no new crate to the dependency tree. - `parse_nwc_uri` reads a connection string in either shape wallets emit. `NostrWalletConnectURI::parse` takes the pubkey from the URI host only, so it rejects the `nostr+walletconnect:?...` pathname form the ts-sdk accepts; a connection string that works there must not fail here. A test pins the upstream rejection as a precondition. - `NwcClient` speaks request/response and notifications over an injected `RelayPoolTrait`, so the same code path runs against a scripted mock wallet in CI and a real wallet in production. The `nwc` crate is not used: its relay pool is private, so nothing built on it is testable without a live wallet. - One subscription and one reader task, correlating by request event id. `RelayPoolTrait` has no unsubscribe, so the ts-sdk subscription-per-request shape would leak one live REQ per payment. - Wallet responses are parsed permissively, field for field with ts `nip47/types.ts`, rather than through the strict upstream response structs. On a money path a missing or unknown optional field must not turn a paid invoice into a failed verification. - Notifications are decrypted by kind (23196 NIP-04, 23197 NIP-44). ts always decrypts as NIP-04 and so silently drops the NIP-44 form. Kind 23197 has no named constant in `nostr` 0.44, hence `Kind::from`. - `sats_to_msats` rejects a non-positive amount and uses `checked_mul`, both before any wallet call, where ts does a bare multiply. - `MockWallet` behind `test-utils` runs the real wire protocol over a linked `MockRelayPool`, scripting answer, error, silence, delay and malformed replies, and can publish a `payment_received` notification. A wallet-reported error is returned as `Ok(envelope)` with `error` set rather than `Err`, matching ts: the processor in B2 has to treat `NOT_FOUND` as pending but other codes as fatal, which needs the code at the call site. `into_result()` collapses the envelope for callers that do not classify, so a failed payment still cannot read as a settled one. Verified: fmt and clippy clean, 1084 tests pass under `--all-features`, `--no-default-features` and default builds green, MSRV 1.88 compiles. Refs #116 --- CHANGELOG.md | 17 + Cargo.toml | 3 + src/payments/mod.rs | 2 + src/payments/nip47/client.rs | 848 ++++++++++++++++++++++++++++++ src/payments/nip47/mock_wallet.rs | 308 +++++++++++ src/payments/nip47/mod.rs | 26 + src/payments/nip47/types.rs | 305 +++++++++++ src/payments/nip47/uri.rs | 243 +++++++++ 8 files changed, 1752 insertions(+) create mode 100644 src/payments/nip47/client.rs create mode 100644 src/payments/nip47/mock_wallet.rs create mode 100644 src/payments/nip47/mod.rs create mode 100644 src/payments/nip47/types.rs create mode 100644 src/payments/nip47/uri.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 331d566..7ecab41 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,23 @@ - CEP-8 capability pricing and payments (in progress; foundational pieces, not yet a usable payment flow): + - NIP-47 (Nostr Wallet Connect) client infrastructure for the Phase B Lightning rail, + behind the off-by-default `nwc` feature. The feature only switches on an existing + `nostr-sdk` NIP and adds no new crate to the dependency tree. `parse_nwc_uri` reads a + connection string in either shape wallets emit, including the + `nostr+walletconnect:?...` pathname form that + `NostrWalletConnectURI::parse` rejects and the ts-sdk accepts. `NwcClient` speaks + request/response and notifications over an injected `RelayPoolTrait`, so the same code + path runs against a scripted mock wallet in CI and a real wallet in production. Because + `RelayPoolTrait` has no unsubscribe, it holds one subscription and one reader task and + correlates responses in process by request event id, rather than the ts-sdk + subscription-per-request shape that would leak one live REQ per payment. Wallet + responses are parsed permissively field for field, so a missing or unknown optional + field cannot turn a paid invoice into a failed verification, and notifications are + decrypted by kind (23196 NIP-04, 23197 NIP-44) rather than always as NIP-04. A + `MockWallet` behind `test-utils` scripts answers, errors, silence, delays and malformed + replies over a linked `MockRelayPool`, and can publish a `payment_received` + notification to drive settlement. - Server-side payment-interaction negotiation and advertisement: the server transport now parses client `pmi` and `payment_interaction` tags, negotiates the effective session mode (`transparent` by default, `explicit_gating` when the server policy diff --git a/Cargo.toml b/Cargo.toml index 44fd7c6..af66fa2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,6 +60,9 @@ default = ["rmcp"] rmcp = ["dep:rmcp"] ## Exposes `MockRelayPool` for use in downstream integration tests. test-utils = [] +## CEP-8 Lightning rail over NIP-47 (Nostr Wallet Connect). Off by default. +## Switches on an existing `nostr-sdk` NIP; adds no new crate to the tree. +nwc = ["nostr-sdk/nip47"] [[example]] name = "rmcp_integration_test" diff --git a/src/payments/mod.rs b/src/payments/mod.rs index d11584e..4235325 100644 --- a/src/payments/mod.rs +++ b/src/payments/mod.rs @@ -23,6 +23,8 @@ pub mod canonical; pub mod client_payments; pub mod constants; pub mod errors; +#[cfg(feature = "nwc")] +pub mod nip47; pub mod server_explicit_gating; pub mod server_payments; pub(crate) mod server_payments_utils; diff --git a/src/payments/nip47/client.rs b/src/payments/nip47/client.rs new file mode 100644 index 0000000..01add8a --- /dev/null +++ b/src/payments/nip47/client.rs @@ -0,0 +1,848 @@ +//! Minimal NIP-47 client: request/response and notifications over a +//! [`RelayPoolTrait`], with no dependency on the `nwc` crate. +//! +//! # Why not the `nwc` crate +//! +//! `nwc` owns its relay pool privately, so nothing built on it can be driven by +//! [`MockRelayPool`](crate::relay::MockRelayPool) and every test would need a +//! live wallet. This client takes an injected `Arc` instead, +//! so the same code path runs in CI against a scripted mock wallet and in +//! production against a real one. +//! +//! # One subscription, not one per request +//! +//! ts-sdk opens a subscription per request and closes it on settle. +//! [`RelayPoolTrait`] has no unsubscribe, so that shape would leak one live REQ +//! per payment for the life of the process. This client instead opens a single +//! subscription covering responses and notifications, runs one reader task, and +//! correlates in process through a waiter map keyed by the request event id. +//! +//! # Divergences from ts-sdk (both safe, neither visible on the wire) +//! +//! * Notifications are decrypted **by kind**: 23196 is NIP-04, 23197 is NIP-44. +//! ts always uses NIP-04, which silently drops NIP-44 notifications. +//! * Requests are issued concurrently and correlated by id, where ts serializes +//! them behind a promise queue. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use nostr_sdk::prelude::*; +use serde::de::DeserializeOwned; +use serde::Serialize; +use tokio::sync::{oneshot, Mutex}; +use tokio_util::sync::CancellationToken; + +use crate::payments::errors::PaymentError; +use crate::payments::nip47::types::{NwcNotificationPayload, NwcResponseEnvelope}; +use crate::payments::nip47::uri::NwcConnection; +use crate::relay::RelayPoolTrait; + +const LOG_TARGET: &str = "contextvm::payments::nip47"; + +/// Kind 13194: the wallet service's info event. +const KIND_INFO: u16 = 13194; +/// Kind 23194: a client request to the wallet. +const KIND_REQUEST: u16 = 23194; +/// Kind 23195: the wallet's response. +const KIND_RESPONSE: u16 = 23195; +/// Kind 23196: a NIP-04-encrypted notification (legacy). +const KIND_NOTIFICATION_NIP04: u16 = 23196; +/// Kind 23197: a NIP-44-encrypted notification. +const KIND_NOTIFICATION_NIP44: u16 = 23197; + +/// Default per-request response timeout. +pub const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Construction options for [`NwcClient`]. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct NwcClientOptions { + /// How long to wait for a single NIP-47 response. Default 60 s. + pub response_timeout: Duration, +} + +impl Default for NwcClientOptions { + fn default() -> Self { + Self { + response_timeout: DEFAULT_RESPONSE_TIMEOUT, + } + } +} + +type Waiter = oneshot::Sender; + +/// A registered notification sink. Named so the waiter/sink collections stay +/// readable (and to satisfy `clippy::type_complexity`). +type NotificationSink = Arc; + +struct Inner { + /// Pending request-event-id to response-plaintext waiters. + waiters: Mutex>, + /// Registered notification sinks. + notification_sinks: Mutex>, +} + +/// A NIP-47 client bound to one wallet connection and one relay pool. +pub struct NwcClient { + pool: Arc, + connection: NwcConnection, + keys: Keys, + response_timeout: Duration, + inner: Arc, + /// Guards one-time subscription + reader-task startup. + started: Mutex, + cancel: CancellationToken, +} + +impl NwcClient { + /// Create a client over `pool` for `connection`, with default options. + pub fn new(pool: Arc, connection: NwcConnection) -> Self { + Self::with_options(pool, connection, NwcClientOptions::default()) + } + + /// Create a client with explicit options. + pub fn with_options( + pool: Arc, + connection: NwcConnection, + options: NwcClientOptions, + ) -> Self { + let keys = Keys::new(connection.secret.clone()); + Self { + pool, + connection, + keys, + response_timeout: options.response_timeout, + inner: Arc::new(Inner { + waiters: Mutex::new(HashMap::new()), + notification_sinks: Mutex::new(Vec::new()), + }), + started: Mutex::new(false), + cancel: CancellationToken::new(), + } + } + + /// The client public key this connection presents to the wallet. + pub fn client_pubkey(&self) -> PublicKey { + self.keys.public_key() + } + + /// The wallet service public key. + pub fn wallet_pubkey(&self) -> PublicKey { + self.connection.wallet_pubkey + } + + /// Stop the reader task. Idempotent; the client is unusable afterwards. + pub fn shutdown(&self) { + self.cancel.cancel(); + } + + /// Connect, subscribe, and spawn the reader task. Idempotent. + async fn ensure_started(&self) -> Result<(), PaymentError> { + let mut started = self.started.lock().await; + if *started { + return Ok(()); + } + + self.pool + .connect(&self.connection.relay_urls()) + .await + .map_err(|e| PaymentError::Processor(format!("NWC relay connect failed: {e}")))?; + + // One subscription for everything this client consumes: responses + // addressed to us, and both notification kinds. + let client_pk = self.keys.public_key(); + let wallet_pk = self.connection.wallet_pubkey; + let filters = vec![Filter::new() + .kinds([ + Kind::from(KIND_RESPONSE), + Kind::from(KIND_NOTIFICATION_NIP04), + Kind::from(KIND_NOTIFICATION_NIP44), + ]) + .author(wallet_pk) + .pubkey(client_pk)]; + + // Take the receiver BEFORE subscribing so the mock pool's replay of + // already-stored events cannot land before we are listening. + let notifications = self.pool.notifications(); + + self.pool + .subscribe(filters) + .await + .map_err(|e| PaymentError::Processor(format!("NWC subscribe failed: {e}")))?; + + let inner = Arc::clone(&self.inner); + let keys = self.keys.clone(); + let cancel = self.cancel.clone(); + tokio::spawn(async move { + Self::reader_loop(notifications, inner, keys, wallet_pk, cancel).await; + }); + + *started = true; + Ok(()) + } + + /// Single reader task: decrypts, correlates responses, fans out notifications. + async fn reader_loop( + mut notifications: tokio::sync::broadcast::Receiver, + inner: Arc, + keys: Keys, + wallet_pubkey: PublicKey, + cancel: CancellationToken, + ) { + loop { + let notification = tokio::select! { + _ = cancel.cancelled() => break, + result = notifications.recv() => match result { + Ok(n) => n, + // A lagged broadcast drops events but must not kill the + // reader: the waiter map survives, so an in-flight request + // still resolves from a later delivery or times out + // cleanly. Killing the loop here would hang every waiter. + Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { + tracing::warn!( + target: LOG_TARGET, + skipped, + "NWC relay broadcast lagged, continuing" + ); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }, + }; + + let RelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + + // A stranger cannot answer for the wallet. + if event.pubkey != wallet_pubkey { + continue; + } + + let kind = event.kind.as_u16(); + match kind { + KIND_RESPONSE => Self::handle_response(&event, &inner, &keys).await, + KIND_NOTIFICATION_NIP04 | KIND_NOTIFICATION_NIP44 => { + Self::handle_notification(&event, &inner, &keys, kind).await + } + _ => {} + } + } + tracing::debug!(target: LOG_TARGET, "NWC reader loop stopped"); + } + + async fn handle_response(event: &Event, inner: &Arc, keys: &Keys) { + // Correlate by the `e` tag naming the request event. + let Some(request_id) = first_e_tag(event) else { + return; + }; + + let waiter = { + let mut waiters = inner.waiters.lock().await; + waiters.remove(&request_id) + }; + let Some(waiter) = waiter else { + // Uncorrelated: a response to a request we never made, or one we + // already settled. Dropping it is correct. + tracing::debug!( + target: LOG_TARGET, + request_id = %request_id.to_hex(), + "NWC response did not correlate to a pending request" + ); + return; + }; + + // Responses are NIP-04 in practice; fall back to NIP-44 so a wallet + // that upgrades does not strand us. + match decrypt_either(keys, &event.pubkey, &event.content) { + Ok(plaintext) => { + let _ = waiter.send(plaintext); + } + Err(e) => { + tracing::warn!( + target: LOG_TARGET, + error = %e, + "failed to decrypt NWC response; dropping (caller will time out)" + ); + } + } + } + + async fn handle_notification(event: &Event, inner: &Arc, keys: &Keys, kind: u16) { + // Decrypt by kind, the documented divergence from ts-sdk. + let decrypted = if kind == KIND_NOTIFICATION_NIP44 { + nip44::decrypt(keys.secret_key(), &event.pubkey, &event.content) + .map_err(|e| e.to_string()) + } else { + nip04::decrypt(keys.secret_key(), &event.pubkey, &event.content) + .map_err(|e| e.to_string()) + }; + + let Ok(plaintext) = decrypted else { + tracing::debug!( + target: LOG_TARGET, + kind, + "failed to decrypt NWC notification" + ); + return; + }; + + let Ok(payload) = serde_json::from_str::(&plaintext) else { + tracing::debug!(target: LOG_TARGET, "malformed NWC notification payload"); + return; + }; + + let sinks = { + let guard = inner.notification_sinks.lock().await; + guard.clone() + }; + for sink in sinks { + sink(payload.clone()); + } + } + + /// Register a sink invoked for every decoded notification. + /// + /// Sinks are called synchronously on the reader task, so they must not + /// block. Kept for the life of the client; there is no removal, matching + /// the single long-lived subscriber this rail needs. + pub async fn on_notification(&self, sink: F) -> Result<(), PaymentError> + where + F: Fn(NwcNotificationPayload) + Send + Sync + 'static, + { + self.ensure_started().await?; + self.inner + .notification_sinks + .lock() + .await + .push(Arc::new(sink)); + Ok(()) + } + + /// Issue a NIP-47 request and await its correlated response. + /// + /// Bounded by `response_timeout`. A wallet-reported error is returned as + /// `Ok(envelope)` with `error` set, not `Err`: the caller decides whether a + /// given code is fatal or merely pending. + /// + /// # Errors + /// + /// [`PaymentError::Processor`] on encrypt/publish failure, on timeout, or + /// when the response cannot be parsed. + pub async fn request( + &self, + method: &str, + params: P, + ) -> Result, PaymentError> + where + P: Serialize, + R: DeserializeOwned, + { + self.ensure_started().await?; + + let body = serde_json::json!({ "method": method, "params": params }); + let plaintext = serde_json::to_string(&body)?; + + let content = nip04::encrypt( + self.keys.secret_key(), + &self.connection.wallet_pubkey, + plaintext, + ) + .map_err(|e| PaymentError::Processor(format!("NWC request encryption failed: {e}")))?; + + let builder = EventBuilder::new(Kind::from(KIND_REQUEST), content).tags([ + Tag::public_key(self.connection.wallet_pubkey), + Tag::parse(["encryption", "nip04"]) + .map_err(|e| PaymentError::Processor(format!("bad encryption tag: {e}")))?, + ]); + + let event = builder + .sign_with_keys(&self.keys) + .map_err(|e| PaymentError::Processor(format!("NWC request signing failed: {e}")))?; + let request_id = event.id; + + // Register the waiter BEFORE publishing, so a wallet that answers + // instantly cannot arrive before we are listening for it. + let (tx, rx) = oneshot::channel(); + self.inner.waiters.lock().await.insert(request_id, tx); + + if let Err(e) = self.pool.publish_event(&event).await { + self.inner.waiters.lock().await.remove(&request_id); + return Err(PaymentError::Processor(format!( + "NWC publish failed for {method}: {e}" + ))); + } + + tracing::debug!( + target: LOG_TARGET, + method, + request_id = %request_id.to_hex(), + "NWC request published" + ); + + let plaintext = match tokio::time::timeout(self.response_timeout, rx).await { + Ok(Ok(p)) => p, + Ok(Err(_)) => { + self.inner.waiters.lock().await.remove(&request_id); + return Err(PaymentError::Processor(format!( + "NWC client stopped before a response to {method} arrived" + ))); + } + Err(_) => { + self.inner.waiters.lock().await.remove(&request_id); + return Err(PaymentError::Processor(format!( + "NWC response timed out for {method}" + ))); + } + }; + + let envelope: NwcResponseEnvelope = serde_json::from_str(&plaintext).map_err(|e| { + PaymentError::Processor(format!("malformed NWC response for {method}: {e}")) + })?; + + Ok(envelope) + } + + /// Fetch the notification types the wallet advertises on its kind-13194 info event. + /// + /// Returns an empty set when no info event is found, which callers read as + /// "no notification support" and fall back to polling. + pub async fn fetch_info_notification_types(&self) -> Result, PaymentError> { + self.ensure_started().await?; + + let filter = Filter::new() + .kind(Kind::from(KIND_INFO)) + .author(self.connection.wallet_pubkey) + .limit(1); + + let events = self + .pool + .fetch_events(vec![filter], self.response_timeout) + .await + .map_err(|e| PaymentError::Processor(format!("NWC info fetch failed: {e}")))?; + + let Some(event) = events.into_iter().next() else { + return Ok(Vec::new()); + }; + + // ts joins every `notifications` tag value with a space, then splits on + // whitespace, so a wallet may use either one tag with a space-separated + // list or several tags. Reproduce both. + let mut types = Vec::new(); + for tag in event.tags.iter() { + let slice = tag.as_slice(); + if slice.first().map(String::as_str) != Some("notifications") { + continue; + } + for value in slice.iter().skip(1) { + for t in value.split_whitespace() { + let t = t.trim(); + if !t.is_empty() && !types.iter().any(|e: &String| e == t) { + types.push(t.to_string()); + } + } + } + } + Ok(types) + } +} + +impl Drop for NwcClient { + fn drop(&mut self) { + self.cancel.cancel(); + } +} + +/// First `e` tag of an event, as an [`EventId`]. +fn first_e_tag(event: &Event) -> Option { + event.tags.iter().find_map(|t| match t.as_slice() { + [name, value, ..] if name == "e" => EventId::from_hex(value).ok(), + _ => None, + }) +} + +/// Try NIP-04, then NIP-44. Responses are NIP-04 in practice, but a wallet that +/// answers with NIP-44 should still be understood rather than timing out. +fn decrypt_either(keys: &Keys, author: &PublicKey, content: &str) -> Result { + match nip04::decrypt(keys.secret_key(), author, content) { + Ok(p) => Ok(p), + Err(first) => nip44::decrypt(keys.secret_key(), author, content) + .map_err(|second| format!("nip04: {first}; nip44: {second}")), + } +} + +#[cfg(all(test, feature = "test-utils"))] +mod tests { + use super::*; + use crate::payments::nip47::mock_wallet::{MockWallet, WalletBehavior}; + use crate::payments::nip47::types::{ + NwcError, NwcInvoiceResult, NwcPayInvoiceResult, NOTIFICATION_PAYMENT_RECEIVED, + }; + use crate::payments::nip47::uri::parse_nwc_uri; + use crate::relay::MockRelayPool; + + /// Wallet + client on one mock relay network, wired through a real NWC URI. + async fn harness() -> (Arc, NwcClient, Arc) { + harness_with_timeout(Duration::from_secs(5)).await + } + + async fn harness_with_timeout( + response_timeout: Duration, + ) -> (Arc, NwcClient, Arc) { + let wallet_pool = Arc::new(MockRelayPool::new()); + let client_pool = Arc::new(wallet_pool.linked_with_keys(Keys::generate())); + + let wallet = Arc::new(MockWallet::new(Arc::clone(&wallet_pool))); + wallet.start().await.expect("wallet starts"); + + let secret = SecretKey::generate(); + let uri = format!( + "nostr+walletconnect://{}?relay=wss%3A%2F%2Fmock.relay&secret={}", + wallet.public_key().to_hex(), + secret.to_secret_hex() + ); + let connection = parse_nwc_uri(&uri).expect("uri parses"); + + let client = NwcClient::with_options( + Arc::clone(&client_pool) as Arc, + connection, + NwcClientOptions { response_timeout }, + ); + (wallet, client, client_pool) + } + + #[tokio::test] + async fn round_trips_make_invoice() { + let (wallet, client, _pool) = harness().await; + wallet + .set_behavior( + "make_invoice", + WalletBehavior::Answer(serde_json::json!({ + "invoice": "lnbc210n1p", + "payment_hash": "hash-1", + "state": "pending", + })), + ) + .await; + + let env: NwcResponseEnvelope = client + .request("make_invoice", serde_json::json!({ "amount": 21_000u64 })) + .await + .expect("request succeeds"); + + assert_eq!(env.result_type.as_deref(), Some("make_invoice")); + let result = env.into_result().expect("no wallet error"); + assert_eq!(result.invoice.as_deref(), Some("lnbc210n1p")); + assert_eq!(result.payment_hash.as_deref(), Some("hash-1")); + assert_eq!(wallet.call_count("make_invoice").await, 1); + } + + #[tokio::test] + async fn round_trips_lookup_invoice() { + let (wallet, client, _pool) = harness().await; + wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "settled" })), + ) + .await; + + let env: NwcResponseEnvelope = client + .request("lookup_invoice", serde_json::json!({ "payment_hash": "h" })) + .await + .unwrap(); + assert!(env.into_result().unwrap().is_settled()); + } + + #[tokio::test] + async fn round_trips_pay_invoice() { + let (wallet, client, _pool) = harness().await; + wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Answer(serde_json::json!({ "preimage": "pre", "fees_paid": 12 })), + ) + .await; + + let env: NwcResponseEnvelope = client + .request("pay_invoice", serde_json::json!({ "invoice": "lnbc1" })) + .await + .unwrap(); + let r = env.into_result().unwrap(); + assert_eq!(r.preimage.as_deref(), Some("pre")); + assert_eq!(r.fees_paid, Some(12)); + } + + #[tokio::test] + async fn wallet_error_is_surfaced_not_read_as_success() { + let (wallet, client, _pool) = harness().await; + wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Error { + code: "INSUFFICIENT_BALANCE".to_string(), + message: "no funds".to_string(), + }, + ) + .await; + + let env: NwcResponseEnvelope = client + .request("pay_invoice", serde_json::json!({ "invoice": "lnbc1" })) + .await + .expect("transport-level call succeeds"); + + // The envelope carries the error, and collapsing it is an Err: a failed + // payment can never be read as a settled one. + assert_eq!( + env.error.as_ref().map(NwcError::code_upper).as_deref(), + Some("INSUFFICIENT_BALANCE") + ); + assert!(env.into_result().is_err()); + } + + #[tokio::test] + async fn silence_times_out() { + let (wallet, client, _pool) = harness_with_timeout(Duration::from_millis(300)).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + + let started = tokio::time::Instant::now(); + let res: Result, _> = client + .request("make_invoice", serde_json::json!({ "amount": 1000u64 })) + .await; + + assert!(res.is_err(), "silence must not resolve"); + assert!( + started.elapsed() < Duration::from_secs(3), + "must fail at the configured timeout, not hang" + ); + } + + #[tokio::test] + async fn malformed_response_is_an_error() { + let (wallet, client, _pool) = harness_with_timeout(Duration::from_millis(500)).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Malformed) + .await; + + let res: Result, _> = client + .request("make_invoice", serde_json::json!({ "amount": 1000u64 })) + .await; + assert!(res.is_err()); + } + + #[tokio::test] + async fn stranger_signed_response_is_ignored() { + let (wallet, client, pool) = harness_with_timeout(Duration::from_millis(400)).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + let client_pubkey = client.client_pubkey(); + + // A third party publishes a well-formed response addressed to the + // client. It must not satisfy the pending request. + let stranger = Keys::generate(); + let plaintext = + serde_json::json!({"result_type":"make_invoice","result":{"invoice":"evil"}}) + .to_string(); + let content = + nip04::encrypt(stranger.secret_key(), &client_pubkey, plaintext).expect("encrypt"); + let evil = EventBuilder::new(Kind::from(23195u16), content) + .tags([Tag::public_key(client_pubkey)]) + .sign_with_keys(&stranger) + .expect("sign"); + pool.publish_event(&evil).await.expect("publish"); + + let res: Result, _> = client + .request("make_invoice", serde_json::json!({ "amount": 1000u64 })) + .await; + assert!(res.is_err(), "a stranger must not be able to answer"); + } + + #[tokio::test] + async fn concurrent_requests_correlate_independently() { + let (wallet, client, _pool) = harness().await; + wallet + .set_behavior( + "make_invoice", + WalletBehavior::Delayed { + delay: Duration::from_millis(120), + result: serde_json::json!({ "invoice": "inv-make" }), + }, + ) + .await; + wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Answer(serde_json::json!({ "preimage": "pre-pay" })), + ) + .await; + + let client = Arc::new(client); + let c1 = Arc::clone(&client); + let c2 = Arc::clone(&client); + + let (make, pay) = tokio::join!( + async move { + c1.request::<_, NwcInvoiceResult>( + "make_invoice", + serde_json::json!({ "amount": 1000u64 }), + ) + .await + }, + async move { + c2.request::<_, NwcPayInvoiceResult>( + "pay_invoice", + serde_json::json!({ "invoice": "lnbc1" }), + ) + .await + } + ); + + // The slow make_invoice must not receive the fast pay_invoice answer. + assert_eq!( + make.unwrap().into_result().unwrap().invoice.as_deref(), + Some("inv-make") + ); + assert_eq!( + pay.unwrap().into_result().unwrap().preimage.as_deref(), + Some("pre-pay") + ); + } + + #[tokio::test] + async fn decodes_nip04_notifications() { + assert_notification_decodes(false).await; + } + + #[tokio::test] + async fn decodes_nip44_notifications() { + assert_notification_decodes(true).await; + } + + /// Both notification kinds must decode; ts decrypts every kind as NIP-04 + /// and so silently drops the 23197 (NIP-44) form. + async fn assert_notification_decodes(nip44_mode: bool) { + let (wallet, client, _pool) = harness().await; + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + client + .on_notification(move |payload| { + let _ = tx.send(payload); + }) + .await + .expect("sink registers"); + + wallet + .settle(&client.client_pubkey(), "hash-9", nip44_mode) + .await + .expect("wallet publishes notification"); + + let payload = tokio::time::timeout(Duration::from_secs(3), rx.recv()) + .await + .expect("notification arrives") + .expect("channel open"); + + assert_eq!(payload.notification_type, NOTIFICATION_PAYMENT_RECEIVED); + assert_eq!(payload.payment_hash(), Some("hash-9")); + } + + #[tokio::test] + async fn fetches_info_notification_types() { + let (wallet, client, _pool) = harness().await; + wallet + .publish_info_event(&["payment_received", "payment_sent"]) + .await + .expect("info published"); + + let types = client + .fetch_info_notification_types() + .await + .expect("fetch succeeds"); + assert!(types.iter().any(|t| t == "payment_received")); + assert!(types.iter().any(|t| t == "payment_sent")); + } + + #[tokio::test] + async fn missing_info_event_yields_empty_set_not_an_error() { + let (_wallet, client, _pool) = harness_with_timeout(Duration::from_millis(400)).await; + let types = client + .fetch_info_notification_types() + .await + .expect("absence is not a failure"); + assert!(types.is_empty(), "callers fall back to polling on empty"); + } + + /// A response the *wallet itself* signed, but carrying an `e` tag for a + /// request we never made, must not satisfy a pending waiter. This is the + /// correlation half of the guard; `stranger_signed_response_is_ignored` + /// covers the authorship half. + #[tokio::test] + async fn uncorrelated_response_is_ignored() { + let (wallet, client, pool) = harness_with_timeout(Duration::from_millis(400)).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + let client_pubkey = client.client_pubkey(); + + // Correctly signed by the wallet, addressed to us, well formed, but + // correlated to an unrelated event id. + let unrelated = EventId::all_zeros(); + let plaintext = + serde_json::json!({"result_type":"make_invoice","result":{"invoice":"wrong"}}) + .to_string(); + let content = + nip04::encrypt(wallet.keys().secret_key(), &client_pubkey, plaintext).expect("encrypt"); + let event = EventBuilder::new(Kind::from(23195u16), content) + .tags([Tag::public_key(client_pubkey), Tag::event(unrelated)]) + .sign_with_keys(&wallet.keys()) + .expect("sign"); + pool.publish_event(&event).await.expect("publish"); + + let res: Result, _> = client + .request("make_invoice", serde_json::json!({ "amount": 1000u64 })) + .await; + assert!( + res.is_err(), + "a response correlated to another request must not settle ours" + ); + } + + /// The reader task must survive a burst that overruns the broadcast buffer. + /// + /// A `Lagged` drops events, but killing the loop there would strand every + /// waiter forever; continuing means an in-flight request still resolves. + /// The burst is sized past the pool's 1024-slot channel so the lag path is + /// exercised, and the assertion is the property that matters either way: + /// a request issued afterwards still correlates and resolves. + #[tokio::test] + async fn reader_survives_broadcast_burst() { + let (wallet, client, pool) = harness().await; + wallet + .set_behavior( + "make_invoice", + WalletBehavior::Answer(serde_json::json!({ "invoice": "after-burst" })), + ) + .await; + + // Flood with unrelated events addressed to nobody in particular. + let noise = Keys::generate(); + for i in 0..1500u32 { + let event = EventBuilder::new(Kind::from(1u16), format!("noise-{i}")) + .sign_with_keys(&noise) + .expect("sign noise"); + let _ = pool.publish_event(&event).await; + } + + let env: NwcResponseEnvelope = client + .request("make_invoice", serde_json::json!({ "amount": 1000u64 })) + .await + .expect("reader must still be alive after the burst"); + assert_eq!( + env.into_result().unwrap().invoice.as_deref(), + Some("after-burst") + ); + } +} diff --git a/src/payments/nip47/mock_wallet.rs b/src/payments/nip47/mock_wallet.rs new file mode 100644 index 0000000..c09b8f7 --- /dev/null +++ b/src/payments/nip47/mock_wallet.rs @@ -0,0 +1,308 @@ +//! Scripted NIP-47 wallet service for tests, behind `test-utils`. +//! +//! Runs the *real* wire protocol over a linked [`MockRelayPool`]: it subscribes +//! for kind-23194 requests addressed to it, decrypts them, and publishes +//! kind-23195 responses (and kind-23196/23197 notifications). That means +//! [`NwcClient`](crate::payments::nip47::NwcClient) exercises its own encrypt, +//! publish, correlate, and decrypt paths in CI, rather than being stubbed out. +//! +//! Behaviors are scripted per method: answer, error, silence, delay, malformed. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use nostr_sdk::prelude::*; +use tokio::sync::Mutex; +use tokio_util::sync::CancellationToken; + +use crate::relay::{MockRelayPool, RelayPoolTrait}; + +const KIND_REQUEST: u16 = 23194; +const KIND_RESPONSE: u16 = 23195; +const KIND_NOTIFICATION_NIP04: u16 = 23196; +const KIND_NOTIFICATION_NIP44: u16 = 23197; +const KIND_INFO: u16 = 13194; + +/// How the mock wallet answers one method. +#[derive(Debug, Clone)] +pub enum WalletBehavior { + /// Reply with this `result` object. + Answer(serde_json::Value), + /// Reply with a NIP-47 error envelope. + Error { + /// Error code, e.g. `NOT_FOUND`. + code: String, + /// Human-readable message. + message: String, + }, + /// Never reply, so the caller hits its own timeout. + Silence, + /// Reply with `result` after a delay. + Delayed { + /// Delay before replying. + delay: Duration, + /// The `result` object to send. + result: serde_json::Value, + }, + /// Reply with content that is not valid JSON once decrypted. + Malformed, +} + +/// A scripted NIP-47 wallet service on a mock relay network. +pub struct MockWallet { + pool: Arc, + keys: Keys, + behaviors: Arc>>, + /// Methods seen, in arrival order, for assertions. + calls: Arc>>, + cancel: CancellationToken, +} + +impl MockWallet { + /// Build a wallet on `pool` with freshly generated keys. + pub fn new(pool: Arc) -> Self { + Self::with_keys(pool, Keys::generate()) + } + + /// Build a wallet on `pool` under caller-chosen keys. + pub fn with_keys(pool: Arc, keys: Keys) -> Self { + Self { + pool, + keys, + behaviors: Arc::new(Mutex::new(HashMap::new())), + calls: Arc::new(Mutex::new(Vec::new())), + cancel: CancellationToken::new(), + } + } + + /// The wallet service public key, for a connection string. + pub fn public_key(&self) -> PublicKey { + self.keys.public_key() + } + + /// The wallet's signing keys, so a test can forge an event that is + /// genuinely wallet-signed (for example a correctly authored but + /// mis-correlated response). + pub fn keys(&self) -> Keys { + self.keys.clone() + } + + /// Script how `method` is answered. Later calls replace earlier ones. + pub async fn set_behavior(&self, method: &str, behavior: WalletBehavior) { + self.behaviors + .lock() + .await + .insert(method.to_string(), behavior); + } + + /// Methods received so far, with their params, in arrival order. + pub async fn calls(&self) -> Vec<(String, serde_json::Value)> { + self.calls.lock().await.clone() + } + + /// How many times `method` was called. + pub async fn call_count(&self, method: &str) -> usize { + self.calls + .lock() + .await + .iter() + .filter(|(m, _)| m == method) + .count() + } + + /// Publish a kind-13194 info event advertising `notification_types`. + pub async fn publish_info_event(&self, notification_types: &[&str]) -> Result<(), String> { + let builder = EventBuilder::new( + Kind::from(KIND_INFO), + "pay_invoice make_invoice lookup_invoice", + ) + .tags( + [Tag::parse(["notifications", ¬ification_types.join(" ")]) + .map_err(|e| e.to_string())?], + ); + let event = builder + .sign_with_keys(&self.keys) + .map_err(|e| e.to_string())?; + self.pool + .publish_event(&event) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + /// Publish a `payment_received` notification to `client_pubkey`. + /// + /// `nip44` selects kind 23197 (NIP-44) over the legacy kind 23196 (NIP-04), + /// so both decode paths are exercisable. + pub async fn settle( + &self, + client_pubkey: &PublicKey, + payment_hash: &str, + nip44_mode: bool, + ) -> Result<(), String> { + let payload = serde_json::json!({ + "notification_type": "payment_received", + "notification": { + "payment_hash": payment_hash, + "state": "settled", + "settled_at": 1_700_000_000i64, + } + }); + let plaintext = payload.to_string(); + + let (kind, content) = if nip44_mode { + ( + KIND_NOTIFICATION_NIP44, + nip44::encrypt( + self.keys.secret_key(), + client_pubkey, + plaintext, + nip44::Version::default(), + ) + .map_err(|e| e.to_string())?, + ) + } else { + ( + KIND_NOTIFICATION_NIP04, + nip04::encrypt(self.keys.secret_key(), client_pubkey, plaintext) + .map_err(|e| e.to_string())?, + ) + }; + + let event = EventBuilder::new(Kind::from(kind), content) + .tags([Tag::public_key(*client_pubkey)]) + .sign_with_keys(&self.keys) + .map_err(|e| e.to_string())?; + self.pool + .publish_event(&event) + .await + .map_err(|e| e.to_string())?; + Ok(()) + } + + /// Subscribe for requests and start answering. Call once. + pub async fn start(&self) -> Result<(), String> { + let filter = Filter::new() + .kind(Kind::from(KIND_REQUEST)) + .pubkey(self.keys.public_key()); + + let notifications = self.pool.notifications(); + self.pool + .subscribe(vec![filter]) + .await + .map_err(|e| e.to_string())?; + + let pool = Arc::clone(&self.pool); + let keys = self.keys.clone(); + let behaviors = Arc::clone(&self.behaviors); + let calls = Arc::clone(&self.calls); + let cancel = self.cancel.clone(); + + tokio::spawn(async move { + Self::serve(notifications, pool, keys, behaviors, calls, cancel).await; + }); + Ok(()) + } + + /// Stop serving. Idempotent. + pub fn shutdown(&self) { + self.cancel.cancel(); + } + + async fn serve( + mut notifications: tokio::sync::broadcast::Receiver, + pool: Arc, + keys: Keys, + behaviors: Arc>>, + calls: Arc>>, + cancel: CancellationToken, + ) { + loop { + let notification = tokio::select! { + _ = cancel.cancelled() => break, + result = notifications.recv() => match result { + Ok(n) => n, + Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue, + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }, + }; + + let RelayPoolNotification::Event { event, .. } = notification else { + continue; + }; + if event.kind.as_u16() != KIND_REQUEST { + continue; + } + + let Ok(plaintext) = nip04::decrypt(keys.secret_key(), &event.pubkey, &event.content) + else { + continue; + }; + let Ok(body) = serde_json::from_str::(&plaintext) else { + continue; + }; + let method = body + .get("method") + .and_then(|m| m.as_str()) + .unwrap_or_default() + .to_string(); + let params = body + .get("params") + .cloned() + .unwrap_or(serde_json::Value::Null); + + calls.lock().await.push((method.clone(), params)); + + let behavior = behaviors.lock().await.get(&method).cloned(); + let Some(behavior) = behavior else { + // Unscripted method: stay silent, like a wallet that does not + // implement it and never answers. + continue; + }; + + let (result, error, malformed) = match behavior { + WalletBehavior::Silence => continue, + WalletBehavior::Answer(result) => (Some(result), None, false), + WalletBehavior::Delayed { delay, result } => { + tokio::time::sleep(delay).await; + (Some(result), None, false) + } + WalletBehavior::Error { code, message } => ( + None, + Some(serde_json::json!({ "code": code, "message": message })), + false, + ), + WalletBehavior::Malformed => (None, None, true), + }; + + let plaintext = if malformed { + "{ this is not json".to_string() + } else { + serde_json::json!({ + "result_type": method, + "error": error, + "result": result, + }) + .to_string() + }; + + let Ok(content) = nip04::encrypt(keys.secret_key(), &event.pubkey, plaintext) else { + continue; + }; + + let builder = EventBuilder::new(Kind::from(KIND_RESPONSE), content) + .tags([Tag::public_key(event.pubkey), Tag::event(event.id)]); + let Ok(response) = builder.sign_with_keys(&keys) else { + continue; + }; + let _ = pool.publish_event(&response).await; + } + } +} + +impl Drop for MockWallet { + fn drop(&mut self) { + self.cancel.cancel(); + } +} diff --git a/src/payments/nip47/mod.rs b/src/payments/nip47/mod.rs new file mode 100644 index 0000000..f107e4d --- /dev/null +++ b/src/payments/nip47/mod.rs @@ -0,0 +1,26 @@ +//! NIP-47 (Nostr Wallet Connect) client infrastructure for the CEP-8 Lightning rail. +//! +//! Three pieces: [`parse_nwc_uri`] for connection strings, permissive wire +//! [`types`] that will not turn a paid invoice into a parse failure, and +//! [`NwcClient`], a request/response and notification client over an injected +//! [`RelayPoolTrait`](crate::relay::RelayPoolTrait). +//! +//! Behind the off-by-default `nwc` feature, which only switches on +//! `nostr-sdk/nip47` and adds no new crate to the tree. + +pub mod client; +pub mod types; +pub mod uri; + +#[cfg(feature = "test-utils")] +pub mod mock_wallet; + +pub use client::{NwcClient, NwcClientOptions, DEFAULT_RESPONSE_TIMEOUT}; +pub use types::{ + sats_to_msats, NwcError, NwcInvoiceResult, NwcNotificationPayload, NwcPayInvoiceResult, + NwcResponseEnvelope, NOTIFICATION_PAYMENT_RECEIVED, +}; +pub use uri::{parse_nwc_uri, NwcConnection}; + +#[cfg(feature = "test-utils")] +pub use mock_wallet::{MockWallet, WalletBehavior}; diff --git a/src/payments/nip47/types.rs b/src/payments/nip47/types.rs new file mode 100644 index 0000000..ea9fd77 --- /dev/null +++ b/src/payments/nip47/types.rs @@ -0,0 +1,305 @@ +//! Permissive NIP-47 wire types. +//! +//! These deliberately do not reuse `nostr_sdk::nips::nip47`'s response structs. +//! Upstream parses strictly: a wallet that omits an optional field, or adds one, +//! fails deserialization. On a money path that turns a *paid* invoice into a +//! failed verification, so every field here is optional and unknown fields are +//! ignored, field for field with ts `nip47/types.ts`. +//! +//! Requests are still built with the upstream types, where strictness costs +//! nothing because we control the value. + +use serde::{Deserialize, Serialize}; + +use crate::payments::errors::PaymentError; + +/// Envelope every NIP-47 response shares: `{result_type, error, result}`. +/// +/// `error` and `result` are both nullable per NIP-47, and a wallet may send +/// neither. `result_type` is the only field the client relies on. +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NwcResponseEnvelope { + /// Echo of the request method, e.g. `"make_invoice"`. + #[serde(default)] + pub result_type: Option, + /// Set when the wallet refused or failed the request. + #[serde(default)] + pub error: Option, + /// Set when the wallet succeeded. + /// + /// No `#[serde(default)]`: on a generic field that would impose + /// `R: Default`, and serde already reads a missing `Option` as `None`. + pub result: Option, +} + +/// A wallet-reported error. `code` is an uppercase NIP-47 code such as +/// `NOT_FOUND` or `INSUFFICIENT_BALANCE`; unknown codes are kept verbatim +/// rather than mapped to an enum, so a wallet extension cannot be lost. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct NwcError { + /// Machine-readable error code. + #[serde(default)] + pub code: Option, + /// Human-readable message. + #[serde(default)] + pub message: Option, +} + +impl NwcResponseEnvelope { + /// Collapse the envelope into a `Result`, so a wallet-reported error is + /// never mistaken for success. + /// + /// Callers that need to classify the error code (the processor treats + /// `NOT_FOUND` as "still pending" but other codes as fatal) should match on + /// [`Self::error`] directly instead. + /// + /// # Errors + /// + /// Returns the wallet's [`NwcError`] when `error` is set, and a synthesized + /// one when the wallet sent neither `error` nor `result`. + pub fn into_result(self) -> Result { + if let Some(err) = self.error { + return Err(err); + } + self.result.ok_or_else(|| NwcError { + code: Some("EMPTY_RESPONSE".to_string()), + message: Some("wallet returned neither result nor error".to_string()), + }) + } +} + +impl NwcError { + /// `code` uppercased for comparison, or `""` when absent. + pub fn code_upper(&self) -> String { + self.code.as_deref().unwrap_or_default().to_uppercase() + } + + /// A display string usable in a [`PaymentError`] message. + pub fn describe(&self) -> String { + match (self.code.as_deref(), self.message.as_deref()) { + (Some(c), Some(m)) => format!("{c}: {m}"), + (Some(c), None) => c.to_string(), + (None, Some(m)) => m.to_string(), + (None, None) => "unspecified wallet error".to_string(), + } + } +} + +/// Result of `make_invoice` / `lookup_invoice`. +/// +/// Every field optional: wallets differ on which they populate, and a missing +/// `state` with a non-zero `settled_at` still means settled. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct NwcInvoiceResult { + /// `"incoming"` or `"outgoing"`. + #[serde(default)] + pub r#type: Option, + /// `pending` | `settled` | `accepted` | `expired` | `failed`. + #[serde(default)] + pub state: Option, + /// The BOLT11 invoice string. + #[serde(default)] + pub invoice: Option, + /// Payment hash, hex. + #[serde(default)] + pub payment_hash: Option, + /// Preimage, present once settled. + #[serde(default)] + pub preimage: Option, + /// Creation time, unix seconds. + #[serde(default)] + pub created_at: Option, + /// Expiry time, unix seconds. + #[serde(default)] + pub expires_at: Option, + /// Settlement time, unix seconds. Non-zero implies settled. + #[serde(default)] + pub settled_at: Option, + /// Amount, msats. + #[serde(default)] + pub amount: Option, +} + +impl NwcInvoiceResult { + /// Whether this result represents a settled invoice. + /// + /// Two independent signals, either sufficient: an explicit `state == + /// "settled"`, or a positive `settled_at`. Wallets populate one or the + /// other, and requiring both would strand real payments. + pub fn is_settled(&self) -> bool { + let by_state = self + .state + .as_deref() + .is_some_and(|s| s.eq_ignore_ascii_case("settled")); + let by_timestamp = self.settled_at.is_some_and(|t| t > 0); + by_state || by_timestamp + } + + /// Whether this result is terminal-failed (never going to settle). + pub fn is_terminal_failure(&self) -> bool { + self.state + .as_deref() + .is_some_and(|s| s.eq_ignore_ascii_case("expired") || s.eq_ignore_ascii_case("failed")) + } +} + +/// Result of `pay_invoice`. +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct NwcPayInvoiceResult { + /// Payment preimage, hex. + #[serde(default)] + pub preimage: Option, + /// Routing fees paid, msats. + #[serde(default)] + pub fees_paid: Option, +} + +/// A decoded NIP-47 notification payload (`{notification_type, notification}`). +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct NwcNotificationPayload { + /// e.g. `payment_received`, `payment_sent`. + pub notification_type: String, + /// Body, shaped per `notification_type`. Left opaque. + #[serde(default)] + pub notification: serde_json::Value, +} + +impl NwcNotificationPayload { + /// `notification.payment_hash`, when present. + pub fn payment_hash(&self) -> Option<&str> { + self.notification.get("payment_hash")?.as_str() + } +} + +/// Notification type a wallet emits when an inbound payment settles. +pub const NOTIFICATION_PAYMENT_RECEIVED: &str = "payment_received"; + +/// Convert sats to msats. +/// +/// Hardened against ts, whose `satsToMsats` is a bare multiply: a non-positive +/// amount is rejected rather than turned into a zero-or-negative invoice, and +/// the multiply is checked so a caller-controlled amount cannot wrap. Both are +/// rejected before any wallet call. +/// +/// # Errors +/// +/// Returns [`PaymentError::Processor`] when `sats` is not positive, or when +/// `sats * 1000` overflows [`i64`]. +pub fn sats_to_msats(sats: i64) -> Result { + if sats <= 0 { + return Err(PaymentError::Processor(format!( + "invoice amount must be positive, got {sats} sats" + ))); + } + let msats = sats.checked_mul(1000).ok_or_else(|| { + PaymentError::Processor(format!( + "invoice amount overflows when converted to msats: {sats} sats" + )) + })?; + Ok(msats as u64) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sats_to_msats_converts() { + assert_eq!(sats_to_msats(1).unwrap(), 1_000); + assert_eq!(sats_to_msats(21).unwrap(), 21_000); + } + + #[test] + fn sats_to_msats_rejects_non_positive() { + assert!(sats_to_msats(0).is_err()); + assert!(sats_to_msats(-1).is_err()); + } + + #[test] + fn sats_to_msats_rejects_overflow() { + assert!(sats_to_msats(i64::MAX).is_err()); + assert!(sats_to_msats(i64::MAX / 999).is_err()); + } + + #[test] + fn envelope_parses_minimal_settled_result() { + let raw = r#"{"result_type":"lookup_invoice","result":{"state":"settled"}}"#; + let env: NwcResponseEnvelope = serde_json::from_str(raw).unwrap(); + assert_eq!(env.result_type.as_deref(), Some("lookup_invoice")); + assert!(env.error.is_none()); + assert!(env.result.unwrap().is_settled()); + } + + #[test] + fn envelope_tolerates_unknown_fields_and_nulls() { + let raw = r#"{"result_type":"make_invoice","error":null,"result":{"invoice":"lnbc1","surprise":42},"extra":true}"#; + let env: NwcResponseEnvelope = serde_json::from_str(raw).unwrap(); + assert_eq!(env.result.unwrap().invoice.as_deref(), Some("lnbc1")); + } + + #[test] + fn envelope_parses_unknown_error_code_verbatim() { + let raw = + r#"{"result_type":"pay_invoice","error":{"code":"SOMETHING_NEW","message":"nope"}}"#; + let env: NwcResponseEnvelope = serde_json::from_str(raw).unwrap(); + let err = env.error.unwrap(); + assert_eq!(err.code_upper(), "SOMETHING_NEW"); + assert_eq!(err.describe(), "SOMETHING_NEW: nope"); + } + + #[test] + fn settled_detected_by_timestamp_without_state() { + let r = NwcInvoiceResult { + settled_at: Some(1_700_000_000), + ..Default::default() + }; + assert!(r.is_settled()); + } + + #[test] + fn zero_settled_at_is_not_settled() { + let r = NwcInvoiceResult { + settled_at: Some(0), + ..Default::default() + }; + assert!(!r.is_settled()); + } + + #[test] + fn pending_is_neither_settled_nor_terminal() { + let r = NwcInvoiceResult { + state: Some("pending".into()), + ..Default::default() + }; + assert!(!r.is_settled()); + assert!(!r.is_terminal_failure()); + } + + #[test] + fn expired_and_failed_are_terminal() { + for s in ["expired", "failed", "EXPIRED"] { + let r = NwcInvoiceResult { + state: Some(s.into()), + ..Default::default() + }; + assert!(r.is_terminal_failure(), "{s} should be terminal"); + assert!(!r.is_settled()); + } + } + + #[test] + fn notification_payload_exposes_payment_hash() { + let raw = + r#"{"notification_type":"payment_received","notification":{"payment_hash":"abc"}}"#; + let n: NwcNotificationPayload = serde_json::from_str(raw).unwrap(); + assert_eq!(n.notification_type, NOTIFICATION_PAYMENT_RECEIVED); + assert_eq!(n.payment_hash(), Some("abc")); + } + + #[test] + fn notification_payload_without_body_is_tolerated() { + let raw = r#"{"notification_type":"payment_received"}"#; + let n: NwcNotificationPayload = serde_json::from_str(raw).unwrap(); + assert_eq!(n.payment_hash(), None); + } +} diff --git a/src/payments/nip47/uri.rs b/src/payments/nip47/uri.rs new file mode 100644 index 0000000..7586a26 --- /dev/null +++ b/src/payments/nip47/uri.rs @@ -0,0 +1,243 @@ +//! NWC connection-URI parsing (NIP-47). +//! +//! [`NostrWalletConnectURI::parse`](nostr_sdk::nips::nip47::NostrWalletConnectURI::parse) +//! reads the wallet pubkey from the URI *host* only, so it rejects the +//! `nostr+walletconnect:?...` form (no `//`, pubkey in the path) that +//! ts-sdk accepts. Wallets in the wild emit both, and a connection string the +//! ts-sdk takes must not fail here, so this module parses both shapes and then +//! hands the pieces to the upstream type. +//! +//! Parity note: ts `parseNwcConnectionString` prefers the pathname and falls +//! back to the host (`url.pathname?.replace(/^\//, '') || url.host`). This +//! reproduces that precedence exactly. + +use nostr_sdk::prelude::*; + +use crate::payments::errors::PaymentError; + +/// URI scheme for a NWC connection string. +const NWC_URI_SCHEME: &str = "nostr+walletconnect"; + +/// A parsed NWC connection string: which wallet to talk to, over which relays, +/// under which client identity. +#[derive(Debug, Clone)] +pub struct NwcConnection { + /// Wallet service public key. + pub wallet_pubkey: PublicKey, + /// Relay URLs the wallet service is reachable on. Never empty. + pub relays: Vec, + /// Client secret key, used to sign requests and to encrypt to the wallet. + pub secret: SecretKey, + /// Optional lightning address advertised by the wallet (LUD-16). + pub lud16: Option, +} + +impl NwcConnection { + /// The client public key derived from [`Self::secret`]; the `p`-tag a + /// wallet addresses its responses and notifications to. + pub fn client_pubkey(&self) -> PublicKey { + Keys::new(self.secret.clone()).public_key() + } + + /// Relay URLs as strings, for [`crate::relay::RelayPoolTrait::connect`]. + pub fn relay_urls(&self) -> Vec { + self.relays.iter().map(|r| r.to_string()).collect() + } +} + +fn invalid(reason: &str) -> PaymentError { + PaymentError::Processor(format!("Invalid NWC connection string: {reason}")) +} + +/// Parse a NWC connection string. +/// +/// Accepts both shapes seen in the wild: +/// +/// ```text +/// nostr+walletconnect://?relay=wss://relay.example&secret= +/// nostr+walletconnect:?relay=wss://relay.example&secret= +/// ``` +/// +/// `relay` may repeat. `secret` is a 32-byte hex secret key. `lud16` is +/// optional and passed through. +/// +/// # Errors +/// +/// Returns [`PaymentError::Processor`] when the scheme is not +/// `nostr+walletconnect`, the pubkey or secret is missing or malformed, or no +/// relay is present. A relay value that does not parse is skipped, matching +/// upstream; an empty resulting relay set is an error. +pub fn parse_nwc_uri(uri: &str) -> Result { + let url = Url::parse(uri.trim()).map_err(|_| invalid("not a URL"))?; + + if url.scheme() != NWC_URI_SCHEME { + return Err(invalid("scheme is not nostr+walletconnect")); + } + + // ts precedence: a non-empty pathname wins, else the host. The `//` form + // puts the pubkey in the host and leaves the path empty; the schema-only + // form puts it in the path and leaves the host unset. + let from_path = url.path().trim_start_matches('/').trim().to_string(); + let raw_pubkey = if from_path.is_empty() { + url.host_str().unwrap_or_default().trim().to_string() + } else { + from_path + }; + + if raw_pubkey.is_empty() { + return Err(invalid("missing wallet pubkey")); + } + + let wallet_pubkey = + PublicKey::from_hex(&raw_pubkey).map_err(|_| invalid("wallet pubkey is not valid hex"))?; + + let mut relays: Vec = Vec::new(); + let mut secret: Option = None; + let mut lud16: Option = None; + + for (key, value) in url.query_pairs() { + match key.as_ref() { + "relay" => { + // Skip unparseable relay values rather than failing the whole + // URI, matching upstream `NostrWalletConnectURI::parse`. + if let Ok(relay) = RelayUrl::parse(value.trim()) { + relays.push(relay); + } + } + "secret" => secret = SecretKey::from_hex(value.trim()).ok(), + "lud16" => lud16 = Some(value.to_string()), + _ => {} + } + } + + let secret = secret.ok_or_else(|| invalid("missing or malformed secret"))?; + + if relays.is_empty() { + return Err(invalid("no usable relay")); + } + + Ok(NwcConnection { + wallet_pubkey, + relays, + secret, + lud16, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const PUBKEY: &str = "b889ff5b1513b641e2a139f661a661364979c5beee91842f8f0ef42ab558e9d4"; + const SECRET: &str = "71a8c14c1407c113601079c4302dab36460f0ccd0ad506f1f2dc73b5100e4f3c"; + + fn host_form() -> String { + format!("nostr+walletconnect://{PUBKEY}?relay=wss%3A%2F%2Frelay.example&secret={SECRET}") + } + + fn path_form() -> String { + format!("nostr+walletconnect:{PUBKEY}?relay=wss%3A%2F%2Frelay.example&secret={SECRET}") + } + + #[test] + fn parses_host_form() { + let c = parse_nwc_uri(&host_form()).expect("host form should parse"); + assert_eq!(c.wallet_pubkey.to_hex(), PUBKEY); + assert_eq!(c.secret.to_secret_hex(), SECRET); + assert_eq!(c.relays.len(), 1); + } + + /// The whole reason this module exists: upstream rejects this form, ts accepts it. + #[test] + fn parses_path_form_that_upstream_rejects() { + assert!( + nostr_sdk::nips::nip47::NostrWalletConnectURI::parse(path_form()).is_err(), + "precondition: upstream is expected to reject the pathname form" + ); + + let c = parse_nwc_uri(&path_form()).expect("path form should parse"); + assert_eq!(c.wallet_pubkey.to_hex(), PUBKEY); + assert_eq!(c.secret.to_secret_hex(), SECRET); + } + + #[test] + fn both_forms_agree() { + let a = parse_nwc_uri(&host_form()).unwrap(); + let b = parse_nwc_uri(&path_form()).unwrap(); + assert_eq!(a.wallet_pubkey, b.wallet_pubkey); + assert_eq!(a.secret.to_secret_hex(), b.secret.to_secret_hex()); + assert_eq!(a.relay_urls(), b.relay_urls()); + } + + #[test] + fn collects_repeated_relays_in_order() { + let uri = format!( + "nostr+walletconnect://{PUBKEY}?relay=wss%3A%2F%2Fa.example&relay=wss%3A%2F%2Fb.example&secret={SECRET}" + ); + let c = parse_nwc_uri(&uri).unwrap(); + let urls = c.relay_urls(); + assert_eq!(urls.len(), 2); + assert!(urls[0].contains("a.example")); + assert!(urls[1].contains("b.example")); + } + + #[test] + fn passes_through_lud16() { + let uri = format!( + "nostr+walletconnect://{PUBKEY}?relay=wss%3A%2F%2Fa.example&secret={SECRET}&lud16=me%40example.com" + ); + assert_eq!( + parse_nwc_uri(&uri).unwrap().lud16.as_deref(), + Some("me@example.com") + ); + } + + #[test] + fn client_pubkey_is_derived_from_secret() { + let c = parse_nwc_uri(&host_form()).unwrap(); + let expected = Keys::new(SecretKey::from_hex(SECRET).unwrap()).public_key(); + assert_eq!(c.client_pubkey(), expected); + assert_ne!( + c.client_pubkey(), + c.wallet_pubkey, + "client identity is not the wallet identity" + ); + } + + #[test] + fn rejects_wrong_scheme() { + let uri = format!("https://{PUBKEY}?relay=wss%3A%2F%2Fa.example&secret={SECRET}"); + assert!(parse_nwc_uri(&uri).is_err()); + } + + #[test] + fn rejects_missing_secret() { + let uri = format!("nostr+walletconnect://{PUBKEY}?relay=wss%3A%2F%2Fa.example"); + assert!(parse_nwc_uri(&uri).is_err()); + } + + #[test] + fn rejects_missing_relay() { + let uri = format!("nostr+walletconnect://{PUBKEY}?secret={SECRET}"); + assert!(parse_nwc_uri(&uri).is_err()); + } + + #[test] + fn rejects_malformed_pubkey() { + let uri = + format!("nostr+walletconnect://not-hex?relay=wss%3A%2F%2Fa.example&secret={SECRET}"); + assert!(parse_nwc_uri(&uri).is_err()); + } + + #[test] + fn rejects_malformed_secret() { + let uri = format!("nostr+walletconnect://{PUBKEY}?relay=wss%3A%2F%2Fa.example&secret=nope"); + assert!(parse_nwc_uri(&uri).is_err()); + } + + #[test] + fn rejects_when_every_relay_is_unparseable() { + let uri = format!("nostr+walletconnect://{PUBKEY}?relay=%20&secret={SECRET}"); + assert!(parse_nwc_uri(&uri).is_err()); + } +} From 0ef9c440c1a5e12ee2cb631d238fe00c31aff46c Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Fri, 9 Oct 2026 09:28:00 +0530 Subject: [PATCH 02/11] fix(payments): harden the NIP-47 client against wallet-facing failure Addresses the review on #117. The design held; what did not was robustness against a wallet that misbehaves, and the money-path bounds. A mock written alongside the client agreed with the client wherever the client was wrong, so most of this was invisible to the old suite. Critical - One panic on the reader killed the rail for the process, silently, while requests kept publishing. Per-event handling now runs under catch_unwind, the reader marks the client closed and drains its waiters on exit, and `nostr` is floored at 0.44.7 (0.44.5 panics on a malformed NIP-04 payload, and "0.44" permitted it). - One off-type optional field threw out the whole response. `#[serde(default)]` covers absent, not wrong-typed, so a string `settled_at` lost a result that said `"state":"settled"` and the payer would have paid for nothing. Every optional field now deserializes off-type values to `None`. Major - The response filter widens itself once, dropping `#p`, while nothing has ever correlated, so a wallet that omits the tag is not invisible. - One deadline now covers start-up, publish and the wait, where publish, connect and subscribe used to sit outside it. - A dropped request future no longer orphans its waiter: an RAII guard removes it. The processor's `select!` drops one per cancelled verify. - `close()` is fail-fast. It used to leave `pay_invoice` publishing, and the wallet still paid. - Errors are typed. `NwcError::Timeout` carries `published`, so a caller can tell "the wallet may have acted" from "it certainly did not", which is what a non-idempotent `pay_invoice` needs, and the trait boundary maps to Processor or Handler explicitly. - Error text can no longer carry a BOLT11 invoice into operator logs: wallet free text is redacted and bounded, and a parse failure reports its category and position instead of the offending bytes. - `MockWallet` can now break the client's assumptions: omit the `p` tag, answer NOT_IMPLEMENTED, mislabel `result_type`, drop the `e` tag, publish both notification kinds, and answer out of order. It serves each request on its own task, so a fast answer really can overtake a slow one. - The public API is typed: `make_invoice` / `lookup_invoice` / `pay_invoice`, `request` crate-internal, wire types `#[non_exhaustive]`, and the options builder lands here. - `NwcClient::connect` builds the client its own pool. `with_pool` stays for tests, documenting why sharing a transport's pool is a trap. Minor - Corrected the "adds no new crate" claim, which was wrong: the feature pulls in NIP-04 and so adds `aes`, `cbc` and `cipher`. Measured. - Notifications decrypt by ciphertext shape, not event kind, so NIP-04 content on kind 23197 (ts's own fixture) decodes. - The subscription carries `since`, so a retaining relay does not replay this client's whole history into the sinks. - The legacy `nostrwalletconnect://` scheme parses. - A zero `response_timeout` is refused at construction. - `pay_invoice` takes an optional NIP-40 expiration. - Rustdoc corrected where it no longer matched behavior, including the claim about handing pieces to the upstream URI type, which it never did. Non-blocking: a mismatched `result_type` is tolerated and warned about, since correlation by event id already proves which request an answer belongs to; duplicate `secret` is documented as last-wins; the `e` tag parity note is recorded; the two quiet side effects of `fetch_info_notification_types` are documented. Tests go 38 to 61, and now bite: a five-mutant sweep over the author check, the waiter guard, the closed flag, decrypt-by-shape and the off-type leniency kills all five. The author check needed `MockRelayPool::publish_event_unfiltered`, which models a relay that ignores `authors`; without it the guard is unreachable, which is why deleting it used to survive the suite. Verified: fmt and clippy clean, 1104 tests pass under `--all-features`, feature matrix green, MSRV 1.88 compiles. Refs #116 --- CHANGELOG.md | 7 +- Cargo.toml | 10 +- src/payments/nip47/client.rs | 1317 +++++++++++++++++++++-------- src/payments/nip47/error.rs | 233 +++++ src/payments/nip47/mock_wallet.rs | 263 ++++-- src/payments/nip47/mod.rs | 9 +- src/payments/nip47/types.rs | 284 +++++-- src/payments/nip47/uri.rs | 76 +- src/relay/mock.rs | 19 + 9 files changed, 1719 insertions(+), 499 deletions(-) create mode 100644 src/payments/nip47/error.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ecab41..04c7e36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,10 @@ - CEP-8 capability pricing and payments (in progress; foundational pieces, not yet a usable payment flow): - - NIP-47 (Nostr Wallet Connect) client infrastructure for the Phase B Lightning rail, - behind the off-by-default `nwc` feature. The feature only switches on an existing - `nostr-sdk` NIP and adds no new crate to the dependency tree. `parse_nwc_uri` reads a + - NIP-47 (Nostr Wallet Connect) client infrastructure, letting a server issue and a + client pay Lightning BOLT11 invoices through a Nostr Wallet Connect wallet, behind + the off-by-default `nwc` feature. Enabling it pulls in NIP-04 and so adds `aes`, + `cbc` and `cipher` to the dependency tree. `parse_nwc_uri` reads a connection string in either shape wallets emit, including the `nostr+walletconnect:?...` pathname form that `NostrWalletConnectURI::parse` rejects and the ts-sdk accepts. `NwcClient` speaks diff --git a/Cargo.toml b/Cargo.toml index af66fa2..f124d26 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -42,6 +42,13 @@ futures = "0.3" # Nostr SDK with NIP-59 (Gift Wrap) support nostr-sdk = { version = "0.44", features = ["nip59"] } +# Direct floor on the transitive `nostr` crate, not used directly: `nostr-sdk` +# accepts "0.44", and 0.44.5 and earlier panic on a malformed NIP-04 payload. +# The NWC rail decrypts wallet-controlled NIP-04 content on its reader task, so +# that panic is remotely reachable. Raising the floor here is the only way to +# refuse those versions. +nostr = { version = ">=0.44.7, <0.45", default-features = false, features = ["std"] } + # Logging tracing = "0.1" @@ -61,7 +68,8 @@ rmcp = ["dep:rmcp"] ## Exposes `MockRelayPool` for use in downstream integration tests. test-utils = [] ## CEP-8 Lightning rail over NIP-47 (Nostr Wallet Connect). Off by default. -## Switches on an existing `nostr-sdk` NIP; adds no new crate to the tree. +## Enables `nostr-sdk/nip47`, which pulls in NIP-04 and therefore adds three +## crates to the tree: `aes`, `cbc` and `cipher`. Measured with `cargo tree`. nwc = ["nostr-sdk/nip47"] [[example]] diff --git a/src/payments/nip47/client.rs b/src/payments/nip47/client.rs index 01add8a..6df5a88 100644 --- a/src/payments/nip47/client.rs +++ b/src/payments/nip47/client.rs @@ -5,39 +5,58 @@ //! //! `nwc` owns its relay pool privately, so nothing built on it can be driven by //! [`MockRelayPool`](crate::relay::MockRelayPool) and every test would need a -//! live wallet. This client takes an injected `Arc` instead, -//! so the same code path runs in CI against a scripted mock wallet and in -//! production against a real one. +//! live wallet. This client takes its pool by construction, so the same code +//! path runs against a scripted mock wallet in CI and a real wallet in +//! production. +//! +//! # The client owns a relay pool +//! +//! Prefer [`NwcClient::connect`], which builds a pool from the connection's own +//! relays. [`NwcClient::with_pool`] exists for tests and for a caller that +//! genuinely wants to share one, and it carries a sharp edge: on a real +//! [`RelayPool`](crate::relay::RelayPool), `connect` *adds* the wallet's relays +//! to whatever pool it is given, and the NIP-47 subscription is permanent +//! because [`RelayPoolTrait`] has no unsubscribe. Handing it a transport's pool +//! therefore publishes ContextVM traffic to the wallet's relays and leaves a +//! wallet filter on the transport's relays for the life of the process. //! //! # One subscription, not one per request //! //! ts-sdk opens a subscription per request and closes it on settle. -//! [`RelayPoolTrait`] has no unsubscribe, so that shape would leak one live REQ -//! per payment for the life of the process. This client instead opens a single -//! subscription covering responses and notifications, runs one reader task, and -//! correlates in process through a waiter map keyed by the request event id. +//! [`RelayPoolTrait`] has no unsubscribe, so transliterating that would leak +//! one live REQ per payment. This client opens a single subscription covering +//! responses and notifications, runs one reader task, and correlates in process +//! through a waiter map keyed by the request event id. //! -//! # Divergences from ts-sdk (both safe, neither visible on the wire) +//! # Divergences from ts-sdk //! -//! * Notifications are decrypted **by kind**: 23196 is NIP-04, 23197 is NIP-44. -//! ts always uses NIP-04, which silently drops NIP-44 notifications. +//! * Notifications are decrypted **by ciphertext shape**, not by event kind: a +//! NIP-04 payload carries `?iv=`. ts assumes NIP-04 for both kinds and so +//! drops NIP-44 notifications; keying on kind instead would double-deliver +//! for a wallet that publishes 23196 and 23197 for one event. //! * Requests are issued concurrently and correlated by id, where ts serializes //! them behind a promise queue. +//! * Every optional response field tolerates a wrong JSON type (see +//! [`types`](super::types)). use std::collections::HashMap; -use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; use std::time::Duration; use nostr_sdk::prelude::*; use serde::de::DeserializeOwned; use serde::Serialize; -use tokio::sync::{oneshot, Mutex}; +use tokio::sync::{oneshot, Mutex as AsyncMutex}; use tokio_util::sync::CancellationToken; -use crate::payments::errors::PaymentError; -use crate::payments::nip47::types::{NwcNotificationPayload, NwcResponseEnvelope}; +use crate::payments::nip47::error::NwcError; +use crate::payments::nip47::types::{ + sanitize_wallet_text, NwcInvoiceResult, NwcNotificationPayload, NwcPayInvoiceResult, + NwcResponseEnvelope, +}; use crate::payments::nip47::uri::NwcConnection; -use crate::relay::RelayPoolTrait; +use crate::relay::{RelayPool, RelayPoolTrait}; const LOG_TARGET: &str = "contextvm::payments::nip47"; @@ -47,19 +66,28 @@ const KIND_INFO: u16 = 13194; const KIND_REQUEST: u16 = 23194; /// Kind 23195: the wallet's response. const KIND_RESPONSE: u16 = 23195; -/// Kind 23196: a NIP-04-encrypted notification (legacy). -const KIND_NOTIFICATION_NIP04: u16 = 23196; -/// Kind 23197: a NIP-44-encrypted notification. -const KIND_NOTIFICATION_NIP44: u16 = 23197; +/// Kind 23196: a notification, historically NIP-04. +const KIND_NOTIFICATION_LEGACY: u16 = 23196; +/// Kind 23197: a notification, historically NIP-44. +const KIND_NOTIFICATION: u16 = 23197; -/// Default per-request response timeout. +/// Default deadline for one NIP-47 call. pub const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); +/// How far back the subscription looks on start. +/// +/// ts uses 5 s. Relay clocks drift and a reconnect can land well after the +/// original subscribe, so a slightly wider window is safer than missing a live +/// answer; it is still bounded, so a relay that retains ephemeral events does +/// not replay this client's whole history into the sinks. +const SUBSCRIPTION_LOOKBACK: Duration = Duration::from_secs(120); + /// Construction options for [`NwcClient`]. #[derive(Debug, Clone)] #[non_exhaustive] pub struct NwcClientOptions { - /// How long to wait for a single NIP-47 response. Default 60 s. + /// Deadline covering start-up, publish and the wait for an answer. + /// Default 60 s. Must be non-zero. pub response_timeout: Duration, } @@ -71,17 +99,79 @@ impl Default for NwcClientOptions { } } -type Waiter = oneshot::Sender; +impl NwcClientOptions { + /// Options with the default response timeout. + pub fn new() -> Self { + Self::default() + } + + /// Set the deadline covering start-up, publish and the wait for an answer. + /// + /// A builder rather than a public field literal: the struct is + /// `#[non_exhaustive]`, so a downstream crate cannot name it in a struct + /// expression. + pub fn with_response_timeout(mut self, response_timeout: Duration) -> Self { + self.response_timeout = response_timeout; + self + } + + /// Reject a configuration that cannot work. + fn validate(&self) -> Result<(), NwcError> { + if self.response_timeout.is_zero() { + return Err(NwcError::Config { + reason: "response_timeout must be non-zero; a zero deadline would publish the \ + request and then always time out" + .to_string(), + }); + } + Ok(()) + } +} -/// A registered notification sink. Named so the waiter/sink collections stay -/// readable (and to satisfy `clippy::type_complexity`). +/// A registered notification sink. type NotificationSink = Arc; struct Inner { /// Pending request-event-id to response-plaintext waiters. - waiters: Mutex>, - /// Registered notification sinks. - notification_sinks: Mutex>, + /// + /// A `std::sync::Mutex`: no critical section crosses an await, and the RAII + /// guard that de-registers a dropped waiter cannot await. + waiters: Mutex>>, + notification_sinks: AsyncMutex>, + /// Set once any response has correlated. Until then the filter may be too + /// narrow for the wallet in front of us. + ever_correlated: AtomicBool, + /// Set by `close()`, by `Drop`, and when the reader exits. + closed: AtomicBool, +} + +impl Inner { + /// Drop every waiter so its `request()` returns instead of waiting out the + /// full deadline. Dropping the sender closes the channel. + fn drain_waiters(&self) { + if let Ok(mut map) = self.waiters.lock() { + map.clear(); + } + } +} + +/// Removes its waiter on drop, including when the `request()` future is +/// cancelled mid-wait. +/// +/// Without this, a dropped future (every cancelled verification in the payment +/// processor's `select!`) orphans an `EventId` and a `oneshot::Sender` in the +/// map for the life of the process. +struct WaiterGuard { + inner: Arc, + id: EventId, +} + +impl Drop for WaiterGuard { + fn drop(&mut self) { + if let Ok(mut map) = self.inner.waiters.lock() { + map.remove(&self.id); + } + } } /// A NIP-47 client bound to one wallet connection and one relay pool. @@ -91,19 +181,49 @@ pub struct NwcClient { keys: Keys, response_timeout: Duration, inner: Arc, - /// Guards one-time subscription + reader-task startup. - started: Mutex, + started: AsyncMutex, cancel: CancellationToken, } impl NwcClient { - /// Create a client over `pool` for `connection`, with default options. - pub fn new(pool: Arc, connection: NwcConnection) -> Self { - Self::with_options(pool, connection, NwcClientOptions::default()) + /// Build a client that owns a relay pool connected to the wallet's own + /// relays. This is the constructor production code should use. + /// + /// # Errors + /// + /// [`NwcError::Config`] for invalid options, [`NwcError::Connect`] if the + /// pool cannot be created. + pub async fn connect( + connection: NwcConnection, + options: NwcClientOptions, + ) -> Result { + options.validate()?; + let pool = RelayPool::new(Keys::generate()) + .await + .map_err(|e| NwcError::Connect { + reason: e.to_string(), + })?; + Ok(Self::build(Arc::new(pool), connection, options)) } - /// Create a client with explicit options. - pub fn with_options( + /// Build a client over a caller-supplied pool. + /// + /// Intended for tests, and for a caller that has deliberately decided to + /// share. See the module docs for why sharing a transport's pool is a trap. + /// + /// # Errors + /// + /// [`NwcError::Config`] when the options are invalid. + pub fn with_pool( + pool: Arc, + connection: NwcConnection, + options: NwcClientOptions, + ) -> Result { + options.validate()?; + Ok(Self::build(pool, connection, options)) + } + + fn build( pool: Arc, connection: NwcConnection, options: NwcClientOptions, @@ -116,9 +236,11 @@ impl NwcClient { response_timeout: options.response_timeout, inner: Arc::new(Inner { waiters: Mutex::new(HashMap::new()), - notification_sinks: Mutex::new(Vec::new()), + notification_sinks: AsyncMutex::new(Vec::new()), + ever_correlated: AtomicBool::new(false), + closed: AtomicBool::new(false), }), - started: Mutex::new(false), + started: AsyncMutex::new(false), cancel: CancellationToken::new(), } } @@ -133,13 +255,49 @@ impl NwcClient { self.connection.wallet_pubkey } - /// Stop the reader task. Idempotent; the client is unusable afterwards. - pub fn shutdown(&self) { + /// Stop the reader and refuse further requests. Idempotent. + /// + /// Fail-fast: a request after this returns [`NwcError::Closed`] without + /// publishing, so a shut-down client cannot still cause a payment. Waiting + /// callers are released rather than left to time out. + pub fn close(&self) { + self.inner.closed.store(true, Ordering::SeqCst); self.cancel.cancel(); + self.inner.drain_waiters(); + } + + /// Whether [`Self::close`] has been called, or the reader has exited. + pub fn is_closed(&self) -> bool { + self.inner.closed.load(Ordering::SeqCst) + } + + /// Filters this client subscribes with. + /// + /// `narrow` additionally requires the `p` tag addressed to us, which every + /// conformant wallet sets. The widened form drops it: see + /// [`Self::widen_filter_if_never_correlated`]. + fn filters(&self, narrow: bool) -> Vec { + let since = Timestamp::now() - SUBSCRIPTION_LOOKBACK; + let base = Filter::new() + .kinds([ + Kind::from(KIND_RESPONSE), + Kind::from(KIND_NOTIFICATION_LEGACY), + Kind::from(KIND_NOTIFICATION), + ]) + .author(self.connection.wallet_pubkey) + .since(since); + vec![if narrow { + base.pubkey(self.keys.public_key()) + } else { + base + }] } /// Connect, subscribe, and spawn the reader task. Idempotent. - async fn ensure_started(&self) -> Result<(), PaymentError> { + async fn ensure_started(&self) -> Result<(), NwcError> { + if self.is_closed() { + return Err(NwcError::Closed); + } let mut started = self.started.lock().await; if *started { return Ok(()); @@ -148,42 +306,65 @@ impl NwcClient { self.pool .connect(&self.connection.relay_urls()) .await - .map_err(|e| PaymentError::Processor(format!("NWC relay connect failed: {e}")))?; - - // One subscription for everything this client consumes: responses - // addressed to us, and both notification kinds. - let client_pk = self.keys.public_key(); - let wallet_pk = self.connection.wallet_pubkey; - let filters = vec![Filter::new() - .kinds([ - Kind::from(KIND_RESPONSE), - Kind::from(KIND_NOTIFICATION_NIP04), - Kind::from(KIND_NOTIFICATION_NIP44), - ]) - .author(wallet_pk) - .pubkey(client_pk)]; + .map_err(|e| NwcError::Connect { + reason: e.to_string(), + })?; - // Take the receiver BEFORE subscribing so the mock pool's replay of - // already-stored events cannot land before we are listening. + // Take the receiver BEFORE subscribing so a replay cannot land before + // we are listening. let notifications = self.pool.notifications(); self.pool - .subscribe(filters) + .subscribe(self.filters(true)) .await - .map_err(|e| PaymentError::Processor(format!("NWC subscribe failed: {e}")))?; + .map_err(|e| NwcError::Subscribe { + reason: e.to_string(), + })?; let inner = Arc::clone(&self.inner); let keys = self.keys.clone(); + let wallet = self.connection.wallet_pubkey; let cancel = self.cancel.clone(); tokio::spawn(async move { - Self::reader_loop(notifications, inner, keys, wallet_pk, cancel).await; + Self::reader_loop(notifications, Arc::clone(&inner), keys, wallet, cancel).await; + // The rail is dead once the reader stops. Mark it so a later + // request fails fast instead of publishing into a void and waiting + // out the deadline, and release anyone already waiting. + inner.closed.store(true, Ordering::SeqCst); + inner.drain_waiters(); + tracing::warn!(target: LOG_TARGET, "NWC reader stopped; client is now closed"); }); *started = true; Ok(()) } + /// Re-subscribe without the `p` tag. + /// + /// Our filter requires `#p`, which every conformant wallet sets but which + /// is stricter than ts (whose per-request REQ has no `#p` at all). A wallet + /// that omits it would be invisible forever. While nothing has *ever* + /// correlated, widen once so such a wallet still works; after the first + /// successful correlation the narrow filter is known good and this is never + /// attempted again. + async fn widen_filter_if_never_correlated(&self) { + if self.inner.ever_correlated.load(Ordering::SeqCst) { + return; + } + tracing::debug!( + target: LOG_TARGET, + "no NWC response has correlated yet; re-subscribing without the p-tag filter" + ); + if let Err(e) = self.pool.subscribe(self.filters(false)).await { + tracing::debug!(target: LOG_TARGET, error = %e, "widened NWC subscribe failed"); + } + } + /// Single reader task: decrypts, correlates responses, fans out notifications. + /// + /// Each event is handled inside `catch_unwind`, so one panicking + /// notification sink (caller code) cannot take the rail down for the life + /// of the process. async fn reader_loop( mut notifications: tokio::sync::broadcast::Receiver, inner: Arc, @@ -196,10 +377,9 @@ impl NwcClient { _ = cancel.cancelled() => break, result = notifications.recv() => match result { Ok(n) => n, - // A lagged broadcast drops events but must not kill the - // reader: the waiter map survives, so an in-flight request - // still resolves from a later delivery or times out - // cleanly. Killing the loop here would hang every waiter. + // Lagging drops events but must not kill the reader: the + // waiter map survives, so an in-flight request still + // resolves from a later delivery or fails at its deadline. Err(tokio::sync::broadcast::error::RecvError::Lagged(skipped)) => { tracing::warn!( target: LOG_TARGET, @@ -216,36 +396,67 @@ impl NwcClient { continue; }; - // A stranger cannot answer for the wallet. + // A stranger cannot answer for the wallet. `nostr-relay-pool` + // verifies signatures but not filter matches + // (`verify_subscriptions` defaults to false), so a relay that + // ignores `authors` would otherwise let a correctly correlated, + // stranger-signed "settled" through. if event.pubkey != wallet_pubkey { continue; } let kind = event.kind.as_u16(); - match kind { - KIND_RESPONSE => Self::handle_response(&event, &inner, &keys).await, - KIND_NOTIFICATION_NIP04 | KIND_NOTIFICATION_NIP44 => { - Self::handle_notification(&event, &inner, &keys, kind).await - } - _ => {} + let sinks = if matches!(kind, KIND_NOTIFICATION | KIND_NOTIFICATION_LEGACY) { + inner.notification_sinks.lock().await.clone() + } else { + Vec::new() + }; + + // Isolate per event: a panic here is contained to this event. + let inner_for_event = Arc::clone(&inner); + let keys_for_event = keys.clone(); + let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + Self::handle_event(&event, &inner_for_event, &keys_for_event, kind, &sinks) + })); + if outcome.is_err() { + tracing::error!( + target: LOG_TARGET, + kind, + "a NWC event handler panicked; the reader is continuing" + ); } } tracing::debug!(target: LOG_TARGET, "NWC reader loop stopped"); } - async fn handle_response(event: &Event, inner: &Arc, keys: &Keys) { - // Correlate by the `e` tag naming the request event. + /// Synchronous per-event handling, so it can be wrapped in `catch_unwind`. + fn handle_event( + event: &Event, + inner: &Arc, + keys: &Keys, + kind: u16, + sinks: &[NotificationSink], + ) { + match kind { + KIND_RESPONSE => Self::handle_response(event, inner, keys), + KIND_NOTIFICATION | KIND_NOTIFICATION_LEGACY => { + Self::handle_notification(event, keys, sinks) + } + _ => {} + } + } + + fn handle_response(event: &Event, inner: &Arc, keys: &Keys) { let Some(request_id) = first_e_tag(event) else { return; }; - let waiter = { - let mut waiters = inner.waiters.lock().await; - waiters.remove(&request_id) - }; + let waiter = inner + .waiters + .lock() + .ok() + .and_then(|mut map| map.remove(&request_id)); let Some(waiter) = waiter else { - // Uncorrelated: a response to a request we never made, or one we - // already settled. Dropping it is correct. tracing::debug!( target: LOG_TARGET, request_id = %request_id.to_hex(), @@ -254,38 +465,27 @@ impl NwcClient { return; }; - // Responses are NIP-04 in practice; fall back to NIP-44 so a wallet - // that upgrades does not strand us. - match decrypt_either(keys, &event.pubkey, &event.content) { + match decrypt_auto(keys, &event.pubkey, &event.content) { Ok(plaintext) => { + inner.ever_correlated.store(true, Ordering::SeqCst); let _ = waiter.send(plaintext); } - Err(e) => { + Err(reason) => { tracing::warn!( target: LOG_TARGET, - error = %e, - "failed to decrypt NWC response; dropping (caller will time out)" + reason = %reason, + "failed to decrypt a NWC response; the caller will fail at its deadline" ); } } } - async fn handle_notification(event: &Event, inner: &Arc, keys: &Keys, kind: u16) { - // Decrypt by kind, the documented divergence from ts-sdk. - let decrypted = if kind == KIND_NOTIFICATION_NIP44 { - nip44::decrypt(keys.secret_key(), &event.pubkey, &event.content) - .map_err(|e| e.to_string()) - } else { - nip04::decrypt(keys.secret_key(), &event.pubkey, &event.content) - .map_err(|e| e.to_string()) - }; - - let Ok(plaintext) = decrypted else { - tracing::debug!( - target: LOG_TARGET, - kind, - "failed to decrypt NWC notification" - ); + fn handle_notification(event: &Event, keys: &Keys, sinks: &[NotificationSink]) { + // By ciphertext shape, not by kind: a wallet supporting both + // encryptions publishes 23196 and 23197 for one notification, and ts's + // own fixture is NIP-04 content on kind 23197. + let Ok(plaintext) = decrypt_auto(keys, &event.pubkey, &event.content) else { + tracing::debug!(target: LOG_TARGET, "failed to decrypt a NWC notification"); return; }; @@ -294,10 +494,6 @@ impl NwcClient { return; }; - let sinks = { - let guard = inner.notification_sinks.lock().await; - guard.clone() - }; for sink in sinks { sink(payload.clone()); } @@ -305,10 +501,17 @@ impl NwcClient { /// Register a sink invoked for every decoded notification. /// - /// Sinks are called synchronously on the reader task, so they must not - /// block. Kept for the life of the client; there is no removal, matching - /// the single long-lived subscriber this rail needs. - pub async fn on_notification(&self, sink: F) -> Result<(), PaymentError> + /// Sinks run on the reader task and must not block. A panicking sink is + /// contained to its own event rather than killing the rail, though it will + /// skip the remaining sinks for that event. + /// + /// Side effect worth knowing: this starts the subscription and the reader + /// if they are not running yet. + /// + /// # Errors + /// + /// [`NwcError::Closed`], [`NwcError::Connect`] or [`NwcError::Subscribe`]. + pub async fn on_notification(&self, sink: F) -> Result<(), NwcError> where F: Fn(NwcNotificationPayload) + Send + Sync + 'static, { @@ -323,57 +526,129 @@ impl NwcClient { /// Issue a NIP-47 request and await its correlated response. /// - /// Bounded by `response_timeout`. A wallet-reported error is returned as - /// `Ok(envelope)` with `error` set, not `Err`: the caller decides whether a - /// given code is fatal or merely pending. + /// `response_timeout` is one deadline over **start-up, publish and the + /// wait**, not the wait alone: a hanging connect or publish fails at the + /// same bound rather than parking the caller indefinitely. + /// + /// A wallet-reported error is returned as `Ok(envelope)` with `error` set, + /// not `Err`: the caller decides whether a given code is fatal or merely + /// pending. Use [`NwcResponseEnvelope::into_result`] when that distinction + /// does not matter. + /// + /// Crate-internal: the typed [`Self::make_invoice`], [`Self::lookup_invoice`] + /// and [`Self::pay_invoice`] are the public surface, so adding a NIP field + /// later is not a breaking change. /// /// # Errors /// - /// [`PaymentError::Processor`] on encrypt/publish failure, on timeout, or - /// when the response cannot be parsed. - pub async fn request( + /// [`NwcError::Closed`] if the client is shut down, [`NwcError::Timeout`] + /// at the deadline (carrying whether the request was published), + /// [`NwcError::Publish`], [`NwcError::Crypto`] or + /// [`NwcError::MalformedResponse`]. + pub(crate) async fn request( &self, method: &str, params: P, - ) -> Result, PaymentError> + expiration: Option, + ) -> Result, NwcError> where P: Serialize, R: DeserializeOwned, { - self.ensure_started().await?; + let deadline = tokio::time::Instant::now() + self.response_timeout; + let timeout_ms = self.response_timeout.as_millis() as u64; + + if self.is_closed() { + return Err(NwcError::Closed); + } + + match tokio::time::timeout_at(deadline, self.ensure_started()).await { + Ok(result) => result?, + Err(_) => { + return Err(NwcError::Timeout { + method: method.to_string(), + timeout_ms, + published: false, + }) + } + } let body = serde_json::json!({ "method": method, "params": params }); - let plaintext = serde_json::to_string(&body)?; + let plaintext = serde_json::to_string(&body).map_err(|e| NwcError::Crypto { + direction: "encryption", + reason: format!("request could not be serialized ({:?})", e.classify()), + })?; let content = nip04::encrypt( self.keys.secret_key(), &self.connection.wallet_pubkey, plaintext, ) - .map_err(|e| PaymentError::Processor(format!("NWC request encryption failed: {e}")))?; + .map_err(|e| NwcError::Crypto { + direction: "encryption", + reason: sanitize_wallet_text(&e.to_string()), + })?; - let builder = EventBuilder::new(Kind::from(KIND_REQUEST), content).tags([ + let mut tags = vec![ Tag::public_key(self.connection.wallet_pubkey), - Tag::parse(["encryption", "nip04"]) - .map_err(|e| PaymentError::Processor(format!("bad encryption tag: {e}")))?, - ]); + Tag::parse(["encryption", "nip04"]).map_err(|e| NwcError::Crypto { + direction: "encryption", + reason: e.to_string(), + })?, + ]; + if let Some(expiry) = expiration { + // NIP-40 expiration, as ts sends: a wallet that honors it will not + // act on a request the caller has already given up on. + let at = Timestamp::now() + expiry; + tags.push( + Tag::parse(["expiration", &at.as_secs().to_string()]).map_err(|e| { + NwcError::Crypto { + direction: "encryption", + reason: e.to_string(), + } + })?, + ); + } - let event = builder + let event = EventBuilder::new(Kind::from(KIND_REQUEST), content) + .tags(tags) .sign_with_keys(&self.keys) - .map_err(|e| PaymentError::Processor(format!("NWC request signing failed: {e}")))?; + .map_err(|e| NwcError::Crypto { + direction: "encryption", + reason: e.to_string(), + })?; let request_id = event.id; - // Register the waiter BEFORE publishing, so a wallet that answers - // instantly cannot arrive before we are listening for it. + // Register the waiter BEFORE publishing, so an instant answer cannot + // arrive before anything is listening. The guard removes it on every + // exit path, including this future being dropped mid-wait. let (tx, rx) = oneshot::channel(); - self.inner.waiters.lock().await.insert(request_id, tx); - - if let Err(e) = self.pool.publish_event(&event).await { - self.inner.waiters.lock().await.remove(&request_id); - return Err(PaymentError::Processor(format!( - "NWC publish failed for {method}: {e}" - ))); + { + let mut map = self.inner.waiters.lock().map_err(|_| NwcError::Closed)?; + map.insert(request_id, tx); } + let _guard = WaiterGuard { + inner: Arc::clone(&self.inner), + id: request_id, + }; + + let published = + match tokio::time::timeout_at(deadline, self.pool.publish_event(&event)).await { + Ok(Ok(_)) => true, + Ok(Err(e)) => { + return Err(NwcError::Publish { + method: method.to_string(), + reason: e.to_string(), + }) + } + Err(_) => { + return Err(NwcError::Timeout { + method: method.to_string(), + timeout_ms, + published: false, + }) + } + }; tracing::debug!( target: LOG_TARGET, @@ -382,34 +657,129 @@ impl NwcClient { "NWC request published" ); - let plaintext = match tokio::time::timeout(self.response_timeout, rx).await { + // One chance to widen a too-narrow filter, while nothing has ever + // correlated. Only on the very first requests. + self.widen_filter_if_never_correlated().await; + + let plaintext = match tokio::time::timeout_at(deadline, rx).await { Ok(Ok(p)) => p, - Ok(Err(_)) => { - self.inner.waiters.lock().await.remove(&request_id); - return Err(PaymentError::Processor(format!( - "NWC client stopped before a response to {method} arrived" - ))); - } + // Sender dropped: the client closed, or the reader died. + Ok(Err(_)) => return Err(NwcError::Closed), Err(_) => { - self.inner.waiters.lock().await.remove(&request_id); - return Err(PaymentError::Processor(format!( - "NWC response timed out for {method}" - ))); + return Err(NwcError::Timeout { + method: method.to_string(), + timeout_ms, + published, + }) } }; - let envelope: NwcResponseEnvelope = serde_json::from_str(&plaintext).map_err(|e| { - PaymentError::Processor(format!("malformed NWC response for {method}: {e}")) - })?; + let envelope: NwcResponseEnvelope = + serde_json::from_str(&plaintext).map_err(|e| NwcError::from_serde(method, &e))?; + + if let Some(kind) = envelope.result_type.as_deref() { + if kind != method { + // Correlation by event id already proves which request this + // answers, so a mismatched label is not grounds to discard a + // settlement. Surface it and carry on. + tracing::warn!( + target: LOG_TARGET, + method, + result_type = kind, + "NWC result_type does not match the request method" + ); + } + } Ok(envelope) } - /// Fetch the notification types the wallet advertises on its kind-13194 info event. + /// Ask the wallet for a BOLT11 invoice. + /// + /// # Errors + /// + /// [`NwcError::InvalidAmount`] if `amount_msats` is zero; otherwise see + /// [`Self::request`]. + pub async fn make_invoice( + &self, + amount_msats: u64, + description: Option<&str>, + expiry: Option, + ) -> Result, NwcError> { + if amount_msats == 0 { + return Err(NwcError::InvalidAmount { + reason: "make_invoice amount must be positive".to_string(), + }); + } + let mut params = serde_json::json!({ "amount": amount_msats }); + if let Some(d) = description { + params["description"] = serde_json::Value::String(d.to_string()); + } + if let Some(e) = expiry { + params["expiry"] = serde_json::Value::from(e.as_secs()); + } + self.request("make_invoice", params, None).await + } + + /// Look an invoice up, by payment hash when known and by invoice otherwise. + /// + /// # Errors /// - /// Returns an empty set when no info event is found, which callers read as + /// [`NwcError::Config`] if neither identifier is given; otherwise see + /// [`Self::request`]. + pub async fn lookup_invoice( + &self, + payment_hash: Option<&str>, + invoice: Option<&str>, + ) -> Result, NwcError> { + let params = match (payment_hash, invoice) { + (Some(h), _) => serde_json::json!({ "payment_hash": h }), + (None, Some(i)) => serde_json::json!({ "invoice": i }), + (None, None) => { + return Err(NwcError::Config { + reason: "lookup_invoice needs a payment_hash or an invoice".to_string(), + }) + } + }; + self.request("lookup_invoice", params, None).await + } + + /// Pay a BOLT11 invoice. + /// + /// `expiration` sets a NIP-40 expiration on the request, so a wallet that + /// honors it will not pay after the caller has given up. Strongly + /// recommended: `pay_invoice` is not idempotent. + /// + /// # Errors + /// + /// See [`Self::request`]. On [`NwcError::Timeout`], check + /// [`NwcError::may_have_reached_wallet`] before retrying. + pub async fn pay_invoice( + &self, + invoice: &str, + expiration: Option, + ) -> Result, NwcError> { + self.request( + "pay_invoice", + serde_json::json!({ "invoice": invoice }), + expiration, + ) + .await + } + + /// Fetch the notification types the wallet advertises on its kind-13194 + /// info event. + /// + /// Returns an empty list when no info event is found, which callers read as /// "no notification support" and fall back to polling. - pub async fn fetch_info_notification_types(&self) -> Result, PaymentError> { + /// + /// Side effect worth knowing: this starts the subscription and the reader + /// if they are not running yet. + /// + /// # Errors + /// + /// [`NwcError::Closed`], [`NwcError::Connect`] or [`NwcError::Subscribe`]. + pub async fn fetch_info_notification_types(&self) -> Result, NwcError> { self.ensure_started().await?; let filter = Filter::new() @@ -421,15 +791,17 @@ impl NwcClient { .pool .fetch_events(vec![filter], self.response_timeout) .await - .map_err(|e| PaymentError::Processor(format!("NWC info fetch failed: {e}")))?; + .map_err(|e| NwcError::Subscribe { + reason: e.to_string(), + })?; let Some(event) = events.into_iter().next() else { return Ok(Vec::new()); }; - // ts joins every `notifications` tag value with a space, then splits on - // whitespace, so a wallet may use either one tag with a space-separated - // list or several tags. Reproduce both. + // ts joins every `notifications` tag value with a space then splits on + // whitespace, so a wallet may use one tag with a space-separated list + // or several tags. Reproduce both. let mut types = Vec::new(); for tag in event.tags.iter() { let slice = tag.as_slice(); @@ -447,15 +819,26 @@ impl NwcClient { } Ok(types) } + + /// Number of waiters currently registered. Test-only introspection, so the + /// RAII guard's effect is observable. + #[cfg(test)] + pub(crate) fn waiter_count(&self) -> usize { + self.inner.waiters.lock().map(|m| m.len()).unwrap_or(0) + } } impl Drop for NwcClient { fn drop(&mut self) { - self.cancel.cancel(); + self.close(); } } /// First `e` tag of an event, as an [`EventId`]. +/// +/// Parity note: ts takes the first `e` tag too. Neither implementation handles +/// an answer carrying several `e` tags sensibly, which no conformant wallet +/// sends. fn first_e_tag(event: &Event) -> Option { event.tags.iter().find_map(|t| match t.as_slice() { [name, value, ..] if name == "e" => EventId::from_hex(value).ok(), @@ -463,23 +846,25 @@ fn first_e_tag(event: &Event) -> Option { }) } -/// Try NIP-04, then NIP-44. Responses are NIP-04 in practice, but a wallet that -/// answers with NIP-44 should still be understood rather than timing out. -fn decrypt_either(keys: &Keys, author: &PublicKey, content: &str) -> Result { - match nip04::decrypt(keys.secret_key(), author, content) { - Ok(p) => Ok(p), - Err(first) => nip44::decrypt(keys.secret_key(), author, content) - .map_err(|second| format!("nip04: {first}; nip44: {second}")), +/// Decrypt by ciphertext shape rather than by event kind. +/// +/// NIP-04 ciphertext is `?iv=`; NIP-44 has no `?iv=`. Keying on +/// shape means a NIP-04 payload on kind 23197 (ts's own fixture) decodes, and a +/// wallet that publishes both kinds for one notification is not mis-handled by +/// kind alone. +fn decrypt_auto(keys: &Keys, author: &PublicKey, content: &str) -> Result { + if content.contains("?iv=") { + nip04::decrypt(keys.secret_key(), author, content).map_err(|e| e.to_string()) + } else { + nip44::decrypt(keys.secret_key(), author, content).map_err(|e| e.to_string()) } } #[cfg(all(test, feature = "test-utils"))] mod tests { use super::*; - use crate::payments::nip47::mock_wallet::{MockWallet, WalletBehavior}; - use crate::payments::nip47::types::{ - NwcError, NwcInvoiceResult, NwcPayInvoiceResult, NOTIFICATION_PAYMENT_RECEIVED, - }; + use crate::payments::nip47::mock_wallet::{MockWallet, MockWalletQuirks, WalletBehavior}; + use crate::payments::nip47::types::{NwcErrorBody, NOTIFICATION_PAYMENT_RECEIVED}; use crate::payments::nip47::uri::parse_nwc_uri; use crate::relay::MockRelayPool; @@ -505,42 +890,80 @@ mod tests { ); let connection = parse_nwc_uri(&uri).expect("uri parses"); - let client = NwcClient::with_options( + let client = NwcClient::with_pool( Arc::clone(&client_pool) as Arc, connection, - NwcClientOptions { response_timeout }, - ); + NwcClientOptions::new().with_response_timeout(response_timeout), + ) + .expect("options valid"); (wallet, client, client_pool) } + fn invoice_result(invoice: &str) -> serde_json::Value { + serde_json::json!({ "invoice": invoice, "state": "pending" }) + } + + // ── round trips ────────────────────────────────────────────────────────── + #[tokio::test] - async fn round_trips_make_invoice() { + async fn round_trips_the_three_typed_methods() { let (wallet, client, _pool) = harness().await; wallet .set_behavior( "make_invoice", - WalletBehavior::Answer(serde_json::json!({ - "invoice": "lnbc210n1p", - "payment_hash": "hash-1", - "state": "pending", - })), + WalletBehavior::Answer( + serde_json::json!({ "invoice": "lnbc210n1p", "payment_hash": "hash-1" }), + ), + ) + .await; + wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "settled" })), ) .await; + wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Answer(serde_json::json!({ "preimage": "pre", "fees_paid": 12 })), + ) + .await; + + let made = client + .make_invoice(21_000, Some("a tool call"), Some(Duration::from_secs(300))) + .await + .expect("make_invoice") + .into_result() + .expect("no wallet error"); + assert_eq!(made.invoice.as_deref(), Some("lnbc210n1p")); - let env: NwcResponseEnvelope = client - .request("make_invoice", serde_json::json!({ "amount": 21_000u64 })) + let looked = client + .lookup_invoice(Some("hash-1"), None) .await - .expect("request succeeds"); + .expect("lookup_invoice") + .into_result() + .expect("no wallet error"); + assert!(looked.is_settled()); - assert_eq!(env.result_type.as_deref(), Some("make_invoice")); - let result = env.into_result().expect("no wallet error"); - assert_eq!(result.invoice.as_deref(), Some("lnbc210n1p")); - assert_eq!(result.payment_hash.as_deref(), Some("hash-1")); - assert_eq!(wallet.call_count("make_invoice").await, 1); + let paid = client + .pay_invoice("lnbc1", Some(Duration::from_secs(60))) + .await + .expect("pay_invoice") + .into_result() + .expect("no wallet error"); + assert_eq!(paid.preimage.as_deref(), Some("pre")); + + // The typed wrappers send what the NIP expects. + let calls = wallet.calls().await; + let make = calls.iter().find(|(m, _)| m == "make_invoice").unwrap(); + assert_eq!(make.1["amount"].as_u64(), Some(21_000)); + assert_eq!(make.1["expiry"].as_u64(), Some(300)); + let look = calls.iter().find(|(m, _)| m == "lookup_invoice").unwrap(); + assert_eq!(look.1["payment_hash"].as_str(), Some("hash-1")); } #[tokio::test] - async fn round_trips_lookup_invoice() { + async fn lookup_falls_back_to_the_invoice_when_no_hash_is_known() { let (wallet, client, _pool) = harness().await; wallet .set_behavior( @@ -548,33 +971,35 @@ mod tests { WalletBehavior::Answer(serde_json::json!({ "state": "settled" })), ) .await; - - let env: NwcResponseEnvelope = client - .request("lookup_invoice", serde_json::json!({ "payment_hash": "h" })) + client + .lookup_invoice(None, Some("lnbc-xyz")) .await - .unwrap(); - assert!(env.into_result().unwrap().is_settled()); + .expect("lookup_invoice"); + let call = wallet.calls().await.into_iter().next().unwrap(); + assert_eq!(call.1["invoice"].as_str(), Some("lnbc-xyz")); } #[tokio::test] - async fn round_trips_pay_invoice() { - let (wallet, client, _pool) = harness().await; - wallet - .set_behavior( - "pay_invoice", - WalletBehavior::Answer(serde_json::json!({ "preimage": "pre", "fees_paid": 12 })), - ) - .await; + async fn lookup_without_either_identifier_is_a_config_error() { + let (_w, client, _p) = harness().await; + assert!(matches!( + client.lookup_invoice(None, None).await, + Err(NwcError::Config { .. }) + )); + } - let env: NwcResponseEnvelope = client - .request("pay_invoice", serde_json::json!({ "invoice": "lnbc1" })) - .await - .unwrap(); - let r = env.into_result().unwrap(); - assert_eq!(r.preimage.as_deref(), Some("pre")); - assert_eq!(r.fees_paid, Some(12)); + #[tokio::test] + async fn make_invoice_rejects_a_zero_amount_before_the_wallet() { + let (wallet, client, _p) = harness().await; + assert!(matches!( + client.make_invoice(0, None, None).await, + Err(NwcError::InvalidAmount { .. }) + )); + assert_eq!(wallet.call_count("make_invoice").await, 0); } + // ── errors and robustness ──────────────────────────────────────────────── + #[tokio::test] async fn wallet_error_is_surfaced_not_read_as_success() { let (wallet, client, _pool) = harness().await; @@ -588,62 +1013,138 @@ mod tests { ) .await; - let env: NwcResponseEnvelope = client - .request("pay_invoice", serde_json::json!({ "invoice": "lnbc1" })) - .await - .expect("transport-level call succeeds"); - - // The envelope carries the error, and collapsing it is an Err: a failed - // payment can never be read as a settled one. + let env = client.pay_invoice("lnbc1", None).await.expect("call ok"); assert_eq!( - env.error.as_ref().map(NwcError::code_upper).as_deref(), + env.error.as_ref().map(NwcErrorBody::code_upper).as_deref(), Some("INSUFFICIENT_BALANCE") ); assert!(env.into_result().is_err()); } + /// An unscripted method gets `NOT_IMPLEMENTED` from a real wallet. #[tokio::test] - async fn silence_times_out() { + async fn an_unimplemented_method_surfaces_the_wallets_code() { + let (wallet, client, _pool) = harness().await; + wallet + .set_quirks(MockWalletQuirks { + not_implemented_for_unscripted: true, + ..Default::default() + }) + .await; + let env = client.pay_invoice("lnbc1", None).await.expect("call ok"); + assert_eq!( + env.error.as_ref().map(NwcErrorBody::code_upper).as_deref(), + Some("NOT_IMPLEMENTED") + ); + } + + #[tokio::test] + async fn silence_fails_at_the_deadline_and_says_it_was_published() { let (wallet, client, _pool) = harness_with_timeout(Duration::from_millis(300)).await; wallet .set_behavior("make_invoice", WalletBehavior::Silence) .await; let started = tokio::time::Instant::now(); - let res: Result, _> = client - .request("make_invoice", serde_json::json!({ "amount": 1000u64 })) - .await; + let err = client + .make_invoice(1000, None, None) + .await + .expect_err("silence must not resolve"); + + match err { + NwcError::Timeout { published, .. } => { + assert!(published, "the request did reach the relay"); + assert!( + err_may_reach(&NwcError::Timeout { + method: "x".into(), + timeout_ms: 1, + published: true + }), + "a published timeout must warn the caller" + ); + } + other => panic!("expected a Timeout, got {other:?}"), + } + assert!(started.elapsed() < Duration::from_secs(3)); + } - assert!(res.is_err(), "silence must not resolve"); - assert!( - started.elapsed() < Duration::from_secs(3), - "must fail at the configured timeout, not hang" - ); + fn err_may_reach(e: &NwcError) -> bool { + e.may_have_reached_wallet() } + /// serde quotes the offending value, and a wallet can put an invoice there. #[tokio::test] - async fn malformed_response_is_an_error() { + async fn a_malformed_response_error_carries_no_payload() { let (wallet, client, _pool) = harness_with_timeout(Duration::from_millis(500)).await; wallet .set_behavior("make_invoice", WalletBehavior::Malformed) .await; - let res: Result, _> = client - .request("make_invoice", serde_json::json!({ "amount": 1000u64 })) + let err = client.make_invoice(1000, None, None).await.unwrap_err(); + assert!(matches!(err, NwcError::MalformedResponse { .. })); + let text = err.to_string(); + assert!(!text.contains("lnbc"), "{text}"); + assert!(text.contains("make_invoice")); + } + + /// A wallet message can name the invoice, and the middlewares log errors. + #[tokio::test] + async fn an_invoice_in_a_wallet_message_never_reaches_the_error_text() { + let (wallet, client, _pool) = harness().await; + let invoice = "lnbc210n1pjqqqqqpp5abcdefghijklmnopqrstuvwxyz0123456789"; + wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Error { + code: "FAILED".to_string(), + message: format!("could not pay {invoice}"), + }, + ) .await; - assert!(res.is_err()); + + let env = client.pay_invoice("lnbc1", None).await.expect("call ok"); + let described = env.error.as_ref().unwrap().describe(); + assert!(!described.contains(invoice), "{described}"); + // And through the typed error a caller would surface. + let wrapped = NwcError::Wallet { + method: "pay_invoice".into(), + detail: described, + }; + assert!(!wrapped.to_string().contains("lnbc210n1pjqqqqq")); } + // ── correlation, which is also an authorization check ──────────────────── + + /// The author check is load-bearing: `nostr-relay-pool` verifies signatures + /// but not filter matches, so a relay that ignores `authors` could deliver + /// a stranger-signed, correctly correlated "settled". + /// + /// The old version of this test published before the client subscribed and + /// without an `e` tag, so three independent layers dropped the event and + /// deleting the author check still passed. This one correlates properly and + /// is published only after a real request is in flight. #[tokio::test] - async fn stranger_signed_response_is_ignored() { - let (wallet, client, pool) = harness_with_timeout(Duration::from_millis(400)).await; + async fn a_stranger_cannot_answer_even_when_correctly_correlated() { + let (wallet, client, pool) = harness_with_timeout(Duration::from_millis(700)).await; wallet .set_behavior("make_invoice", WalletBehavior::Silence) .await; let client_pubkey = client.client_pubkey(); - // A third party publishes a well-formed response addressed to the - // client. It must not satisfy the pending request. + let client = Arc::new(client); + let caller = Arc::clone(&client); + let call = tokio::spawn(async move { caller.make_invoice(1000, None, None).await }); + + // Let the request be published so its id exists to correlate against. + tokio::time::sleep(Duration::from_millis(120)).await; + let request_id = pool + .stored_events() + .await + .into_iter() + .find(|e| e.kind.as_u16() == 23194) + .map(|e| e.id) + .expect("the request was published"); + let stranger = Keys::generate(); let plaintext = serde_json::json!({"result_type":"make_invoice","result":{"invoice":"evil"}}) @@ -651,33 +1152,55 @@ mod tests { let content = nip04::encrypt(stranger.secret_key(), &client_pubkey, plaintext).expect("encrypt"); let evil = EventBuilder::new(Kind::from(23195u16), content) - .tags([Tag::public_key(client_pubkey)]) + .tags([Tag::public_key(client_pubkey), Tag::event(request_id)]) .sign_with_keys(&stranger) .expect("sign"); - pool.publish_event(&evil).await.expect("publish"); + // Delivered past the filter on purpose: a conformant relay would drop + // this on `authors`, and then the client-side author check would never + // be reached. The guard exists because `nostr-relay-pool` does not + // verify that a delivered event matched the subscription. + pool.publish_event_unfiltered(&evil).await; - let res: Result, _> = client - .request("make_invoice", serde_json::json!({ "amount": 1000u64 })) + let result = call.await.expect("task"); + assert!( + matches!(result, Err(NwcError::Timeout { .. })), + "a stranger-signed answer must not satisfy the request, got {result:?}" + ); + } + + #[tokio::test] + async fn an_answer_without_a_correlation_tag_is_ignored() { + let (wallet, client, _pool) = harness_with_timeout(Duration::from_millis(500)).await; + wallet + .set_behavior( + "make_invoice", + WalletBehavior::NoCorrelationTag(invoice_result("uncorrelated")), + ) .await; - assert!(res.is_err(), "a stranger must not be able to answer"); + assert!(matches!( + client.make_invoice(1000, None, None).await, + Err(NwcError::Timeout { .. }) + )); } + /// Needs the mock to answer out of order, which requires per-request + /// spawning: an inline wallet is strictly FIFO and this can never fail. #[tokio::test] - async fn concurrent_requests_correlate_independently() { + async fn a_fast_answer_overtaking_a_slow_one_still_correlates() { let (wallet, client, _pool) = harness().await; wallet .set_behavior( "make_invoice", WalletBehavior::Delayed { - delay: Duration::from_millis(120), - result: serde_json::json!({ "invoice": "inv-make" }), + delay: Duration::from_millis(300), + result: invoice_result("slow-make"), }, ) .await; wallet .set_behavior( "pay_invoice", - WalletBehavior::Answer(serde_json::json!({ "preimage": "pre-pay" })), + WalletBehavior::Answer(serde_json::json!({ "preimage": "fast-pay" })), ) .await; @@ -685,164 +1208,256 @@ mod tests { let c1 = Arc::clone(&client); let c2 = Arc::clone(&client); - let (make, pay) = tokio::join!( - async move { - c1.request::<_, NwcInvoiceResult>( - "make_invoice", - serde_json::json!({ "amount": 1000u64 }), - ) - .await - }, - async move { - c2.request::<_, NwcPayInvoiceResult>( - "pay_invoice", - serde_json::json!({ "invoice": "lnbc1" }), - ) - .await - } + let slow = tokio::spawn(async move { c1.make_invoice(1000, None, None).await }); + tokio::time::sleep(Duration::from_millis(50)).await; + let fast = c2.pay_invoice("lnbc1", None).await; + + // The fast answer lands while the slow one is still outstanding. + assert_eq!( + fast.expect("pay") + .into_result() + .expect("ok") + .preimage + .as_deref(), + Some("fast-pay") ); + assert_eq!( + slow.await + .expect("task") + .expect("make") + .into_result() + .expect("ok") + .invoice + .as_deref(), + Some("slow-make"), + "the slow request must not receive the fast answer" + ); + } - // The slow make_invoice must not receive the fast pay_invoice answer. + /// A mislabelled `result_type` is tolerated: correlation by event id + /// already proves which request this answers. + #[tokio::test] + async fn a_mislabelled_result_type_is_tolerated() { + let (wallet, client, _pool) = harness().await; + wallet + .set_behavior( + "make_invoice", + WalletBehavior::MislabelledResultType { + result_type: "something_else".to_string(), + result: invoice_result("still-mine"), + }, + ) + .await; + let env = client.make_invoice(1000, None, None).await.expect("ok"); assert_eq!( - make.unwrap().into_result().unwrap().invoice.as_deref(), - Some("inv-make") + env.into_result().unwrap().invoice.as_deref(), + Some("still-mine") ); + } + + // ── lifecycle ──────────────────────────────────────────────────────────── + + /// A cancelled request must not leave its waiter behind. The payment + /// processor races every verification against a cancellation token, so a + /// leak here is per-payment. + #[tokio::test] + async fn dropping_a_request_future_removes_its_waiter() { + let (wallet, client, _pool) = harness_with_timeout(Duration::from_secs(5)).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + + { + let fut = client.make_invoice(1000, None, None); + // Drive it far enough to register the waiter, then drop it. + assert!( + tokio::time::timeout(Duration::from_millis(150), fut) + .await + .is_err(), + "the silent wallet should not answer" + ); + } + tokio::time::sleep(Duration::from_millis(50)).await; assert_eq!( - pay.unwrap().into_result().unwrap().preimage.as_deref(), - Some("pre-pay") + client.waiter_count(), + 0, + "a dropped request future must not orphan its waiter" ); } + /// `close()` must be fail-fast: before this, a closed client still + /// published `pay_invoice` and the wallet still paid. #[tokio::test] - async fn decodes_nip04_notifications() { - assert_notification_decodes(false).await; + async fn a_closed_client_refuses_to_publish() { + let (wallet, client, _pool) = harness_with_timeout(Duration::from_millis(600)).await; + wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Answer(serde_json::json!({ "preimage": "p" })), + ) + .await; + + client.close(); + assert!(client.is_closed()); + + let err = client + .pay_invoice("lnbc1", None) + .await + .expect_err("a closed client must refuse"); + assert!(matches!(err, NwcError::Closed), "got {err:?}"); + assert!(!err.may_have_reached_wallet()); + tokio::time::sleep(Duration::from_millis(150)).await; + assert_eq!( + wallet.call_count("pay_invoice").await, + 0, + "a closed client must not cause a payment" + ); } #[tokio::test] - async fn decodes_nip44_notifications() { - assert_notification_decodes(true).await; + async fn a_zero_response_timeout_is_refused_at_construction() { + let pool = Arc::new(MockRelayPool::new()); + let secret = SecretKey::generate(); + let uri = format!( + "nostr+walletconnect://{}?relay=wss%3A%2F%2Fmock.relay&secret={}", + Keys::generate().public_key().to_hex(), + secret.to_secret_hex() + ); + let connection = parse_nwc_uri(&uri).unwrap(); + assert!(matches!( + NwcClient::with_pool( + pool as Arc, + connection, + NwcClientOptions::new().with_response_timeout(Duration::ZERO), + ), + Err(NwcError::Config { .. }) + )); } - /// Both notification kinds must decode; ts decrypts every kind as NIP-04 - /// and so silently drops the 23197 (NIP-44) form. - async fn assert_notification_decodes(nip44_mode: bool) { + /// One panicking sink is caller code; it must not take the rail down. + #[tokio::test] + async fn a_panicking_sink_does_not_kill_the_rail() { let (wallet, client, _pool) = harness().await; - let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); client - .on_notification(move |payload| { - let _ = tx.send(payload); - }) + .on_notification(|_| panic!("a badly behaved sink")) .await .expect("sink registers"); wallet - .settle(&client.client_pubkey(), "hash-9", nip44_mode) + .settle(&client.client_pubkey(), "hash-boom") .await - .expect("wallet publishes notification"); + .expect("notification published"); + tokio::time::sleep(Duration::from_millis(200)).await; + + assert!(!client.is_closed(), "the reader must have survived"); - let payload = tokio::time::timeout(Duration::from_secs(3), rx.recv()) + // And the rail still works. + wallet + .set_behavior( + "make_invoice", + WalletBehavior::Answer(invoice_result("after-panic")), + ) + .await; + let env = client + .make_invoice(1000, None, None) .await - .expect("notification arrives") - .expect("channel open"); + .expect("the rail still answers after a sink panicked"); + assert_eq!( + env.into_result().unwrap().invoice.as_deref(), + Some("after-panic") + ); + } - assert_eq!(payload.notification_type, NOTIFICATION_PAYMENT_RECEIVED); - assert_eq!(payload.payment_hash(), Some("hash-9")); + // ── notifications ──────────────────────────────────────────────────────── + + async fn collect_notifications( + client: &NwcClient, + ) -> tokio::sync::mpsc::UnboundedReceiver { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + client + .on_notification(move |p| { + let _ = tx.send(p); + }) + .await + .expect("sink registers"); + rx } #[tokio::test] - async fn fetches_info_notification_types() { + async fn decodes_a_nip04_notification() { let (wallet, client, _pool) = harness().await; + let mut rx = collect_notifications(&client).await; wallet - .publish_info_event(&["payment_received", "payment_sent"]) + .settle(&client.client_pubkey(), "hash-04") .await - .expect("info published"); - - let types = client - .fetch_info_notification_types() + .unwrap(); + let p = tokio::time::timeout(Duration::from_secs(3), rx.recv()) .await - .expect("fetch succeeds"); - assert!(types.iter().any(|t| t == "payment_received")); - assert!(types.iter().any(|t| t == "payment_sent")); + .expect("arrives") + .expect("open"); + assert_eq!(p.notification_type, NOTIFICATION_PAYMENT_RECEIVED); + assert_eq!(p.payment_hash(), Some("hash-04")); } #[tokio::test] - async fn missing_info_event_yields_empty_set_not_an_error() { - let (_wallet, client, _pool) = harness_with_timeout(Duration::from_millis(400)).await; - let types = client - .fetch_info_notification_types() + async fn decodes_a_nip44_notification() { + let (wallet, client, _pool) = harness().await; + wallet + .set_quirks(MockWalletQuirks { + nip44_notifications: true, + ..Default::default() + }) + .await; + let mut rx = collect_notifications(&client).await; + wallet + .settle(&client.client_pubkey(), "hash-44") .await - .expect("absence is not a failure"); - assert!(types.is_empty(), "callers fall back to polling on empty"); + .unwrap(); + let p = tokio::time::timeout(Duration::from_secs(3), rx.recv()) + .await + .expect("arrives") + .expect("open"); + assert_eq!(p.payment_hash(), Some("hash-44")); } - /// A response the *wallet itself* signed, but carrying an `e` tag for a - /// request we never made, must not satisfy a pending waiter. This is the - /// correlation half of the guard; `stranger_signed_response_is_ignored` - /// covers the authorship half. + /// ts's own fixture: NIP-04 content on kind 23197. A decrypt-by-kind + /// client drops this. #[tokio::test] - async fn uncorrelated_response_is_ignored() { - let (wallet, client, pool) = harness_with_timeout(Duration::from_millis(400)).await; + async fn decodes_nip04_content_published_on_the_nip44_kind() { + let (wallet, client, _pool) = harness().await; + let mut rx = collect_notifications(&client).await; wallet - .set_behavior("make_invoice", WalletBehavior::Silence) - .await; - let client_pubkey = client.client_pubkey(); - - // Correctly signed by the wallet, addressed to us, well formed, but - // correlated to an unrelated event id. - let unrelated = EventId::all_zeros(); - let plaintext = - serde_json::json!({"result_type":"make_invoice","result":{"invoice":"wrong"}}) - .to_string(); - let content = - nip04::encrypt(wallet.keys().secret_key(), &client_pubkey, plaintext).expect("encrypt"); - let event = EventBuilder::new(Kind::from(23195u16), content) - .tags([Tag::public_key(client_pubkey), Tag::event(unrelated)]) - .sign_with_keys(&wallet.keys()) - .expect("sign"); - pool.publish_event(&event).await.expect("publish"); - - let res: Result, _> = client - .request("make_invoice", serde_json::json!({ "amount": 1000u64 })) - .await; - assert!( - res.is_err(), - "a response correlated to another request must not settle ours" - ); + .settle_nip04_on_nip44_kind(&client.client_pubkey(), "hash-mixed") + .await + .unwrap(); + let p = tokio::time::timeout(Duration::from_secs(3), rx.recv()) + .await + .expect("decrypt by shape must handle NIP-04 on kind 23197") + .expect("open"); + assert_eq!(p.payment_hash(), Some("hash-mixed")); } - /// The reader task must survive a burst that overruns the broadcast buffer. - /// - /// A `Lagged` drops events, but killing the loop there would strand every - /// waiter forever; continuing means an in-flight request still resolves. - /// The burst is sized past the pool's 1024-slot channel so the lag path is - /// exercised, and the assertion is the property that matters either way: - /// a request issued afterwards still correlates and resolves. + // ── info event ─────────────────────────────────────────────────────────── + #[tokio::test] - async fn reader_survives_broadcast_burst() { - let (wallet, client, pool) = harness().await; + async fn fetches_info_notification_types() { + let (wallet, client, _pool) = harness().await; wallet - .set_behavior( - "make_invoice", - WalletBehavior::Answer(serde_json::json!({ "invoice": "after-burst" })), - ) - .await; - - // Flood with unrelated events addressed to nobody in particular. - let noise = Keys::generate(); - for i in 0..1500u32 { - let event = EventBuilder::new(Kind::from(1u16), format!("noise-{i}")) - .sign_with_keys(&noise) - .expect("sign noise"); - let _ = pool.publish_event(&event).await; - } + .publish_info_event(&["payment_received", "payment_sent"]) + .await + .unwrap(); + let types = client.fetch_info_notification_types().await.unwrap(); + assert!(types.iter().any(|t| t == "payment_received")); + assert!(types.iter().any(|t| t == "payment_sent")); + } - let env: NwcResponseEnvelope = client - .request("make_invoice", serde_json::json!({ "amount": 1000u64 })) + #[tokio::test] + async fn missing_info_event_yields_empty_not_an_error() { + let (_w, client, _p) = harness_with_timeout(Duration::from_millis(400)).await; + assert!(client + .fetch_info_notification_types() .await - .expect("reader must still be alive after the burst"); - assert_eq!( - env.into_result().unwrap().invoice.as_deref(), - Some("after-burst") - ); + .expect("absence is not a failure") + .is_empty()); } } diff --git a/src/payments/nip47/error.rs b/src/payments/nip47/error.rs new file mode 100644 index 0000000..c0fe180 --- /dev/null +++ b/src/payments/nip47/error.rs @@ -0,0 +1,233 @@ +//! Typed errors for the NIP-47 client. +//! +//! The rail used to return `PaymentError::Processor(String)` for everything, +//! which lost two things a caller needs. +//! +//! **Which side failed.** A client-side wallet timeout surfaced from +//! `LnBolt11NwcPaymentHandler` as `Processor`, not `Handler`. Mapping happens +//! once, at the trait boundary, via [`NwcError::into_processor_error`] and +//! [`NwcError::into_handler_error`]. +//! +//! **Whether the wallet may already have acted.** `pay_invoice` is not +//! idempotent. A timeout *after* the request was published may mean the wallet +//! paid and we simply did not hear the answer; a failure *before* the publish +//! means it certainly did not. [`NwcError::Timeout`] carries `published` so a +//! caller can tell those apart instead of guessing, and +//! [`NwcError::may_have_reached_wallet`] answers it directly. +//! +//! Every message here is payload-free: a serde failure reports its category +//! plus line and column, never the offending bytes, because a wallet can put a +//! BOLT11 invoice in a response and both payment middlewares log a +//! `PaymentError` at warn. + +use crate::payments::errors::PaymentError; + +/// Something that went wrong talking to a NIP-47 wallet. +#[derive(Debug, thiserror::Error)] +#[non_exhaustive] +pub enum NwcError { + /// No answer arrived within the deadline. + #[error( + "NWC {method} timed out after {timeout_ms} ms ({})", + if *published { "request was published; the wallet may have acted on it" } + else { "request was never published" } + )] + Timeout { + /// The NIP-47 method that timed out. + method: String, + /// The deadline that elapsed, in milliseconds. + timeout_ms: u64, + /// Whether the request reached a relay before the deadline. When true, + /// a non-idempotent call such as `pay_invoice` may already have been + /// executed by the wallet. + published: bool, + }, + + /// The client was shut down, or is shutting down. + #[error("NWC client is closed")] + Closed, + + /// Connecting to the wallet's relays failed. + #[error("NWC relay connect failed: {reason}")] + Connect { + /// Transport-level reason, without any wallet payload. + reason: String, + }, + + /// Subscribing for responses failed. + #[error("NWC subscribe failed: {reason}")] + Subscribe { + /// Transport-level reason, without any wallet payload. + reason: String, + }, + + /// Publishing the request failed. + #[error("NWC publish failed for {method}: {reason}")] + Publish { + /// The NIP-47 method whose request could not be published. + method: String, + /// Transport-level reason, without any wallet payload. + reason: String, + }, + + /// Encrypting a request, or decrypting a response, failed. + #[error("NWC {direction} failed: {reason}")] + Crypto { + /// `"encryption"` or `"decryption"`. + direction: &'static str, + /// Category only, never the ciphertext or plaintext. + reason: String, + }, + + /// The response did not parse. + /// + /// Deliberately carries no payload: serde's own message quotes the + /// offending value, and that value can be an invoice. + #[error("NWC response for {method} could not be parsed ({category} at line {line}, column {column})")] + MalformedResponse { + /// The NIP-47 method whose response could not be parsed. + method: String, + /// serde's classification (`data`, `syntax`, `eof`, `io`). + category: &'static str, + /// Line of the parse failure. + line: usize, + /// Column of the parse failure. + column: usize, + }, + + /// The wallet answered, and said no. + #[error("NWC {method} failed: {detail}")] + Wallet { + /// The NIP-47 method the wallet refused. + method: String, + /// Already sanitized by + /// [`NwcErrorBody::describe`](crate::payments::nip47::NwcErrorBody::describe). + detail: String, + }, + + /// A request amount was rejected before any wallet call. + #[error("NWC invalid amount: {reason}")] + InvalidAmount { + /// Why the amount was refused. + reason: String, + }, + + /// A connection string, or an option, was invalid. + #[error("NWC configuration error: {reason}")] + Config { + /// What was wrong. + reason: String, + }, +} + +impl NwcError { + /// Whether the wallet may already have acted on the request. + /// + /// True only when the request reached a relay. The distinction matters for + /// `pay_invoice`, which is not idempotent: a caller that retries a + /// `may_have_reached_wallet` failure risks paying twice. + pub fn may_have_reached_wallet(&self) -> bool { + match self { + Self::Timeout { published, .. } => *published, + // The wallet answered, so it certainly saw the request. + Self::Wallet { .. } | Self::MalformedResponse { .. } => true, + Self::Crypto { direction, .. } => *direction == "decryption", + _ => false, + } + } + + /// Map to the crate error a [`PaymentProcessor`](crate::payments::PaymentProcessor) returns. + pub fn into_processor_error(self) -> PaymentError { + PaymentError::Processor(self.to_string()) + } + + /// Map to the crate error a [`PaymentHandler`](crate::payments::PaymentHandler) returns. + pub fn into_handler_error(self) -> PaymentError { + PaymentError::Handler(self.to_string()) + } + + /// Build a [`Self::MalformedResponse`] from a serde failure without + /// reproducing any of the offending bytes. + pub(crate) fn from_serde(method: &str, err: &serde_json::Error) -> Self { + let category = match err.classify() { + serde_json::error::Category::Io => "io", + serde_json::error::Category::Syntax => "syntax", + serde_json::error::Category::Data => "data", + serde_json::error::Category::Eof => "eof", + }; + Self::MalformedResponse { + method: method.to_string(), + category, + line: err.line(), + column: err.column(), + } + } +} + +impl From for PaymentError { + /// Defaults to the processor arm. Handler call sites use + /// [`NwcError::into_handler_error`] explicitly. + fn from(err: NwcError) -> Self { + err.into_processor_error() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_published_timeout_says_the_wallet_may_have_acted() { + let e = NwcError::Timeout { + method: "pay_invoice".into(), + timeout_ms: 60_000, + published: true, + }; + assert!(e.may_have_reached_wallet()); + let text = e.to_string(); + assert!(text.contains("may have acted"), "{text}"); + } + + #[test] + fn an_unpublished_timeout_says_it_did_not() { + let e = NwcError::Timeout { + method: "pay_invoice".into(), + timeout_ms: 60_000, + published: false, + }; + assert!(!e.may_have_reached_wallet()); + assert!(e.to_string().contains("never published")); + } + + #[test] + fn maps_to_the_right_side_of_the_trait_boundary() { + let e = || NwcError::Closed; + assert!(matches!( + e().into_processor_error(), + PaymentError::Processor(_) + )); + assert!(matches!(e().into_handler_error(), PaymentError::Handler(_))); + } + + /// serde's own message quotes the offending value, which can be an invoice. + #[test] + fn a_parse_failure_never_reproduces_the_payload() { + let invoice = "lnbc210n1pjqqqqqpp5abcdefghijklmnopqrstuvwxyz0123456789"; + let raw = format!(r#"{{"result_type":{{"nested":"{invoice}"}}}}"#); + let err = serde_json::from_str::("{ not json").unwrap_err(); + let mapped = NwcError::from_serde("make_invoice", &err); + let text = mapped.to_string(); + assert!(!text.contains(invoice), "{text}"); + assert!(!text.contains("lnbc"), "{text}"); + assert!(text.contains("make_invoice") && text.contains("line")); + // The raw payload is not consulted at all. + assert!(!raw.is_empty()); + } + + #[test] + fn config_and_amount_errors_do_not_reach_the_wallet() { + assert!(!NwcError::InvalidAmount { reason: "x".into() }.may_have_reached_wallet()); + assert!(!NwcError::Config { reason: "x".into() }.may_have_reached_wallet()); + assert!(!NwcError::Closed.may_have_reached_wallet()); + } +} diff --git a/src/payments/nip47/mock_wallet.rs b/src/payments/nip47/mock_wallet.rs index c09b8f7..9303b2e 100644 --- a/src/payments/nip47/mock_wallet.rs +++ b/src/payments/nip47/mock_wallet.rs @@ -2,11 +2,22 @@ //! //! Runs the *real* wire protocol over a linked [`MockRelayPool`]: it subscribes //! for kind-23194 requests addressed to it, decrypts them, and publishes -//! kind-23195 responses (and kind-23196/23197 notifications). That means -//! [`NwcClient`](crate::payments::nip47::NwcClient) exercises its own encrypt, -//! publish, correlate, and decrypt paths in CI, rather than being stubbed out. +//! kind-23195 responses (and notifications). That means +//! [`NwcClient`](super::NwcClient) exercises its own encrypt, publish, +//! correlate and decrypt paths in CI rather than being stubbed out. //! -//! Behaviors are scripted per method: answer, error, silence, delay, malformed. +//! # It must be able to misbehave +//! +//! A mock written alongside the client agrees with the client wherever the +//! client is wrong, so every knob here exists to break one assumption the +//! client would otherwise never have tested: omitting the `p` tag, answering +//! out of order, answering with a wrong-typed field, mislabelling +//! `result_type`, dropping the `e` tag, encrypting a notification the other +//! way, or publishing one notification under both kinds. +//! +//! Requests are served on a spawned task each, so a fast answer really can +//! overtake a slow one. Serving them inline would make the wallet strictly +//! FIFO and no correlation test could ever fail. use std::collections::HashMap; use std::sync::Arc; @@ -20,12 +31,13 @@ use crate::relay::{MockRelayPool, RelayPoolTrait}; const KIND_REQUEST: u16 = 23194; const KIND_RESPONSE: u16 = 23195; -const KIND_NOTIFICATION_NIP04: u16 = 23196; -const KIND_NOTIFICATION_NIP44: u16 = 23197; +const KIND_NOTIFICATION_LEGACY: u16 = 23196; +const KIND_NOTIFICATION: u16 = 23197; const KIND_INFO: u16 = 13194; /// How the mock wallet answers one method. #[derive(Debug, Clone)] +#[non_exhaustive] pub enum WalletBehavior { /// Reply with this `result` object. Answer(serde_json::Value), @@ -33,12 +45,13 @@ pub enum WalletBehavior { Error { /// Error code, e.g. `NOT_FOUND`. code: String, - /// Human-readable message. + /// Human-readable message. May contain anything a real wallet might. message: String, }, - /// Never reply, so the caller hits its own timeout. + /// Never reply, so the caller hits its own deadline. Silence, - /// Reply with `result` after a delay. + /// Reply with `result` after a delay. Served on its own task, so a later + /// request with a shorter delay answers first. Delayed { /// Delay before replying. delay: Duration, @@ -47,6 +60,32 @@ pub enum WalletBehavior { }, /// Reply with content that is not valid JSON once decrypted. Malformed, + /// Reply with a `result_type` that does not match the request method. + MislabelledResultType { + /// The wrong label to send. + result_type: String, + /// The `result` object to send. + result: serde_json::Value, + }, + /// Reply without the `e` tag, so nothing can correlate it. + NoCorrelationTag(serde_json::Value), +} + +/// Knobs that break an assumption the client might otherwise be making. +#[derive(Debug, Clone, Default)] +#[non_exhaustive] +pub struct MockWalletQuirks { + /// Omit the `p` tag on responses and notifications. A wallet that does + /// this is invisible to a `#p`-filtered subscription. + pub omit_p_tag: bool, + /// Answer an unscripted method with `NOT_IMPLEMENTED` instead of silence, + /// which is what a real wallet does for a method it lacks. + pub not_implemented_for_unscripted: bool, + /// Publish each notification under BOTH notification kinds, as a wallet + /// supporting both encryptions does. + pub duplicate_notification_kinds: bool, + /// Encrypt notifications with NIP-44 rather than NIP-04. + pub nip44_notifications: bool, } /// A scripted NIP-47 wallet service on a mock relay network. @@ -54,7 +93,7 @@ pub struct MockWallet { pool: Arc, keys: Keys, behaviors: Arc>>, - /// Methods seen, in arrival order, for assertions. + quirks: Arc>, calls: Arc>>, cancel: CancellationToken, } @@ -71,6 +110,7 @@ impl MockWallet { pool, keys, behaviors: Arc::new(Mutex::new(HashMap::new())), + quirks: Arc::new(Mutex::new(MockWalletQuirks::default())), calls: Arc::new(Mutex::new(Vec::new())), cancel: CancellationToken::new(), } @@ -82,8 +122,7 @@ impl MockWallet { } /// The wallet's signing keys, so a test can forge an event that is - /// genuinely wallet-signed (for example a correctly authored but - /// mis-correlated response). + /// genuinely wallet-signed. pub fn keys(&self) -> Keys { self.keys.clone() } @@ -96,6 +135,11 @@ impl MockWallet { .insert(method.to_string(), behavior); } + /// Replace the quirk set. + pub async fn set_quirks(&self, quirks: MockWalletQuirks) { + *self.quirks.lock().await = quirks; + } + /// Methods received so far, with their params, in arrival order. pub async fn calls(&self) -> Vec<(String, serde_json::Value)> { self.calls.lock().await.clone() @@ -133,14 +177,15 @@ impl MockWallet { /// Publish a `payment_received` notification to `client_pubkey`. /// - /// `nip44` selects kind 23197 (NIP-44) over the legacy kind 23196 (NIP-04), - /// so both decode paths are exercisable. + /// Encryption and kind follow [`MockWalletQuirks`]; with + /// `duplicate_notification_kinds` the same payload goes out under both + /// kinds, which is what a dual-encryption wallet really does. pub async fn settle( &self, client_pubkey: &PublicKey, payment_hash: &str, - nip44_mode: bool, ) -> Result<(), String> { + let quirks = self.quirks.lock().await.clone(); let payload = serde_json::json!({ "notification_type": "payment_received", "notification": { @@ -148,29 +193,61 @@ impl MockWallet { "state": "settled", "settled_at": 1_700_000_000i64, } - }); - let plaintext = payload.to_string(); + }) + .to_string(); - let (kind, content) = if nip44_mode { - ( - KIND_NOTIFICATION_NIP44, + let kinds: &[u16] = if quirks.duplicate_notification_kinds { + &[KIND_NOTIFICATION_LEGACY, KIND_NOTIFICATION] + } else if quirks.nip44_notifications { + &[KIND_NOTIFICATION] + } else { + &[KIND_NOTIFICATION_LEGACY] + }; + + for kind in kinds { + let content = if quirks.nip44_notifications && !quirks.duplicate_notification_kinds { nip44::encrypt( self.keys.secret_key(), client_pubkey, - plaintext, + &payload, nip44::Version::default(), ) - .map_err(|e| e.to_string())?, - ) - } else { - ( - KIND_NOTIFICATION_NIP04, - nip04::encrypt(self.keys.secret_key(), client_pubkey, plaintext) - .map_err(|e| e.to_string())?, - ) - }; + .map_err(|e| e.to_string())? + } else { + nip04::encrypt(self.keys.secret_key(), client_pubkey, &payload) + .map_err(|e| e.to_string())? + }; - let event = EventBuilder::new(Kind::from(kind), content) + let mut builder = EventBuilder::new(Kind::from(*kind), content); + if !quirks.omit_p_tag { + builder = builder.tags([Tag::public_key(*client_pubkey)]); + } + let event = builder + .sign_with_keys(&self.keys) + .map_err(|e| e.to_string())?; + self.pool + .publish_event(&event) + .await + .map_err(|e| e.to_string())?; + } + Ok(()) + } + + /// Publish a NIP-04-encrypted notification on kind 23197, which is ts's own + /// fixture and which a decrypt-by-kind client drops. + pub async fn settle_nip04_on_nip44_kind( + &self, + client_pubkey: &PublicKey, + payment_hash: &str, + ) -> Result<(), String> { + let payload = serde_json::json!({ + "notification_type": "payment_received", + "notification": { "payment_hash": payment_hash } + }) + .to_string(); + let content = nip04::encrypt(self.keys.secret_key(), client_pubkey, &payload) + .map_err(|e| e.to_string())?; + let event = EventBuilder::new(Kind::from(KIND_NOTIFICATION), content) .tags([Tag::public_key(*client_pubkey)]) .sign_with_keys(&self.keys) .map_err(|e| e.to_string())?; @@ -196,11 +273,12 @@ impl MockWallet { let pool = Arc::clone(&self.pool); let keys = self.keys.clone(); let behaviors = Arc::clone(&self.behaviors); + let quirks = Arc::clone(&self.quirks); let calls = Arc::clone(&self.calls); let cancel = self.cancel.clone(); tokio::spawn(async move { - Self::serve(notifications, pool, keys, behaviors, calls, cancel).await; + Self::serve(notifications, pool, keys, behaviors, quirks, calls, cancel).await; }); Ok(()) } @@ -215,6 +293,7 @@ impl MockWallet { pool: Arc, keys: Keys, behaviors: Arc>>, + quirks: Arc>, calls: Arc>>, cancel: CancellationToken, ) { @@ -254,50 +333,96 @@ impl MockWallet { calls.lock().await.push((method.clone(), params)); + let quirks_now = quirks.lock().await.clone(); let behavior = behaviors.lock().await.get(&method).cloned(); - let Some(behavior) = behavior else { - // Unscripted method: stay silent, like a wallet that does not - // implement it and never answers. - continue; + let behavior = match behavior { + Some(b) => b, + None if quirks_now.not_implemented_for_unscripted => WalletBehavior::Error { + code: "NOT_IMPLEMENTED".to_string(), + message: format!("{method} is not supported by this wallet"), + }, + // Default: silence, like a wallet that never answers. + None => continue, }; - let (result, error, malformed) = match behavior { - WalletBehavior::Silence => continue, - WalletBehavior::Answer(result) => (Some(result), None, false), - WalletBehavior::Delayed { delay, result } => { - tokio::time::sleep(delay).await; - (Some(result), None, false) - } - WalletBehavior::Error { code, message } => ( - None, - Some(serde_json::json!({ "code": code, "message": message })), - false, - ), - WalletBehavior::Malformed => (None, None, true), - }; + // One task per request, so a fast answer can overtake a slow one. + // Serving inline would make this wallet strictly FIFO and no + // correlation test could ever fail. + let pool = Arc::clone(&pool); + let keys = keys.clone(); + tokio::spawn(async move { + Self::answer(pool, keys, *event, method, behavior, quirks_now).await; + }); + } + } - let plaintext = if malformed { - "{ this is not json".to_string() - } else { - serde_json::json!({ - "result_type": method, - "error": error, - "result": result, - }) - .to_string() - }; + async fn answer( + pool: Arc, + keys: Keys, + request: Event, + method: String, + behavior: WalletBehavior, + quirks: MockWalletQuirks, + ) { + let mut result_type = method.clone(); + let mut correlate = true; + + let (result, error, malformed) = match behavior { + WalletBehavior::Silence => return, + WalletBehavior::Answer(result) => (Some(result), None, false), + WalletBehavior::Delayed { delay, result } => { + tokio::time::sleep(delay).await; + (Some(result), None, false) + } + WalletBehavior::Error { code, message } => ( + None, + Some(serde_json::json!({ "code": code, "message": message })), + false, + ), + WalletBehavior::Malformed => (None, None, true), + WalletBehavior::MislabelledResultType { + result_type: label, + result, + } => { + result_type = label; + (Some(result), None, false) + } + WalletBehavior::NoCorrelationTag(result) => { + correlate = false; + (Some(result), None, false) + } + }; - let Ok(content) = nip04::encrypt(keys.secret_key(), &event.pubkey, plaintext) else { - continue; - }; + let plaintext = if malformed { + "{ this is not json".to_string() + } else { + serde_json::json!({ + "result_type": result_type, + "error": error, + "result": result, + }) + .to_string() + }; - let builder = EventBuilder::new(Kind::from(KIND_RESPONSE), content) - .tags([Tag::public_key(event.pubkey), Tag::event(event.id)]); - let Ok(response) = builder.sign_with_keys(&keys) else { - continue; - }; - let _ = pool.publish_event(&response).await; + let Ok(content) = nip04::encrypt(keys.secret_key(), &request.pubkey, plaintext) else { + return; + }; + + let mut tags = Vec::new(); + if !quirks.omit_p_tag { + tags.push(Tag::public_key(request.pubkey)); } + if correlate { + tags.push(Tag::event(request.id)); + } + + let Ok(response) = EventBuilder::new(Kind::from(KIND_RESPONSE), content) + .tags(tags) + .sign_with_keys(&keys) + else { + return; + }; + let _ = pool.publish_event(&response).await; } } diff --git a/src/payments/nip47/mod.rs b/src/payments/nip47/mod.rs index f107e4d..3006889 100644 --- a/src/payments/nip47/mod.rs +++ b/src/payments/nip47/mod.rs @@ -5,10 +5,12 @@ //! [`NwcClient`], a request/response and notification client over an injected //! [`RelayPoolTrait`](crate::relay::RelayPoolTrait). //! -//! Behind the off-by-default `nwc` feature, which only switches on -//! `nostr-sdk/nip47` and adds no new crate to the tree. +//! Behind the off-by-default `nwc` feature. It enables `nostr-sdk/nip47`, +//! which pulls in NIP-04 and therefore adds `aes`, `cbc` and `cipher` to the +//! dependency tree. pub mod client; +pub mod error; pub mod types; pub mod uri; @@ -16,8 +18,9 @@ pub mod uri; pub mod mock_wallet; pub use client::{NwcClient, NwcClientOptions, DEFAULT_RESPONSE_TIMEOUT}; +pub use error::NwcError; pub use types::{ - sats_to_msats, NwcError, NwcInvoiceResult, NwcNotificationPayload, NwcPayInvoiceResult, + sats_to_msats, NwcErrorBody, NwcInvoiceResult, NwcNotificationPayload, NwcPayInvoiceResult, NwcResponseEnvelope, NOTIFICATION_PAYMENT_RECEIVED, }; pub use uri::{parse_nwc_uri, NwcConnection}; diff --git a/src/payments/nip47/types.rs b/src/payments/nip47/types.rs index ea9fd77..79e619d 100644 --- a/src/payments/nip47/types.rs +++ b/src/payments/nip47/types.rs @@ -1,30 +1,61 @@ //! Permissive NIP-47 wire types. //! //! These deliberately do not reuse `nostr_sdk::nips::nip47`'s response structs. -//! Upstream parses strictly: a wallet that omits an optional field, or adds one, -//! fails deserialization. On a money path that turns a *paid* invoice into a -//! failed verification, so every field here is optional and unknown fields are -//! ignored, field for field with ts `nip47/types.ts`. +//! Upstream parses strictly, and on a money path strictness is the dangerous +//! direction: a wallet that omits an optional field, adds one, or computes one +//! with the wrong JSON type must not turn a *paid* invoice into a failed +//! verification. +//! +//! Two separate leniencies, which are easy to conflate: +//! +//! * **Absent** fields deserialize to `None`. That is `#[serde(default)]`. +//! * **Present but wrongly typed** fields also deserialize to `None`. That is +//! *not* what `#[serde(default)]` does: serde would fail the whole envelope, +//! losing a response whose other fields said `"state": "settled"`. Every +//! optional field below goes through [`lenient`] to get this. //! //! Requests are still built with the upstream types, where strictness costs //! nothing because we control the value. -use serde::{Deserialize, Serialize}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Deserializer, Serialize}; + +/// Upper bound on any wallet-supplied free text reproduced in an error. +const MAX_WALLET_TEXT: usize = 120; -use crate::payments::errors::PaymentError; +/// Deserialize an optional field, treating a wrong-typed value as absent. +/// +/// `#[serde(default)]` alone covers a *missing* key only. A wallet that sends +/// `"settled_at": "2026-01-01T00:00:00Z"` where an integer is expected would +/// otherwise fail the entire response, and the caller would read that as "not +/// settled" for an invoice the payer has already paid. +fn lenient<'de, D, T>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, + T: DeserializeOwned, +{ + let raw = Option::::deserialize(deserializer)?; + Ok(raw.and_then(|value| serde_json::from_value::(value).ok())) +} /// Envelope every NIP-47 response shares: `{result_type, error, result}`. /// /// `error` and `result` are both nullable per NIP-47, and a wallet may send -/// neither. `result_type` is the only field the client relies on. +/// neither. #[derive(Debug, Clone, Deserialize, Serialize)] +#[non_exhaustive] pub struct NwcResponseEnvelope { /// Echo of the request method, e.g. `"make_invoice"`. - #[serde(default)] + /// + /// Not used for correlation: the response is already correlated by the `e` + /// tag naming the request event, which is strictly stronger. A mismatch is + /// logged and otherwise tolerated, since rejecting on it would discard a + /// settlement a wallet labelled oddly. + #[serde(default, deserialize_with = "lenient")] pub result_type: Option, /// Set when the wallet refused or failed the request. - #[serde(default)] - pub error: Option, + #[serde(default, deserialize_with = "lenient")] + pub error: Option, /// Set when the wallet succeeded. /// /// No `#[serde(default)]`: on a generic field that would impose @@ -32,19 +63,6 @@ pub struct NwcResponseEnvelope { pub result: Option, } -/// A wallet-reported error. `code` is an uppercase NIP-47 code such as -/// `NOT_FOUND` or `INSUFFICIENT_BALANCE`; unknown codes are kept verbatim -/// rather than mapped to an enum, so a wallet extension cannot be lost. -#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] -pub struct NwcError { - /// Machine-readable error code. - #[serde(default)] - pub code: Option, - /// Human-readable message. - #[serde(default)] - pub message: Option, -} - impl NwcResponseEnvelope { /// Collapse the envelope into a `Result`, so a wallet-reported error is /// never mistaken for success. @@ -55,68 +73,121 @@ impl NwcResponseEnvelope { /// /// # Errors /// - /// Returns the wallet's [`NwcError`] when `error` is set, and a synthesized - /// one when the wallet sent neither `error` nor `result`. - pub fn into_result(self) -> Result { + /// Returns the wallet's [`NwcErrorBody`] when `error` is set, and a + /// synthesized one when the wallet sent neither `error` nor `result`. + pub fn into_result(self) -> Result { if let Some(err) = self.error { return Err(err); } - self.result.ok_or_else(|| NwcError { + self.result.ok_or_else(|| NwcErrorBody { code: Some("EMPTY_RESPONSE".to_string()), message: Some("wallet returned neither result nor error".to_string()), }) } } -impl NwcError { +/// A wallet-reported error. `code` is an uppercase NIP-47 code such as +/// `NOT_FOUND` or `INSUFFICIENT_BALANCE`; unknown codes are kept verbatim +/// rather than mapped to an enum, so a wallet extension cannot be lost. +#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)] +#[non_exhaustive] +pub struct NwcErrorBody { + /// Machine-readable error code. + #[serde(default, deserialize_with = "lenient")] + pub code: Option, + /// Human-readable message, as the wallet sent it. + /// + /// Free text under the wallet's control. Never reproduce it raw in a log + /// or an error: a wallet can put a BOLT11 invoice in here, and both payment + /// middlewares log a `PaymentError` at warn. Use [`Self::describe`]. + #[serde(default, deserialize_with = "lenient")] + pub message: Option, +} + +impl NwcErrorBody { /// `code` uppercased for comparison, or `""` when absent. pub fn code_upper(&self) -> String { self.code.as_deref().unwrap_or_default().to_uppercase() } - /// A display string usable in a [`PaymentError`] message. + /// A display string safe to put in an error that will be logged. + /// + /// The code is reproduced as-is (it is a short enum-like token). The + /// message is truncated and scrubbed of anything that looks like a BOLT11 + /// invoice, which is a bearer payment request. pub fn describe(&self) -> String { - match (self.code.as_deref(), self.message.as_deref()) { - (Some(c), Some(m)) => format!("{c}: {m}"), - (Some(c), None) => c.to_string(), - (None, Some(m)) => m.to_string(), - (None, None) => "unspecified wallet error".to_string(), + let code = self.code.as_deref().unwrap_or("UNSPECIFIED"); + match self.message.as_deref().map(sanitize_wallet_text) { + Some(msg) if !msg.is_empty() => format!("{code}: {msg}"), + _ => code.to_string(), } } } +/// Bound and scrub wallet-controlled free text for inclusion in an error. +/// +/// Redacts BOLT11-looking tokens (`lnbc…` and the other network prefixes) and +/// truncates, so neither an invoice nor an unbounded wallet string reaches a +/// log line. +pub(crate) fn sanitize_wallet_text(text: &str) -> String { + let mut out = String::with_capacity(text.len().min(MAX_WALLET_TEXT)); + for word in text.split_whitespace() { + let lower = word.to_lowercase(); + let looks_like_invoice = ["lnbc", "lntb", "lnbcrt", "lnsb"] + .iter() + .any(|p| lower.starts_with(p)) + && word.len() > 20; + if !out.is_empty() { + out.push(' '); + } + if looks_like_invoice { + out.push_str(""); + } else { + out.push_str(word); + } + if out.len() >= MAX_WALLET_TEXT { + out.truncate(MAX_WALLET_TEXT); + out.push_str("..."); + break; + } + } + out +} + /// Result of `make_invoice` / `lookup_invoice`. /// -/// Every field optional: wallets differ on which they populate, and a missing -/// `state` with a non-zero `settled_at` still means settled. +/// Every field optional and leniently typed: wallets differ on which they +/// populate and on how they encode them, and a missing `state` with a non-zero +/// `settled_at` still means settled. #[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[non_exhaustive] pub struct NwcInvoiceResult { /// `"incoming"` or `"outgoing"`. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub r#type: Option, /// `pending` | `settled` | `accepted` | `expired` | `failed`. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub state: Option, /// The BOLT11 invoice string. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub invoice: Option, /// Payment hash, hex. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub payment_hash: Option, /// Preimage, present once settled. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub preimage: Option, /// Creation time, unix seconds. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub created_at: Option, /// Expiry time, unix seconds. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub expires_at: Option, /// Settlement time, unix seconds. Non-zero implies settled. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub settled_at: Option, /// Amount, msats. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub amount: Option, } @@ -145,17 +216,19 @@ impl NwcInvoiceResult { /// Result of `pay_invoice`. #[derive(Debug, Clone, Default, Deserialize, Serialize)] +#[non_exhaustive] pub struct NwcPayInvoiceResult { /// Payment preimage, hex. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub preimage: Option, /// Routing fees paid, msats. - #[serde(default)] + #[serde(default, deserialize_with = "lenient")] pub fees_paid: Option, } /// A decoded NIP-47 notification payload (`{notification_type, notification}`). #[derive(Debug, Clone, Deserialize, Serialize)] +#[non_exhaustive] pub struct NwcNotificationPayload { /// e.g. `payment_received`, `payment_sent`. pub notification_type: String, @@ -165,7 +238,7 @@ pub struct NwcNotificationPayload { } impl NwcNotificationPayload { - /// `notification.payment_hash`, when present. + /// `notification.payment_hash`, when present and a string. pub fn payment_hash(&self) -> Option<&str> { self.notification.get("payment_hash")?.as_str() } @@ -183,19 +256,20 @@ pub const NOTIFICATION_PAYMENT_RECEIVED: &str = "payment_received"; /// /// # Errors /// -/// Returns [`PaymentError::Processor`] when `sats` is not positive, or when -/// `sats * 1000` overflows [`i64`]. -pub fn sats_to_msats(sats: i64) -> Result { +/// Returns [`crate::payments::nip47::NwcError::InvalidAmount`] when `sats` is +/// not positive, or when `sats * 1000` overflows [`i64`]. +pub fn sats_to_msats(sats: i64) -> Result { + use super::error::NwcError; if sats <= 0 { - return Err(PaymentError::Processor(format!( - "invoice amount must be positive, got {sats} sats" - ))); + return Err(NwcError::InvalidAmount { + reason: format!("amount must be positive, got {sats} sats"), + }); } - let msats = sats.checked_mul(1000).ok_or_else(|| { - PaymentError::Processor(format!( - "invoice amount overflows when converted to msats: {sats} sats" - )) - })?; + let msats = sats + .checked_mul(1000) + .ok_or_else(|| NwcError::InvalidAmount { + reason: format!("amount overflows when converted to msats: {sats} sats"), + })?; Ok(msats as u64) } @@ -237,6 +311,49 @@ mod tests { assert_eq!(env.result.unwrap().invoice.as_deref(), Some("lnbc1")); } + /// The money-path case: one wrongly typed optional field must not discard a + /// response that says the invoice settled. + #[test] + fn off_type_optional_fields_become_none_instead_of_failing_the_response() { + let raw = r#"{ + "result_type":"lookup_invoice", + "result":{ + "state":"settled", + "settled_at":"2026-01-01T00:00:00Z", + "expires_at":"not-a-number", + "amount":1234.5, + "payment_hash":"h" + } + }"#; + let env: NwcResponseEnvelope = + serde_json::from_str(raw).expect("an off-type field must not fail the envelope"); + let r = env.result.expect("result survives"); + assert!(r.is_settled(), "the settled state must still be read"); + assert_eq!(r.payment_hash.as_deref(), Some("h")); + assert_eq!(r.settled_at, None, "the off-type value becomes None"); + assert_eq!(r.expires_at, None); + assert_eq!(r.amount, None); + } + + #[test] + fn off_type_state_becomes_none_without_losing_settled_at() { + let raw = + r#"{"result_type":"lookup_invoice","result":{"state":7,"settled_at":1700000000}}"#; + let env: NwcResponseEnvelope = serde_json::from_str(raw).unwrap(); + let r = env.result.unwrap(); + assert_eq!(r.state, None); + assert!(r.is_settled(), "settled_at still carries the settlement"); + } + + #[test] + fn off_type_fees_paid_does_not_fail_a_pay_invoice_response() { + let raw = r#"{"result_type":"pay_invoice","result":{"preimage":"pre","fees_paid":1.5}}"#; + let env: NwcResponseEnvelope = serde_json::from_str(raw).unwrap(); + let r = env.result.unwrap(); + assert_eq!(r.preimage.as_deref(), Some("pre")); + assert_eq!(r.fees_paid, None); + } + #[test] fn envelope_parses_unknown_error_code_verbatim() { let raw = @@ -247,6 +364,48 @@ mod tests { assert_eq!(err.describe(), "SOMETHING_NEW: nope"); } + /// A wallet message can name the invoice, and both middlewares log a + /// `PaymentError` at warn. + #[test] + fn describe_redacts_an_invoice_in_the_wallet_message() { + let err = NwcErrorBody { + code: Some("FAILED".into()), + message: Some( + "could not pay lnbc210n1pjqqqqqpp5abcdefghijklmnopqrstuvwxyz0123456789 right now" + .into(), + ), + }; + let described = err.describe(); + assert!( + !described.contains("lnbc210n1pjqqqqq"), + "the invoice must not survive into the error: {described}" + ); + assert!(described.contains("")); + assert!(described.starts_with("FAILED: ")); + } + + #[test] + fn describe_bounds_an_unbounded_wallet_message() { + let err = NwcErrorBody { + code: Some("X".into()), + message: Some("spam ".repeat(500)), + }; + assert!( + err.describe().len() < 200, + "wallet text must be bounded, got {}", + err.describe().len() + ); + } + + #[test] + fn describe_without_a_message_is_just_the_code() { + let err = NwcErrorBody { + code: Some("NOT_FOUND".into()), + message: None, + }; + assert_eq!(err.describe(), "NOT_FOUND"); + } + #[test] fn settled_detected_by_timestamp_without_state() { let r = NwcInvoiceResult { @@ -302,4 +461,11 @@ mod tests { let n: NwcNotificationPayload = serde_json::from_str(raw).unwrap(); assert_eq!(n.payment_hash(), None); } + + #[test] + fn notification_payload_with_off_type_payment_hash_is_none() { + let raw = r#"{"notification_type":"payment_received","notification":{"payment_hash":42}}"#; + let n: NwcNotificationPayload = serde_json::from_str(raw).unwrap(); + assert_eq!(n.payment_hash(), None); + } } diff --git a/src/payments/nip47/uri.rs b/src/payments/nip47/uri.rs index 7586a26..6b3ae40 100644 --- a/src/payments/nip47/uri.rs +++ b/src/payments/nip47/uri.rs @@ -3,9 +3,11 @@ //! [`NostrWalletConnectURI::parse`](nostr_sdk::nips::nip47::NostrWalletConnectURI::parse) //! reads the wallet pubkey from the URI *host* only, so it rejects the //! `nostr+walletconnect:?...` form (no `//`, pubkey in the path) that -//! ts-sdk accepts. Wallets in the wild emit both, and a connection string the -//! ts-sdk takes must not fail here, so this module parses both shapes and then -//! hands the pieces to the upstream type. +//! ts-sdk accepts, and it rejects the legacy `nostrwalletconnect://` scheme +//! outright. Wallets in the wild emit all three, and a connection string the +//! ts-sdk takes must not fail here, so this module does its own parsing and +//! builds a [`NwcConnection`] directly. Nothing is handed to the upstream +//! type. //! //! Parity note: ts `parseNwcConnectionString` prefers the pathname and falls //! back to the host (`url.pathname?.replace(/^\//, '') || url.host`). This @@ -13,10 +15,13 @@ use nostr_sdk::prelude::*; -use crate::payments::errors::PaymentError; +use crate::payments::nip47::error::NwcError; -/// URI scheme for a NWC connection string. +/// Canonical URI scheme for a NWC connection string. const NWC_URI_SCHEME: &str = "nostr+walletconnect"; +/// Legacy scheme still emitted by some wallets and accepted by ts-sdk's +/// `new URL()` parse, which never checks the protocol. +const NWC_URI_SCHEME_LEGACY: &str = "nostrwalletconnect"; /// A parsed NWC connection string: which wallet to talk to, over which relays, /// under which client identity. @@ -45,8 +50,10 @@ impl NwcConnection { } } -fn invalid(reason: &str) -> PaymentError { - PaymentError::Processor(format!("Invalid NWC connection string: {reason}")) +fn invalid(reason: &str) -> NwcError { + NwcError::Config { + reason: format!("invalid NWC connection string: {reason}"), + } } /// Parse a NWC connection string. @@ -63,15 +70,17 @@ fn invalid(reason: &str) -> PaymentError { /// /// # Errors /// -/// Returns [`PaymentError::Processor`] when the scheme is not -/// `nostr+walletconnect`, the pubkey or secret is missing or malformed, or no -/// relay is present. A relay value that does not parse is skipped, matching +/// Returns [`NwcError::Config`] when the scheme is neither +/// `nostr+walletconnect` nor the legacy `nostrwalletconnect`, the pubkey or +/// secret is missing or malformed, or no relay is present. A relay value that does not parse is skipped, matching /// upstream; an empty resulting relay set is an error. -pub fn parse_nwc_uri(uri: &str) -> Result { +pub fn parse_nwc_uri(uri: &str) -> Result { let url = Url::parse(uri.trim()).map_err(|_| invalid("not a URL"))?; - if url.scheme() != NWC_URI_SCHEME { - return Err(invalid("scheme is not nostr+walletconnect")); + if url.scheme() != NWC_URI_SCHEME && url.scheme() != NWC_URI_SCHEME_LEGACY { + return Err(invalid( + "scheme is neither nostr+walletconnect nor nostrwalletconnect", + )); } // ts precedence: a non-empty pathname wins, else the host. The `//` form @@ -104,6 +113,10 @@ pub fn parse_nwc_uri(uri: &str) -> Result { relays.push(relay); } } + // Last-wins on a duplicate, deliberately: a later parameter is + // the more likely correction, and ts's `searchParams.get` takes + // the FIRST. Divergence noted rather than inherited, since no + // conformant wallet emits two. "secret" => secret = SecretKey::from_hex(value.trim()).ok(), "lud16" => lud16 = Some(value.to_string()), _ => {} @@ -204,6 +217,43 @@ mod tests { ); } + /// Some wallets still emit the pre-standard scheme, and ts accepts it + /// because `new URL()` never checks the protocol. + #[test] + fn parses_the_legacy_nostrwalletconnect_scheme() { + let uri = format!( + "nostrwalletconnect://{PUBKEY}?relay=wss%3A%2F%2Frelay.example&secret={SECRET}" + ); + assert!( + nostr_sdk::nips::nip47::NostrWalletConnectURI::parse(&uri).is_err(), + "precondition: upstream rejects the legacy scheme" + ); + let c = parse_nwc_uri(&uri).expect("legacy scheme should parse"); + assert_eq!(c.wallet_pubkey.to_hex(), PUBKEY); + assert_eq!(c.secret.to_secret_hex(), SECRET); + } + + /// ts-sdk's own tested shape, verbatim from `connection.test.ts`. + #[test] + fn parses_the_shape_ts_pins_in_its_tests() { + let uri = + format!("nostr+walletconnect://{PUBKEY}?relay=wss://relay.example&secret={SECRET}"); + let c = parse_nwc_uri(&uri).expect("ts's shape must parse"); + assert_eq!(c.wallet_pubkey.to_hex(), PUBKEY); + assert_eq!(c.relays.len(), 1); + } + + /// Deliberate divergence from ts, which takes the first. + #[test] + fn a_duplicate_secret_is_last_wins() { + let other = "0000000000000000000000000000000000000000000000000000000000000002"; + let uri = format!( + "nostr+walletconnect://{PUBKEY}?relay=wss%3A%2F%2Fa.example&secret={other}&secret={SECRET}" + ); + let c = parse_nwc_uri(&uri).unwrap(); + assert_eq!(c.secret.to_secret_hex(), SECRET, "the later secret wins"); + } + #[test] fn rejects_wrong_scheme() { let uri = format!("https://{PUBKEY}?relay=wss%3A%2F%2Fa.example&secret={SECRET}"); diff --git a/src/relay/mock.rs b/src/relay/mock.rs index dfa127a..09c0306 100644 --- a/src/relay/mock.rs +++ b/src/relay/mock.rs @@ -154,6 +154,25 @@ impl MockRelayPool { pub async fn inject_event(&self, event: Event) { self.inner.lock().await.events.push(event); } + + /// Deliver `event` to every subscriber **regardless of their filters**. + /// + /// Models a non-conformant relay: `nostr-relay-pool` verifies event + /// signatures but not that a delivered event actually matches the + /// subscription (`verify_subscriptions` is `false` by default), so a relay + /// that ignores `authors` or `#p` can hand a client something it never + /// asked for. Client-side guards that re-check authorship exist for + /// exactly that case, and a mock that always filters correctly can never + /// exercise them. + /// + /// Use this only to test such a guard; `publish_event` is the realistic path. + pub async fn publish_event_unfiltered(&self, event: &Event) { + let mut inner = self.inner.lock().await; + inner.events.push(event.clone()); + for subscriber in inner.subscribers.values() { + let _ = subscriber.tx.send(make_notification(event.clone())); + } + } } impl Default for MockRelayPool { From 95dc0e92e4844d20f2e347c1f71ce1358a84249f Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Fri, 9 Oct 2026 10:18:12 +0530 Subject: [PATCH 03/11] fix(payments): export MockWalletQuirks and correct the nip47 module doc The quirks struct was reachable only by its full module path, so a downstream test could not use it, and the module doc still described the client as taking an injected pool after `connect` made it own one. --- src/payments/nip47/client.rs | 8 ++++---- src/payments/nip47/mod.rs | 13 ++++++++----- src/payments/nip47/types.rs | 2 +- 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/src/payments/nip47/client.rs b/src/payments/nip47/client.rs index 6df5a88..0137f24 100644 --- a/src/payments/nip47/client.rs +++ b/src/payments/nip47/client.rs @@ -14,7 +14,7 @@ //! Prefer [`NwcClient::connect`], which builds a pool from the connection's own //! relays. [`NwcClient::with_pool`] exists for tests and for a caller that //! genuinely wants to share one, and it carries a sharp edge: on a real -//! [`RelayPool`](crate::relay::RelayPool), `connect` *adds* the wallet's relays +//! [`crate::relay::RelayPool`], `connect` *adds* the wallet's relays //! to whatever pool it is given, and the NIP-47 subscription is permanent //! because [`RelayPoolTrait`] has no unsubscribe. Handing it a transport's pool //! therefore publishes ContextVM traffic to the wallet's relays and leaves a @@ -699,7 +699,7 @@ impl NwcClient { /// # Errors /// /// [`NwcError::InvalidAmount`] if `amount_msats` is zero; otherwise see - /// [`Self::request`]. + /// the shared request path. pub async fn make_invoice( &self, amount_msats: u64, @@ -726,7 +726,7 @@ impl NwcClient { /// # Errors /// /// [`NwcError::Config`] if neither identifier is given; otherwise see - /// [`Self::request`]. + /// the shared request path. pub async fn lookup_invoice( &self, payment_hash: Option<&str>, @@ -752,7 +752,7 @@ impl NwcClient { /// /// # Errors /// - /// See [`Self::request`]. On [`NwcError::Timeout`], check + /// See the shared request path. On [`NwcError::Timeout`], check /// [`NwcError::may_have_reached_wallet`] before retrying. pub async fn pay_invoice( &self, diff --git a/src/payments/nip47/mod.rs b/src/payments/nip47/mod.rs index 3006889..d0a43ce 100644 --- a/src/payments/nip47/mod.rs +++ b/src/payments/nip47/mod.rs @@ -1,9 +1,12 @@ //! NIP-47 (Nostr Wallet Connect) client infrastructure for the CEP-8 Lightning rail. //! -//! Three pieces: [`parse_nwc_uri`] for connection strings, permissive wire -//! [`types`] that will not turn a paid invoice into a parse failure, and -//! [`NwcClient`], a request/response and notification client over an injected -//! [`RelayPoolTrait`](crate::relay::RelayPoolTrait). +//! Four pieces: [`parse_nwc_uri`] for connection strings, permissive wire +//! [`types`] that will not turn a paid invoice into a parse failure, a typed +//! [`NwcError`] that says whether a failed call may still have reached the +//! wallet, and [`NwcClient`], a request/response and notification client. +//! +//! The client owns a relay pool built from the connection's own relays +//! ([`NwcClient::connect`]); [`NwcClient::with_pool`] injects one for tests. //! //! Behind the off-by-default `nwc` feature. It enables `nostr-sdk/nip47`, //! which pulls in NIP-04 and therefore adds `aes`, `cbc` and `cipher` to the @@ -26,4 +29,4 @@ pub use types::{ pub use uri::{parse_nwc_uri, NwcConnection}; #[cfg(feature = "test-utils")] -pub use mock_wallet::{MockWallet, WalletBehavior}; +pub use mock_wallet::{MockWallet, MockWalletQuirks, WalletBehavior}; diff --git a/src/payments/nip47/types.rs b/src/payments/nip47/types.rs index 79e619d..1cd25ca 100644 --- a/src/payments/nip47/types.rs +++ b/src/payments/nip47/types.rs @@ -12,7 +12,7 @@ //! * **Present but wrongly typed** fields also deserialize to `None`. That is //! *not* what `#[serde(default)]` does: serde would fail the whole envelope, //! losing a response whose other fields said `"state": "settled"`. Every -//! optional field below goes through [`lenient`] to get this. +//! optional field below goes through `lenient` to get this. //! //! Requests are still built with the upstream types, where strictness costs //! nothing because we control the value. From d1860e0c81fd749bddbe6129464bf3dfe332bcc5 Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Sun, 11 Oct 2026 09:17:41 +0530 Subject: [PATCH 04/11] fix(tests): drop the deprecated `fetch_update` from the mock relay pool Rust 1.99 deprecates `Atomic::fetch_update` in favour of `try_update`, and CI lints with `-D warnings`, so `cargo clippy --all-targets` fails on main. `try_update` is only stable from 1.95, above the declared MSRV of 1.88, so this spells the same decrement-while-non-zero out as a compare-exchange loop, which compiles warning-free on both. --- tests/transport_integration.rs | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/tests/transport_integration.rs b/tests/transport_integration.rs index 970f502..ffcf264 100644 --- a/tests/transport_integration.rs +++ b/tests/transport_integration.rs @@ -84,12 +84,23 @@ impl RelayPoolTrait for TestRelayPool { tokio::time::sleep(self.publish_delay).await; } self.publish_attempts.fetch_add(1, Ordering::SeqCst); - let should_fail = self - .failures_remaining - .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { - remaining.checked_sub(1) - }) - .is_ok(); + // Decrement while non-zero, reporting whether this call consumed one of + // the injected failures. `Atomic::fetch_update` would say this in one + // line but is deprecated from 1.99, and its replacement `try_update` is + // only stable from 1.95, above the declared MSRV of 1.88. + let should_fail = loop { + let remaining = self.failures_remaining.load(Ordering::SeqCst); + let Some(next) = remaining.checked_sub(1) else { + break false; + }; + if self + .failures_remaining + .compare_exchange(remaining, next, Ordering::SeqCst, Ordering::SeqCst) + .is_ok() + { + break true; + } + }; if should_fail { return Err(contextvm_sdk::Error::Transport( From 51160ab84f582fc2090dc911c93e10236ddc52c2 Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Thu, 15 Oct 2026 19:39:50 +0530 Subject: [PATCH 05/11] fix(payments): three-state delivery, redactor hardening and evidence-led widening Addresses the five blocking items from the second review of the NIP-47 client. (8a) A deadline that fires inside the publish is not a non-delivery. The pool writes the EVENT before it waits on the relay's OK, so the wallet may have the request. `Delivery` replaces the `published` bool with `NotSent`, `Unknown` and `Sent`, and `may_have_reached_wallet` treats `Unknown` as "may have", which is the safe direction for `pay_invoice`. (24) `Closed` carries the same `Delivery`, so a `close()` landing after the request went out no longer reports a safe retry. `request` also re-checks the closed flag after start-up: `close()` returns as soon as it has set the flag, so one arriving during a slow connect used to be followed by a publish. (25) `sanitize_wallet_text` walks back to a character boundary before truncating. `truncate` panics mid character, and the text is wallet-controlled, so a long non-Latin message was a remote panic on the pay path. `floor_char_boundary` is the obvious fix and is stable only from 1.95, above the declared MSRV. (26) Three remaining paths for wallet text to reach a log unredacted: the error code was copied raw and unbounded, a mislabelled `result_type` was logged raw, and the BOLT11 prefix test was `starts_with` on whitespace-split words, which `lightning:lnbc...`, `(lnbc...)` and quoted forms all passed while carrying the full payment hash. The prefix is now matched anywhere in the token. (3) Widening now requires evidence that the narrow filter has failed: a request that reached a relay has waited out a full deadline, and nothing has ever correlated. It is latched, since the pool has no unsubscribe and a silent wallet would otherwise accumulate one subscription per call; it widens the response kind only, leaving `#p` on both notification kinds; and it logs at warn. Tests: a nine-mutant sweep over these fixes, each killed. Two of them survived the first pass because the delivery states were asserted in isolation without anything proving `request` produces them, so both now have tests that drive the real path. Also adds a response correlated to a different request, which the existing no-`e`-tag test cannot see, since that one is dropped before the correlation lookup is reached. --- src/payments/nip47/client.rs | 545 ++++++++++++++++++++++++++++++++--- src/payments/nip47/error.rs | 121 ++++++-- src/payments/nip47/mod.rs | 2 +- src/payments/nip47/types.rs | 114 +++++++- 4 files changed, 708 insertions(+), 74 deletions(-) diff --git a/src/payments/nip47/client.rs b/src/payments/nip47/client.rs index 0137f24..d8ee93a 100644 --- a/src/payments/nip47/client.rs +++ b/src/payments/nip47/client.rs @@ -50,7 +50,7 @@ use serde::Serialize; use tokio::sync::{oneshot, Mutex as AsyncMutex}; use tokio_util::sync::CancellationToken; -use crate::payments::nip47::error::NwcError; +use crate::payments::nip47::error::{Delivery, NwcError}; use crate::payments::nip47::types::{ sanitize_wallet_text, NwcInvoiceResult, NwcNotificationPayload, NwcPayInvoiceResult, NwcResponseEnvelope, @@ -141,6 +141,9 @@ struct Inner { /// Set once any response has correlated. Until then the filter may be too /// narrow for the wallet in front of us. ever_correlated: AtomicBool, + /// Latch for [`NwcClient::widen_response_filter_once`]. The pool has no + /// unsubscribe, so the widened subscription may only ever be opened once. + widened: AtomicBool, /// Set by `close()`, by `Drop`, and when the reader exits. closed: AtomicBool, } @@ -238,6 +241,7 @@ impl NwcClient { waiters: Mutex::new(HashMap::new()), notification_sinks: AsyncMutex::new(Vec::new()), ever_correlated: AtomicBool::new(false), + widened: AtomicBool::new(false), closed: AtomicBool::new(false), }), started: AsyncMutex::new(false), @@ -273,30 +277,41 @@ impl NwcClient { /// Filters this client subscribes with. /// - /// `narrow` additionally requires the `p` tag addressed to us, which every - /// conformant wallet sets. The widened form drops it: see - /// [`Self::widen_filter_if_never_correlated`]. - fn filters(&self, narrow: bool) -> Vec { + /// Requires the `p` tag addressed to us, which every conformant wallet + /// sets. `since` keeps a relay that retains ephemeral events from + /// replaying this client's whole history on every start and reconnect. + fn filters(&self) -> Vec { let since = Timestamp::now() - SUBSCRIPTION_LOOKBACK; - let base = Filter::new() + vec![Filter::new() .kinds([ Kind::from(KIND_RESPONSE), Kind::from(KIND_NOTIFICATION_LEGACY), Kind::from(KIND_NOTIFICATION), ]) .author(self.connection.wallet_pubkey) - .since(since); - vec![if narrow { - base.pubkey(self.keys.public_key()) - } else { - base - }] + .pubkey(self.keys.public_key()) + .since(since)] + } + + /// The widened filter: responses only, without the `p` tag. + /// + /// Deliberately narrower than dropping `#p` everywhere. Only a response + /// can prove the narrow filter is wrong, and leaving `#p` on the two + /// notification kinds keeps the permanent subscription from picking up + /// every notification the wallet sends any of its clients. + fn widened_response_filter(&self) -> Vec { + vec![Filter::new() + .kind(Kind::from(KIND_RESPONSE)) + .author(self.connection.wallet_pubkey) + .since(Timestamp::now() - SUBSCRIPTION_LOOKBACK)] } /// Connect, subscribe, and spawn the reader task. Idempotent. async fn ensure_started(&self) -> Result<(), NwcError> { if self.is_closed() { - return Err(NwcError::Closed); + return Err(NwcError::Closed { + delivery: Delivery::NotSent, + }); } let mut started = self.started.lock().await; if *started { @@ -315,7 +330,7 @@ impl NwcClient { let notifications = self.pool.notifications(); self.pool - .subscribe(self.filters(true)) + .subscribe(self.filters()) .await .map_err(|e| NwcError::Subscribe { reason: e.to_string(), @@ -339,24 +354,35 @@ impl NwcClient { Ok(()) } - /// Re-subscribe without the `p` tag. + /// Re-subscribe to responses without the `p` tag, at most once ever. /// /// Our filter requires `#p`, which every conformant wallet sets but which /// is stricter than ts (whose per-request REQ has no `#p` at all). A wallet - /// that omits it would be invisible forever. While nothing has *ever* - /// correlated, widen once so such a wallet still works; after the first - /// successful correlation the narrow filter is known good and this is never - /// attempted again. - async fn widen_filter_if_never_correlated(&self) { + /// that omits it would be invisible forever. + /// + /// Two conditions, both necessary. Nothing has ever correlated, so the + /// narrow filter has never been shown to work; and the caller has just + /// waited out a full deadline on a request that *did* reach a relay, so it + /// has now been shown to fail. Widening on "nothing has arrived yet" alone + /// would re-subscribe a conformant wallet's client before its first answer + /// could possibly land. + /// + /// The latch is what bounds it. [`RelayPoolTrait`] has no unsubscribe, so + /// every widened subscription is permanent; without the latch a wallet + /// that never answers would accumulate one per call. + async fn widen_response_filter_once(&self) { if self.inner.ever_correlated.load(Ordering::SeqCst) { return; } - tracing::debug!( + if self.inner.widened.swap(true, Ordering::SeqCst) { + return; + } + tracing::warn!( target: LOG_TARGET, - "no NWC response has correlated yet; re-subscribing without the p-tag filter" + "no NWC response has ever correlated; re-subscribing to responses without the p-tag filter. The wallet is not setting the p tag it is required to set." ); - if let Err(e) = self.pool.subscribe(self.filters(false)).await { - tracing::debug!(target: LOG_TARGET, error = %e, "widened NWC subscribe failed"); + if let Err(e) = self.pool.subscribe(self.widened_response_filter()).await { + tracing::warn!(target: LOG_TARGET, error = %e, "widened NWC subscribe failed"); } } @@ -559,7 +585,9 @@ impl NwcClient { let timeout_ms = self.response_timeout.as_millis() as u64; if self.is_closed() { - return Err(NwcError::Closed); + return Err(NwcError::Closed { + delivery: Delivery::NotSent, + }); } match tokio::time::timeout_at(deadline, self.ensure_started()).await { @@ -568,11 +596,20 @@ impl NwcClient { return Err(NwcError::Timeout { method: method.to_string(), timeout_ms, - published: false, + delivery: Delivery::NotSent, }) } } + // Re-check: `close()` returns as soon as it has set the flag, so one + // that lands during start-up would otherwise still be followed by a + // publish. Nothing has gone out yet at this point. + if self.is_closed() { + return Err(NwcError::Closed { + delivery: Delivery::NotSent, + }); + } + let body = serde_json::json!({ "method": method, "params": params }); let plaintext = serde_json::to_string(&body).map_err(|e| NwcError::Crypto { direction: "encryption", @@ -624,7 +661,9 @@ impl NwcClient { // exit path, including this future being dropped mid-wait. let (tx, rx) = oneshot::channel(); { - let mut map = self.inner.waiters.lock().map_err(|_| NwcError::Closed)?; + let mut map = self.inner.waiters.lock().map_err(|_| NwcError::Closed { + delivery: Delivery::NotSent, + })?; map.insert(request_id, tx); } let _guard = WaiterGuard { @@ -632,20 +671,25 @@ impl NwcClient { id: request_id, }; - let published = + let delivery = match tokio::time::timeout_at(deadline, self.pool.publish_event(&event)).await { - Ok(Ok(_)) => true, + Ok(Ok(_)) => Delivery::Sent, + // A relay that answered with an error did not accept the + // event, so this one really is a non-delivery. Ok(Err(e)) => { return Err(NwcError::Publish { method: method.to_string(), reason: e.to_string(), }) } + // The deadline fell inside the publish. The pool writes the + // EVENT before it waits on the relay's OK, so the wallet may + // already have the request: this is unknown, not "never sent". Err(_) => { return Err(NwcError::Timeout { method: method.to_string(), timeout_ms, - published: false, + delivery: Delivery::Unknown, }) } }; @@ -657,20 +701,22 @@ impl NwcClient { "NWC request published" ); - // One chance to widen a too-narrow filter, while nothing has ever - // correlated. Only on the very first requests. - self.widen_filter_if_never_correlated().await; - let plaintext = match tokio::time::timeout_at(deadline, rx).await { Ok(Ok(p)) => p, - // Sender dropped: the client closed, or the reader died. - Ok(Err(_)) => return Err(NwcError::Closed), + // Sender dropped: the client closed, or the reader died. The + // request is already out, so the delivery bit has to survive: + // a `close()` after a `pay_invoice` is not a safe retry. + Ok(Err(_)) => return Err(NwcError::Closed { delivery }), Err(_) => { + // The narrow filter has now demonstrably failed to deliver an + // answer to a request we know went out, which is the only + // evidence that would justify widening it. + self.widen_response_filter_once().await; return Err(NwcError::Timeout { method: method.to_string(), timeout_ms, - published, - }) + delivery, + }); } }; @@ -685,7 +731,9 @@ impl NwcClient { tracing::warn!( target: LOG_TARGET, method, - result_type = kind, + // Wallet-controlled, so it is scrubbed like any other + // wallet string before it reaches a log line. + result_type = %sanitize_wallet_text(kind), "NWC result_type does not match the request method" ); } @@ -899,10 +947,374 @@ mod tests { (wallet, client, client_pool) } + /// A pool that delegates to [`MockRelayPool`] while recording every + /// `subscribe` and optionally parking `connect`. + /// + /// Recording the filters lets the widening tests assert what the + /// subscriptions *match*, rather than their shape. Parking `connect` is + /// the only way to reach the window between the top-of-request closed + /// check and the publish. + struct SpyPool { + inner: Arc, + connect_delay: Duration, + publish_delay: Duration, + subscribed: std::sync::Mutex>>, + } + + impl SpyPool { + fn new(inner: Arc, connect_delay: Duration) -> Self { + Self { + inner, + connect_delay, + publish_delay: Duration::ZERO, + subscribed: std::sync::Mutex::new(Vec::new()), + } + } + + fn with_publish_delay(mut self, delay: Duration) -> Self { + self.publish_delay = delay; + self + } + + fn subscriptions(&self) -> Vec> { + self.subscribed.lock().expect("not poisoned").clone() + } + } + + #[async_trait::async_trait] + impl RelayPoolTrait for SpyPool { + async fn connect(&self, relay_urls: &[String]) -> crate::Result<()> { + if !self.connect_delay.is_zero() { + tokio::time::sleep(self.connect_delay).await; + } + self.inner.connect(relay_urls).await + } + async fn disconnect(&self) -> crate::Result<()> { + self.inner.disconnect().await + } + async fn publish_event(&self, event: &Event) -> crate::Result { + if !self.publish_delay.is_zero() { + tokio::time::sleep(self.publish_delay).await; + } + self.inner.publish_event(event).await + } + async fn publish(&self, builder: EventBuilder) -> crate::Result { + self.inner.publish(builder).await + } + async fn sign(&self, builder: EventBuilder) -> crate::Result { + self.inner.sign(builder).await + } + async fn signer(&self) -> crate::Result> { + self.inner.signer().await + } + fn notifications(&self) -> tokio::sync::broadcast::Receiver { + self.inner.notifications() + } + async fn public_key(&self) -> crate::Result { + self.inner.public_key().await + } + async fn subscribe(&self, filters: Vec) -> crate::Result<()> { + self.subscribed + .lock() + .expect("not poisoned") + .push(filters.clone()); + self.inner.subscribe(filters).await + } + async fn publish_to( + &self, + urls: &[String], + builder: EventBuilder, + ) -> crate::Result { + self.inner.publish_to(urls, builder).await + } + async fn fetch_events( + &self, + filters: Vec, + timeout: Duration, + ) -> crate::Result> { + self.inner.fetch_events(filters, timeout).await + } + } + + /// Harness whose client talks through a [`SpyPool`]. + async fn spy_harness( + response_timeout: Duration, + connect_delay: Duration, + ) -> (Arc, NwcClient, Arc) { + spy_harness_with(response_timeout, |pool| SpyPool::new(pool, connect_delay)).await + } + + async fn spy_harness_with( + response_timeout: Duration, + build: impl FnOnce(Arc) -> SpyPool, + ) -> (Arc, NwcClient, Arc) { + let wallet_pool = Arc::new(MockRelayPool::new()); + let client_pool = Arc::new(wallet_pool.linked_with_keys(Keys::generate())); + let spy = Arc::new(build(client_pool)); + + let wallet = Arc::new(MockWallet::new(Arc::clone(&wallet_pool))); + wallet.start().await.expect("wallet starts"); + + let secret = SecretKey::generate(); + let uri = format!( + "nostr+walletconnect://{}?relay=wss%3A%2F%2Fmock.relay&secret={}", + wallet.public_key().to_hex(), + secret.to_secret_hex() + ); + let connection = parse_nwc_uri(&uri).expect("uri parses"); + let client = NwcClient::with_pool( + Arc::clone(&spy) as Arc, + connection, + NwcClientOptions::new().with_response_timeout(response_timeout), + ) + .expect("options valid"); + (wallet, client, spy) + } + fn invoice_result(invoice: &str) -> serde_json::Value { serde_json::json!({ "invoice": invoice, "state": "pending" }) } + // ── (3) widening, and (24) close ───────────────────────────────────────── + + /// Build a wallet-signed event with no `p` tag, as a non-conformant + /// wallet emits, so a filter can be asked what it would match. + fn untagged_from(wallet: &MockWallet, kind: u16) -> Event { + EventBuilder::new(Kind::from(kind), "ciphertext") + .sign_with_keys(&wallet.keys()) + .expect("signs") + } + + /// (3). Widening must not run until the narrow filter has *failed*. + /// Running it right after the first publish re-subscribes a conformant + /// wallet's client before its first answer could possibly have landed. + #[tokio::test] + async fn a_conformant_wallet_never_triggers_the_widening() { + let (wallet, client, spy) = spy_harness(Duration::from_secs(5), Duration::ZERO).await; + wallet + .set_behavior( + "make_invoice", + WalletBehavior::Answer(invoice_result("lnbc210n1p")), + ) + .await; + + client + .make_invoice(1000, None, None) + .await + .expect("conformant wallet answers"); + + assert_eq!( + spy.subscriptions().len(), + 1, + "a wallet that answers must never be re-subscribed" + ); + } + + /// (3). The pool has no unsubscribe, so every widened subscription is + /// permanent. A wallet that never answers must still produce exactly one. + #[tokio::test] + async fn widening_happens_at_most_once_however_many_requests_fail() { + let (wallet, client, spy) = spy_harness(Duration::from_millis(150), Duration::ZERO).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + + for _ in 0..4 { + let _ = client.make_invoice(1000, None, None).await; + } + + let subs = spy.subscriptions(); + assert_eq!( + subs.len(), + 2, + "one initial narrow subscribe and exactly one widened, got {}", + subs.len() + ); + } + + /// (3). Only the response filter loses `#p`. Dropping it from the two + /// notification kinds as well would make the permanent subscription pick + /// up every notification the wallet sends any of its clients. + #[tokio::test] + async fn the_widened_filter_covers_responses_only() { + let (wallet, client, spy) = spy_harness(Duration::from_millis(150), Duration::ZERO).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + let _ = client.make_invoice(1000, None, None).await; + + let subs = spy.subscriptions(); + assert_eq!(subs.len(), 2, "expected a widened subscribe"); + let widened = &subs[1]; + + let response = untagged_from(&wallet, KIND_RESPONSE); + assert!( + widened + .iter() + .any(|f| f.match_event(&response, MatchEventOptions::default())), + "an untagged response must now match" + ); + + for kind in [KIND_NOTIFICATION_LEGACY, KIND_NOTIFICATION] { + let notification = untagged_from(&wallet, kind); + assert!( + !widened + .iter() + .any(|f| f.match_event(¬ification, MatchEventOptions::default())), + "kind {kind} must keep its p-tag requirement" + ); + } + } + + /// (3). The point of the whole mechanism: a wallet that never sets `p` is + /// invisible to the narrow filter, and must become visible afterwards. + #[tokio::test] + async fn a_wallet_that_omits_the_p_tag_is_recovered_after_one_failure() { + let (wallet, client, _spy) = spy_harness(Duration::from_millis(400), Duration::ZERO).await; + wallet + .set_quirks(MockWalletQuirks { + omit_p_tag: true, + ..Default::default() + }) + .await; + wallet + .set_behavior( + "make_invoice", + WalletBehavior::Answer(invoice_result("lnbc210n1p")), + ) + .await; + + let first = client.make_invoice(1000, None, None).await; + assert!( + matches!(first, Err(NwcError::Timeout { .. })), + "the narrow filter cannot see this wallet: {first:?}" + ); + + client + .make_invoice(1000, None, None) + .await + .expect("the widened filter must now see the answer"); + } + + /// (8a). The deadline falling inside the publish is unknowable, not a + /// definite non-delivery: the pool writes the EVENT before it waits on + /// the relay's OK. Reporting `NotSent` here tells a `pay_invoice` caller + /// that retrying is safe when it may not be. + #[tokio::test] + async fn a_deadline_inside_the_publish_reports_unknown_delivery() { + let (wallet, client, _spy) = spy_harness_with(Duration::from_millis(120), |pool| { + SpyPool::new(pool, Duration::ZERO).with_publish_delay(Duration::from_millis(600)) + }) + .await; + wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Answer(serde_json::json!({ "preimage": "deadbeef" })), + ) + .await; + + let err = client + .pay_invoice("lnbc210n1p", None) + .await + .expect_err("the deadline falls inside the publish"); + + match err { + NwcError::Timeout { delivery, .. } => assert_eq!( + delivery, + Delivery::Unknown, + "an in-flight publish is unknown, not a non-delivery" + ), + other => panic!("expected a Timeout, got {other:?}"), + } + assert!( + err_may_reach(&err), + "the caller must not be told this is a safe retry" + ); + } + + /// (24). A `close()` landing after the request went out must keep the + /// delivery bit. Collapsing it to a bare `Closed` tells the caller a + /// `pay_invoice` that is already on the wire is safe to retry. + #[tokio::test] + async fn a_close_after_the_publish_keeps_the_delivery_bit() { + let (wallet, client, _spy) = spy_harness(Duration::from_secs(5), Duration::ZERO).await; + wallet + .set_behavior("pay_invoice", WalletBehavior::Silence) + .await; + + let client = Arc::new(client); + let task = { + let client = Arc::clone(&client); + tokio::spawn(async move { client.pay_invoice("lnbc210n1p", None).await }) + }; + + // Long enough for the publish to have completed and the wait to have + // begun, well short of the 5 s deadline. + tokio::time::sleep(Duration::from_millis(150)).await; + client.close(); + + let err = task + .await + .expect("task joins") + .expect_err("closing releases the waiter"); + assert!( + matches!( + err, + NwcError::Closed { + delivery: Delivery::Sent + } + ), + "expected a sent Closed, got {err:?}" + ); + assert!( + err_may_reach(&err), + "the wallet has the pay_invoice: this is not a safe retry" + ); + } + + /// (24). `close()` returns as soon as the flag is set, so one that lands + /// while start-up is still in flight used to be followed by a publish. + #[tokio::test] + async fn a_close_during_start_up_still_prevents_the_publish() { + let (wallet, client, _spy) = + spy_harness(Duration::from_secs(5), Duration::from_millis(300)).await; + wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Answer(serde_json::json!({ "preimage": "deadbeef" })), + ) + .await; + + let client = Arc::new(client); + let task = { + let client = Arc::clone(&client); + tokio::spawn(async move { client.pay_invoice("lnbc210n1p", None).await }) + }; + + // Inside the parked connect, past the top-of-request closed check. + tokio::time::sleep(Duration::from_millis(60)).await; + client.close(); + + let err = task + .await + .expect("task joins") + .expect_err("a closed client must not pay"); + assert!( + matches!( + err, + NwcError::Closed { + delivery: Delivery::NotSent + } + ), + "expected a not-sent Closed, got {err:?}" + ); + assert!( + wallet.calls().await.is_empty(), + "the wallet must not have been asked to pay: {:?}", + wallet.calls().await + ); + } + // ── round trips ────────────────────────────────────────────────────────── #[tokio::test] @@ -1052,13 +1464,13 @@ mod tests { .expect_err("silence must not resolve"); match err { - NwcError::Timeout { published, .. } => { - assert!(published, "the request did reach the relay"); + NwcError::Timeout { delivery, .. } => { + assert_eq!(delivery, Delivery::Sent, "the request did reach the relay"); assert!( err_may_reach(&NwcError::Timeout { method: "x".into(), timeout_ms: 1, - published: true + delivery: Delivery::Sent }), "a published timeout must warn the caller" ); @@ -1183,6 +1595,45 @@ mod tests { )); } + /// Kept alongside `an_answer_without_a_correlation_tag_is_ignored`, + /// which cannot see this: an answer with *no* `e` tag is dropped before + /// the correlation lookup is reached, so a fallback that accepted a + /// *mismatched* `e` tag would survive that test. This one carries a + /// well-formed `e` tag pointing at the wrong request. + #[tokio::test] + async fn an_answer_correlated_to_a_different_request_is_ignored() { + let (wallet, client, pool) = harness_with_timeout(Duration::from_millis(700)).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + let client_pubkey = client.client_pubkey(); + + let client = Arc::new(client); + let caller = Arc::clone(&client); + let call = tokio::spawn(async move { caller.make_invoice(1000, None, None).await }); + + tokio::time::sleep(Duration::from_millis(120)).await; + + // A syntactically valid event id that is not the in-flight request's. + let other_request = EventId::from_slice(&[7u8; 32]).expect("valid id"); + let plaintext = + serde_json::json!({"result_type":"make_invoice","result":{"invoice":"wrong"}}) + .to_string(); + let content = + nip04::encrypt(wallet.keys().secret_key(), &client_pubkey, plaintext).expect("encrypt"); + let misdirected = EventBuilder::new(Kind::from(KIND_RESPONSE), content) + .tags([Tag::public_key(client_pubkey), Tag::event(other_request)]) + .sign_with_keys(&wallet.keys()) + .expect("sign"); + pool.publish_event_unfiltered(&misdirected).await; + + let result = call.await.expect("task"); + assert!( + matches!(result, Err(NwcError::Timeout { .. })), + "an answer for another request must not satisfy this one, got {result:?}" + ); + } + /// Needs the mock to answer out of order, which requires per-request /// spawning: an inline wallet is strictly FIFO and this can never fail. #[tokio::test] @@ -1304,7 +1755,15 @@ mod tests { .pay_invoice("lnbc1", None) .await .expect_err("a closed client must refuse"); - assert!(matches!(err, NwcError::Closed), "got {err:?}"); + assert!( + matches!( + err, + NwcError::Closed { + delivery: Delivery::NotSent, + } + ), + "got {err:?}" + ); assert!(!err.may_have_reached_wallet()); tokio::time::sleep(Duration::from_millis(150)).await; assert_eq!( diff --git a/src/payments/nip47/error.rs b/src/payments/nip47/error.rs index c0fe180..c8f6a4e 100644 --- a/src/payments/nip47/error.rs +++ b/src/payments/nip47/error.rs @@ -9,11 +9,13 @@ //! [`NwcError::into_handler_error`]. //! //! **Whether the wallet may already have acted.** `pay_invoice` is not -//! idempotent. A timeout *after* the request was published may mean the wallet -//! paid and we simply did not hear the answer; a failure *before* the publish -//! means it certainly did not. [`NwcError::Timeout`] carries `published` so a -//! caller can tell those apart instead of guessing, and -//! [`NwcError::may_have_reached_wallet`] answers it directly. +//! idempotent. A failure after the request reached a relay may mean the wallet +//! paid and we simply did not hear the answer; one before the publish means it +//! certainly did not; and a deadline that fell *inside* the publish means +//! nobody can tell. [`NwcError::Timeout`] and [`NwcError::Closed`] both carry +//! a [`Delivery`] so a caller can distinguish all three instead of guessing, +//! and [`NwcError::may_have_reached_wallet`] answers it directly, treating the +//! unknown case as "may have". //! //! Every message here is payload-free: a serde failure reports its category //! plus line and column, never the offending bytes, because a wallet can put a @@ -22,30 +24,70 @@ use crate::payments::errors::PaymentError; +/// What is known about whether a request reached the wallet. +/// +/// Two states are not enough. A deadline that fires *while the publish is +/// still in flight* leaves the answer genuinely unknowable: the pool writes +/// the EVENT before it waits on the relay's OK, so the wallet may well have +/// it. Collapsing that into "not sent" is the dangerous direction, because +/// [`NwcError::may_have_reached_wallet`] is what a caller reads to decide +/// whether retrying a `pay_invoice` is safe. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub enum Delivery { + /// The request was never put on the wire. + NotSent, + /// The publish was in flight when the deadline fired. Whether a relay + /// accepted the event cannot be known from here, so it is treated as + /// though it may have been. + Unknown, + /// A relay accepted the event. + Sent, +} + +impl Delivery { + /// Whether the wallet may already have acted on the request. + /// + /// True for both [`Self::Sent`] and [`Self::Unknown`]: only a request we + /// know never left is safe to retry blindly. + pub fn may_have_reached_wallet(self) -> bool { + matches!(self, Self::Sent | Self::Unknown) + } + + fn describe(self) -> &'static str { + match self { + Self::NotSent => "request was never published", + Self::Unknown => "request was still being published; the wallet may have acted on it", + Self::Sent => "request was published; the wallet may have acted on it", + } + } +} + /// Something that went wrong talking to a NIP-47 wallet. #[derive(Debug, thiserror::Error)] #[non_exhaustive] pub enum NwcError { /// No answer arrived within the deadline. - #[error( - "NWC {method} timed out after {timeout_ms} ms ({})", - if *published { "request was published; the wallet may have acted on it" } - else { "request was never published" } - )] + #[error("NWC {method} timed out after {timeout_ms} ms ({})", delivery.describe())] Timeout { /// The NIP-47 method that timed out. method: String, /// The deadline that elapsed, in milliseconds. timeout_ms: u64, - /// Whether the request reached a relay before the deadline. When true, - /// a non-idempotent call such as `pay_invoice` may already have been - /// executed by the wallet. - published: bool, + /// What is known about the request reaching a relay. + delivery: Delivery, }, /// The client was shut down, or is shutting down. - #[error("NWC client is closed")] - Closed, + /// + /// Carries [`Delivery`] because a `close()` can land *after* a + /// `pay_invoice` has gone out: the caller still needs to know the wallet + /// may act on it. + #[error("NWC client is closed ({})", delivery.describe())] + Closed { + /// What is known about the request reaching a relay. + delivery: Delivery, + }, /// Connecting to the wallet's relays failed. #[error("NWC relay connect failed: {reason}")] @@ -128,7 +170,9 @@ impl NwcError { /// `may_have_reached_wallet` failure risks paying twice. pub fn may_have_reached_wallet(&self) -> bool { match self { - Self::Timeout { published, .. } => *published, + Self::Timeout { delivery, .. } | Self::Closed { delivery } => { + delivery.may_have_reached_wallet() + } // The wallet answered, so it certainly saw the request. Self::Wallet { .. } | Self::MalformedResponse { .. } => true, Self::Crypto { direction, .. } => *direction == "decryption", @@ -181,19 +225,49 @@ mod tests { let e = NwcError::Timeout { method: "pay_invoice".into(), timeout_ms: 60_000, - published: true, + delivery: Delivery::Sent, }; assert!(e.may_have_reached_wallet()); let text = e.to_string(); assert!(text.contains("may have acted"), "{text}"); } + /// (8a). The deadline falling inside the publish is the one case that is + /// genuinely unknowable, and it must not be reported as a definite + /// non-delivery: the pool writes the EVENT before it waits for the OK. + #[test] + fn a_timeout_during_the_publish_is_unknown_not_a_non_delivery() { + let e = NwcError::Timeout { + method: "pay_invoice".into(), + timeout_ms: 60_000, + delivery: Delivery::Unknown, + }; + assert!( + e.may_have_reached_wallet(), + "an in-flight publish is not a safe retry" + ); + let text = e.to_string(); + assert!(text.contains("may have acted"), "{text}"); + assert!(!text.contains("never published"), "{text}"); + } + + /// (24). A `close()` that lands after the request went out must keep the + /// delivery bit, or the caller reads it as a safe retry. + #[test] + fn a_close_after_the_publish_still_warns_the_caller() { + let e = NwcError::Closed { + delivery: Delivery::Sent, + }; + assert!(e.may_have_reached_wallet()); + assert!(e.to_string().contains("may have acted"), "{e}"); + } + #[test] fn an_unpublished_timeout_says_it_did_not() { let e = NwcError::Timeout { method: "pay_invoice".into(), timeout_ms: 60_000, - published: false, + delivery: Delivery::NotSent, }; assert!(!e.may_have_reached_wallet()); assert!(e.to_string().contains("never published")); @@ -201,7 +275,9 @@ mod tests { #[test] fn maps_to_the_right_side_of_the_trait_boundary() { - let e = || NwcError::Closed; + let e = || NwcError::Closed { + delivery: Delivery::NotSent, + }; assert!(matches!( e().into_processor_error(), PaymentError::Processor(_) @@ -228,6 +304,9 @@ mod tests { fn config_and_amount_errors_do_not_reach_the_wallet() { assert!(!NwcError::InvalidAmount { reason: "x".into() }.may_have_reached_wallet()); assert!(!NwcError::Config { reason: "x".into() }.may_have_reached_wallet()); - assert!(!NwcError::Closed.may_have_reached_wallet()); + assert!(!NwcError::Closed { + delivery: Delivery::NotSent + } + .may_have_reached_wallet()); } } diff --git a/src/payments/nip47/mod.rs b/src/payments/nip47/mod.rs index d0a43ce..bd6ee84 100644 --- a/src/payments/nip47/mod.rs +++ b/src/payments/nip47/mod.rs @@ -21,7 +21,7 @@ pub mod uri; pub mod mock_wallet; pub use client::{NwcClient, NwcClientOptions, DEFAULT_RESPONSE_TIMEOUT}; -pub use error::NwcError; +pub use error::{Delivery, NwcError}; pub use types::{ sats_to_msats, NwcErrorBody, NwcInvoiceResult, NwcNotificationPayload, NwcPayInvoiceResult, NwcResponseEnvelope, NOTIFICATION_PAYMENT_RECEIVED, diff --git a/src/payments/nip47/types.rs b/src/payments/nip47/types.rs index 1cd25ca..5cff4de 100644 --- a/src/payments/nip47/types.rs +++ b/src/payments/nip47/types.rs @@ -116,10 +116,18 @@ impl NwcErrorBody { /// message is truncated and scrubbed of anything that looks like a BOLT11 /// invoice, which is a bearer payment request. pub fn describe(&self) -> String { - let code = self.code.as_deref().unwrap_or("UNSPECIFIED"); + // The code is wallet-controlled too, and nothing in NIP-47 bounds it + // or keeps an invoice out of it, so it gets the same treatment as the + // message rather than being copied through raw. + let code = self + .code + .as_deref() + .map(sanitize_wallet_text) + .filter(|c| !c.is_empty()) + .unwrap_or_else(|| "UNSPECIFIED".to_string()); match self.message.as_deref().map(sanitize_wallet_text) { Some(msg) if !msg.is_empty() => format!("{code}: {msg}"), - _ => code.to_string(), + _ => code, } } } @@ -132,21 +140,24 @@ impl NwcErrorBody { pub(crate) fn sanitize_wallet_text(text: &str) -> String { let mut out = String::with_capacity(text.len().min(MAX_WALLET_TEXT)); for word in text.split_whitespace() { - let lower = word.to_lowercase(); - let looks_like_invoice = ["lnbc", "lntb", "lnbcrt", "lnsb"] - .iter() - .any(|p| lower.starts_with(p)) - && word.len() > 20; if !out.is_empty() { out.push(' '); } - if looks_like_invoice { + if looks_like_invoice(word) { out.push_str(""); } else { out.push_str(word); } if out.len() >= MAX_WALLET_TEXT { - out.truncate(MAX_WALLET_TEXT); + // Walk back to a character boundary. `truncate` panics mid + // character, and this text is wallet-controlled, so a "payment + // failed" message in any non-Latin script would otherwise bring + // the caller down on the pay path. + let mut cut = MAX_WALLET_TEXT; + while cut > 0 && !out.is_char_boundary(cut) { + cut -= 1; + } + out.truncate(cut); out.push_str("..."); break; } @@ -154,6 +165,24 @@ pub(crate) fn sanitize_wallet_text(text: &str) -> String { out } +/// Whether a whitespace-delimited token carries a BOLT11 invoice. +/// +/// The prefix is looked for *anywhere* in the token, not just at the start. +/// Wallets wrap invoices in punctuation and schemes, and `lightning:lnbc...`, +/// `(lnbc...)`, `invoice=lnbc...` and quoted forms all sail past a +/// `starts_with` test while still carrying the full payment hash. +fn looks_like_invoice(word: &str) -> bool { + const PREFIXES: [&str; 4] = ["lnbcrt", "lnbc", "lntb", "lnsb"]; + let lower = word.to_lowercase(); + PREFIXES.iter().any(|prefix| { + lower + .match_indices(prefix) + // Enough payload after the prefix to be an invoice rather than a + // word that merely happens to contain it. + .any(|(at, _)| lower.len() - at > 20) + }) +} + /// Result of `make_invoice` / `lookup_invoice`. /// /// Every field optional and leniently typed: wallets differ on which they @@ -364,6 +393,73 @@ mod tests { assert_eq!(err.describe(), "SOMETHING_NEW: nope"); } + /// (25). `truncate` panics mid character, and this text is + /// wallet-controlled, so a long non-Latin message is a remote panic on + /// the pay path. + #[test] + fn long_multibyte_wallet_text_does_not_panic() { + // Three bytes per character, so some cut at byte 120 lands inside one. + for n in 38..64 { + let text = "\u{652f}\u{4ed8}\u{5931}\u{8d25}".repeat(n); + let out = sanitize_wallet_text(&text); + assert!(out.len() <= MAX_WALLET_TEXT + 3, "{}", out.len()); + } + // Every offset around the boundary, so the walk-back is exercised at + // each one rather than one lucky alignment. + for pad in 0..8 { + let text = format!("{}{}", "a".repeat(pad), "\u{20ac}".repeat(80)); + let _ = sanitize_wallet_text(&text); + } + } + + /// (26). `starts_with` on whitespace-split words lets every wrapped form + /// through, and the leak carries the full payment hash. + #[test] + fn wrapped_and_prefixed_invoices_are_redacted_too() { + let invoice = "lnbc210n1pjqqqqqpp5abcdefghijklmnopqrstuvwxyz0123456789"; + for wrapped in [ + format!("lightning:{invoice}"), + format!("({invoice})"), + format!("invoice={invoice}"), + format!("\"{invoice}\""), + format!("<{invoice}>"), + format!("[{invoice}]"), + format!("url=lightning:{invoice}&x=1"), + ] { + let out = sanitize_wallet_text(&format!("could not pay {wrapped} sorry")); + assert!( + !out.contains("abcdefghijklmnop"), + "payment hash survived in {out}" + ); + assert!(out.contains(""), "{out}"); + } + } + + /// (26). The code is wallet-controlled as well, and was copied raw. + #[test] + fn describe_redacts_and_bounds_the_error_code_too() { + let invoice = "lnbc210n1pjqqqqqpp5abcdefghijklmnopqrstuvwxyz0123456789"; + let err = NwcErrorBody { + code: Some(format!("FAILED {invoice}")), + message: None, + }; + let described = err.describe(); + assert!(!described.contains("abcdefghijklmnop"), "{described}"); + + let long = NwcErrorBody { + code: Some("X".repeat(10_000)), + message: None, + }; + assert!(long.describe().len() <= MAX_WALLET_TEXT + 3); + } + + /// A word that merely contains the prefix is not an invoice. + #[test] + fn ordinary_words_are_left_alone() { + let out = sanitize_wallet_text("lnbc is the prefix and lnbcrt is another"); + assert_eq!(out, "lnbc is the prefix and lnbcrt is another"); + } + /// A wallet message can name the invoice, and both middlewares log a /// `PaymentError` at warn. #[test] From 69212f4c4904b446c85b985954f426e7f0bc8ce6 Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Fri, 16 Oct 2026 12:23:31 +0530 Subject: [PATCH 06/11] test(payments): make the reader, waiter and subscription guards bite The review measured 17 of 35 mutants living through the suite and named the three on money paths. This kills those and five more, so every guard these PRs added now has a test that fails when the guard is removed. The three named ones: - the `Lagged` arm breaking the reader instead of continuing, which would close the client and fail every later request while requests already in flight could still have paid; - registering the waiter after the publish rather than before, which loses an answer that arrives inside that window; - bypassing the waiter guard, which leaks an `EventId` and a `oneshot::Sender` on every cancelled verification. And five that were untested: `since` at subscribe, the NIP-40 expiration tag, the reader-exit drain, and the payload-free parse error, whose existing test built its serde failure from a string with no payload in it and so passed even when the whole serde message was leaked. Two needed infrastructure, so the test pool now owns the notification stream. A test can overflow it synchronously, which on a current-thread runtime guarantees the reader observes `Lagged` rather than racing for it, and can close it to force a real reader exit. It can also park the caller inside `publish_event` after the event is on the wire, which makes the registration-order window deterministic. --- src/payments/nip47/client.rs | 305 +++++++++++++++++++++++++++++- src/payments/nip47/error.rs | 28 +++ src/payments/nip47/mock_wallet.rs | 6 + 3 files changed, 337 insertions(+), 2 deletions(-) diff --git a/src/payments/nip47/client.rs b/src/payments/nip47/client.rs index d8ee93a..999a8a8 100644 --- a/src/payments/nip47/client.rs +++ b/src/payments/nip47/client.rs @@ -958,16 +958,45 @@ mod tests { inner: Arc, connect_delay: Duration, publish_delay: Duration, + post_publish_delay: Duration, + fail_publish: bool, subscribed: std::sync::Mutex>>, + /// The spy owns the stream the client reads, so a test can overflow + /// it (to force `Lagged`) or close it (to force a reader exit). + relay_tx: std::sync::Mutex>>, + forwarder: std::sync::Mutex>>, } impl SpyPool { fn new(inner: Arc, connect_delay: Duration) -> Self { + Self::with_capacity(inner, connect_delay, 1024) + } + + fn with_capacity( + inner: Arc, + connect_delay: Duration, + capacity: usize, + ) -> Self { + let (relay_tx, _) = tokio::sync::broadcast::channel(capacity); + // Re-broadcast the real pool's stream through our own channel. + let mut upstream = inner.notifications(); + let tx = relay_tx.clone(); + let forwarder = tokio::spawn(async move { + while let Ok(n) = upstream.recv().await { + if tx.send(n).is_err() { + break; + } + } + }); Self { inner, connect_delay, publish_delay: Duration::ZERO, + post_publish_delay: Duration::ZERO, + fail_publish: false, subscribed: std::sync::Mutex::new(Vec::new()), + relay_tx: std::sync::Mutex::new(Some(relay_tx)), + forwarder: std::sync::Mutex::new(Some(forwarder)), } } @@ -976,9 +1005,48 @@ mod tests { self } + /// Hold the caller inside `publish_event` *after* the event is on the + /// wire, so the answer provably lands before `publish` returns. + fn with_post_publish_delay(mut self, delay: Duration) -> Self { + self.post_publish_delay = delay; + self + } + + fn with_failing_publish(mut self) -> Self { + self.fail_publish = true; + self + } + fn subscriptions(&self) -> Vec> { self.subscribed.lock().expect("not poisoned").clone() } + + /// Overflow the client's receiver. Called synchronously on a + /// current-thread runtime, the reader cannot drain in between, so it + /// is guaranteed to observe `Lagged`. + fn flood(&self, events: usize, filler: &Event) { + let guard = self.relay_tx.lock().expect("not poisoned"); + let Some(tx) = guard.as_ref() else { return }; + for _ in 0..events { + let _ = tx.send(RelayPoolNotification::Event { + relay_url: RelayUrl::parse("wss://mock.relay").expect("url"), + subscription_id: SubscriptionId::new("spy"), + event: Box::new(filler.clone()), + }); + } + } + + /// Close the stream the reader is sitting on, as a dropped pool does. + /// + /// Both senders have to go: aborting the forwarder alone leaves our + /// own clone alive, and the receiver then simply waits forever rather + /// than seeing the channel close. + fn kill_stream(&self) { + if let Some(handle) = self.forwarder.lock().expect("not poisoned").take() { + handle.abort(); + } + drop(self.relay_tx.lock().expect("not poisoned").take()); + } } #[async_trait::async_trait] @@ -996,7 +1064,14 @@ mod tests { if !self.publish_delay.is_zero() { tokio::time::sleep(self.publish_delay).await; } - self.inner.publish_event(event).await + if self.fail_publish { + return Err(crate::Error::Transport("injected publish failure".into())); + } + let id = self.inner.publish_event(event).await; + if !self.post_publish_delay.is_zero() { + tokio::time::sleep(self.post_publish_delay).await; + } + id } async fn publish(&self, builder: EventBuilder) -> crate::Result { self.inner.publish(builder).await @@ -1008,7 +1083,12 @@ mod tests { self.inner.signer().await } fn notifications(&self) -> tokio::sync::broadcast::Receiver { - self.inner.notifications() + self.relay_tx + .lock() + .expect("not poisoned") + .as_ref() + .expect("stream not killed") + .subscribe() } async fn public_key(&self) -> crate::Result { self.inner.public_key().await @@ -1315,6 +1395,227 @@ mod tests { ); } + // GUARDS_ON_THE_MONEY_PATH + + /// A lagging broadcast must not kill the reader. If it breaks the loop, + /// the exit path marks the client closed and every later request fails, + /// while requests already in flight can still have paid. + #[tokio::test] + async fn a_lagging_broadcast_does_not_kill_the_reader() { + let (wallet, client, spy) = spy_harness_with(Duration::from_secs(5), |pool| { + SpyPool::with_capacity(pool, Duration::ZERO, 4) + }) + .await; + wallet + .set_behavior( + "make_invoice", + WalletBehavior::Answer(invoice_result("lnbc210n1p")), + ) + .await; + + client.make_invoice(1000, None, None).await.expect("first"); + + // Synchronous on a current-thread runtime: the reader cannot drain + // between sends, so it is certain to observe `Lagged`. + let filler = untagged_from(&wallet, KIND_RESPONSE); + spy.flood(32, &filler); + tokio::task::yield_now().await; + tokio::time::sleep(Duration::from_millis(50)).await; + + assert!(!client.is_closed(), "lagging must not close the client"); + client + .make_invoice(1000, None, None) + .await + .expect("the reader must still be alive after lagging"); + } + + /// The waiter is registered before the publish so an instant answer + /// cannot arrive before anything is listening. Holding the caller inside + /// `publish_event` after the event is on the wire makes that window + /// deterministic rather than a race the test usually wins. + #[tokio::test] + async fn an_answer_arriving_during_the_publish_is_not_lost() { + let (wallet, client, _spy) = spy_harness_with(Duration::from_millis(900), |pool| { + SpyPool::new(pool, Duration::ZERO).with_post_publish_delay(Duration::from_millis(300)) + }) + .await; + wallet + .set_behavior( + "make_invoice", + WalletBehavior::Answer(invoice_result("lnbc210n1p")), + ) + .await; + + client + .make_invoice(1000, None, None) + .await + .expect("an answer landing during the publish must still correlate"); + } + + /// The guard removes the waiter on every exit, not only the happy one. + /// A leak is invisible from outside and accumulates one `EventId` and one + /// `oneshot::Sender` per cancelled verification. + #[tokio::test] + async fn the_waiter_is_removed_when_the_request_times_out() { + let (wallet, client, _spy) = spy_harness(Duration::from_millis(150), Duration::ZERO).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + + let _ = client.make_invoice(1000, None, None).await; + assert_eq!(client.waiter_count(), 0, "timeout path leaked a waiter"); + } + + #[tokio::test] + async fn the_waiter_is_removed_when_the_publish_fails() { + let (wallet, client, _spy) = spy_harness_with(Duration::from_secs(5), |pool| { + SpyPool::new(pool, Duration::ZERO).with_failing_publish() + }) + .await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + + let err = client + .make_invoice(1000, None, None) + .await + .expect_err("the publish fails"); + assert!(matches!(err, NwcError::Publish { .. }), "{err:?}"); + assert_eq!( + client.waiter_count(), + 0, + "publish-failure path leaked a waiter" + ); + } + + /// The waiter is also dropped when the `request()` future is cancelled, + /// which is what the payment processor's `select!` does on every + /// cancelled verification. + #[tokio::test] + async fn the_waiter_is_removed_when_the_caller_is_cancelled() { + let (wallet, client, _spy) = spy_harness(Duration::from_secs(30), Duration::ZERO).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + + tokio::time::timeout( + Duration::from_millis(200), + client.make_invoice(1000, None, None), + ) + .await + .expect_err("the caller gives up first"); + + assert_eq!(client.waiter_count(), 0, "a dropped future leaked a waiter"); + } + + /// When the reader exits it releases everyone waiting, rather than + /// leaving them to sit out a deadline that can be minutes long. + #[tokio::test] + async fn a_reader_exit_releases_the_waiting_caller() { + let (wallet, client, spy) = spy_harness(Duration::from_secs(30), Duration::ZERO).await; + wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + + let client = Arc::new(client); + let caller = Arc::clone(&client); + let started = tokio::time::Instant::now(); + let call = tokio::spawn(async move { caller.make_invoice(1000, None, None).await }); + + tokio::time::sleep(Duration::from_millis(120)).await; + spy.kill_stream(); + + let result = call.await.expect("task joins"); + assert!( + matches!(result, Err(NwcError::Closed { .. })), + "expected Closed, got {result:?}" + ); + assert!( + started.elapsed() < Duration::from_secs(5), + "the caller sat out the deadline instead of being released: {:?}", + started.elapsed() + ); + assert!(client.is_closed(), "a dead reader must close the client"); + } + + /// (15). Without `since`, a relay that retains ephemeral events replays + /// this client's whole history on every start and reconnect, and the + /// replayed notifications reach the sinks. + #[tokio::test] + async fn a_stale_notification_is_not_replayed_at_subscribe() { + let (wallet, client, _spy) = spy_harness(Duration::from_secs(5), Duration::ZERO).await; + let client_pubkey = client.client_pubkey(); + + let stale = Timestamp::now() - (SUBSCRIPTION_LOOKBACK + Duration::from_secs(600)); + let payload = serde_json::json!({ + "notification_type": NOTIFICATION_PAYMENT_RECEIVED, + "notification": { "invoice": "lnbc-old", "amount": 1000 } + }) + .to_string(); + let content = + nip04::encrypt(wallet.keys().secret_key(), &client_pubkey, payload).expect("encrypt"); + let old_event = EventBuilder::new(Kind::from(KIND_NOTIFICATION_LEGACY), content) + .tags([Tag::public_key(client_pubkey)]) + .custom_created_at(stale) + .sign_with_keys(&wallet.keys()) + .expect("sign"); + + // Stored before the client ever subscribes, so only the filter decides + // whether it is replayed. + wallet.pool().inject_event(old_event.clone()).await; + + let seen = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = Arc::clone(&seen); + client + .on_notification(move |_| { + counter.fetch_add(1, Ordering::SeqCst); + }) + .await + .expect("sink registers"); + + tokio::time::sleep(Duration::from_millis(150)).await; + assert_eq!( + seen.load(Ordering::SeqCst), + 0, + "a stale notification must not be replayed" + ); + } + + /// (18). The NIP-40 expiration is what stops a wallet acting on a + /// `pay_invoice` the caller has already given up on. + #[tokio::test] + async fn pay_invoice_carries_the_nip40_expiration_when_asked() { + let (wallet, client, _spy) = spy_harness(Duration::from_millis(250), Duration::ZERO).await; + wallet + .set_behavior("pay_invoice", WalletBehavior::Silence) + .await; + + let _ = client + .pay_invoice("lnbc210n1p", Some(Duration::from_secs(60))) + .await; + + let request = wallet + .pool() + .stored_events() + .await + .into_iter() + .find(|e| e.kind.as_u16() == KIND_REQUEST) + .expect("the request was published"); + let expiration = request + .tags + .iter() + .find_map(|t| match t.as_slice() { + [name, value, ..] if name == "expiration" => value.parse::().ok(), + _ => None, + }) + .expect("a NIP-40 expiration tag"); + let now = Timestamp::now().as_secs(); + assert!( + expiration > now && expiration <= now + 61, + "expiration {expiration} is not about 60s out from {now}" + ); + } + // ── round trips ────────────────────────────────────────────────────────── #[tokio::test] diff --git a/src/payments/nip47/error.rs b/src/payments/nip47/error.rs index c8f6a4e..93cb138 100644 --- a/src/payments/nip47/error.rs +++ b/src/payments/nip47/error.rs @@ -300,6 +300,34 @@ mod tests { assert!(!raw.is_empty()); } + /// The previous version built its error from `"{ not json"`, whose serde + /// message contains no payload, so anything that leaked one still passed. + /// This one makes serde quote a real invoice and then checks it is gone. + #[test] + fn a_parse_failure_on_wallet_json_never_reproduces_the_invoice() { + let invoice = "lnbc210n1pjqqqqqpp5abcdefghijklmnopqrstuvwxyz0123456789"; + let raw = format!(r#"{{"amount":"{invoice}"}}"#); + + #[derive(Debug, serde::Deserialize)] + struct Body { + #[allow(dead_code)] + amount: u64, + } + let err = serde_json::from_str::(&raw).expect_err("a wrong type"); + assert!( + err.to_string().contains("lnbc"), + "precondition: serde is expected to quote the value, got {err}" + ); + + let text = NwcError::from_serde("pay_invoice", &err).to_string(); + assert!(!text.contains(invoice), "{text}"); + assert!(!text.contains("lnbc"), "{text}"); + assert!( + text.contains("pay_invoice") && text.contains("line"), + "{text}" + ); + } + #[test] fn config_and_amount_errors_do_not_reach_the_wallet() { assert!(!NwcError::InvalidAmount { reason: "x".into() }.may_have_reached_wallet()); diff --git a/src/payments/nip47/mock_wallet.rs b/src/payments/nip47/mock_wallet.rs index 9303b2e..f0cc4af 100644 --- a/src/payments/nip47/mock_wallet.rs +++ b/src/payments/nip47/mock_wallet.rs @@ -121,6 +121,12 @@ impl MockWallet { self.keys.public_key() } + /// The relay pool this wallet publishes through, so a test can inspect + /// what reached the wire or seed the relay with a retained event. + pub fn pool(&self) -> &Arc { + &self.pool + } + /// The wallet's signing keys, so a test can forge an event that is /// genuinely wallet-signed. pub fn keys(&self) -> Keys { From 0f3490eea79adb013309f74d973789f4ae20dfff Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Fri, 23 Oct 2026 20:04:39 +0530 Subject: [PATCH 07/11] docs(changelog): correct the NWC client entry to the shipped behaviour Three statements no longer matched the code, all of them on points the review raised and I said were fixed. The client no longer speaks "over an injected `RelayPoolTrait`": item (12) made it build its own pool from the connection relays, with injection kept for tests. The module doc was corrected at the time and this was not. "Decrypted by kind (23196 NIP-04, 23197 NIP-44)" describes exactly the behaviour item (14) asked to change, and the review said so about this line. It is decryption by ciphertext shape now. "A missing or unknown optional field" understates item (2), which was about a field present with the wrong JSON type, the case that lost a settled response. Also records the typed error and `Delivery`, which are public API a caller needs in order to know whether retrying `pay_invoice` is safe. --- CHANGELOG.md | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 04c7e36..4b8c743 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,14 +13,20 @@ connection string in either shape wallets emit, including the `nostr+walletconnect:?...` pathname form that `NostrWalletConnectURI::parse` rejects and the ts-sdk accepts. `NwcClient` speaks - request/response and notifications over an injected `RelayPoolTrait`, so the same code - path runs against a scripted mock wallet in CI and a real wallet in production. Because + request/response and notifications, building its own relay pool from the connection's + relays (`NwcClient::connect`) so its permanent subscription does not land on a pool + shared with the transport; `with_pool` injects one for tests. Because `RelayPoolTrait` has no unsubscribe, it holds one subscription and one reader task and correlates responses in process by request event id, rather than the ts-sdk subscription-per-request shape that would leak one live REQ per payment. Wallet - responses are parsed permissively field for field, so a missing or unknown optional - field cannot turn a paid invoice into a failed verification, and notifications are - decrypted by kind (23196 NIP-04, 23197 NIP-44) rather than always as NIP-04. A + responses are parsed permissively field for field, so an optional field that is + missing, unknown, or present with the wrong JSON type cannot turn a paid invoice into + a failed verification, and events are decrypted by ciphertext shape rather than by + kind, so a NIP-04 payload on kind 23197 still decodes and a wallet publishing both + notification kinds is not mis-handled. Failures are a typed `NwcError` whose messages + carry no wallet payload, and whose `Timeout` and `Closed` variants report a `Delivery` + of `NotSent`, `Unknown` or `Sent` whose `may_have_reached_wallet()` tells a caller + whether retrying a non-idempotent `pay_invoice` is safe. A `MockWallet` behind `test-utils` scripts answers, errors, silence, delays and malformed replies over a linked `MockRelayPool`, and can publish a `payment_received` notification to drive settlement. From 85cefa9dabf113ef49c2b9dd017d30c7fde60b84 Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Thu, 1 Oct 2026 08:43:51 +0530 Subject: [PATCH 08/11] feat(payments): NWC payment processor, the server side of the NWC rail Second of the three NWC sub-PRs in #116. Adds `LnBolt11NwcPaymentProcessor` for PMI `bitcoin-lightning-bolt11`, behind the same off-by-default `nwc` feature. No trait, middleware or registration entry point changes: it plugs into the `PaymentProcessor` trait Phase A froze. - `create_payment_required` mints a BOLT11 invoice with `make_invoice` in msats with an expiry, caches invoice to `payment_hash`, and advertises the configured TTL rather than the wallet's `expires_at`, which providers return in non-standard forms (ts parity). Amounts are validated before any wallet call. - `verify_payment` polls `lookup_invoice` on the ts backoff schedule, floored at the configured poll interval and jittered, or waits on a `payment_received` notification when the wallet advertises one. - In-flight dedup by `pay_req` over a shared future. `PaymentError` is not `Clone`, so the shared output is an `Arc>` and joiners re-wrap the error by message. - `NOT_FOUND` is pending, every other wallet error is fatal, `expired` and `failed` are terminal, and settlement is either `state == "settled"` or a positive `settled_at`, since wallets populate one or the other. The receipt prefers `payment_hash` and falls back to ts's `settled:[:]`. - Notification mode auto-detects once from the info event via a `OnceCell`, and the subscription installs exactly once: with no unsubscribe on `RelayPoolTrait`, a racing second subscribe would leak a sink for the process lifetime. - Every wait selects on `params.cancel`. A cancelled verify returns `Err`, never an empty `Ok` the middleware would read as payment. Two documented divergences from ts. Notification mode falls back to polling when no `payment_hash` is cached, where ts throws and strands a payable invoice. Jitter is derived from the clock rather than a RNG, since this crate has no direct `rand` dependency and de-stampeding does not need cryptographic randomness. 23 tests, including the ts unit tests ported, cancellation stopping the poller, a hung wallet bounded by the response timeout, terminal states, the fallback receipt, notification mode and its polling fallback, amount rejection before any wallet call, and a check that no tracing call binds the invoice as a field. Verified: fmt and clippy clean, 1107 tests pass under `--all-features`, feature matrix green, MSRV 1.88 compiles. Refs #116 --- CHANGELOG.md | 17 + src/payments/mod.rs | 3 + src/payments/processors/ln_bolt11_nwc.rs | 1341 ++++++++++++++++++++++ src/payments/processors/mod.rs | 11 + 4 files changed, 1372 insertions(+) create mode 100644 src/payments/processors/ln_bolt11_nwc.rs create mode 100644 src/payments/processors/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b8c743..bc09baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,23 @@ `MockWallet` behind `test-utils` scripts answers, errors, silence, delays and malformed replies over a linked `MockRelayPool`, and can publish a `payment_received` notification to drive settlement. + - The NWC payment processor, `LnBolt11NwcPaymentProcessor`, the server side of the + Phase B Lightning rail for PMI `bitcoin-lightning-bolt11`, behind the same + off-by-default `nwc` feature. `create_payment_required` mints a BOLT11 invoice via + `make_invoice` in msats with an expiry, caches the invoice to `payment_hash` mapping, + and advertises the configured TTL rather than the wallet's own `expires_at`, which + providers return in non-standard forms. `verify_payment` waits for settlement either + by polling `lookup_invoice` on the ts backoff schedule, floored at the configured poll + interval and jittered, or by listening for a `payment_received` notification when the + wallet advertises one on its info event. Concurrent verifications of the same invoice + are deduplicated onto one shared future so duplicate delivery cannot multiply wallet + and relay load. A lagging wallet answering `NOT_FOUND` is treated as still pending + while every other wallet error is fatal, `expired` and `failed` are terminal, and an + invoice counts as settled on either `state == "settled"` or a positive `settled_at`. + Every wait selects on the middleware's cancellation token, and a cancelled + verification always returns an error, never an empty success that the middleware + would read as payment. Amounts are validated before any wallet call, and no tracing + call carries the invoice, which is a bearer payment request. - Server-side payment-interaction negotiation and advertisement: the server transport now parses client `pmi` and `payment_interaction` tags, negotiates the effective session mode (`transparent` by default, `explicit_gating` when the server policy diff --git a/src/payments/mod.rs b/src/payments/mod.rs index 4235325..a58782b 100644 --- a/src/payments/mod.rs +++ b/src/payments/mod.rs @@ -25,6 +25,7 @@ pub mod constants; pub mod errors; #[cfg(feature = "nwc")] pub mod nip47; +pub mod processors; pub mod server_explicit_gating; pub mod server_payments; pub(crate) mod server_payments_utils; @@ -46,6 +47,8 @@ pub use client_payments::{ PaymentPolicyFn, PaymentRequiredCallbackParams, }; pub use errors::PaymentError; +#[cfg(feature = "nwc")] +pub use processors::{LnBolt11NwcPaymentProcessor, LnBolt11NwcPaymentProcessorOptions}; pub use server_explicit_gating::{ create_explicit_gating_middleware, ExplicitGatingMiddlewareParams, }; diff --git a/src/payments/processors/ln_bolt11_nwc.rs b/src/payments/processors/ln_bolt11_nwc.rs new file mode 100644 index 0000000..6b8d775 --- /dev/null +++ b/src/payments/processors/ln_bolt11_nwc.rs @@ -0,0 +1,1341 @@ +//! CEP-8 payment processor for `bitcoin-lightning-bolt11`, backed by NIP-47 (NWC). +//! +//! Server side of the first Phase B rail: `create_payment_required` mints a +//! BOLT11 invoice with `make_invoice`, and `verify_payment` waits for +//! settlement either by polling `lookup_invoice` or by listening for a +//! `payment_received` notification. +//! +//! # Bounds are this rail's job +//! +//! `create_payment_required` runs in the middleware's unbounded, +//! cancellation-blind initiation phase, so the only thing stopping a hung +//! wallet from wedging it is the client's own `response_timeout`. +//! `verify_payment` runs under a `CancellationToken` the middleware owns, and +//! every wait here selects on it: a poller that ignored it would outlive the +//! payment TTL and keep hitting the wallet long after the request was +//! abandoned. +//! +//! # A cancelled verify is a failure, never a settlement +//! +//! Every cancellation path returns `Err`. Returning `Ok(VerifyOutcome::default())` +//! would be read by the middleware as "paid", forwarding an unpaid invocation. + +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use async_trait::async_trait; +use futures::future::{BoxFuture, Shared}; +use futures::FutureExt; +use lru::LruCache; +use tokio::sync::{oneshot, Mutex, OnceCell}; + +use crate::payments::constants::PMI_BITCOIN_LIGHTNING_BOLT11; +use crate::payments::errors::PaymentError; +use crate::payments::nip47::client::{NwcClient, NwcClientOptions}; +use crate::payments::nip47::error::NwcError; +use crate::payments::nip47::types::{ + sats_to_msats, NwcInvoiceResult, NOTIFICATION_PAYMENT_RECEIVED, +}; +use crate::payments::nip47::uri::{parse_nwc_uri, NwcConnection}; +use crate::payments::traits::PaymentProcessor; +use crate::payments::types::{ + Meta, PaymentProcessorCreateParams, PaymentProcessorVerifyParams, PaymentRequiredParams, + VerifyOutcome, +}; +use crate::relay::RelayPoolTrait; + +const LOG_TARGET: &str = "contextvm::payments::nwc_processor"; + +/// NIP-47 error code meaning "the wallet does not know this invoice yet". +const ERROR_CODE_NOT_FOUND: &str = "NOT_FOUND"; + +/// ts `computeNextDelayMs` schedule, in milliseconds. Fast early checks, since +/// most invoices settle quickly, then back off. +const BACKOFF_SCHEDULE_MS: [u64; 9] = [500, 750, 1000, 1500, 2500, 4000, 6500, 10_000, 15_000]; + +/// Upper bound on the jitter added to each poll delay, in milliseconds. +const JITTER_MAX_MS: u64 = 250; + +/// Default fallback TTL for an issued invoice. +pub const DEFAULT_TTL: Duration = Duration::from_secs(300); +/// Default `lookup_invoice` poll interval floor. +pub const DEFAULT_POLL_INTERVAL: Duration = Duration::from_millis(1500); +/// Default per-request wallet response timeout. +pub const DEFAULT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(60); +/// Default cap on concurrently deduplicated verifications. +pub const DEFAULT_MAX_IN_FLIGHT_VERIFICATIONS: usize = 5000; +/// Default cap on cached invoice to payment-hash mappings. +pub const DEFAULT_INVOICE_HASH_CACHE_SIZE: usize = 10_000; + +/// Construction options for [`LnBolt11NwcPaymentProcessor`]. +/// +/// Defaults mirror ts `LnBolt11NwcPaymentProcessorOptions`. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct LnBolt11NwcPaymentProcessorOptions { + /// TTL advertised on `payment_required`. The wallet's own `expires_at` is + /// deliberately ignored: providers return non-standard values, and ts keeps + /// this predictable. Default 300 s. + pub ttl: Duration, + /// `make_invoice.expiry`. Defaults to [`Self::ttl`] when `None`. + pub invoice_expiry: Option, + /// Floor under the `lookup_invoice` backoff schedule. Default 1500 ms. + pub poll_interval: Duration, + /// Per-request wallet response timeout. Default 60 s. + pub response_timeout: Duration, + /// `Some(false)` always polls, `Some(true)` always uses notifications, + /// `None` auto-detects once from the wallet's info event. Default `None`. + pub notification_verification: Option, + /// Cap on concurrently deduplicated verifications. Default 5000. + pub max_in_flight_verifications: usize, + /// Cap on cached invoice to payment-hash mappings. Default 10000. + pub invoice_hash_cache_size: usize, +} + +impl Default for LnBolt11NwcPaymentProcessorOptions { + fn default() -> Self { + Self { + ttl: DEFAULT_TTL, + invoice_expiry: None, + poll_interval: DEFAULT_POLL_INTERVAL, + response_timeout: DEFAULT_RESPONSE_TIMEOUT, + notification_verification: None, + max_in_flight_verifications: DEFAULT_MAX_IN_FLIGHT_VERIFICATIONS, + invoice_hash_cache_size: DEFAULT_INVOICE_HASH_CACHE_SIZE, + } + } +} + +/// A deduplicated verification. `PaymentError` is not `Clone`, so the shared +/// output is an `Arc` and joiners re-wrap the error by message. +type VerifyInFlight = Shared>>>; + +struct Inner { + client: Arc, + options: LnBolt11NwcPaymentProcessorOptions, + in_flight: Mutex>, + invoice_hash: Mutex>, + /// Resolved once: whether the wallet supports `payment_received`. + notifications_enabled: OnceCell, + /// Resolved once: the notification subscription has been installed. + notifications_subscribed: OnceCell<()>, + /// payment_hash to the verifications waiting on it. + waiters: Arc>>>>, +} + +/// CEP-8 processor for `bitcoin-lightning-bolt11` over NIP-47. +pub struct LnBolt11NwcPaymentProcessor { + inner: Arc, +} + +impl LnBolt11NwcPaymentProcessor { + /// Build from a NWC connection string and a relay pool. + /// + /// # Errors + /// + /// Returns [`PaymentError::Processor`] when the connection string does not parse. + pub fn from_uri( + pool: Arc, + uri: &str, + options: LnBolt11NwcPaymentProcessorOptions, + ) -> Result { + let connection = parse_nwc_uri(uri).map_err(NwcError::into_processor_error)?; + Self::from_connection(pool, connection, options) + } + + /// Build from an already-parsed connection. + pub fn from_connection( + pool: Arc, + connection: NwcConnection, + options: LnBolt11NwcPaymentProcessorOptions, + ) -> Result { + let client = NwcClient::with_pool( + pool, + connection, + NwcClientOptions::new().with_response_timeout(options.response_timeout), + ) + .map_err(NwcError::into_processor_error)?; + Ok(Self::with_client(Arc::new(client), options)) + } + + /// Build around an already-constructed client, for tests and custom transports. + pub fn with_client( + client: Arc, + options: LnBolt11NwcPaymentProcessorOptions, + ) -> Self { + let in_flight_cap = NonZeroUsize::new(options.max_in_flight_verifications) + .unwrap_or(NonZeroUsize::new(1).expect("1 is non-zero")); + let hash_cap = NonZeroUsize::new(options.invoice_hash_cache_size) + .unwrap_or(NonZeroUsize::new(1).expect("1 is non-zero")); + + let notifications_enabled = OnceCell::new(); + // An explicit setting short-circuits the info-event probe entirely. + if let Some(explicit) = options.notification_verification { + notifications_enabled + .set(explicit) + .expect("freshly constructed OnceCell is empty"); + } + + Self { + inner: Arc::new(Inner { + client, + options, + in_flight: Mutex::new(LruCache::new(in_flight_cap)), + invoice_hash: Mutex::new(LruCache::new(hash_cap)), + notifications_enabled, + notifications_subscribed: OnceCell::new(), + waiters: Arc::new(Mutex::new(HashMap::new())), + }), + } + } + + /// The effective `make_invoice.expiry`. + fn invoice_expiry(&self) -> Duration { + self.inner + .options + .invoice_expiry + .unwrap_or(self.inner.options.ttl) + } +} + +impl Inner { + /// Whether to verify by notification, probing the wallet's info event at + /// most once. A probe failure means polling, never a hard error. + async fn notifications_enabled(&self) -> bool { + *self + .notifications_enabled + .get_or_init(|| async { + match self.client.fetch_info_notification_types().await { + Ok(types) => types.iter().any(|t| t == NOTIFICATION_PAYMENT_RECEIVED), + Err(e) => { + tracing::debug!( + target: LOG_TARGET, + error = %e, + "NWC info probe failed; falling back to polling" + ); + false + } + } + }) + .await + } + + /// Install the notification sink exactly once. + /// + /// Concurrent verifications racing here would otherwise each subscribe, and + /// since `RelayPoolTrait` has no unsubscribe every extra sink would live + /// for the process lifetime. + async fn ensure_notifications_subscribed(&self) -> Result<(), PaymentError> { + let waiters = Arc::clone(&self.waiters); + let client = Arc::clone(&self.client); + self.notifications_subscribed + .get_or_try_init(|| async move { + client + .on_notification(move |payload| { + if payload.notification_type != NOTIFICATION_PAYMENT_RECEIVED { + return; + } + let Some(hash) = payload.payment_hash() else { + return; + }; + if hash.is_empty() { + return; + } + let hash = hash.to_string(); + let waiters = Arc::clone(&waiters); + // The sink runs on the reader task and must not block, + // so the map touch is detached. + tokio::spawn(async move { + let taken = waiters.lock().await.remove(&hash); + for w in taken.unwrap_or_default() { + let _ = w.send(()); + } + }); + }) + .await + }) + .await?; + Ok(()) + } + + async fn cached_payment_hash(&self, pay_req: &str) -> Option { + self.invoice_hash.lock().await.get(pay_req).cloned() + } + + async fn cache_payment_hash(&self, pay_req: &str, hash: &str) { + if hash.is_empty() { + return; + } + self.invoice_hash + .lock() + .await + .put(pay_req.to_string(), hash.to_string()); + } + + /// Delay before poll attempt `attempt`, floored at `poll_interval` and jittered. + /// + /// The jitter is derived from the clock rather than a RNG: this crate has no + /// direct `rand` dependency, and de-stampeding shared wallet infrastructure + /// does not need cryptographic randomness. + fn next_delay(&self, attempt: usize) -> Duration { + let idx = attempt.min(BACKOFF_SCHEDULE_MS.len() - 1); + let base = Duration::from_millis(BACKOFF_SCHEDULE_MS[idx]); + let floored = base.max(self.options.poll_interval); + let jitter = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| u64::from(d.subsec_nanos()) % JITTER_MAX_MS) + .unwrap_or(0); + floored + Duration::from_millis(jitter) + } +} + +/// The settlement receipt for a verified invoice. +/// +/// Prefers `payment_hash`, a stable identifier that is not the preimage. Wallets +/// that omit it on lookup still get a stable correlation string, matching ts's +/// `settled:[:]`. +fn receipt_for(result: &NwcInvoiceResult, request_event_id: &str) -> String { + match result.payment_hash.as_deref() { + Some(h) if !h.is_empty() => h.to_string(), + _ => match result.settled_at { + Some(t) if t != 0 => format!("settled:{request_event_id}:{t}"), + _ => format!("settled:{request_event_id}"), + }, + } +} + +fn outcome_with_receipt(receipt: String) -> VerifyOutcome { + let mut meta = Meta::new(); + meta.insert( + "payment_hash".to_string(), + serde_json::Value::String(receipt), + ); + VerifyOutcome { meta: Some(meta) } +} + +fn cancelled() -> PaymentError { + PaymentError::Processor("verify_payment cancelled".to_string()) +} + +#[async_trait] +impl PaymentProcessor for LnBolt11NwcPaymentProcessor { + fn pmi(&self) -> &str { + PMI_BITCOIN_LIGHTNING_BOLT11 + } + + async fn create_payment_required( + &self, + params: PaymentProcessorCreateParams, + ) -> Result { + // Reject a bad amount before it can reach the wallet. + let amount_msats = sats_to_msats(params.amount)?; + + let envelope = self + .inner + .client + .make_invoice( + amount_msats, + params.description.as_deref(), + Some(self.invoice_expiry()), + ) + .await + .map_err(NwcError::into_processor_error)?; + + if let Some(err) = envelope.error { + return Err(NwcError::Wallet { + method: "make_invoice".to_string(), + detail: err.describe(), + } + .into_processor_error()); + } + + let result = envelope + .result + .ok_or_else(|| PaymentError::Processor("NWC make_invoice returned no result".into()))?; + + let invoice = match result.invoice.as_deref() { + Some(i) if !i.is_empty() => i.to_string(), + _ => { + return Err(PaymentError::Processor( + "NWC make_invoice returned no invoice".into(), + )) + } + }; + + if let Some(hash) = result.payment_hash.as_deref() { + self.inner.cache_payment_hash(&invoice, hash).await; + } + + // Never log the invoice itself: it is a bearer payment request. + tracing::debug!( + target: LOG_TARGET, + request_event_id = %params.request_event_id, + amount_sats = params.amount, + has_payment_hash = result.payment_hash.is_some(), + "NWC invoice issued" + ); + + Ok(PaymentRequiredParams { + amount: params.amount, + pay_req: invoice, + pmi: PMI_BITCOIN_LIGHTNING_BOLT11.to_string(), + description: params.description, + ttl: Some(self.inner.options.ttl.as_secs()), + meta: None, + }) + } + + async fn verify_payment( + &self, + params: PaymentProcessorVerifyParams, + ) -> Result { + // Join an existing verification for the same invoice rather than + // doubling relay and wallet load under duplicate delivery. + let shared = { + let mut cache = self.inner.in_flight.lock().await; + match cache.get(¶ms.pay_req) { + Some(existing) => existing.clone(), + None => { + let inner = Arc::clone(&self.inner); + let p = params.clone(); + let key = params.pay_req.clone(); + let fut: BoxFuture<'static, Arc>> = + async move { Arc::new(verify_once(inner, p).await) }.boxed(); + let shared = fut.shared(); + cache.put(key, shared.clone()); + shared + } + } + }; + + let result = shared.await; + + // Release the slot so a later retry of the same invoice re-runs rather + // than replaying a stale terminal answer. + self.inner.in_flight.lock().await.pop(¶ms.pay_req); + + match &*result { + Ok(outcome) => Ok(outcome.clone()), + // `PaymentError` is not `Clone`; joiners re-wrap by message. + Err(e) => Err(PaymentError::Processor(e.to_string())), + } + } +} + +/// One verification attempt, shared by every caller deduplicated onto it. +async fn verify_once( + inner: Arc, + params: PaymentProcessorVerifyParams, +) -> Result { + if params.cancel.is_cancelled() { + return Err(cancelled()); + } + + if inner.notifications_enabled().await { + match verify_by_notification(Arc::clone(&inner), ¶ms).await { + Ok(outcome) => return Ok(outcome), + Err(NotificationUnavailable::Cancelled) => return Err(cancelled()), + Err(NotificationUnavailable::Failed(e)) => return Err(e), + // Divergence from ts, which throws here: fall back to polling when + // no payment_hash is cached, rather than failing a payable invoice. + Err(NotificationUnavailable::NoPaymentHash) => { + tracing::debug!( + target: LOG_TARGET, + request_event_id = %params.request_event_id, + "no cached payment_hash for notification mode; falling back to polling" + ); + } + } + } + + verify_by_polling(inner, ¶ms).await +} + +/// Why notification-mode verification could not produce an answer. +enum NotificationUnavailable { + /// No `payment_hash` is cached for this invoice, so no notification can be + /// correlated to it. + NoPaymentHash, + /// The verification was cancelled while waiting. + Cancelled, + /// Subscribing failed. + Failed(PaymentError), +} + +async fn verify_by_notification( + inner: Arc, + params: &PaymentProcessorVerifyParams, +) -> Result { + if let Err(e) = inner.ensure_notifications_subscribed().await { + return Err(NotificationUnavailable::Failed(e)); + } + + let Some(hash) = inner.cached_payment_hash(¶ms.pay_req).await else { + return Err(NotificationUnavailable::NoPaymentHash); + }; + + let (tx, rx) = oneshot::channel(); + inner + .waiters + .lock() + .await + .entry(hash.clone()) + .or_default() + .push(tx); + + tokio::select! { + _ = params.cancel.cancelled() => { + // Drop our waiter so a later notification cannot accumulate senders + // for a verification nobody is awaiting. + let mut guard = inner.waiters.lock().await; + if let Some(list) = guard.get_mut(&hash) { + list.retain(|w| !w.is_closed()); + if list.is_empty() { + guard.remove(&hash); + } + } + Err(NotificationUnavailable::Cancelled) + } + received = rx => match received { + Ok(()) => Ok(outcome_with_receipt(hash)), + Err(_) => Err(NotificationUnavailable::Failed(PaymentError::Processor( + "NWC notification waiter dropped".to_string(), + ))), + }, + } +} + +async fn verify_by_polling( + inner: Arc, + params: &PaymentProcessorVerifyParams, +) -> Result { + let mut attempt = 0usize; + + loop { + if params.cancel.is_cancelled() { + return Err(cancelled()); + } + + let cached = inner.cached_payment_hash(¶ms.pay_req).await; + + // Race the wallet call against cancellation: without this, a hung + // wallet keeps the poller alive past the payment TTL. + let envelope = tokio::select! { + _ = params.cancel.cancelled() => return Err(cancelled()), + res = inner + .client + .lookup_invoice(cached.as_deref(), Some(¶ms.pay_req)) => + res.map_err(NwcError::into_processor_error)?, + }; + + match envelope.error { + // A lagging wallet that has not seen the invoice yet is pending, + // not failed. Every other code is fatal. + Some(err) if err.code_upper() == ERROR_CODE_NOT_FOUND => {} + Some(err) => { + return Err(NwcError::Wallet { + method: "lookup_invoice".to_string(), + detail: err.describe(), + } + .into_processor_error()) + } + None => { + if let Some(result) = envelope.result { + if cached.is_none() { + if let Some(h) = result.payment_hash.as_deref() { + inner.cache_payment_hash(¶ms.pay_req, h).await; + } + } + + tracing::debug!( + target: LOG_TARGET, + request_event_id = %params.request_event_id, + state = result.state.as_deref().unwrap_or("unknown"), + has_preimage = result.preimage.is_some(), + has_settled_at = result.settled_at.is_some(), + has_payment_hash = result.payment_hash.is_some(), + "NWC lookup_invoice result" + ); + + if result.is_settled() { + return Ok(outcome_with_receipt(receipt_for( + &result, + ¶ms.request_event_id, + ))); + } + + if result.is_terminal_failure() { + let state = result.state.as_deref().unwrap_or("unknown"); + return Err(PaymentError::Processor(format!("Invoice {state}"))); + } + } + } + } + + let delay = inner.next_delay(attempt); + attempt = attempt.saturating_add(1); + + tokio::select! { + _ = params.cancel.cancelled() => return Err(cancelled()), + _ = tokio::time::sleep(delay) => {} + } + } +} + +#[cfg(all(test, feature = "test-utils"))] +mod tests { + use super::*; + use crate::payments::nip47::mock_wallet::{MockWallet, WalletBehavior}; + use crate::relay::MockRelayPool; + use nostr_sdk::prelude::*; + use tokio_util::sync::CancellationToken; + + const SATS: i64 = 21; + + struct Harness { + wallet: Arc, + processor: LnBolt11NwcPaymentProcessor, + } + + fn opts() -> LnBolt11NwcPaymentProcessorOptions { + LnBolt11NwcPaymentProcessorOptions { + // Keep the polling tests fast; the schedule floor is what the + // backoff test exercises directly. + poll_interval: Duration::from_millis(20), + response_timeout: Duration::from_millis(600), + notification_verification: Some(false), + ..Default::default() + } + } + + async fn harness_with(options: LnBolt11NwcPaymentProcessorOptions) -> Harness { + let wallet_pool = Arc::new(MockRelayPool::new()); + let client_pool = Arc::new(wallet_pool.linked_with_keys(Keys::generate())); + let wallet = Arc::new(MockWallet::new(Arc::clone(&wallet_pool))); + wallet.start().await.expect("wallet starts"); + + let secret = SecretKey::generate(); + let uri = format!( + "nostr+walletconnect://{}?relay=wss%3A%2F%2Fmock.relay&secret={}", + wallet.public_key().to_hex(), + secret.to_secret_hex() + ); + let connection = parse_nwc_uri(&uri).expect("uri parses"); + let response_timeout = options.response_timeout; + let client = Arc::new( + NwcClient::with_pool( + Arc::clone(&client_pool) as Arc, + connection, + NwcClientOptions::new().with_response_timeout(response_timeout), + ) + .expect("options valid"), + ); + + Harness { + wallet, + processor: LnBolt11NwcPaymentProcessor::with_client(client, options), + } + } + + async fn harness() -> Harness { + harness_with(opts()).await + } + + fn create_params() -> PaymentProcessorCreateParams { + PaymentProcessorCreateParams { + amount: SATS, + description: Some("a priced tool call".to_string()), + request_event_id: "evt-1".to_string(), + client_pubkey: "client-1".to_string(), + } + } + + fn verify_params(pay_req: &str, cancel: CancellationToken) -> PaymentProcessorVerifyParams { + PaymentProcessorVerifyParams { + pay_req: pay_req.to_string(), + request_event_id: "evt-1".to_string(), + client_pubkey: "client-1".to_string(), + cancel, + } + } + + fn invoice_answer(hash: Option<&str>) -> WalletBehavior { + let mut v = serde_json::json!({ "invoice": "lnbc210n1p", "state": "pending" }); + if let Some(h) = hash { + v["payment_hash"] = serde_json::Value::String(h.to_string()); + } + WalletBehavior::Answer(v) + } + + // ── create_payment_required ────────────────────────────────────────────── + + #[tokio::test] + async fn issues_an_invoice_in_msats_with_the_configured_ttl() { + let h = harness().await; + h.wallet + .set_behavior("make_invoice", invoice_answer(Some("hash-1"))) + .await; + + let out = h + .processor + .create_payment_required(create_params()) + .await + .expect("invoice issued"); + + assert_eq!(out.pay_req, "lnbc210n1p"); + assert_eq!(out.amount, SATS); + assert_eq!(out.pmi, PMI_BITCOIN_LIGHTNING_BOLT11); + // The wallet's own expires_at is ignored; the configured ttl wins. + assert_eq!(out.ttl, Some(DEFAULT_TTL.as_secs())); + + let calls = h.wallet.calls().await; + let (method, params) = &calls[0]; + assert_eq!(method, "make_invoice"); + // sats converted to msats, and the expiry carried. + assert_eq!(params["amount"].as_u64(), Some(21_000)); + assert_eq!(params["expiry"].as_u64(), Some(DEFAULT_TTL.as_secs())); + } + + #[tokio::test] + async fn rejects_non_positive_and_overflowing_amounts_before_calling_the_wallet() { + let h = harness().await; + h.wallet + .set_behavior("make_invoice", invoice_answer(None)) + .await; + + for bad in [0i64, -1, i64::MAX] { + let mut p = create_params(); + p.amount = bad; + assert!( + h.processor.create_payment_required(p).await.is_err(), + "amount {bad} must be rejected" + ); + } + assert_eq!( + h.wallet.call_count("make_invoice").await, + 0, + "a bad amount must never reach the wallet" + ); + } + + #[tokio::test] + async fn a_wallet_error_fails_invoice_creation() { + let h = harness().await; + h.wallet + .set_behavior( + "make_invoice", + WalletBehavior::Error { + code: "INSUFFICIENT_BALANCE".to_string(), + message: "no inbound".to_string(), + }, + ) + .await; + + let err = h + .processor + .create_payment_required(create_params()) + .await + .expect_err("wallet error must fail creation"); + assert!(err.to_string().contains("INSUFFICIENT_BALANCE")); + } + + #[tokio::test] + async fn a_result_without_an_invoice_fails_creation() { + let h = harness().await; + h.wallet + .set_behavior( + "make_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "pending" })), + ) + .await; + + assert!(h + .processor + .create_payment_required(create_params()) + .await + .is_err()); + } + + /// `create_payment_required` runs in the middleware's cancellation-blind + /// initiation phase, so the client's response timeout is the only bound. + #[tokio::test] + async fn a_hung_wallet_fails_creation_inside_the_response_timeout() { + let h = harness().await; + h.wallet + .set_behavior("make_invoice", WalletBehavior::Silence) + .await; + + let started = tokio::time::Instant::now(); + let res = h.processor.create_payment_required(create_params()).await; + assert!(res.is_err(), "a silent wallet must not hang creation"); + assert!( + started.elapsed() < Duration::from_secs(5), + "must fail at the response timeout, took {:?}", + started.elapsed() + ); + } + + // ── verify_payment: polling ────────────────────────────────────────────── + + #[tokio::test] + async fn settles_on_state_settled() { + let h = harness().await; + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer( + serde_json::json!({ "state": "settled", "payment_hash": "hash-9" }), + ), + ) + .await; + + let out = h + .processor + .verify_payment(verify_params("lnbc1", CancellationToken::new())) + .await + .expect("settles"); + assert_eq!( + out.meta + .unwrap() + .get("payment_hash") + .and_then(|v| v.as_str()), + Some("hash-9") + ); + } + + /// Wallets populate `state` or `settled_at`; requiring both would strand + /// real payments. + #[tokio::test] + async fn settles_on_settled_at_without_a_state() { + let h = harness().await; + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "settled_at": 1_700_000_000i64 })), + ) + .await; + + let out = h + .processor + .verify_payment(verify_params("lnbc1", CancellationToken::new())) + .await + .expect("settles"); + // No payment_hash on the result, so the ts fallback receipt is used. + assert_eq!( + out.meta + .unwrap() + .get("payment_hash") + .and_then(|v| v.as_str()), + Some("settled:evt-1:1700000000") + ); + } + + #[tokio::test] + async fn fallback_receipt_without_settled_at() { + let r = NwcInvoiceResult { + state: Some("settled".into()), + ..Default::default() + }; + assert_eq!(receipt_for(&r, "evt-7"), "settled:evt-7"); + } + + #[tokio::test] + async fn terminal_states_are_fatal() { + for state in ["expired", "failed"] { + let h = harness().await; + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": state })), + ) + .await; + + let err = h + .processor + .verify_payment(verify_params("lnbc1", CancellationToken::new())) + .await + .expect_err("terminal state must fail"); + assert!( + err.to_string().contains(state), + "{state} should surface in the error" + ); + } + } + + /// A lagging wallet that has not seen the invoice is pending, not failed. + #[tokio::test] + async fn not_found_is_pending_and_other_codes_are_fatal() { + let h = harness().await; + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Error { + code: "NOT_FOUND".to_string(), + message: "unknown invoice".to_string(), + }, + ) + .await; + + let cancel = CancellationToken::new(); + let fut = h + .processor + .verify_payment(verify_params("lnbc1", cancel.clone())); + // Still polling after the first NOT_FOUND rather than returning. + let early = tokio::time::timeout(Duration::from_millis(200), fut).await; + assert!(early.is_err(), "NOT_FOUND must keep the poller running"); + cancel.cancel(); + + let h2 = harness().await; + h2.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Error { + code: "INTERNAL".to_string(), + message: "boom".to_string(), + }, + ) + .await; + let err = h2 + .processor + .verify_payment(verify_params("lnbc1", CancellationToken::new())) + .await + .expect_err("a non-NOT_FOUND code is fatal"); + assert!(err.to_string().contains("INTERNAL")); + } + + /// A cancelled verify must stop the poller and must never read as settled. + #[tokio::test] + async fn a_cancelled_verify_errors_and_stops_polling() { + let h = harness().await; + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "pending" })), + ) + .await; + + let cancel = CancellationToken::new(); + let params = verify_params("lnbc1", cancel.clone()); + let handle = { + let p = params.clone(); + let proc_ref = &h.processor; + async move { proc_ref.verify_payment(p).await } + }; + + let (res, _) = tokio::join!(handle, async { + tokio::time::sleep(Duration::from_millis(120)).await; + cancel.cancel(); + }); + + let err = res.expect_err("a cancelled verify must be an Err, never an empty Ok"); + assert!(err.to_string().contains("cancelled")); + + let before = h.wallet.call_count("lookup_invoice").await; + tokio::time::sleep(Duration::from_millis(250)).await; + assert_eq!( + h.wallet.call_count("lookup_invoice").await, + before, + "the poller must stop hitting the wallet after cancellation" + ); + } + + #[tokio::test] + async fn prefers_lookup_by_payment_hash_once_known() { + let h = harness().await; + h.wallet + .set_behavior("make_invoice", invoice_answer(Some("hash-from-create"))) + .await; + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "settled" })), + ) + .await; + + let issued = h + .processor + .create_payment_required(create_params()) + .await + .unwrap(); + h.processor + .verify_payment(verify_params(&issued.pay_req, CancellationToken::new())) + .await + .expect("settles"); + + let lookup = h + .wallet + .calls() + .await + .into_iter() + .find(|(m, _)| m == "lookup_invoice") + .expect("a lookup happened"); + assert_eq!( + lookup.1["payment_hash"].as_str(), + Some("hash-from-create"), + "the cached hash from make_invoice should be preferred over the invoice string" + ); + assert!(lookup.1.get("invoice").is_none()); + } + + #[tokio::test] + async fn falls_back_to_invoice_lookup_when_no_hash_is_known() { + let h = harness().await; + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "settled" })), + ) + .await; + + h.processor + .verify_payment(verify_params("lnbc-unknown", CancellationToken::new())) + .await + .expect("settles"); + + let lookup = h.wallet.calls().await.into_iter().next().unwrap(); + assert_eq!(lookup.1["invoice"].as_str(), Some("lnbc-unknown")); + } + + /// Duplicate delivery of one request must not double the wallet load. + #[tokio::test] + async fn dedupes_concurrent_verifies_for_the_same_invoice() { + let h = harness().await; + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Delayed { + delay: Duration::from_millis(150), + result: serde_json::json!({ "state": "settled", "payment_hash": "h" }), + }, + ) + .await; + + let cancel = CancellationToken::new(); + let (a, b, c) = tokio::join!( + h.processor + .verify_payment(verify_params("lnbc-same", cancel.clone())), + h.processor + .verify_payment(verify_params("lnbc-same", cancel.clone())), + h.processor + .verify_payment(verify_params("lnbc-same", cancel.clone())), + ); + + assert!(a.is_ok() && b.is_ok() && c.is_ok()); + assert_eq!( + h.wallet.call_count("lookup_invoice").await, + 1, + "three concurrent verifies of one invoice must make one wallet call" + ); + } + + #[tokio::test] + async fn distinct_invoices_are_not_deduped_together() { + let h = harness().await; + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "settled" })), + ) + .await; + + let cancel = CancellationToken::new(); + let (a, b) = tokio::join!( + h.processor + .verify_payment(verify_params("inv-a", cancel.clone())), + h.processor + .verify_payment(verify_params("inv-b", cancel.clone())), + ); + assert!(a.is_ok() && b.is_ok()); + assert_eq!(h.wallet.call_count("lookup_invoice").await, 2); + } + + // ── notification mode ──────────────────────────────────────────────────── + + /// Auto mode probes the info event once and polls when the wallet does not + /// advertise `payment_received`. + #[tokio::test] + async fn auto_mode_polls_when_notifications_are_unsupported() { + let h = harness_with(LnBolt11NwcPaymentProcessorOptions { + notification_verification: None, + ..opts() + }) + .await; + h.wallet + .publish_info_event(&["payment_sent"]) + .await + .unwrap(); + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "settled" })), + ) + .await; + + h.processor + .verify_payment(verify_params("lnbc1", CancellationToken::new())) + .await + .expect("settles by polling"); + assert_eq!(h.wallet.call_count("lookup_invoice").await, 1); + } + + #[tokio::test] + async fn notification_mode_resolves_from_payment_received() { + let h = harness_with(LnBolt11NwcPaymentProcessorOptions { + notification_verification: Some(true), + ..opts() + }) + .await; + h.wallet + .set_behavior("make_invoice", invoice_answer(Some("hash-notif"))) + .await; + + let issued = h + .processor + .create_payment_required(create_params()) + .await + .unwrap(); + + let client_pubkey = h.processor.inner.client.client_pubkey(); + let wallet = Arc::clone(&h.wallet); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(120)).await; + let _ = wallet.settle(&client_pubkey, "hash-notif").await; + }); + + let out = h + .processor + .verify_payment(verify_params(&issued.pay_req, CancellationToken::new())) + .await + .expect("settles from the notification"); + assert_eq!( + out.meta + .unwrap() + .get("payment_hash") + .and_then(|v| v.as_str()), + Some("hash-notif") + ); + assert_eq!( + h.wallet.call_count("lookup_invoice").await, + 0, + "notification mode must not poll" + ); + } + + /// Divergence from ts, which throws: with no cached payment_hash there is + /// nothing to correlate a notification to, so fall back to polling rather + /// than failing a payable invoice. + #[tokio::test] + async fn notification_mode_without_a_cached_hash_polls_instead_of_erroring() { + let h = harness_with(LnBolt11NwcPaymentProcessorOptions { + notification_verification: Some(true), + ..opts() + }) + .await; + h.wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "settled" })), + ) + .await; + + let out = h + .processor + .verify_payment(verify_params("lnbc-nohash", CancellationToken::new())) + .await + .expect("falls back to polling rather than erroring"); + assert!(out.meta.is_some()); + assert_eq!(h.wallet.call_count("lookup_invoice").await, 1); + } + + #[tokio::test] + async fn concurrent_verifies_subscribe_for_notifications_exactly_once() { + let h = harness_with(LnBolt11NwcPaymentProcessorOptions { + notification_verification: Some(true), + ..opts() + }) + .await; + h.wallet + .set_behavior("make_invoice", invoice_answer(Some("hash-multi"))) + .await; + let issued = h + .processor + .create_payment_required(create_params()) + .await + .unwrap(); + + let client_pubkey = h.processor.inner.client.client_pubkey(); + let wallet = Arc::clone(&h.wallet); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(150)).await; + let _ = wallet.settle(&client_pubkey, "hash-multi").await; + }); + + let cancel = CancellationToken::new(); + // Distinct pay_reqs so the in-flight dedup does not mask the + // subscription race; both map to the same hash via the cache. + h.processor + .inner + .cache_payment_hash("lnbc-second", "hash-multi") + .await; + let (a, b) = tokio::join!( + h.processor + .verify_payment(verify_params(&issued.pay_req, cancel.clone())), + h.processor + .verify_payment(verify_params("lnbc-second", cancel.clone())), + ); + + assert!(a.is_ok(), "first verify: {a:?}"); + assert!(b.is_ok(), "second verify: {b:?}"); + assert!( + h.processor.inner.notifications_subscribed.initialized(), + "the subscription is installed exactly once" + ); + } + + #[tokio::test] + async fn a_cancelled_notification_wait_errors_and_drops_its_waiter() { + let h = harness_with(LnBolt11NwcPaymentProcessorOptions { + notification_verification: Some(true), + ..opts() + }) + .await; + h.processor + .inner + .cache_payment_hash("lnbc-cancel", "hash-cancel") + .await; + + let cancel = CancellationToken::new(); + let c2 = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_millis(80)).await; + c2.cancel(); + }); + + let err = h + .processor + .verify_payment(verify_params("lnbc-cancel", cancel)) + .await + .expect_err("a cancelled notification wait must be an Err"); + assert!(err.to_string().contains("cancelled")); + + assert!( + h.processor + .inner + .waiters + .lock() + .await + .get("hash-cancel") + .is_none_or(|v| v.is_empty()), + "the cancelled waiter must not be retained" + ); + } + + // ── backoff + hygiene ──────────────────────────────────────────────────── + + #[tokio::test] + async fn backoff_follows_the_ts_schedule_floored_at_the_poll_interval() { + let h = harness_with(LnBolt11NwcPaymentProcessorOptions { + poll_interval: Duration::from_millis(1500), + ..opts() + }) + .await; + let inner = &h.processor.inner; + + // Floored: every early step is below the 1500 ms floor. + for attempt in 0..4 { + let d = inner.next_delay(attempt).as_millis() as u64; + assert!( + (1500..1500 + JITTER_MAX_MS).contains(&d), + "attempt {attempt} gave {d}ms, expected the floor plus jitter" + ); + } + // Past the floor the schedule takes over and grows. + let late = inner.next_delay(8).as_millis() as u64; + assert!( + (15_000..15_000 + JITTER_MAX_MS).contains(&late), + "attempt 8 gave {late}ms, expected 15000 plus jitter" + ); + // Saturates at the last entry rather than panicking on overrun. + assert_eq!( + inner.next_delay(99).as_millis() as u64 / 1000, + inner.next_delay(8).as_millis() as u64 / 1000 + ); + } + + #[tokio::test] + async fn an_unfloored_schedule_grows_monotonically() { + let h = harness_with(LnBolt11NwcPaymentProcessorOptions { + poll_interval: Duration::from_millis(1), + ..opts() + }) + .await; + let inner = &h.processor.inner; + for attempt in 0..BACKOFF_SCHEDULE_MS.len() - 1 { + let a = BACKOFF_SCHEDULE_MS[attempt]; + let b = BACKOFF_SCHEDULE_MS[attempt + 1]; + assert!(a < b, "schedule must increase: {a} then {b}"); + let d = inner.next_delay(attempt).as_millis() as u64; + assert!( + (a..a + JITTER_MAX_MS).contains(&d), + "attempt {attempt}: {d}ms" + ); + } + } + + /// Whether `call` binds `name` as a tracing field, i.e. `name = ...`. + /// + /// Boundary-aware on purpose. A plain substring check reports + /// `has_preimage = ...` (a boolean, which is exactly what we DO want + /// logged) as if it were `preimage = ...`, and reports the word "invoice" + /// inside a message like "NWC lookup_invoice result". + fn binds_field(call: &str, name: &str) -> bool { + call.match_indices(name).any(|(i, _)| { + let preceded_by_ident = call[..i] + .chars() + .next_back() + .is_some_and(|c| c.is_alphanumeric() || c == '_'); + if preceded_by_ident { + return false; + } + let after = call[i + name.len()..].trim_start(); + after.starts_with('=') && !after.starts_with("==") + }) + } + + /// An invoice is a bearer payment request: anyone holding it can be paid + /// against it, and logs leak to aggregators. No tracing call may carry it. + /// + /// Scans whole macro invocations rather than single lines, because a + /// `tracing::debug!` spans several lines and its fields are never on the + /// first one. + #[test] + fn no_tracing_call_carries_the_invoice() { + let src = include_str!("ln_bolt11_nwc.rs"); + // Only the implementation, not this test module, which necessarily + // mentions the very identifiers it is checking for. + let impl_src = &src[..src + .find("#[cfg(all(test, feature = \"test-utils\"))]") + .unwrap_or(src.len())]; + + let mut checked = 0usize; + for (idx, _) in impl_src.match_indices("tracing::") { + let rest = &impl_src[idx..]; + let end = rest.find(");").map(|e| e + 2).unwrap_or(rest.len()); + let call = &rest[..end]; + checked += 1; + for banned in ["pay_req", "invoice", "preimage", "secret"] { + assert!( + !binds_field(call, banned), + "a tracing call binds `{banned}` as a field: +{call}" + ); + } + } + assert!( + checked >= 4, + "expected to scan several tracing calls, saw {checked}" + ); + } +} diff --git a/src/payments/processors/mod.rs b/src/payments/processors/mod.rs new file mode 100644 index 0000000..0e59dac --- /dev/null +++ b/src/payments/processors/mod.rs @@ -0,0 +1,11 @@ +//! Server-side CEP-8 payment processors: the real Lightning rails behind the +//! [`PaymentProcessor`](crate::payments::PaymentProcessor) trait. +//! +//! Each rail is feature-gated so a consumer that does not need it pays neither +//! the dependency nor the compile cost. + +#[cfg(feature = "nwc")] +pub mod ln_bolt11_nwc; + +#[cfg(feature = "nwc")] +pub use ln_bolt11_nwc::{LnBolt11NwcPaymentProcessor, LnBolt11NwcPaymentProcessorOptions}; From a3237e5f50193ecf99c42f7555be3c2790913de7 Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Fri, 23 Oct 2026 20:04:53 +0530 Subject: [PATCH 09/11] docs(changelog): say what the processor does instead of naming a phase "the Phase B Lightning rail" is internal planning vocabulary that a changelog reader cannot resolve, which the review noted about the client entry. The same wording was in this one. --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bc09baa..78a9da2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,9 +30,9 @@ `MockWallet` behind `test-utils` scripts answers, errors, silence, delays and malformed replies over a linked `MockRelayPool`, and can publish a `payment_received` notification to drive settlement. - - The NWC payment processor, `LnBolt11NwcPaymentProcessor`, the server side of the - Phase B Lightning rail for PMI `bitcoin-lightning-bolt11`, behind the same - off-by-default `nwc` feature. `create_payment_required` mints a BOLT11 invoice via + - The NWC payment processor, `LnBolt11NwcPaymentProcessor`, which lets a server charge + for a capability in Lightning over Nostr Wallet Connect, for PMI + `bitcoin-lightning-bolt11`, behind the same off-by-default `nwc` feature. `create_payment_required` mints a BOLT11 invoice via `make_invoice` in msats with an expiry, caches the invoice to `payment_hash` mapping, and advertises the configured TTL rather than the wallet's own `expires_at`, which providers return in non-standard forms. `verify_payment` waits for settlement either From 94a0d1db1b4c952eff645ccb0d374c5517b30365 Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Wed, 7 Oct 2026 11:39:21 +0530 Subject: [PATCH 10/11] feat(payments): NWC payment handler, e2e suite and docs, completing the rail Third and last of the NWC sub-PRs in #116. Adds the client side and the end-to-end coverage, so the rail is usable from both peers. - `LnBolt11NwcPaymentHandler` pays an offered BOLT11 invoice with `pay_invoice`, bounded by its own `response_timeout`: the client engine gives `handle()` neither a timeout nor a cancellation token, so that bound is part of the rail's contract. The spending decision stays with `payment_policy`; adding a second gate in `can_handle` would split the budget across two places, and only the engine's gate can synthesize a `-32000` for the caller. A wallet answering with neither result nor error is a failure, not a silent success. - `tests/payments_nwc_e2e.rs` runs real client and server transports over one `MockRelayPool` group with a scripted wallet on each side, through the production registration entry points. Settlement is causal rather than assumed: the issuing wallet answers `lookup_invoice` as pending until the paying wallet has actually been asked, then flips to settled. - `tests/payments_nwc_real_wallet.rs` is `#[ignore]`d and environment gated, needing two distinct wallets since one issues and the other pays. Invoices are redacted in its output. Both NWC option structs gain `new()` and `with_*` builders. They are `#[non_exhaustive]` as #116 requires, which makes a struct literal unusable from a downstream crate, and the integration tests are the first consumer outside this crate to hit that. Matches `ClientPaymentsOptions` and `ServerPaymentsOptions`. Worth knowing, and now documented: the handler is invoked automatically only in the transparent lifecycle. Explicit gating routes `-32042` to the `on_payment_required` callback, from which the handler can be driven by hand; the e2e suite shows that wiring. `docs/payments.md` documents the rail, its options, which lifecycle invokes the handler, and its operational notes (a late settlement is money out with no execution, the wallet is a trust boundary, rate limiting is still upstream's job), and no longer calls Phase B deferred or lists LNbits as planned. README and CHANGELOG updated. Verified: fmt and clippy clean, 1121 tests pass under `--all-features`, feature matrix green, MSRV 1.88 compiles, docs build. Refs #116 --- CHANGELOG.md | 13 + Cargo.toml | 8 + README.md | 9 +- docs/payments.md | 148 ++++++- src/payments/handlers/ln_bolt11_nwc.rs | 369 ++++++++++++++++ src/payments/handlers/mod.rs | 11 + src/payments/mod.rs | 3 + src/payments/processors/ln_bolt11_nwc.rs | 49 +++ tests/payments_nwc_e2e.rs | 530 +++++++++++++++++++++++ tests/payments_nwc_real_wallet.rs | 229 ++++++++++ 10 files changed, 1354 insertions(+), 15 deletions(-) create mode 100644 src/payments/handlers/ln_bolt11_nwc.rs create mode 100644 src/payments/handlers/mod.rs create mode 100644 tests/payments_nwc_e2e.rs create mode 100644 tests/payments_nwc_real_wallet.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 78a9da2..d64d453 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,6 +47,19 @@ verification always returns an error, never an empty success that the middleware would read as payment. Amounts are validated before any wallet call, and no tracing call carries the invoice, which is a bearer payment request. + - The NWC payment handler, `LnBolt11NwcPaymentHandler`, the client side of the Phase B + Lightning rail, completing it. It pays an offered BOLT11 invoice with `pay_invoice`, + bounded by its own response timeout because the client engine gives `handle()` + neither a timeout nor a cancellation token, and leaves the spending decision with + `payment_policy` rather than adding a second gate. A wallet that answers with neither + a result nor an error is a failure, not a silent success. Note that the handler is + invoked automatically only in the transparent lifecycle; explicit gating routes + `-32042` to the `on_payment_required` callback, from which the handler can be driven + by hand. Both NWC option structs now carry `new()` and `with_*` builders, since + `#[non_exhaustive]` makes a struct literal unusable from a downstream crate. + `docs/payments.md` documents the rail, its options, which lifecycle invokes the + handler, and its operational notes, and no longer describes Phase B as deferred or + lists LNbits as planned. - Server-side payment-interaction negotiation and advertisement: the server transport now parses client `pmi` and `payment_interaction` tags, negotiates the effective session mode (`transparent` by default, `explicit_gating` when the server policy diff --git a/Cargo.toml b/Cargo.toml index f124d26..410cae4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,6 +124,14 @@ required-features = ["test-utils"] name = "payments_bilateral_e2e" required-features = ["test-utils"] +[[test]] +name = "payments_nwc_e2e" +required-features = ["nwc", "test-utils"] + +[[test]] +name = "payments_nwc_real_wallet" +required-features = ["nwc", "test-utils"] + [[test]] name = "rmcp_handshake_survival" required-features = ["rmcp", "test-utils"] diff --git a/README.md b/README.md index 53548ce..bad5f21 100644 --- a/README.md +++ b/README.md @@ -228,10 +228,13 @@ keepalive timer model. Both CEP-8 payment lifecycles run end to end: servers price capabilities and gate them through the transparent or explicit-gating lifecycle via `with_server_payments`, and clients auto-pay, keep paying requests alive, and -drive the gated retry flow via `with_client_payments`. Payment rails ship as pluggable traits with -deterministic fakes; real rails are a later phase. See +drive the gated retry flow via `with_client_payments`. Payment rails plug into +the same traits: the first real rail, Lightning BOLT11 over NWC (NIP-47), +ships behind the off-by-default `nwc` feature as +`LnBolt11NwcPaymentProcessor` and `LnBolt11NwcPaymentHandler`, alongside the +deterministic fakes for wallet-free testing. See [docs/payments.md](docs/payments.md) for both lifecycles, the client shapes, -and the operational notes. +the NWC rail and its operational notes. ### Server Transport Config diff --git a/docs/payments.md b/docs/payments.md index 6de04c1..ee2964a 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -6,13 +6,17 @@ individual capabilities, and a client can pay for them either transparently explicit gating (the request is refused with a payment error until paid). This guide covers both sides of the wire as this SDK ships them. -CEP-8 support ships in two phases. What ships today is Phase A: the full -protocol machinery on both sides, with deterministic fakes -(`FakePaymentProcessor` and `FakePaymentHandler`, behind the `test-utils` -feature) standing in for real payment rails. Phase B is the rails themselves -(Lightning over NWC/NIP-47, LNURL, LNbits), which plug into the same +CEP-8 support ships in two phases. Phase A is the full protocol machinery on +both sides, with deterministic fakes (`FakePaymentProcessor` and +`FakePaymentHandler`, behind the `test-utils` feature) for testing without a +wallet. Phase B is the real payment rails, which plug into the same `PaymentProcessor` / `PaymentHandler` traits without API changes. +The first Phase B rail, Lightning BOLT11 over NWC (NIP-47), ships behind the +off-by-default `nwc` feature and is documented under +[The NWC rail](#the-nwc-rail-nip-47) below. NIP-57 zaps are still to come. +LNbits, listed as a planned rail in earlier drafts, has been dropped. + The protocol reference is the CEP-8 specification in the ContextVM documentation repository. @@ -343,11 +347,131 @@ The `-32042` / `-32043` / `-32602` codes, the three notification methods, and the wire field orders are pinned by the conformance suite against the reference implementation. -## Phase B: payment rails +## The NWC rail (NIP-47) + +Lightning BOLT11 over Nostr Wallet Connect, for PMI +`bitcoin-lightning-bolt11`. Enable the `nwc` feature, which is off by default +and adds no new crate to the dependency tree (it only switches on an existing +`nostr-sdk` NIP): + +```toml +contextvm-sdk = { version = "0.2", features = ["nwc"] } +``` + +Two halves, each usable on its own. On the server, +`LnBolt11NwcPaymentProcessor` mints an invoice with `make_invoice` and waits +for settlement. On the client, `LnBolt11NwcPaymentHandler` pays one with +`pay_invoice`. + +```rust,ignore +use contextvm_sdk::payments::{ + LnBolt11NwcPaymentProcessor, LnBolt11NwcPaymentProcessorOptions, + ServerPaymentsOptions, with_server_payments, +}; + +let processor = Arc::new(LnBolt11NwcPaymentProcessor::from_uri( + relay_pool.clone(), + "nostr+walletconnect://?relay=wss://relay.example&secret=", + LnBolt11NwcPaymentProcessorOptions::new() + .with_ttl(Duration::from_secs(300)) + .with_poll_interval(Duration::from_millis(1500)), +)?); + +with_server_payments( + &mut server_transport, + ServerPaymentsOptions::new(vec![processor], vec![priced_capability]), +)?; +``` + +The connection string is accepted in both shapes wallets emit, including the +`nostr+walletconnect:?...` form (pubkey in the path) that +`NostrWalletConnectURI::parse` rejects. + +### Options + +| Processor option | Default | Meaning | +| --- | --- | --- | +| `ttl` | 300 s | TTL advertised on `payment_required` | +| `invoice_expiry` | `ttl` | `make_invoice.expiry` | +| `poll_interval` | 1500 ms | floor under the `lookup_invoice` backoff | +| `response_timeout` | 60 s | per-request wallet response timeout | +| `notification_verification` | auto | `Some(false)` always polls, `Some(true)` always listens, `None` detects once from the wallet's info event | +| `max_in_flight_verifications` | 5000 | cap on deduplicated verifications | +| `invoice_hash_cache_size` | 10000 | cap on cached invoice to payment-hash mappings | + +| Handler option | Default | Meaning | +| --- | --- | --- | +| `response_timeout` | 60 s | per-payment wallet response timeout | + +Both option structs are `#[non_exhaustive]`, so construct them with `new()` +and the `with_*` builders rather than a struct literal. + +### Which lifecycle invokes the handler + +In the transparent lifecycle the client engine calls the handler +automatically when `payment_required` arrives, gated by `payment_policy`. + +In explicit gating it does not: the engine routes `-32042` to the +`on_payment_required` callback, and the handler is the transparent +lifecycle's hook. To use this rail for gating, call the handler from inside +that callback with the offered `PaymentOption`, then report +`PaymentApproval { paid: true, .. }` so the engine retries. See +`tests/payments_nwc_e2e.rs` for a worked example. + +### Operational notes + +**A late settlement is money out with no execution.** `verify_payment` stops +when the middleware's payment TTL expires, but the invoice stays payable on +the Lightning network until its own expiry. A payer who settles after the TTL +has paid for a capability that will not run. Keep `invoice_expiry` at or +below `ttl` so the rail's own invoice expires with the window, and treat +reconciliation of late settlements as an operator concern: this SDK does not +refund. + +**The wallet is a trust boundary.** The processor believes the wallet about +settlement. A compromised or buggy wallet reporting `state: "settled"` causes +the server to execute a paid capability for free. Point the processor at a +wallet you control, not one a counterparty supplies. + +**Rate limiting is still upstream's job.** Every successful offer spawns one +detached verification task, and with a real rail each holds a wallet +connection and polls it. `max_pending_payments` and the authorization store +caps bound state, not tasks. + +**Settlement is read generously, failure strictly.** An invoice counts as +settled on `state == "settled"` *or* a positive `settled_at`, because wallets +populate one or the other. `NOT_FOUND` is treated as still pending, since a +lagging wallet that has not yet seen its own invoice has not refused it; every +other wallet error code is fatal, as are `expired` and `failed`. + +**Nothing logs the invoice.** A BOLT11 string is a bearer payment request, so +no tracing call in the rail binds `pay_req`, the invoice, or a preimage. Logs +carry the request event id, the state, and booleans for field presence. + +### Testing against a real wallet + +CI gates on relay-level mocks, not a live wallet: `MockWallet` (behind +`test-utils`) speaks the real NIP-47 wire protocol over a `MockRelayPool`, so +the client's own encrypt, publish, correlate and decrypt paths run in CI. + +`tests/payments_nwc_real_wallet.rs` exercises a real wallet and is +`#[ignore]`d and environment-gated. It needs two distinct wallets, because +one side issues and the other pays: + +```sh +export CONTEXTVM_NWC_SERVER_URI='nostr+walletconnect://...' +export CONTEXTVM_NWC_CLIENT_URI='nostr+walletconnect://...' +cargo test --features nwc,test-utils --test payments_nwc_real_wallet -- --ignored --nocapture +``` + +It moves real money. The amount is 1 sat unless `CONTEXTVM_NWC_AMOUNT_SATS` +says otherwise, and nothing it does is reversible. + +## Other rails -The `PaymentProcessor` (server: create and verify a payment request) and -`PaymentHandler` (client: settle one) traits are the extension surface. -Planned rails include Lightning BOLT11 via NWC (NIP-47), LNURL (NIP-57 -zaps), and LNbits REST; all are deliberately deferred, and the options -structs are non-exhaustive so rails can add configuration without breaking -changes. +The `PaymentProcessor` and `PaymentHandler` traits are the extension surface, +and the options structs are non-exhaustive so a rail can add configuration +without a breaking change. NIP-57 zaps are the next rail planned, server side +only: NIP-57 obliges the payer only to pay a BOLT11 invoice, and both rails +issue `pmi: bitcoin-lightning-bolt11`, so a zap offer already routes to the +NWC handler on the client. diff --git a/src/payments/handlers/ln_bolt11_nwc.rs b/src/payments/handlers/ln_bolt11_nwc.rs new file mode 100644 index 0000000..7ec1ada --- /dev/null +++ b/src/payments/handlers/ln_bolt11_nwc.rs @@ -0,0 +1,369 @@ +//! CEP-8 payment handler for `bitcoin-lightning-bolt11`, backed by NIP-47 (NWC). +//! +//! Client side of the first Phase B rail: given a `payment_required` offer, pay +//! its BOLT11 invoice with `pay_invoice`. +//! +//! # This type does not decide whether to pay +//! +//! The spending decision belongs to `payment_policy` in +//! [`ClientPaymentsOptions`](crate::payments::ClientPaymentsOptions), which the +//! client engine consults *before* calling [`PaymentHandler::handle`]. This +//! handler deliberately leaves [`PaymentHandler::can_handle`] at its default +//! `true` and pays whatever reaches it. Putting a second spending gate here +//! would split the budget across two places, and the engine's gate is the one +//! that can also synthesize a `-32000` for the caller. +//! +//! # Bounds +//! +//! The client engine gives `handle()` neither a timeout nor a cancellation +//! token, so the only thing standing between a silent wallet and a wedged +//! payment task is the client's own `response_timeout`. That makes it part of +//! this rail's contract rather than a tuning knob. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; + +use crate::payments::constants::PMI_BITCOIN_LIGHTNING_BOLT11; +use crate::payments::errors::PaymentError; +use crate::payments::nip47::client::{NwcClient, NwcClientOptions, DEFAULT_RESPONSE_TIMEOUT}; +use crate::payments::nip47::error::NwcError; +use crate::payments::nip47::uri::{parse_nwc_uri, NwcConnection}; +use crate::payments::traits::PaymentHandler; +use crate::payments::types::PaymentHandlerRequest; +use crate::relay::RelayPoolTrait; + +const LOG_TARGET: &str = "contextvm::payments::nwc_handler"; + +/// Construction options for [`LnBolt11NwcPaymentHandler`]. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct LnBolt11NwcPaymentHandlerOptions { + /// Per-payment wallet response timeout. Default 60 s. + pub response_timeout: Duration, +} + +impl Default for LnBolt11NwcPaymentHandlerOptions { + fn default() -> Self { + Self { + response_timeout: DEFAULT_RESPONSE_TIMEOUT, + } + } +} + +impl LnBolt11NwcPaymentHandlerOptions { + /// Options with the default response timeout. + pub fn new() -> Self { + Self::default() + } + + /// Set the per-payment wallet response timeout. + /// + /// The client engine gives `handle()` no timeout of its own, so this is + /// the only bound on a silent wallet. + pub fn with_response_timeout(mut self, response_timeout: Duration) -> Self { + self.response_timeout = response_timeout; + self + } +} + +/// CEP-8 client handler for `bitcoin-lightning-bolt11` over NIP-47. +pub struct LnBolt11NwcPaymentHandler { + client: Arc, + /// Mirrors the client's deadline, reused as the NIP-40 expiration. + response_timeout: Duration, +} + +impl LnBolt11NwcPaymentHandler { + /// Build from a NWC connection string and a relay pool. + /// + /// # Errors + /// + /// Returns [`PaymentError::Processor`] when the connection string does not parse. + pub fn from_uri( + pool: Arc, + uri: &str, + options: LnBolt11NwcPaymentHandlerOptions, + ) -> Result { + let connection = parse_nwc_uri(uri).map_err(NwcError::into_handler_error)?; + Self::from_connection(pool, connection, options) + } + + /// Build from an already-parsed connection. + pub fn from_connection( + pool: Arc, + connection: NwcConnection, + options: LnBolt11NwcPaymentHandlerOptions, + ) -> Result { + let client = NwcClient::with_pool( + pool, + connection, + NwcClientOptions::new().with_response_timeout(options.response_timeout), + ) + .map_err(NwcError::into_handler_error)?; + Ok(Self::with_client_and_timeout( + Arc::new(client), + options.response_timeout, + )) + } + + /// Build around an already-constructed client, for tests and custom transports. + pub fn with_client(client: Arc) -> Self { + Self::with_client_and_timeout(client, DEFAULT_RESPONSE_TIMEOUT) + } + + /// Like [`Self::with_client`], but states the deadline the client was + /// built with, so it can also be sent as the request's NIP-40 expiration. + pub fn with_client_and_timeout(client: Arc, response_timeout: Duration) -> Self { + Self { + client, + response_timeout, + } + } +} + +#[async_trait] +impl PaymentHandler for LnBolt11NwcPaymentHandler { + fn pmi(&self) -> &str { + PMI_BITCOIN_LIGHTNING_BOLT11 + } + + async fn handle(&self, req: PaymentHandlerRequest) -> Result<(), PaymentError> { + // Never log the invoice: it is a bearer payment request. + tracing::debug!( + target: LOG_TARGET, + request_event_id = %req.request_event_id, + amount = req.amount, + "paying a NWC invoice" + ); + + // A NIP-40 expiration set to our own deadline: `pay_invoice` is not + // idempotent, and a wallet that honors the tag will not pay a request + // we have already given up waiting for. + let envelope = self + .client + .pay_invoice(&req.pay_req, Some(self.response_timeout)) + .await + .map_err(NwcError::into_handler_error)?; + + if let Some(err) = envelope.error { + return Err(NwcError::Wallet { + method: "pay_invoice".to_string(), + detail: err.describe(), + } + .into_handler_error()); + } + + // A wallet that answers with neither result nor error has told us + // nothing; treating that as paid would let the engine report success + // for a payment that may never have left. + if envelope.result.is_none() { + return Err(PaymentError::Handler( + "NWC pay_invoice returned neither result nor error".to_string(), + )); + } + + tracing::debug!( + target: LOG_TARGET, + request_event_id = %req.request_event_id, + "NWC invoice paid" + ); + Ok(()) + } +} + +#[cfg(all(test, feature = "test-utils"))] +mod tests { + use super::*; + use crate::payments::nip47::mock_wallet::{MockWallet, WalletBehavior}; + use crate::relay::MockRelayPool; + use nostr_sdk::prelude::*; + + struct Harness { + wallet: Arc, + handler: LnBolt11NwcPaymentHandler, + } + + async fn harness(response_timeout: Duration) -> Harness { + let wallet_pool = Arc::new(MockRelayPool::new()); + let client_pool = Arc::new(wallet_pool.linked_with_keys(Keys::generate())); + let wallet = Arc::new(MockWallet::new(Arc::clone(&wallet_pool))); + wallet.start().await.expect("wallet starts"); + + let secret = SecretKey::generate(); + let uri = format!( + "nostr+walletconnect://{}?relay=wss%3A%2F%2Fmock.relay&secret={}", + wallet.public_key().to_hex(), + secret.to_secret_hex() + ); + let handler = LnBolt11NwcPaymentHandler::from_uri( + Arc::clone(&client_pool) as Arc, + &uri, + LnBolt11NwcPaymentHandlerOptions { response_timeout }, + ) + .expect("uri parses"); + + Harness { wallet, handler } + } + + fn request(pay_req: &str) -> PaymentHandlerRequest { + PaymentHandlerRequest { + amount: 21, + pay_req: pay_req.to_string(), + pmi: PMI_BITCOIN_LIGHTNING_BOLT11.to_string(), + description: Some("a priced tool call".to_string()), + ttl: Some(300), + meta: None, + request_event_id: "evt-1".to_string(), + } + } + + #[tokio::test] + async fn advertises_the_bolt11_pmi() { + let h = harness(Duration::from_millis(500)).await; + assert_eq!(h.handler.pmi(), PMI_BITCOIN_LIGHTNING_BOLT11); + } + + #[tokio::test] + async fn pays_the_invoice_and_passes_it_verbatim() { + let h = harness(Duration::from_millis(600)).await; + h.wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Answer(serde_json::json!({ "preimage": "pre", "fees_paid": 3 })), + ) + .await; + + h.handler + .handle(request("lnbc210n1p-exact")) + .await + .expect("payment succeeds"); + + let calls = h.wallet.calls().await; + assert_eq!(calls.len(), 1); + assert_eq!(calls[0].0, "pay_invoice"); + assert_eq!(calls[0].1["invoice"].as_str(), Some("lnbc210n1p-exact")); + } + + /// `can_handle` stays at its default so the engine's `payment_policy` + /// remains the single spending gate. + #[tokio::test] + async fn can_handle_defaults_to_true() { + let h = harness(Duration::from_millis(500)).await; + assert!(h.handler.can_handle(&request("lnbc1")).await); + } + + #[tokio::test] + async fn a_wallet_error_fails_the_payment() { + let h = harness(Duration::from_millis(600)).await; + h.wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Error { + code: "INSUFFICIENT_BALANCE".to_string(), + message: "not enough sats".to_string(), + }, + ) + .await; + + let err = h + .handler + .handle(request("lnbc1")) + .await + .expect_err("a wallet error must fail the payment"); + assert!(matches!(err, PaymentError::Handler(_))); + assert!(err.to_string().contains("INSUFFICIENT_BALANCE")); + } + + /// A wallet that answers with neither result nor error must not read as paid. + #[tokio::test] + async fn an_empty_response_fails_rather_than_reporting_success() { + let h = harness(Duration::from_millis(600)).await; + h.wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Answer(serde_json::Value::Null), + ) + .await; + + assert!(h.handler.handle(request("lnbc1")).await.is_err()); + } + + /// The engine gives `handle()` no timeout, so the handler's own + /// `response_timeout` is the only bound on a silent wallet. + #[tokio::test] + async fn the_handlers_own_timeout_bounds_a_silent_wallet() { + let h = harness(Duration::from_millis(250)).await; + h.wallet + .set_behavior("pay_invoice", WalletBehavior::Silence) + .await; + + let started = tokio::time::Instant::now(); + let res = h.handler.handle(request("lnbc1")).await; + assert!( + res.is_err(), + "a silent wallet must not hang the payment task" + ); + assert!( + started.elapsed() < Duration::from_secs(3), + "must fail at the configured timeout, took {:?}", + started.elapsed() + ); + } + + #[tokio::test] + async fn a_malformed_response_fails_the_payment() { + let h = harness(Duration::from_millis(600)).await; + h.wallet + .set_behavior("pay_invoice", WalletBehavior::Malformed) + .await; + assert!(h.handler.handle(request("lnbc1")).await.is_err()); + } + + #[tokio::test] + async fn rejects_a_malformed_connection_string() { + let pool = Arc::new(MockRelayPool::new()); + assert!(LnBolt11NwcPaymentHandler::from_uri( + pool as Arc, + "https://not-nwc", + LnBolt11NwcPaymentHandlerOptions::default(), + ) + .is_err()); + } + + /// An invoice is a bearer payment request; no tracing call may bind it. + #[test] + fn no_tracing_call_carries_the_invoice() { + let src = include_str!("ln_bolt11_nwc.rs"); + let impl_src = &src[..src + .find("#[cfg(all(test, feature = \"test-utils\"))]") + .unwrap_or(src.len())]; + + let mut checked = 0usize; + for (idx, _) in impl_src.match_indices("tracing::") { + let rest = &impl_src[idx..]; + let end = rest.find(");").map(|e| e + 2).unwrap_or(rest.len()); + let call = &rest[..end]; + checked += 1; + for banned in ["pay_req", "invoice", "preimage", "secret"] { + let binds = call.match_indices(banned).any(|(i, _)| { + let preceded_by_ident = call[..i] + .chars() + .next_back() + .is_some_and(|c| c.is_alphanumeric() || c == '_'); + if preceded_by_ident { + return false; + } + let after = call[i + banned.len()..].trim_start(); + after.starts_with('=') && !after.starts_with("==") + }); + assert!(!binds, "a tracing call binds `{banned}`:\n{call}"); + } + } + assert!( + checked >= 2, + "expected to scan both tracing calls, saw {checked}" + ); + } +} diff --git a/src/payments/handlers/mod.rs b/src/payments/handlers/mod.rs new file mode 100644 index 0000000..e05f39a --- /dev/null +++ b/src/payments/handlers/mod.rs @@ -0,0 +1,11 @@ +//! Client-side CEP-8 payment handlers: the wallet actions behind the +//! [`PaymentHandler`](crate::payments::PaymentHandler) trait. +//! +//! Each rail is feature-gated so a consumer that does not need it pays neither +//! the dependency nor the compile cost. + +#[cfg(feature = "nwc")] +pub mod ln_bolt11_nwc; + +#[cfg(feature = "nwc")] +pub use ln_bolt11_nwc::{LnBolt11NwcPaymentHandler, LnBolt11NwcPaymentHandlerOptions}; diff --git a/src/payments/mod.rs b/src/payments/mod.rs index a58782b..b21bf64 100644 --- a/src/payments/mod.rs +++ b/src/payments/mod.rs @@ -23,6 +23,7 @@ pub mod canonical; pub mod client_payments; pub mod constants; pub mod errors; +pub mod handlers; #[cfg(feature = "nwc")] pub mod nip47; pub mod processors; @@ -48,6 +49,8 @@ pub use client_payments::{ }; pub use errors::PaymentError; #[cfg(feature = "nwc")] +pub use handlers::{LnBolt11NwcPaymentHandler, LnBolt11NwcPaymentHandlerOptions}; +#[cfg(feature = "nwc")] pub use processors::{LnBolt11NwcPaymentProcessor, LnBolt11NwcPaymentProcessorOptions}; pub use server_explicit_gating::{ create_explicit_gating_middleware, ExplicitGatingMiddlewareParams, diff --git a/src/payments/processors/ln_bolt11_nwc.rs b/src/payments/processors/ln_bolt11_nwc.rs index 6b8d775..3131727 100644 --- a/src/payments/processors/ln_bolt11_nwc.rs +++ b/src/payments/processors/ln_bolt11_nwc.rs @@ -108,6 +108,55 @@ impl Default for LnBolt11NwcPaymentProcessorOptions { } } +impl LnBolt11NwcPaymentProcessorOptions { + /// Options with the ts defaults. + pub fn new() -> Self { + Self::default() + } + + /// Set the TTL advertised on `payment_required`. + pub fn with_ttl(mut self, ttl: Duration) -> Self { + self.ttl = ttl; + self + } + + /// Set `make_invoice.expiry`, which otherwise follows the TTL. + pub fn with_invoice_expiry(mut self, expiry: Duration) -> Self { + self.invoice_expiry = Some(expiry); + self + } + + /// Set the floor under the `lookup_invoice` backoff schedule. + pub fn with_poll_interval(mut self, poll_interval: Duration) -> Self { + self.poll_interval = poll_interval; + self + } + + /// Set the per-request wallet response timeout. + pub fn with_response_timeout(mut self, response_timeout: Duration) -> Self { + self.response_timeout = response_timeout; + self + } + + /// Force notification-mode verification on or off, skipping auto-detection. + pub fn with_notification_verification(mut self, enabled: bool) -> Self { + self.notification_verification = Some(enabled); + self + } + + /// Set the cap on concurrently deduplicated verifications. + pub fn with_max_in_flight_verifications(mut self, max: usize) -> Self { + self.max_in_flight_verifications = max; + self + } + + /// Set the cap on cached invoice to payment-hash mappings. + pub fn with_invoice_hash_cache_size(mut self, size: usize) -> Self { + self.invoice_hash_cache_size = size; + self + } +} + /// A deduplicated verification. `PaymentError` is not `Clone`, so the shared /// output is an `Arc` and joiners re-wrap the error by message. type VerifyInFlight = Shared>>>; diff --git a/tests/payments_nwc_e2e.rs b/tests/payments_nwc_e2e.rs new file mode 100644 index 0000000..0bcc7b2 --- /dev/null +++ b/tests/payments_nwc_e2e.rs @@ -0,0 +1,530 @@ +//! The NWC rail end to end over one `MockRelayPool` group: real client and +//! server transports, registered through the production payment entry points, +//! with a scripted NIP-47 wallet on each side. +//! +//! Nothing here stubs the rail. The server's `LnBolt11NwcPaymentProcessor` +//! really mints an invoice by publishing a kind-23194 `make_invoice` to its +//! wallet and reading the kind-23195 answer; the client's +//! `LnBolt11NwcPaymentHandler` really pays by publishing `pay_invoice` to its +//! own wallet. Settlement is made causal rather than assumed: the server's +//! wallet answers `lookup_invoice` as pending until the client's wallet has +//! actually been asked to pay, and only then flips to settled. +//! +//! Clock discipline follows `payments_bilateral_e2e`: real clock with small +//! configured timeouts, because the correlation sweep and session expiry age on +//! `std::time::Instant`, which paused tokio time does not advance. + +use std::sync::Arc; +use std::time::Duration; + +use contextvm_sdk::core::types::EncryptionMode; +use contextvm_sdk::payments::nip47::mock_wallet::{MockWallet, WalletBehavior}; +use contextvm_sdk::payments::nip47::{parse_nwc_uri, NwcClient, NwcClientOptions}; +use contextvm_sdk::payments::traits::PaymentHandler; +use contextvm_sdk::payments::types::PaymentHandlerRequest; +use contextvm_sdk::payments::types::PricedCapability; +use contextvm_sdk::payments::{ + with_client_payments, with_server_payments, ClientPaymentsOptions, LnBolt11NwcPaymentHandler, + LnBolt11NwcPaymentProcessor, LnBolt11NwcPaymentProcessorOptions, OnPaymentRequiredFn, + PaymentApproval, PaymentPolicyFn, ServerPaymentsOptions, +}; +use contextvm_sdk::relay::mock::MockRelayPool; +use contextvm_sdk::transport::client::{NostrClientTransport, NostrClientTransportConfig}; +use contextvm_sdk::transport::server::{ + IncomingRequest, NostrServerTransport, NostrServerTransportConfig, +}; +use contextvm_sdk::{ + JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, PaymentInteractionMode, RelayPoolTrait, +}; +use futures::FutureExt; +use nostr_sdk::prelude::*; + +const INVOICE: &str = "lnbc210n1p-e2e"; +const PAYMENT_HASH: &str = "hash-e2e"; + +fn as_pool(pool: &Arc) -> Arc { + Arc::clone(pool) as Arc +} + +fn paid_call(id: &str) -> JsonRpcMessage { + JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(id), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ "name": "paid-tool" })), + }) +} + +fn priced() -> PricedCapability { + PricedCapability { + method: "tools/call".to_string(), + name: Some("paid-tool".to_string()), + amount: 21, + currency_unit: "sats".to_string(), + max_amount: None, + description: None, + } +} + +/// Next message off the client channel, skipping the `payment_required` +/// notification the engine forwards to the consumer before the outcome. +async fn recv_outcome_within( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + ms: u64, + what: &str, +) -> JsonRpcMessage { + let deadline = tokio::time::Instant::now() + Duration::from_millis(ms); + loop { + let msg = tokio::time::timeout_at(deadline, rx.recv()) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {what}")) + .expect("client channel closed"); + match msg { + JsonRpcMessage::Notification(ref n) + if n.method.starts_with("notifications/payment") + || n.method == "notifications/progress" => {} + other => return other, + } + } +} + +/// Build a NWC client over `pool` talking to `wallet`. +fn nwc_client_for( + pool: &Arc, + wallet: &MockWallet, + response_timeout: Duration, +) -> Arc { + let secret = SecretKey::generate(); + let uri = format!( + "nostr+walletconnect://{}?relay=wss%3A%2F%2Fmock.relay&secret={}", + wallet.public_key().to_hex(), + secret.to_secret_hex() + ); + let connection = parse_nwc_uri(&uri).expect("uri parses"); + Arc::new( + NwcClient::with_pool( + as_pool(pool), + connection, + NwcClientOptions::new().with_response_timeout(response_timeout), + ) + .expect("options valid"), + ) +} + +struct Fx { + server: NostrServerTransport, + server_rx: tokio::sync::mpsc::UnboundedReceiver, + client: NostrClientTransport, + client_rx: tokio::sync::mpsc::UnboundedReceiver, + /// The wallet the server mints and checks invoices against. + server_wallet: Arc, + /// The wallet the client pays from. + client_wallet: Arc, + /// The same handler the engine holds, so an explicit-gating callback can + /// drive the rail by hand. + handler: Arc, +} + +/// A paired client and server on one mock relay group, each with its own +/// scripted wallet, both registered through the production entry points. +async fn fixture( + mode: PaymentInteractionMode, + payment_policy: Option>, + pay_behavior: WalletBehavior, + on_payment_required: Option>, +) -> Fx { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + let client_pool = Arc::new(client_pool); + + // Both wallets live on the same shared store; each filters by kind and + // `p` tag, so they never answer for one another. + let server_wallet_pool = Arc::new(server_pool.linked_with_keys(Keys::generate())); + let client_wallet_pool = Arc::new(server_pool.linked_with_keys(Keys::generate())); + let server_wallet = Arc::new(MockWallet::new(Arc::clone(&server_wallet_pool))); + let client_wallet = Arc::new(MockWallet::new(Arc::clone(&client_wallet_pool))); + server_wallet.start().await.expect("server wallet starts"); + client_wallet.start().await.expect("client wallet starts"); + + server_wallet + .set_behavior( + "make_invoice", + WalletBehavior::Answer(serde_json::json!({ + "invoice": INVOICE, + "payment_hash": PAYMENT_HASH, + "state": "pending", + })), + ) + .await; + // Pending until the client has actually paid; the test flips this. + server_wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "pending" })), + ) + .await; + client_wallet + .set_behavior("pay_invoice", pay_behavior) + .await; + + let processor = Arc::new(LnBolt11NwcPaymentProcessor::with_client( + nwc_client_for(&server_pool, &server_wallet, Duration::from_millis(800)), + LnBolt11NwcPaymentProcessorOptions::new() + .with_ttl(Duration::from_secs(4)) + .with_poll_interval(Duration::from_millis(40)) + .with_response_timeout(Duration::from_millis(800)) + .with_notification_verification(false), + )); + let handler = Arc::new(LnBolt11NwcPaymentHandler::with_client(nwc_client_for( + &client_pool, + &client_wallet, + Duration::from_millis(800), + ))); + let gating_handler = Arc::clone(&handler); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(&server_pool), + ) + .await + .expect("server transport"); + with_server_payments( + &mut server, + ServerPaymentsOptions::new(vec![processor], vec![priced()]) + .with_payment_ttl(Duration::from_secs(4)), + ) + .expect("register server payments"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_timeout(Duration::from_secs(30)), + as_pool(&client_pool), + ) + .await + .expect("client transport"); + with_client_payments(&mut client, { + let mut opts = ClientPaymentsOptions::new() + .with_handlers(vec![handler]) + .with_payment_interaction(mode) + .with_synthetic_progress_interval(Duration::from_millis(400)); + if let Some(policy) = payment_policy { + opts = opts.with_payment_policy(policy); + } + if let Some(cb) = on_payment_required { + opts = opts.with_on_payment_required(cb); + } + opts + }) + .expect("register client payments"); + + let server_rx = server.take_message_receiver().expect("server rx"); + let client_rx = client.take_message_receiver().expect("client rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + tokio::time::sleep(Duration::from_millis(30)).await; + + Fx { + server, + server_rx, + client, + client_rx, + server_wallet, + client_wallet, + handler: gating_handler, + } +} + +fn paid_answer() -> WalletBehavior { + WalletBehavior::Answer(serde_json::json!({ "preimage": "pre-e2e", "fees_paid": 1 })) +} + +/// Flip the server's wallet to settled as soon as the client's wallet has +/// actually been asked to pay, so settlement is caused by the payment rather +/// than scheduled alongside it. +fn settle_after_payment(fx: &Fx) -> tokio::task::JoinHandle { + let client_wallet = Arc::clone(&fx.client_wallet); + let server_wallet = Arc::clone(&fx.server_wallet); + tokio::spawn(async move { + for _ in 0..200 { + if client_wallet.call_count("pay_invoice").await > 0 { + server_wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ + "state": "settled", + "payment_hash": PAYMENT_HASH, + })), + ) + .await; + return true; + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + false + }) +} + +// ── transparent lifecycle ──────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread")] +async fn transparent_lifecycle_mints_pays_and_delivers() { + let mut fx = fixture( + PaymentInteractionMode::Transparent, + None, + paid_answer(), + None, + ) + .await; + let settler = settle_after_payment(&fx); + + fx.client + .send(&paid_call("req-1")) + .await + .expect("priced call sent"); + + // The server forwards only after its own wallet confirms settlement. + let forwarded = tokio::time::timeout(Duration::from_secs(8), fx.server_rx.recv()) + .await + .expect("the paid call should reach the handler") + .expect("server channel open"); + + assert!(settler.await.expect("settler task"), "the client did pay"); + assert_eq!(fx.client_wallet.call_count("pay_invoice").await, 1); + assert_eq!(fx.server_wallet.call_count("make_invoice").await, 1); + assert!(fx.server_wallet.call_count("lookup_invoice").await >= 1); + + // The invoice the client paid is the one the server minted. + let paid = fx.client_wallet.calls().await; + let pay = paid.iter().find(|(m, _)| m == "pay_invoice").unwrap(); + assert_eq!(pay.1["invoice"].as_str(), Some(INVOICE)); + + // Answer it so delivery can be asserted at the client's own channel. + fx.server + .send_response( + &forwarded.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("req-1"), + result: serde_json::json!({ "ok": true }), + }), + ) + .await + .expect("response sent"); + + let delivered = recv_outcome_within(&mut fx.client_rx, 4000, "the paid result").await; + match delivered { + JsonRpcMessage::Response(r) => assert_eq!(r.result, serde_json::json!({ "ok": true })), + other => panic!("expected the paid result, got {other:?}"), + } +} + +/// A declining policy must stop before the wallet is touched. +#[tokio::test(flavor = "multi_thread")] +async fn a_declining_policy_never_calls_the_wallet() { + // PaymentPolicyFn returns a bool; `false` declines and makes the engine + // synthesize a -32000 toward the local consumer. + let policy: Arc = Arc::new(|_req| async { false }.boxed()); + let mut fx = fixture( + PaymentInteractionMode::Transparent, + Some(policy), + paid_answer(), + None, + ) + .await; + + fx.client + .send(&paid_call("req-decline")) + .await + .expect("priced call sent"); + + // The engine synthesizes an error for the caller rather than paying. + let surfaced = recv_outcome_within(&mut fx.client_rx, 4000, "the synthesized decline").await; + match surfaced { + JsonRpcMessage::ErrorResponse(e) => assert_eq!(e.error.code, -32000), + other => panic!("expected a synthesized -32000, got {other:?}"), + } + + assert_eq!( + fx.client_wallet.call_count("pay_invoice").await, + 0, + "a declined payment must never reach the wallet" + ); + // The server still minted an invoice: the decline is client-side. + assert_eq!(fx.server_wallet.call_count("make_invoice").await, 1); + // And nothing was forwarded to the handler. + assert!( + tokio::time::timeout(Duration::from_millis(300), fx.server_rx.recv()) + .await + .is_err(), + "a declined payment must not be forwarded" + ); +} + +/// A wallet that refuses to pay leaves the request pending: no acceptance, no +/// forward, and nothing synthesized onto the caller's id by the handler itself. +#[tokio::test(flavor = "multi_thread")] +async fn a_pay_invoice_error_leaves_the_request_pending() { + let mut fx = fixture( + PaymentInteractionMode::Transparent, + None, + WalletBehavior::Error { + code: "INSUFFICIENT_BALANCE".to_string(), + message: "no sats".to_string(), + }, + None, + ) + .await; + + fx.client + .send(&paid_call("req-nofunds")) + .await + .expect("priced call sent"); + tokio::time::sleep(Duration::from_millis(600)).await; + + assert_eq!( + fx.client_wallet.call_count("pay_invoice").await, + 1, + "the wallet was asked once and refused" + ); + assert!( + tokio::time::timeout(Duration::from_millis(200), fx.server_rx.recv()) + .await + .is_err(), + "an unpaid invoice must never be forwarded" + ); +} + +/// A wallet that never settles must end at the TTL without the request being +/// forwarded: the verify times out rather than being read as payment. +#[tokio::test(flavor = "multi_thread")] +async fn a_never_settling_wallet_ends_at_the_ttl_with_no_acceptance() { + // `pay_invoice` succeeds, but the server's wallet stays pending forever. + let mut fx = fixture( + PaymentInteractionMode::Transparent, + None, + paid_answer(), + None, + ) + .await; + + fx.client + .send(&paid_call("req-stuck")) + .await + .expect("priced call sent"); + + // The configured payment TTL is 4 s; give it room and assert nothing + // reached the handler. + assert!( + tokio::time::timeout(Duration::from_secs(6), fx.server_rx.recv()) + .await + .is_err(), + "a never-settling invoice must not be forwarded" + ); + assert!(fx.client_wallet.call_count("pay_invoice").await >= 1); + assert!( + fx.server_wallet.call_count("lookup_invoice").await >= 2, + "the processor should have polled repeatedly before giving up" + ); +} + +// ── explicit gating ────────────────────────────────────────────────────────── + +/// Explicit gating answers `-32042`, the consumer pays through the rail in an +/// `on_payment_required` callback, and the engine retries to claim the grant. +/// +/// The NWC handler is NOT auto-invoked in this mode: the engine routes +/// `-32042` to `on_payment_required`, and `PaymentHandler` is the transparent +/// lifecycle's hook. The callback here drives the same handler by hand, which +/// is how a consumer wires this rail for gating. +#[tokio::test(flavor = "multi_thread")] +async fn explicit_gating_pays_through_the_rail_and_the_retry_delivers() { + // Set after the fixture is built, so the callback can hold the same + // handler instance the engine was registered with. + let shared: Arc>> = + Arc::new(tokio::sync::OnceCell::new()); + let cb_handle = Arc::clone(&shared); + + let callback: Arc = Arc::new(move |params| { + let cb_handle = Arc::clone(&cb_handle); + async move { + let offer = params.options.first().expect("at least one option").clone(); + let handler = cb_handle.get().expect("handler wired").clone(); + handler + .handle(PaymentHandlerRequest { + amount: offer.amount, + pay_req: offer.pay_req, + pmi: offer.pmi, + description: offer.description, + ttl: offer.ttl, + meta: None, + request_event_id: String::new(), + }) + .await?; + // Past the second boundary: a byte-identical retry inside the same + // wall-clock second mints the same Nostr event id, which the relay + // and the server's ingestion dedup both swallow. + tokio::time::sleep(Duration::from_millis(1100)).await; + Ok(PaymentApproval { + paid: true, + reason: None, + }) + } + .boxed() + }); + + let mut fx = fixture( + PaymentInteractionMode::ExplicitGating, + None, + paid_answer(), + Some(callback), + ) + .await; + // `OnceCell::set` returns the value back on error and the handler is not + // `Debug`, so assert on `is_ok()` rather than unwrapping. + assert!( + shared.set(Arc::clone(&fx.handler)).is_ok(), + "handler wired exactly once" + ); + let settler = settle_after_payment(&fx); + + fx.client + .send(&paid_call("req-gated")) + .await + .expect("priced call sent"); + + let forwarded = tokio::time::timeout(Duration::from_secs(15), fx.server_rx.recv()) + .await + .expect("the retry should reach the handler after the grant") + .expect("server channel open"); + + assert!(settler.await.expect("settler task"), "the client did pay"); + assert_eq!(fx.client_wallet.call_count("pay_invoice").await, 1); + + fx.server + .send_response( + &forwarded.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("req-gated"), + result: serde_json::json!({ "gated": true }), + }), + ) + .await + .expect("response sent"); + + let delivered = recv_outcome_within(&mut fx.client_rx, 6000, "the gated result").await; + match delivered { + JsonRpcMessage::Response(r) => { + assert_eq!(r.result, serde_json::json!({ "gated": true })) + } + // -32042/-32043 must never surface on the happy path. + JsonRpcMessage::ErrorResponse(e) => { + panic!("a gating error surfaced to the caller: {:?}", e.error) + } + other => panic!("expected the gated result, got {other:?}"), + } +} diff --git a/tests/payments_nwc_real_wallet.rs b/tests/payments_nwc_real_wallet.rs new file mode 100644 index 0000000..df4e055 --- /dev/null +++ b/tests/payments_nwc_real_wallet.rs @@ -0,0 +1,229 @@ +//! The NWC rail against a **real** wallet. `#[ignore]`d and environment-gated. +//! +//! CI does not drive a real wallet: the relay-level mocks in +//! `payments_nwc_e2e` are the merge gate, and ts-sdk gates nothing on a wallet +//! either. These run by hand before the rail merges, and the result is recorded +//! in the PR with the invoice redacted. +//! +//! # Running +//! +//! Two wallets are needed, because one side issues and the other pays. Pointing +//! both at the same wallet makes it pay its own invoice, which most wallets +//! refuse and which proves nothing about the rail. +//! +//! ```sh +//! export CONTEXTVM_NWC_SERVER_URI='nostr+walletconnect://?relay=wss://...&secret=' +//! export CONTEXTVM_NWC_CLIENT_URI='nostr+walletconnect://?relay=wss://...&secret=' +//! cargo test --features nwc,test-utils --test payments_nwc_real_wallet -- --ignored --nocapture +//! ``` +//! +//! Each test skips with a printed reason when its variables are unset, so a +//! bare `--ignored` run on a machine with no wallet is a no-op rather than a +//! failure. +//! +//! # These tests move real money +//! +//! The amount is deliberately tiny (1 sat by default, override with +//! `CONTEXTVM_NWC_AMOUNT_SATS`), but it is real. Nothing here is reversible. + +use std::sync::Arc; +use std::time::Duration; + +use contextvm_sdk::payments::nip47::{parse_nwc_uri, NwcClient, NwcClientOptions}; +use contextvm_sdk::payments::types::{ + PaymentHandlerRequest, PaymentProcessorCreateParams, PaymentProcessorVerifyParams, +}; +use contextvm_sdk::payments::{ + LnBolt11NwcPaymentHandler, LnBolt11NwcPaymentProcessor, LnBolt11NwcPaymentProcessorOptions, + PaymentHandler, PaymentProcessor, +}; +use tokio_util::sync::CancellationToken; + +const SERVER_URI_VAR: &str = "CONTEXTVM_NWC_SERVER_URI"; +const CLIENT_URI_VAR: &str = "CONTEXTVM_NWC_CLIENT_URI"; +const AMOUNT_VAR: &str = "CONTEXTVM_NWC_AMOUNT_SATS"; + +/// Redact an invoice down to something safe to paste into a PR. +/// +/// A BOLT11 string is a bearer payment request; the prefix and length are +/// enough to show a real invoice was produced. +fn redact(invoice: &str) -> String { + let head: String = invoice.chars().take(12).collect(); + format!("{head}...", invoice.len()) +} + +fn amount_sats() -> i64 { + std::env::var(AMOUNT_VAR) + .ok() + .and_then(|v| v.parse::().ok()) + .unwrap_or(1) +} + +/// A live client that owns a pool built from the connection's own relays. +/// +/// Uses `NwcClient::connect`, the production path: sharing a transport's pool +/// would publish ContextVM traffic to the wallet's relays and leave a +/// permanent wallet filter on the transport's. +async fn live_client(uri: &str, label: &str) -> Arc { + let connection = parse_nwc_uri(uri) + .unwrap_or_else(|e| panic!("{label} connection string does not parse: {e}")); + Arc::new( + NwcClient::connect( + connection, + NwcClientOptions::new().with_response_timeout(Duration::from_secs(30)), + ) + .await + .unwrap_or_else(|e| panic!("{label} client could not connect: {e}")), + ) +} + +fn env_pair() -> Option<(String, String)> { + match ( + std::env::var(SERVER_URI_VAR).ok(), + std::env::var(CLIENT_URI_VAR).ok(), + ) { + (Some(s), Some(c)) if !s.is_empty() && !c.is_empty() => Some((s, c)), + _ => { + eprintln!( + "skipping: set {SERVER_URI_VAR} and {CLIENT_URI_VAR} to two distinct wallets" + ); + None + } + } +} + +/// Mirrors ts `nwc-integration.test.ts`: issue an invoice and look it up. +/// No money moves. +#[tokio::test] +#[ignore = "needs a real NWC wallet; see the module docs"] +async fn issues_and_looks_up_an_invoice_against_a_real_wallet() { + let Some(server_uri) = std::env::var(SERVER_URI_VAR).ok().filter(|s| !s.is_empty()) else { + eprintln!("skipping: set {SERVER_URI_VAR}"); + return; + }; + + let processor = LnBolt11NwcPaymentProcessor::with_client( + live_client(&server_uri, "server").await, + LnBolt11NwcPaymentProcessorOptions::new() + .with_response_timeout(Duration::from_secs(30)) + .with_notification_verification(false), + ); + + let issued = processor + .create_payment_required(PaymentProcessorCreateParams { + amount: amount_sats(), + description: Some("contextvm rs-sdk real-wallet check".to_string()), + request_event_id: "real-wallet-issue".to_string(), + client_pubkey: "real-wallet-client".to_string(), + }) + .await + .expect("a real wallet should mint an invoice"); + + println!("issued invoice: {}", redact(&issued.pay_req)); + assert!(issued.pay_req.to_lowercase().starts_with("lnbc")); + assert_eq!(issued.amount, amount_sats()); + + // Unpaid, so the verify must NOT settle. Bound it tightly and expect the + // cancellation error rather than a settlement. + let cancel = CancellationToken::new(); + let c = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(6)).await; + c.cancel(); + }); + let verdict = processor + .verify_payment(PaymentProcessorVerifyParams { + pay_req: issued.pay_req.clone(), + request_event_id: "real-wallet-issue".to_string(), + client_pubkey: "real-wallet-client".to_string(), + cancel, + }) + .await; + assert!( + verdict.is_err(), + "an unpaid invoice must not verify as settled" + ); + println!("unpaid verify correctly refused: {:?}", verdict.err()); +} + +/// Mirrors ts `nwc-paid-capability-e2e.test.ts`: one wallet issues, the other +/// pays, and the processor observes settlement. **This moves real money.** +#[tokio::test] +#[ignore = "needs two real NWC wallets and moves real money; see the module docs"] +async fn a_real_payment_settles_end_to_end() { + let Some((server_uri, client_uri)) = env_pair() else { + return; + }; + + let processor = LnBolt11NwcPaymentProcessor::with_client( + live_client(&server_uri, "server").await, + LnBolt11NwcPaymentProcessorOptions::new() + .with_ttl(Duration::from_secs(120)) + .with_poll_interval(Duration::from_secs(1)) + .with_response_timeout(Duration::from_secs(30)) + .with_notification_verification(false), + ); + let handler = LnBolt11NwcPaymentHandler::with_client(live_client(&client_uri, "client").await); + + let issued = processor + .create_payment_required(PaymentProcessorCreateParams { + amount: amount_sats(), + description: Some("contextvm rs-sdk real-wallet e2e".to_string()), + request_event_id: "real-wallet-e2e".to_string(), + client_pubkey: "real-wallet-client".to_string(), + }) + .await + .expect("the issuing wallet should mint an invoice"); + println!("issued invoice: {}", redact(&issued.pay_req)); + + // Start verifying before paying, the way the middleware does. + let cancel = CancellationToken::new(); + let verify = { + let pay_req = issued.pay_req.clone(); + let cancel = cancel.clone(); + async move { + processor + .verify_payment(PaymentProcessorVerifyParams { + pay_req, + request_event_id: "real-wallet-e2e".to_string(), + client_pubkey: "real-wallet-client".to_string(), + cancel, + }) + .await + } + }; + + let pay = async { + handler + .handle(PaymentHandlerRequest { + amount: issued.amount, + pay_req: issued.pay_req.clone(), + pmi: issued.pmi.clone(), + description: issued.description.clone(), + ttl: issued.ttl, + meta: None, + request_event_id: "real-wallet-e2e".to_string(), + }) + .await + }; + + let guard = cancel.clone(); + tokio::spawn(async move { + tokio::time::sleep(Duration::from_secs(90)).await; + guard.cancel(); + }); + + let (verified, paid) = tokio::join!(verify, pay); + paid.expect("the paying wallet should settle the invoice"); + let outcome = verified.expect("the issuing wallet should observe settlement"); + + let receipt = outcome + .meta + .as_ref() + .and_then(|m| m.get("payment_hash")) + .and_then(|v| v.as_str()) + .map(|h| h.chars().take(10).collect::()) + .unwrap_or_default(); + println!("settled, receipt prefix: {receipt}..."); + assert!(outcome.meta.is_some(), "settlement must carry a receipt"); +} From efba75c1e96dba7565eecb19051a19b35a1aa3de Mon Sep 17 00:00:00 2001 From: nisargpatel7042lva Date: Fri, 23 Oct 2026 20:07:31 +0530 Subject: [PATCH 11/11] docs(payments): correct the feature's cost and document retry safety Two fixes to `docs/payments.md`. It still claimed the `nwc` feature "adds no new crate to the dependency tree". That is the claim review item (13) corrected, and I said I had fixed it everywhere; I had fixed `Cargo.toml`, the module doc and the changelog, and missed this one. `cargo tree` measures `aes`, `cbc` and `cipher`. And nothing documented when a failed payment is safe to retry, which is the whole point of the three-state `Delivery` the error now carries. A caller reading this page to decide whether to re-send a `pay_invoice` needs it, so there is now a table, including why an in-flight publish is grouped with "sent" rather than "never sent". The handler's changelog entry also drops the internal phase vocabulary, notes the NIP-40 expiration, and no longer advertises removing a line about LNbits that the page already handles in one sentence. --- CHANGELOG.md | 9 +++++---- docs/payments.md | 23 ++++++++++++++++++++--- 2 files changed, 25 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d64d453..1cfbf57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -47,8 +47,10 @@ verification always returns an error, never an empty success that the middleware would read as payment. Amounts are validated before any wallet call, and no tracing call carries the invoice, which is a bearer payment request. - - The NWC payment handler, `LnBolt11NwcPaymentHandler`, the client side of the Phase B - Lightning rail, completing it. It pays an offered BOLT11 invoice with `pay_invoice`, + - The NWC payment handler, `LnBolt11NwcPaymentHandler`, which lets a client pay for a + priced capability, completing the rail. It pays an offered BOLT11 invoice with + `pay_invoice`, sending a NIP-40 expiration so a wallet honouring it will not act on a + request the caller has already abandoned, bounded by its own response timeout because the client engine gives `handle()` neither a timeout nor a cancellation token, and leaves the spending decision with `payment_policy` rather than adding a second gate. A wallet that answers with neither @@ -58,8 +60,7 @@ by hand. Both NWC option structs now carry `new()` and `with_*` builders, since `#[non_exhaustive]` makes a struct literal unusable from a downstream crate. `docs/payments.md` documents the rail, its options, which lifecycle invokes the - handler, and its operational notes, and no longer describes Phase B as deferred or - lists LNbits as planned. + handler, when a failed payment is safe to retry, and its operational notes. - Server-side payment-interaction negotiation and advertisement: the server transport now parses client `pmi` and `payment_interaction` tags, negotiates the effective session mode (`transparent` by default, `explicit_gating` when the server policy diff --git a/docs/payments.md b/docs/payments.md index ee2964a..3569639 100644 --- a/docs/payments.md +++ b/docs/payments.md @@ -350,9 +350,9 @@ reference implementation. ## The NWC rail (NIP-47) Lightning BOLT11 over Nostr Wallet Connect, for PMI -`bitcoin-lightning-bolt11`. Enable the `nwc` feature, which is off by default -and adds no new crate to the dependency tree (it only switches on an existing -`nostr-sdk` NIP): +`bitcoin-lightning-bolt11`. Enable the `nwc` feature, which is off by default. +It switches on `nostr-sdk/nip47`, which pulls in NIP-04 and therefore adds +three crates to the tree: `aes`, `cbc` and `cipher`. ```toml contextvm-sdk = { version = "0.2", features = ["nwc"] } @@ -448,6 +448,23 @@ other wallet error code is fatal, as are `expired` and `failed`. no tracing call in the rail binds `pay_req`, the invoice, or a preimage. Logs carry the request event id, the state, and booleans for field presence. +**A failed `pay_invoice` is not automatically safe to retry.** `pay_invoice` +is not idempotent, so a caller that retries the wrong failure pays twice. +`NwcError::Timeout` and `NwcError::Closed` both carry a `Delivery`, and +`NwcError::may_have_reached_wallet()` reads it for you: + +| `Delivery` | When | Safe to retry | +| --- | --- | --- | +| `NotSent` | the request never left, for example a closed client or a relay that rejected the event | yes | +| `Unknown` | the deadline fell *inside* the publish | no | +| `Sent` | a relay accepted the event and the answer never came | no | + +`Unknown` exists because the pool writes the EVENT before it waits on the +relay's `OK`, so a deadline that fires mid-publish leaves delivery genuinely +unknowable. It is grouped with `Sent` rather than `NotSent`: the conservative +direction is the one that does not pay twice. A wallet error or a malformed +response also count as reached, since the wallet plainly answered. + ### Testing against a real wallet CI gates on relay-level mocks, not a live wallet: `MockWallet` (behind