diff --git a/CHANGELOG.md b/CHANGELOG.md index 331d566..1cfbf57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,61 @@ - CEP-8 capability pricing and payments (in progress; foundational pieces, not yet a usable payment flow): + - 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 + 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 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. + - 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 + 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. + - 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 + 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, 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/Cargo.toml b/Cargo.toml index 44fd7c6..410cae4 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" @@ -60,6 +67,10 @@ 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. +## 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]] name = "rmcp_integration_test" @@ -113,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..3569639 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,148 @@ 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. +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"] } +``` + +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. + +**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 +`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 d11584e..b21bf64 100644 --- a/src/payments/mod.rs +++ b/src/payments/mod.rs @@ -23,6 +23,10 @@ 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; pub mod server_explicit_gating; pub mod server_payments; pub(crate) mod server_payments_utils; @@ -44,6 +48,10 @@ pub use client_payments::{ PaymentPolicyFn, PaymentRequiredCallbackParams, }; 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/nip47/client.rs b/src/payments/nip47/client.rs new file mode 100644 index 0000000..999a8a8 --- /dev/null +++ b/src/payments/nip47/client.rs @@ -0,0 +1,2223 @@ +//! 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 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 +//! [`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 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 +//! +//! * 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::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 as AsyncMutex}; +use tokio_util::sync::CancellationToken; + +use crate::payments::nip47::error::{Delivery, NwcError}; +use crate::payments::nip47::types::{ + sanitize_wallet_text, NwcInvoiceResult, NwcNotificationPayload, NwcPayInvoiceResult, + NwcResponseEnvelope, +}; +use crate::payments::nip47::uri::NwcConnection; +use crate::relay::{RelayPool, 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 notification, historically NIP-04. +const KIND_NOTIFICATION_LEGACY: u16 = 23196; +/// Kind 23197: a notification, historically NIP-44. +const KIND_NOTIFICATION: u16 = 23197; + +/// 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 { + /// Deadline covering start-up, publish and the wait for an answer. + /// Default 60 s. Must be non-zero. + pub response_timeout: Duration, +} + +impl Default for NwcClientOptions { + fn default() -> Self { + Self { + response_timeout: DEFAULT_RESPONSE_TIMEOUT, + } + } +} + +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. +type NotificationSink = Arc; + +struct Inner { + /// Pending request-event-id to response-plaintext waiters. + /// + /// 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, + /// 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, +} + +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. +pub struct NwcClient { + pool: Arc, + connection: NwcConnection, + keys: Keys, + response_timeout: Duration, + inner: Arc, + started: AsyncMutex, + cancel: CancellationToken, +} + +impl NwcClient { + /// 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)) + } + + /// 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, + ) -> 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: AsyncMutex::new(Vec::new()), + ever_correlated: AtomicBool::new(false), + widened: AtomicBool::new(false), + closed: AtomicBool::new(false), + }), + started: AsyncMutex::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 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. + /// + /// 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; + vec![Filter::new() + .kinds([ + Kind::from(KIND_RESPONSE), + Kind::from(KIND_NOTIFICATION_LEGACY), + Kind::from(KIND_NOTIFICATION), + ]) + .author(self.connection.wallet_pubkey) + .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 { + delivery: Delivery::NotSent, + }); + } + let mut started = self.started.lock().await; + if *started { + return Ok(()); + } + + self.pool + .connect(&self.connection.relay_urls()) + .await + .map_err(|e| NwcError::Connect { + reason: e.to_string(), + })?; + + // Take the receiver BEFORE subscribing so a replay cannot land before + // we are listening. + let notifications = self.pool.notifications(); + + self.pool + .subscribe(self.filters()) + .await + .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, 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 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. + /// + /// 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; + } + if self.inner.widened.swap(true, Ordering::SeqCst) { + return; + } + tracing::warn!( + target: LOG_TARGET, + "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.widened_response_filter()).await { + tracing::warn!(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, + keys: Keys, + wallet_pubkey: PublicKey, + cancel: CancellationToken, + ) { + loop { + let notification = tokio::select! { + _ = cancel.cancelled() => break, + result = notifications.recv() => match result { + Ok(n) => n, + // 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, + 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. `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(); + 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"); + } + + /// 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 = inner + .waiters + .lock() + .ok() + .and_then(|mut map| map.remove(&request_id)); + let Some(waiter) = waiter else { + tracing::debug!( + target: LOG_TARGET, + request_id = %request_id.to_hex(), + "NWC response did not correlate to a pending request" + ); + return; + }; + + match decrypt_auto(keys, &event.pubkey, &event.content) { + Ok(plaintext) => { + inner.ever_correlated.store(true, Ordering::SeqCst); + let _ = waiter.send(plaintext); + } + Err(reason) => { + tracing::warn!( + target: LOG_TARGET, + reason = %reason, + "failed to decrypt a NWC response; the caller will fail at its deadline" + ); + } + } + } + + 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; + }; + + let Ok(payload) = serde_json::from_str::(&plaintext) else { + tracing::debug!(target: LOG_TARGET, "malformed NWC notification payload"); + return; + }; + + for sink in sinks { + sink(payload.clone()); + } + } + + /// Register a sink invoked for every decoded notification. + /// + /// 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, + { + 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. + /// + /// `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 + /// + /// [`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, + expiration: Option, + ) -> Result, NwcError> + where + P: Serialize, + R: DeserializeOwned, + { + 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 { + delivery: Delivery::NotSent, + }); + } + + match tokio::time::timeout_at(deadline, self.ensure_started()).await { + Ok(result) => result?, + Err(_) => { + return Err(NwcError::Timeout { + method: method.to_string(), + timeout_ms, + 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", + 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| NwcError::Crypto { + direction: "encryption", + reason: sanitize_wallet_text(&e.to_string()), + })?; + + let mut tags = vec![ + Tag::public_key(self.connection.wallet_pubkey), + 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 = EventBuilder::new(Kind::from(KIND_REQUEST), content) + .tags(tags) + .sign_with_keys(&self.keys) + .map_err(|e| NwcError::Crypto { + direction: "encryption", + reason: e.to_string(), + })?; + let request_id = event.id; + + // 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(); + { + let mut map = self.inner.waiters.lock().map_err(|_| NwcError::Closed { + delivery: Delivery::NotSent, + })?; + map.insert(request_id, tx); + } + let _guard = WaiterGuard { + inner: Arc::clone(&self.inner), + id: request_id, + }; + + let delivery = + match tokio::time::timeout_at(deadline, self.pool.publish_event(&event)).await { + 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, + delivery: Delivery::Unknown, + }) + } + }; + + tracing::debug!( + target: LOG_TARGET, + method, + request_id = %request_id.to_hex(), + "NWC request published" + ); + + let plaintext = match tokio::time::timeout_at(deadline, rx).await { + Ok(Ok(p)) => p, + // 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, + delivery, + }); + } + }; + + 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, + // 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" + ); + } + } + + Ok(envelope) + } + + /// Ask the wallet for a BOLT11 invoice. + /// + /// # Errors + /// + /// [`NwcError::InvalidAmount`] if `amount_msats` is zero; otherwise see + /// the shared request path. + 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 + /// + /// [`NwcError::Config`] if neither identifier is given; otherwise see + /// the shared request path. + 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 the shared request path. 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. + /// + /// 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() + .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| 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 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) + } + + /// 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.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(), + _ => None, + }) +} + +/// 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, MockWalletQuirks, WalletBehavior}; + use crate::payments::nip47::types::{NwcErrorBody, 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_pool( + Arc::clone(&client_pool) as Arc, + connection, + NwcClientOptions::new().with_response_timeout(response_timeout), + ) + .expect("options valid"); + (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, + 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)), + } + } + + fn with_publish_delay(mut self, delay: Duration) -> Self { + self.publish_delay = delay; + 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] + 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; + } + 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 + } + 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.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 + } + 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 + ); + } + + // 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] + 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" }), + ), + ) + .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 looked = client + .lookup_invoice(Some("hash-1"), None) + .await + .expect("lookup_invoice") + .into_result() + .expect("no wallet error"); + assert!(looked.is_settled()); + + 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 lookup_falls_back_to_the_invoice_when_no_hash_is_known() { + let (wallet, client, _pool) = harness().await; + wallet + .set_behavior( + "lookup_invoice", + WalletBehavior::Answer(serde_json::json!({ "state": "settled" })), + ) + .await; + client + .lookup_invoice(None, Some("lnbc-xyz")) + .await + .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 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 { .. }) + )); + } + + #[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; + wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Error { + code: "INSUFFICIENT_BALANCE".to_string(), + message: "no funds".to_string(), + }, + ) + .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("INSUFFICIENT_BALANCE") + ); + assert!(env.into_result().is_err()); + } + + /// An unscripted method gets `NOT_IMPLEMENTED` from a real wallet. + #[tokio::test] + 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 err = client + .make_invoice(1000, None, None) + .await + .expect_err("silence must not resolve"); + + match err { + 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, + delivery: Delivery::Sent + }), + "a published timeout must warn the caller" + ); + } + other => panic!("expected a Timeout, got {other:?}"), + } + assert!(started.elapsed() < Duration::from_secs(3)); + } + + 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 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 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; + + 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 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(); + + 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"}}) + .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), Tag::event(request_id)]) + .sign_with_keys(&stranger) + .expect("sign"); + // 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 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!(matches!( + client.make_invoice(1000, None, None).await, + Err(NwcError::Timeout { .. }) + )); + } + + /// 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] + 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(300), + result: invoice_result("slow-make"), + }, + ) + .await; + wallet + .set_behavior( + "pay_invoice", + WalletBehavior::Answer(serde_json::json!({ "preimage": "fast-pay" })), + ) + .await; + + let client = Arc::new(client); + let c1 = Arc::clone(&client); + let c2 = Arc::clone(&client); + + 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" + ); + } + + /// 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!( + 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!( + 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 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 { + delivery: Delivery::NotSent, + } + ), + "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 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 { .. }) + )); + } + + /// 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; + client + .on_notification(|_| panic!("a badly behaved sink")) + .await + .expect("sink registers"); + + wallet + .settle(&client.client_pubkey(), "hash-boom") + .await + .expect("notification published"); + tokio::time::sleep(Duration::from_millis(200)).await; + + assert!(!client.is_closed(), "the reader must have survived"); + + // 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("the rail still answers after a sink panicked"); + assert_eq!( + env.into_result().unwrap().invoice.as_deref(), + Some("after-panic") + ); + } + + // ── 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 decodes_a_nip04_notification() { + let (wallet, client, _pool) = harness().await; + let mut rx = collect_notifications(&client).await; + wallet + .settle(&client.client_pubkey(), "hash-04") + .await + .unwrap(); + let p = tokio::time::timeout(Duration::from_secs(3), rx.recv()) + .await + .expect("arrives") + .expect("open"); + assert_eq!(p.notification_type, NOTIFICATION_PAYMENT_RECEIVED); + assert_eq!(p.payment_hash(), Some("hash-04")); + } + + #[tokio::test] + 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 + .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")); + } + + /// ts's own fixture: NIP-04 content on kind 23197. A decrypt-by-kind + /// client drops this. + #[tokio::test] + async fn decodes_nip04_content_published_on_the_nip44_kind() { + let (wallet, client, _pool) = harness().await; + let mut rx = collect_notifications(&client).await; + wallet + .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")); + } + + // ── info event ─────────────────────────────────────────────────────────── + + #[tokio::test] + async fn fetches_info_notification_types() { + let (wallet, client, _pool) = harness().await; + wallet + .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")); + } + + #[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("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..93cb138 --- /dev/null +++ b/src/payments/nip47/error.rs @@ -0,0 +1,340 @@ +//! 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 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 +//! BOLT11 invoice in a response and both payment middlewares log a +//! `PaymentError` at warn. + +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 ({})", delivery.describe())] + Timeout { + /// The NIP-47 method that timed out. + method: String, + /// The deadline that elapsed, in milliseconds. + timeout_ms: u64, + /// What is known about the request reaching a relay. + delivery: Delivery, + }, + + /// The client was shut down, or is shutting down. + /// + /// 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}")] + 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 { 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", + _ => 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, + 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, + delivery: Delivery::NotSent, + }; + 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 { + delivery: Delivery::NotSent, + }; + 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()); + } + + /// 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()); + assert!(!NwcError::Config { reason: "x".into() }.may_have_reached_wallet()); + assert!(!NwcError::Closed { + delivery: Delivery::NotSent + } + .may_have_reached_wallet()); + } +} diff --git a/src/payments/nip47/mock_wallet.rs b/src/payments/nip47/mock_wallet.rs new file mode 100644 index 0000000..f0cc4af --- /dev/null +++ b/src/payments/nip47/mock_wallet.rs @@ -0,0 +1,439 @@ +//! 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 notifications). That means +//! [`NwcClient`](super::NwcClient) exercises its own encrypt, publish, +//! correlate and decrypt paths in CI rather than being stubbed out. +//! +//! # 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; +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_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), + /// Reply with a NIP-47 error envelope. + Error { + /// Error code, e.g. `NOT_FOUND`. + code: String, + /// Human-readable message. May contain anything a real wallet might. + message: String, + }, + /// Never reply, so the caller hits its own deadline. + Silence, + /// 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, + /// The `result` object to send. + result: serde_json::Value, + }, + /// 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. +pub struct MockWallet { + pool: Arc, + keys: Keys, + behaviors: Arc>>, + quirks: Arc>, + 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())), + quirks: Arc::new(Mutex::new(MockWalletQuirks::default())), + 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 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 { + 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); + } + + /// 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() + } + + /// 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`. + /// + /// 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, + ) -> Result<(), String> { + let quirks = self.quirks.lock().await.clone(); + let payload = serde_json::json!({ + "notification_type": "payment_received", + "notification": { + "payment_hash": payment_hash, + "state": "settled", + "settled_at": 1_700_000_000i64, + } + }) + .to_string(); + + 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, + &payload, + nip44::Version::default(), + ) + .map_err(|e| e.to_string())? + } else { + nip04::encrypt(self.keys.secret_key(), client_pubkey, &payload) + .map_err(|e| e.to_string())? + }; + + 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())?; + 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 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, quirks, 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>>, + quirks: 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 quirks_now = quirks.lock().await.clone(); + let behavior = behaviors.lock().await.get(&method).cloned(); + 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, + }; + + // 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; + }); + } + } + + 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 plaintext = if malformed { + "{ this is not json".to_string() + } else { + serde_json::json!({ + "result_type": result_type, + "error": error, + "result": result, + }) + .to_string() + }; + + 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; + } +} + +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..bd6ee84 --- /dev/null +++ b/src/payments/nip47/mod.rs @@ -0,0 +1,32 @@ +//! NIP-47 (Nostr Wallet Connect) client infrastructure for the CEP-8 Lightning rail. +//! +//! 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 +//! dependency tree. + +pub mod client; +pub mod error; +pub mod types; +pub mod uri; + +#[cfg(feature = "test-utils")] +pub mod mock_wallet; + +pub use client::{NwcClient, NwcClientOptions, DEFAULT_RESPONSE_TIMEOUT}; +pub use error::{Delivery, NwcError}; +pub use types::{ + sats_to_msats, NwcErrorBody, NwcInvoiceResult, NwcNotificationPayload, NwcPayInvoiceResult, + NwcResponseEnvelope, NOTIFICATION_PAYMENT_RECEIVED, +}; +pub use uri::{parse_nwc_uri, NwcConnection}; + +#[cfg(feature = "test-utils")] +pub use mock_wallet::{MockWallet, MockWalletQuirks, WalletBehavior}; diff --git a/src/payments/nip47/types.rs b/src/payments/nip47/types.rs new file mode 100644 index 0000000..5cff4de --- /dev/null +++ b/src/payments/nip47/types.rs @@ -0,0 +1,567 @@ +//! Permissive NIP-47 wire types. +//! +//! These deliberately do not reuse `nostr_sdk::nips::nip47`'s response structs. +//! 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::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; + +/// 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. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[non_exhaustive] +pub struct NwcResponseEnvelope { + /// Echo of the request method, e.g. `"make_invoice"`. + /// + /// 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, deserialize_with = "lenient")] + 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, +} + +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 [`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(|| NwcErrorBody { + code: Some("EMPTY_RESPONSE".to_string()), + message: Some("wallet returned neither result nor error".to_string()), + }) + } +} + +/// 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 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 { + // 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, + } + } +} + +/// 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() { + if !out.is_empty() { + out.push(' '); + } + if looks_like_invoice(word) { + out.push_str(""); + } else { + out.push_str(word); + } + if out.len() >= 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; + } + } + 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 +/// 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, deserialize_with = "lenient")] + pub r#type: Option, + /// `pending` | `settled` | `accepted` | `expired` | `failed`. + #[serde(default, deserialize_with = "lenient")] + pub state: Option, + /// The BOLT11 invoice string. + #[serde(default, deserialize_with = "lenient")] + pub invoice: Option, + /// Payment hash, hex. + #[serde(default, deserialize_with = "lenient")] + pub payment_hash: Option, + /// Preimage, present once settled. + #[serde(default, deserialize_with = "lenient")] + pub preimage: Option, + /// Creation time, unix seconds. + #[serde(default, deserialize_with = "lenient")] + pub created_at: Option, + /// Expiry time, unix seconds. + #[serde(default, deserialize_with = "lenient")] + pub expires_at: Option, + /// Settlement time, unix seconds. Non-zero implies settled. + #[serde(default, deserialize_with = "lenient")] + pub settled_at: Option, + /// Amount, msats. + #[serde(default, deserialize_with = "lenient")] + 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)] +#[non_exhaustive] +pub struct NwcPayInvoiceResult { + /// Payment preimage, hex. + #[serde(default, deserialize_with = "lenient")] + pub preimage: Option, + /// Routing fees paid, msats. + #[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, + /// Body, shaped per `notification_type`. Left opaque. + #[serde(default)] + pub notification: serde_json::Value, +} + +impl NwcNotificationPayload { + /// `notification.payment_hash`, when present and a string. + 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 [`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(NwcError::InvalidAmount { + reason: format!("amount must be positive, got {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) +} + +#[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")); + } + + /// 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 = + 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"); + } + + /// (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] + 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 { + 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); + } + + #[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 new file mode 100644 index 0000000..6b3ae40 --- /dev/null +++ b/src/payments/nip47/uri.rs @@ -0,0 +1,293 @@ +//! 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, 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 +//! reproduces that precedence exactly. + +use nostr_sdk::prelude::*; + +use crate::payments::nip47::error::NwcError; + +/// 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. +#[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) -> NwcError { + NwcError::Config { + reason: 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 [`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 { + let url = Url::parse(uri.trim()).map_err(|_| invalid("not a URL"))?; + + 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 + // 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); + } + } + // 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()), + _ => {} + } + } + + 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" + ); + } + + /// 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}"); + 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()); + } +} diff --git a/src/payments/processors/ln_bolt11_nwc.rs b/src/payments/processors/ln_bolt11_nwc.rs new file mode 100644 index 0000000..3131727 --- /dev/null +++ b/src/payments/processors/ln_bolt11_nwc.rs @@ -0,0 +1,1390 @@ +//! 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, + } + } +} + +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>>>; + +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}; 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 { 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"); +} 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(