diff --git a/CHANGELOG.md b/CHANGELOG.md index 3280d1f..52c1a43 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -37,6 +37,21 @@ tag sets when it is built, so build it after those tags are set. Both `send_targeted_response` and the `TargetedResponseSender` alias are re-exported from `contextvm_sdk::transport`. + - Transparent-lifecycle payment gating on the server transport: a priced invocation now + triggers `notifications/payment_required` and reaches the MCP handler only after the + configured payment processor verifies settlement, with `notifications/payment_accepted` + sent on the way. A dynamic pricing callback can reject the invocation (emitting + `notifications/payment_rejected`) or waive payment (forwarding it untouched). Duplicate + deliveries of the same request event share one payment rather than charging twice, and + once an invoice has been issued a later failure never re-charges a client who already + paid: a verified payment is delivered even if the acceptance notification cannot be + published. Because a payment can outlast the 60 s stale-route sweep, the transport now + captures the request's routing fields when it emits `payment_required` and delivers the + eventual result from that capture when the route is gone. + `payment_notification_sender` returns the payment-notification publish as an injectable + closure for a detached middleware, alongside the existing `targeted_response_sender`. + The middleware is not registered by default; registration arrives with the payments + configuration entry point. ### Fixed @@ -82,6 +97,11 @@ construct `ClientSession` via `ClientSession::new` and destructure either struct with `..` rather than exhaustively. This matches `InboundContext`, which is already `#[non_exhaustive]`, and makes future field additions non-breaking. +- The server transport's inbound middleware context now carries a per-event cancellation + token derived from the transport's shutdown token, so a middleware doing long-running + work stops when the transport closes. `send_notification`'s body moved into a shared + publish that both it and the new injectable sender use, so the two cannot drift. A + gated (dropped) request now releases the open-stream slot it reserved. ## [0.2.2] - 2026-07-29 diff --git a/Cargo.toml b/Cargo.toml index 414b856..09c57e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -97,6 +97,10 @@ required-features = ["rmcp", "test-utils"] name = "payments_negotiation_e2e" required-features = ["test-utils"] +[[test]] +name = "payments_transparent_e2e" +required-features = ["test-utils"] + [[test]] name = "rmcp_handshake_survival" required-features = ["rmcp", "test-utils"] diff --git a/src/payments/constants.rs b/src/payments/constants.rs index 3dff6e5..bf1078b 100644 --- a/src/payments/constants.rs +++ b/src/payments/constants.rs @@ -19,6 +19,8 @@ pub const PMI_BITCOIN_LIGHTNING_BOLT11: &str = "bitcoin-lightning-bolt11"; /// Default payment TTL when `payment_required` carries no `ttl` (ms). Mirrors the server default. pub const DEFAULT_PAYMENT_TTL_MS: u64 = 300_000; +/// Default cap on concurrently tracked pending-payment request ids (a DoS/memory guardrail). +pub const DEFAULT_MAX_PENDING_PAYMENTS: usize = 1000; /// Default synthetic-progress heartbeat interval (ms). Half the 60 s MCP request timeout. pub const DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS: u64 = 30_000; /// Per-map cap for the `AuthorizationStore` LRUs. Equals [`core::constants::DEFAULT_LRU_SIZE`](crate::core::constants::DEFAULT_LRU_SIZE). @@ -46,6 +48,7 @@ mod tests { fn pmi_and_ttls_match_ts_sdk() { assert_eq!(PMI_BITCOIN_LIGHTNING_BOLT11, "bitcoin-lightning-bolt11"); assert_eq!(DEFAULT_PAYMENT_TTL_MS, 300_000); + assert_eq!(DEFAULT_MAX_PENDING_PAYMENTS, 1000); assert_eq!(DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS, 30_000); assert_eq!(AUTH_STORE_MAX_ENTRIES, 5000); // Mirrors the core LRU default; keep the two in lockstep. diff --git a/src/payments/mod.rs b/src/payments/mod.rs index 00fa3c4..ba6c075 100644 --- a/src/payments/mod.rs +++ b/src/payments/mod.rs @@ -19,6 +19,8 @@ pub mod authorization_store; pub mod canonical; pub mod constants; pub mod errors; +pub mod server_payments; +pub(crate) mod server_payments_utils; pub mod tags; pub mod traits; pub mod types; @@ -32,6 +34,9 @@ pub use canonical::{ CanonicalInvocationIdentity, }; pub use errors::PaymentError; +pub use server_payments::{ + create_server_payments_middleware, ServerPaymentsMiddlewareParams, ServerPaymentsOptions, +}; pub use traits::{PaymentHandler, PaymentProcessor, ResolvePrice}; pub use types::{ Meta, PaymentAcceptedParams, PaymentHandlerRequest, PaymentInteractionPolicy, PaymentOption, diff --git a/src/payments/server_payments.rs b/src/payments/server_payments.rs new file mode 100644 index 0000000..e7b086e --- /dev/null +++ b/src/payments/server_payments.rs @@ -0,0 +1,2010 @@ +//! CEP-8 transparent-lifecycle server payment middleware. +//! +//! A priced invocation is gated at the transport seam: the middleware emits +//! `notifications/payment_required`, waits for the configured [`PaymentProcessor`] +//! to verify settlement, emits `notifications/payment_accepted`, and only then +//! forwards the request to the MCP handler. A dynamic pricing callback can +//! reject the invocation (emitting `notifications/payment_rejected` and +//! dropping it) or waive payment (forwarding it untouched). Duplicate +//! deliveries of one request event share one payment and are never charged +//! twice within the configured bounds. + +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::panic::AssertUnwindSafe; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use futures::future::{BoxFuture, FutureExt, Shared}; +use lru::LruCache; +use tokio::time::Instant; + +use crate::core::types::{ + JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, PaymentInteractionMode, +}; +use crate::payments::constants::{ + DEFAULT_MAX_PENDING_PAYMENTS, DEFAULT_PAYMENT_TTL_MS, PAYMENT_ACCEPTED_METHOD, + PAYMENT_REJECTED_METHOD, PAYMENT_REQUIRED_METHOD, +}; +use crate::payments::errors::PaymentError; +use crate::payments::server_payments_utils::{ + build_processors_by_pmi, match_priced_capability, resolve_and_initiate_payment, + InitiationOutcome, +}; +use crate::payments::traits::{PaymentProcessor, ResolvePrice}; +use crate::payments::types::{ + PaymentAcceptedParams, PaymentInteractionPolicy, PaymentProcessorVerifyParams, + PaymentRejectedParams, PaymentRequiredParams, PricedCapability, +}; +use crate::transport::server::middleware::{InboundContext, InboundMiddleware, Next}; +use crate::transport::server::PaymentNotificationSender; + +const LOG_TARGET: &str = "contextvm_sdk::payments::server_payments"; + +/// How many entries the opportunistic pending-cache purge inspects per lookup, from the +/// least-recently-used end. Internal hygiene knob with no wire meaning; the correctness +/// gate is the expiry check at lookup, not this scan. +const PENDING_PURGE_SCAN_LIMIT: usize = 25; + +/// Configuration for the server-side payment middlewares. +/// +/// Mirrors the reference implementation's options object: the same six fields, +/// with the millisecond TTL expressed as a [`Duration`]. +#[derive(Clone)] +#[non_exhaustive] +pub struct ServerPaymentsOptions { + /// Payment processors, one per PMI. The first entry is the fallback when the + /// client advertises no PMI the server has a processor for. + pub processors: Vec>, + /// The capability patterns that are priced. First match wins; an entry with + /// `name: None` prices every invocation of its method. + pub priced_capabilities: Vec, + /// Optional dynamic pricing callback computing a per-request quote (or + /// rejecting / waiving). `None` charges each capability's static amount. + pub resolve_price: Option>, + /// How long a request stays in pending-payment state. + /// + /// This bounds the redelivery dedup: a duplicate delivery of the same + /// request event inside this window shares the first delivery's payment + /// and is never re-charged; outside it (or after a process restart, or + /// under pending-cache capacity pressure) a redelivery re-runs the + /// lifecycle. The settlement wait itself is bounded by the minimum of this + /// value and the verification timeout derived from the payment request's + /// own `ttl`. + /// + /// Setting this above the transport's session timeout (300 s by default) + /// is a silently paid-but-undelivered configuration: the paying client's + /// session can expire before its payment resolves, costing the client the + /// acceptance notification (the paid result itself is still delivered + /// from the captured route snapshot). + pub payment_ttl: Duration, + /// Maximum number of concurrently tracked pending-payment request ids (a + /// DoS/memory guardrail). When the cache is full and every entry is live, + /// a new priced request is refused rather than evicting a live payment. + pub max_pending_payments: usize, + /// Which payment-interaction lifecycles the server accepts. Carried here so + /// the options stay API-stable; consumed by the payments configuration + /// entry point, not by the transparent middleware itself. + pub payment_interaction: PaymentInteractionPolicy, +} + +impl ServerPaymentsOptions { + /// Build options with the given processors and priced capabilities, and the + /// reference defaults for everything else: no dynamic pricing, a 300 s + /// payment TTL, 1000 tracked pending payments, and the permissive + /// [`PaymentInteractionPolicy::Optional`]. + pub fn new( + processors: Vec>, + priced_capabilities: Vec, + ) -> Self { + Self { + processors, + priced_capabilities, + resolve_price: None, + payment_ttl: Duration::from_millis(DEFAULT_PAYMENT_TTL_MS), + max_pending_payments: DEFAULT_MAX_PENDING_PAYMENTS, + payment_interaction: PaymentInteractionPolicy::default(), + } + } + + /// Set the dynamic pricing callback. + pub fn with_resolve_price(mut self, resolve_price: Arc) -> Self { + self.resolve_price = Some(resolve_price); + self + } + + /// Set how long a request stays in pending-payment state. + pub fn with_payment_ttl(mut self, payment_ttl: Duration) -> Self { + self.payment_ttl = payment_ttl; + self + } + + /// Set the cap on concurrently tracked pending-payment request ids. + pub fn with_max_pending_payments(mut self, max_pending_payments: usize) -> Self { + self.max_pending_payments = max_pending_payments; + self + } + + /// Set the accepted payment-interaction policy. + pub fn with_payment_interaction(mut self, policy: PaymentInteractionPolicy) -> Self { + self.payment_interaction = policy; + self + } +} + +/// Inputs to [`create_server_payments_middleware`]. +/// +/// A struct rather than a positional list so later payment work can add fields (a shared +/// processor map is already optional here) without a breaking signature change. +#[non_exhaustive] +pub struct ServerPaymentsMiddlewareParams { + /// The payment configuration (processors, priced capabilities, TTLs). + pub options: ServerPaymentsOptions, + /// The transport's injected payment-notification publish + /// ([`NostrServerTransport::payment_notification_sender`](crate::transport::server::NostrServerTransport::payment_notification_sender)). + pub sender: PaymentNotificationSender, + /// Pre-built PMI-to-processor map, shared across middlewares. Built locally when `None`. + pub processors_by_pmi: Option>>>, +} + +impl ServerPaymentsMiddlewareParams { + /// Build params from the two required inputs, with no shared processor map. + pub fn new(options: ServerPaymentsOptions, sender: PaymentNotificationSender) -> Self { + Self { + options, + sender, + processors_by_pmi: None, + } + } +} + +/// Create the CEP-8 transparent-lifecycle payment middleware. +/// +/// Three self-gates run before any payment work, each forwarding the message untouched: +/// a non-request is never gated; a session whose negotiated payment-interaction mode is +/// present and not transparent is another lifecycle's business; and an unpriced request +/// is free. Everything else runs the lifecycle: emit `notifications/payment_required`, +/// verify settlement through the configured processor, emit +/// `notifications/payment_accepted`, and forward. +/// +/// The middleware chain runs on a detached task, so long verification never blocks the +/// transport's inbound loop. Cancellation rides the per-event token on +/// [`InboundContext`]: it is a child of the transport's shutdown token, so closing the +/// transport aborts an in-flight verification instead of leaving it running against +/// cleared state. Only the verification phase is bounded that way: the initiation phase +/// (the pricing callback plus the processor's payment-request creation) runs under no +/// timeout and does not observe cancellation, matching the reference implementation, so +/// a processor that hangs in creation parks its chain task past shutdown. +pub fn create_server_payments_middleware( + params: ServerPaymentsMiddlewareParams, +) -> Arc { + let processors_by_pmi = params + .processors_by_pmi + .unwrap_or_else(|| Arc::new(build_processors_by_pmi(¶ms.options.processors))); + let capacity = NonZeroUsize::new(params.options.max_pending_payments) + .unwrap_or_else(|| NonZeroUsize::new(1).expect("1 is non-zero")); + Arc::new(ServerPaymentsMiddleware { + options: params.options, + sender: params.sender, + processors_by_pmi, + pending: tokio::sync::Mutex::new(LruCache::new(capacity)), + }) +} + +/// How one transparent lifecycle run ended, from the dedup's point of view. +/// +/// Private: the retention rule below is the only consumer. +enum LifecycleFailure { + /// Price resolution or `create_payment_required` failed: no invoice exists. + Initiate(PaymentError), + /// The `payment_required` publish failed: treated as no invoice reaching the client. + PublishRequired(crate::Error), + /// The `payment_rejected` publish failed (also before any invoice). + PublishRejected(crate::Error), + /// `verify_payment` returned an error: the invoice exists and may have settled. + Verify(PaymentError), + /// The verification deadline elapsed: the invoice exists and may settle later. + VerifyTimeout, +} + +impl LifecycleFailure { + /// Whether this failure happened strictly before an invoice could have reached the + /// client. The retention rule: delete the pending entry only for pre-invoice + /// failures, because once an invoice exists the client's money may already be gone + /// and a redelivery must never mint a second charge. + fn is_pre_invoice(&self) -> bool { + match self { + Self::Initiate(_) | Self::PublishRequired(_) | Self::PublishRejected(_) => true, + Self::Verify(_) | Self::VerifyTimeout => false, + } + } +} + +impl std::fmt::Display for LifecycleFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Initiate(e) => write!(f, "payment initiation failed: {e}"), + Self::PublishRequired(e) => write!(f, "payment_required publish failed: {e}"), + Self::PublishRejected(e) => write!(f, "payment_rejected publish failed: {e}"), + Self::Verify(e) => write!(f, "payment verification failed: {e}"), + Self::VerifyTimeout => write!(f, "payment verification timed out"), + } + } +} + +type InFlight = Shared>>>; + +/// One tracked pending payment: when the dedup window closes, and the shared lifecycle +/// future a duplicate delivery joins. +struct PendingEntry { + expires_at: Instant, + in_flight: InFlight, +} + +/// Opportunistic hygiene against one-shot spam: drop up to +/// [`PENDING_PURGE_SCAN_LIMIT`] expired entries per lookup, scanning from the +/// least-recently-used end (`iter()` yields most-recently-used first, so the scan needs +/// `.rev()` to inspect the oldest entries, which are the ones that can be expired). +fn purge_expired_pending(cache: &mut LruCache, now: Instant) { + let expired: Vec = cache + .iter() + .rev() + .take(PENDING_PURGE_SCAN_LIMIT) + .filter(|(_, entry)| entry.expires_at <= now) + .map(|(key, _)| key.clone()) + .collect(); + for key in expired { + cache.pop(&key); + } +} + +/// The three wire notification builders. Serializing the params structs cannot fail for +/// the shapes this middleware produces, but the signature is honest about serde. +fn payment_notification( + method: &str, + params: impl serde::Serialize, +) -> Result { + Ok(JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: method.to_string(), + params: Some(serde_json::to_value(params)?), + })) +} + +struct ServerPaymentsMiddleware { + options: ServerPaymentsOptions, + sender: PaymentNotificationSender, + processors_by_pmi: Arc>>, + /// The pending-payment dedup cache. A `tokio::sync::Mutex`, but the guard is NEVER + /// held across an await: the single critical section below covers purge, lookup and + /// insert only, and every await happens after it drops. Holding it across the verify + /// await would serialize every priced request in the server behind one payment. + pending: tokio::sync::Mutex>, +} + +#[async_trait] +impl InboundMiddleware for ServerPaymentsMiddleware { + async fn handle(&self, message: JsonRpcMessage, ctx: &InboundContext, next: Next) -> bool { + // Self-gate 1: only requests are gated; notifications and responses pass. + let request = match &message { + JsonRpcMessage::Request(request) => request.clone(), + _ => return next.run(message).await, + }; + + // Self-gate 2: a session negotiated onto another lifecycle is not this + // middleware's business. An absent mode does NOT forward: it falls through to + // the priced check, mirroring the reference implementation (in production the + // seam always resolves the mode, so `None` is reachable only from a hand-built + // context). + if let Some(mode) = ctx.payment_interaction { + if mode != PaymentInteractionMode::Transparent { + return next.run(message).await; + } + } + + // Self-gate 3: an unpriced request is free. + let Some(priced) = + match_priced_capability(&request, &self.options.priced_capabilities).cloned() + else { + return next.run(message).await; + }; + + tracing::debug!( + target: LOG_TARGET, + method = %request.method, + request_event_id = %ctx.request_event_id, + client_pubkey = %ctx.client_pubkey, + "priced capability matched" + ); + + let event_id = ctx.request_event_id.clone(); + let now = Instant::now(); + + // Built before the guard is taken, so no allocation happens inside the critical + // section. An async block is inert until polled, so nothing has run yet. + let fresh: InFlight = run_lifecycle( + self.options.clone(), + Arc::clone(&self.processors_by_pmi), + Arc::clone(&self.sender), + request, + message, + priced, + ctx.clone(), + next, + ) + .boxed() + .shared(); + + // ONE critical section covering purge, lookup and insert. This is the rule that + // prevents a double charge under concurrent duplicate delivery; splitting the + // lookup and the insert into separate lock acquisitions is the defect. The + // guard drops before any await. + let existing = { + let mut cache = self.pending.lock().await; + purge_expired_pending(&mut cache, now); + + match cache.peek(&event_id) { + Some(entry) if entry.expires_at > now => Some(entry.in_flight.clone()), + _ => { + // Fail closed: never let `put`'s recency eviction remove a live + // entry. `LruCache::put` evicts strictly by recency, so on a full + // cache an expired entry mid-order would SURVIVE while the live + // entry at the LRU end dies, disarming its dedup. Pop a specific + // expired key first; refuse only when every entry is live. + if cache.len() == cache.cap().get() { + let expired_key = cache + .iter() + .find(|(_, entry)| entry.expires_at <= now) + .map(|(key, _)| key.clone()); + match expired_key { + Some(key) => { + cache.pop(&key); + } + None => { + tracing::warn!( + target: LOG_TARGET, + event_id = %event_id, + "pending-payment capacity reached with every entry \ + live; refusing to start a new payment" + ); + return false; // drop, do not forward + } + } + } + cache.put( + event_id.clone(), + PendingEntry { + expires_at: now + self.options.payment_ttl, + in_flight: fresh.clone(), + }, + ); + None + } + } + }; // guard dropped BEFORE any await + + if let Some(in_flight) = existing { + // A duplicate delivery joins the in-flight (or completed) lifecycle and + // never forwards; the first delivery's future owns the forward. + let _ = in_flight.await; + return false; + } + + let result = fresh.clone().await; + + // Delete ONLY on a strictly pre-invoice failure, and only if this task's own + // entry is still the one at the key: a same-key entry inserted by a later + // legitimate retry must never be popped by a stale cleanup. + if matches!(result.as_ref(), Err(e) if e.is_pre_invoice()) { + let mut cache = self.pending.lock().await; + if cache + .peek(&event_id) + .is_some_and(|entry| entry.in_flight.ptr_eq(&fresh)) + { + cache.pop(&event_id); + } + } + + matches!(result.as_ref(), Ok(())) + } +} + +/// The whole transparent lifecycle for one priced request, owned by the shared future so +/// exactly one forward can happen per request event id (only the first delivery's `Next` +/// is moved in; `Next` is not `Clone`). +/// +/// A panic anywhere inside is caught and converted to a failure classified by whether an +/// invoice had been issued, so a panicking processor neither propagates into a duplicate +/// awaiter nor leaves the entry undeletable. +#[allow(clippy::too_many_arguments)] +async fn run_lifecycle( + options: ServerPaymentsOptions, + processors_by_pmi: Arc>>, + sender: PaymentNotificationSender, + request: JsonRpcRequest, + message: JsonRpcMessage, + priced: PricedCapability, + ctx: InboundContext, + next: Next, +) -> Arc> { + let invoice_issued = Arc::new(AtomicBool::new(false)); + let issued_flag = Arc::clone(&invoice_issued); + let event_id = ctx.request_event_id.clone(); + + let inner = run_lifecycle_inner( + options, + processors_by_pmi, + sender, + request, + message, + priced, + ctx, + next, + issued_flag, + ); + match AssertUnwindSafe(inner).catch_unwind().await { + Ok(result) => { + if let Err(failure) = &result { + tracing::warn!( + target: LOG_TARGET, + event_id = %event_id, + failure = %failure, + "payment lifecycle did not complete" + ); + } + Arc::new(result) + } + Err(_panic) => { + tracing::error!( + target: LOG_TARGET, + event_id = %event_id, + "payment lifecycle panicked" + ); + Arc::new(Err(if invoice_issued.load(Ordering::SeqCst) { + // Post-invoice: keep the entry until TTL, exactly like a verify error; + // the client may already have paid. + LifecycleFailure::Verify(PaymentError::Processor( + "payment lifecycle panicked after the invoice was issued".to_string(), + )) + } else { + LifecycleFailure::Initiate(PaymentError::Processor( + "payment lifecycle panicked before any invoice was issued".to_string(), + )) + })) + } + } +} + +#[allow(clippy::too_many_arguments)] +async fn run_lifecycle_inner( + options: ServerPaymentsOptions, + processors_by_pmi: Arc>>, + sender: PaymentNotificationSender, + request: JsonRpcRequest, + message: JsonRpcMessage, + priced: PricedCapability, + ctx: InboundContext, + next: Next, + invoice_issued: Arc, +) -> Result<(), LifecycleFailure> { + let event_id = ctx.request_event_id.clone(); + let client_pubkey = ctx.client_pubkey.clone(); + + let outcome = resolve_and_initiate_payment( + &request, + &priced, + &event_id, + &client_pubkey, + ctx.client_pmis.as_deref(), + &options, + &processors_by_pmi, + ) + .await + .map_err(LifecycleFailure::Initiate)?; + + let (processor, payment_required, merged_meta, verify_timeout) = match outcome { + InitiationOutcome::Rejected { + pmi, + amount, + message: reason, + } => { + tracing::info!( + target: LOG_TARGET, + request_event_id = %event_id, + pmi = %pmi, + amount, + reason = reason.as_deref().unwrap_or(""), + "payment rejected" + ); + let rejected = payment_notification( + PAYMENT_REJECTED_METHOD, + PaymentRejectedParams { + pmi, + amount: Some(amount), + message: reason, + }, + ) + .map_err(LifecycleFailure::Initiate)?; + sender( + client_pubkey.clone(), + event_id.clone(), + ctx.mirrored_wrap_kind, + rejected, + ) + .await + .map_err(LifecycleFailure::PublishRejected)?; + // Dropped: the request is answered by the rejection, not forwarded. + return Ok(()); + } + InitiationOutcome::Waived => { + tracing::debug!( + target: LOG_TARGET, + request_event_id = %event_id, + "payment waived, forwarding priced request" + ); + next.run(message).await; + return Ok(()); + } + InitiationOutcome::PaymentRequired { + processor, + payment_required, + merged_meta, + verify_timeout, + } => (processor, payment_required, merged_meta, verify_timeout), + }; + + let required = payment_notification( + PAYMENT_REQUIRED_METHOD, + PaymentRequiredParams { + amount: payment_required.amount, + pay_req: payment_required.pay_req.clone(), + pmi: payment_required.pmi.clone(), + description: payment_required.description.clone(), + ttl: payment_required.ttl, + meta: merged_meta, + }, + ) + .map_err(LifecycleFailure::Initiate)?; + + tracing::info!( + target: LOG_TARGET, + request_event_id = %event_id, + pmi = %payment_required.pmi, + amount = payment_required.amount, + ttl = payment_required.ttl, + "payment required notification sent" + ); + + sender( + client_pubkey.clone(), + event_id.clone(), + ctx.mirrored_wrap_kind, + required, + ) + .await + .map_err(LifecycleFailure::PublishRequired)?; + // From this point the client can be holding a payable invoice, so no failure below + // may delete the pending entry. + invoice_issued.store(true, Ordering::SeqCst); + + // The settlement wait is bounded by the stricter of the invoice's own TTL and the + // configured pending TTL; those are deliberately different clocks (the entry's + // `expires_at` above uses the pending TTL alone, which bounds redelivery dedup). + let polling_timeout = verify_timeout.min(options.payment_ttl); + let verify_cancel = ctx.cancel.child_token(); + + tracing::debug!( + target: LOG_TARGET, + request_event_id = %event_id, + pmi = %payment_required.pmi, + timeout_ms = polling_timeout.as_millis() as u64, + "verifying payment" + ); + + let verify_result = tokio::time::timeout( + polling_timeout, + processor.verify_payment(PaymentProcessorVerifyParams { + pay_req: payment_required.pay_req.clone(), + request_event_id: event_id.clone(), + client_pubkey: client_pubkey.clone(), + cancel: verify_cancel.clone(), + }), + ) + .await; + // Not dead code: `timeout` drops the verify future, but a processor that spawned + // its own background poller keeps ticking until this cancel. Fired on the + // completion path too, mirroring an abort-on-settle contract. + verify_cancel.cancel(); + + let verified = match verify_result { + Err(_elapsed) => return Err(LifecycleFailure::VerifyTimeout), + Ok(Err(error)) => return Err(LifecycleFailure::Verify(error)), + Ok(Ok(verified)) => verified, + }; + + tracing::info!( + target: LOG_TARGET, + request_event_id = %event_id, + pmi = %payment_required.pmi, + amount = payment_required.amount, + "payment accepted" + ); + + let accepted = payment_notification( + PAYMENT_ACCEPTED_METHOD, + PaymentAcceptedParams { + amount: payment_required.amount, + pmi: payment_required.pmi.clone(), + meta: verified.meta, + }, + ); + // A verified payment forwards unconditionally. The money has moved: delivering the + // paid-for result is the invocation's whole point, while the acceptance + // notification is a courtesy the spec only SHOULDs. A publish failure here (the + // paying client's session can be gone after a long payment) is logged and the + // forward still happens. Do not "tidy" this back into an early return. + match accepted { + Ok(accepted) => { + if let Err(error) = sender( + client_pubkey.clone(), + event_id.clone(), + ctx.mirrored_wrap_kind, + accepted, + ) + .await + { + tracing::error!( + target: LOG_TARGET, + request_event_id = %event_id, + error = %error, + "payment verified but the acceptance notification failed to \ + publish; forwarding the paid request anyway" + ); + } + } + Err(error) => { + tracing::error!( + target: LOG_TARGET, + request_event_id = %event_id, + error = %error, + "payment verified but the acceptance notification failed to build; \ + forwarding the paid request anyway" + ); + } + } + + tracing::debug!( + target: LOG_TARGET, + request_event_id = %event_id, + "forwarding priced request after payment" + ); + next.run(message).await; + Ok(()) +} + +// `Arc` / `Arc` are not `Debug`, so the +// derive is unavailable; print the processor PMIs and the resolver's presence. +impl std::fmt::Debug for ServerPaymentsOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ServerPaymentsOptions") + .field( + "processors", + &self.processors.iter().map(|p| p.pmi()).collect::>(), + ) + .field("priced_capabilities", &self.priced_capabilities) + .field( + "resolve_price", + &self.resolve_price.as_ref().map(|_| "Some(..)"), + ) + .field("payment_ttl", &self.payment_ttl) + .field("max_pending_payments", &self.max_pending_payments) + .field("payment_interaction", &self.payment_interaction) + .finish() + } +} + +#[cfg(test)] +mod option_tests { + use super::*; + + #[test] + fn defaults_match_ts_sdk() { + let options = ServerPaymentsOptions::new(Vec::new(), Vec::new()); + assert_eq!(options.payment_ttl, Duration::from_millis(300_000)); + assert_eq!(options.max_pending_payments, 1000); + assert!(options.resolve_price.is_none()); + assert_eq!( + options.payment_interaction, + PaymentInteractionPolicy::Optional + ); + } + + #[test] + fn builders_override_each_default() { + struct NeverResolve; + #[async_trait::async_trait] + impl ResolvePrice for NeverResolve { + async fn resolve_price( + &self, + _params: crate::payments::types::ResolvePriceParams, + ) -> Result< + crate::payments::types::ResolvePriceResult, + crate::payments::errors::PaymentError, + > { + unreachable!("never called in this test") + } + } + + let options = ServerPaymentsOptions::new(Vec::new(), Vec::new()) + .with_resolve_price(Arc::new(NeverResolve)) + .with_payment_ttl(Duration::from_secs(7)) + .with_max_pending_payments(3) + .with_payment_interaction(PaymentInteractionPolicy::Transparent); + assert!(options.resolve_price.is_some()); + assert_eq!(options.payment_ttl, Duration::from_secs(7)); + assert_eq!(options.max_pending_payments, 3); + assert_eq!( + options.payment_interaction, + PaymentInteractionPolicy::Transparent + ); + } + + #[test] + fn debug_prints_pmis_without_processor_internals() { + let options = ServerPaymentsOptions::new(Vec::new(), Vec::new()); + let printed = format!("{options:?}"); + assert!(printed.contains("ServerPaymentsOptions")); + assert!(printed.contains("max_pending_payments: 1000")); + } +} + +#[cfg(test)] +mod lifecycle_tests { + use super::*; + use crate::core::types::JsonRpcRequest; + use crate::payments::traits::ResolvePrice; + use crate::payments::types::{ + PaymentProcessorCreateParams, ResolvePriceParams, ResolvePriceResult, VerifyOutcome, + }; + use crate::transport::open_stream::OpenStreamConfig; + use crate::transport::server::middleware::run_inbound_chain; + use crate::transport::server::{IncomingRequest, ServerEventRouteStore, ServerOpenStreamState}; + use std::collections::VecDeque; + use std::sync::atomic::AtomicUsize; + use std::sync::Mutex as StdMutex; + use tokio::sync::mpsc::{self, UnboundedReceiver, UnboundedSender}; + use tokio_util::sync::CancellationToken; + + // ── local doubles (this module runs in every feature configuration) ── + + /// Ordered record of lifecycle steps: "create", "verify", "publish:", "forward". + #[derive(Clone, Default)] + struct EventLog(Arc>>); + + impl EventLog { + fn push(&self, item: impl Into) { + self.0.lock().unwrap().push(item.into()); + } + fn items(&self) -> Vec { + self.0.lock().unwrap().clone() + } + fn count_of(&self, item: &str) -> usize { + self.items().iter().filter(|i| i.as_str() == item).count() + } + } + + /// Published notifications: (method, params). + #[derive(Clone, Default)] + struct Sent(Arc>>); + + impl Sent { + fn items(&self) -> Vec<(String, serde_json::Value)> { + self.0.lock().unwrap().clone() + } + fn methods(&self) -> Vec { + self.items().into_iter().map(|(m, _)| m).collect() + } + } + + /// A recording sender double; methods in `fail_methods` return `Err` without recording. + fn test_sender( + log: EventLog, + sent: Sent, + fail_methods: &'static [&'static str], + ) -> PaymentNotificationSender { + Arc::new(move |_pk, _event_id, _wrap, notification| { + let log = log.clone(); + let sent = sent.clone(); + Box::pin(async move { + if let JsonRpcMessage::Notification(n) = ¬ification { + log.push(format!("publish:{}", n.method)); + if fail_methods.contains(&n.method.as_str()) { + return Err(crate::Error::Other("publish failed".to_string())); + } + sent.0.lock().unwrap().push(( + n.method.clone(), + n.params.clone().unwrap_or(serde_json::Value::Null), + )); + } + Ok(()) + }) + }) + } + + enum CreatePlan { + Ok, + OkAfter(Duration), + Err, + ErrAfter(Duration), + Panic, + } + + enum VerifyPlan { + /// Settle immediately. + Instant, + /// Settle after a delay. + SettleAfter(Duration), + /// Never settle. Spawns a watcher (like a background poller) that records the + /// elapsed time at which the per-verify cancellation fires. + Hang, + } + + struct TestProcessor { + pmi: String, + ttl: Option, + log: EventLog, + create_calls: Arc, + /// Per-call behaviors, popped front; an empty queue means `Ok`. + create_plan: StdMutex>, + verify_plan: VerifyPlan, + cancel_observed_after: Arc>>, + } + + impl TestProcessor { + fn new(pmi: &str, log: EventLog) -> Self { + Self { + pmi: pmi.to_string(), + ttl: None, + log, + create_calls: Arc::new(AtomicUsize::new(0)), + create_plan: StdMutex::new(VecDeque::new()), + verify_plan: VerifyPlan::Instant, + cancel_observed_after: Arc::new(StdMutex::new(None)), + } + } + + fn with_ttl(mut self, ttl: u64) -> Self { + self.ttl = Some(ttl); + self + } + + fn with_verify(mut self, plan: VerifyPlan) -> Self { + self.verify_plan = plan; + self + } + + fn with_create_plan(self, plan: Vec) -> Self { + *self.create_plan.lock().unwrap() = plan.into(); + self + } + } + + #[async_trait] + impl PaymentProcessor for TestProcessor { + fn pmi(&self) -> &str { + &self.pmi + } + + async fn create_payment_required( + &self, + params: PaymentProcessorCreateParams, + ) -> Result { + self.create_calls.fetch_add(1, Ordering::SeqCst); + self.log.push("create"); + let plan = self.create_plan.lock().unwrap().pop_front(); + match plan { + Some(CreatePlan::Err) => { + return Err(PaymentError::Processor("create failed".to_string())) + } + Some(CreatePlan::ErrAfter(delay)) => { + tokio::time::sleep(delay).await; + return Err(PaymentError::Processor("create failed".to_string())); + } + Some(CreatePlan::OkAfter(delay)) => tokio::time::sleep(delay).await, + Some(CreatePlan::Panic) => panic!("processor create panicked"), + Some(CreatePlan::Ok) | None => {} + } + Ok(PaymentRequiredParams { + amount: params.amount, + pay_req: format!("invoice-{}", params.request_event_id), + pmi: self.pmi.clone(), + description: params.description, + ttl: self.ttl, + meta: None, + }) + } + + async fn verify_payment( + &self, + params: PaymentProcessorVerifyParams, + ) -> Result { + self.log.push("verify"); + match &self.verify_plan { + VerifyPlan::Instant => Ok(VerifyOutcome::default()), + VerifyPlan::SettleAfter(delay) => { + tokio::time::sleep(*delay).await; + Ok(VerifyOutcome::default()) + } + VerifyPlan::Hang => { + let started = Instant::now(); + let observed = Arc::clone(&self.cancel_observed_after); + let cancel = params.cancel.clone(); + // A background poller, exactly the shape the per-verify cancel + // exists for: the timeout drops the verify future, so only the + // explicit cancel can stop this task. + tokio::spawn(async move { + cancel.cancelled().await; + *observed.lock().unwrap() = Some(started.elapsed()); + }); + std::future::pending::<()>().await; + unreachable!("pending() never resolves") + } + } + } + } + + struct StaticResolver(ResolvePriceResult); + #[async_trait] + impl ResolvePrice for StaticResolver { + async fn resolve_price( + &self, + _params: ResolvePriceParams, + ) -> Result { + Ok(self.0.clone()) + } + } + + /// A second middleware placed after the payments one, so "forward" lands in the same + /// ordered log as the emissions. + struct RecordForward(EventLog); + #[async_trait] + impl InboundMiddleware for RecordForward { + async fn handle(&self, message: JsonRpcMessage, _ctx: &InboundContext, next: Next) -> bool { + self.0.push("forward"); + next.run(message).await + } + } + + // ── harness ───────────────────────────────────────────────────── + + struct Harness { + middleware: Arc, + forward_recorder: Arc, + routes: ServerEventRouteStore, + open_stream: ServerOpenStreamState, + tx: UnboundedSender, + rx: StdMutex>, + } + + impl Harness { + fn new(middleware: Arc, log: EventLog) -> Self { + let (tx, rx) = mpsc::unbounded_channel(); + Self { + middleware, + forward_recorder: Arc::new(RecordForward(log)), + routes: ServerEventRouteStore::new(), + open_stream: ServerOpenStreamState::new(&OpenStreamConfig::default(), 10), + tx, + rx: StdMutex::new(rx), + } + } + + fn chain(&self) -> Arc<[Arc]> { + Arc::from(vec![ + Arc::clone(&self.middleware), + Arc::clone(&self.forward_recorder) as Arc, + ]) + } + + /// Run the real chain to completion for one event. + async fn dispatch(&self, ctx: Arc, message: JsonRpcMessage) { + run_inbound_chain( + self.chain(), + ctx, + self.tx.clone(), + self.routes.clone(), + self.open_stream.clone(), + message, + None, + ) + .await; + } + + /// How many messages reached the worker channel so far. + fn delivered(&self) -> usize { + let mut count = 0; + let mut rx = self.rx.lock().unwrap(); + while rx.try_recv().is_ok() { + count += 1; + } + count + } + } + + fn priced_call(amount: i64) -> PricedCapability { + PricedCapability { + method: "tools/call".to_string(), + name: Some("echo".to_string()), + amount, + max_amount: None, + currency_unit: "sats".to_string(), + description: None, + } + } + + fn call_message() -> JsonRpcMessage { + JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("client-req-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ "name": "echo" })), + }) + } + + fn test_ctx(event_id: &str) -> InboundContext { + InboundContext { + client_pubkey: "c".repeat(64), + request_event_id: event_id.to_string(), + is_encrypted: false, + mirrored_wrap_kind: None, + client_pmis: None, + payment_interaction: Some(PaymentInteractionMode::Transparent), + cancel: CancellationToken::new(), + } + } + + struct Fixture { + harness: Harness, + log: EventLog, + sent: Sent, + create_calls: Arc, + } + + fn fixture_with( + configure: impl FnOnce(ServerPaymentsOptions) -> ServerPaymentsOptions, + processor: TestProcessor, + fail_methods: &'static [&'static str], + ) -> Fixture { + let log = processor.log.clone(); + let sent = Sent::default(); + let create_calls = Arc::clone(&processor.create_calls); + let options = configure(ServerPaymentsOptions::new( + vec![Arc::new(processor)], + vec![priced_call(21)], + )); + let middleware = create_server_payments_middleware(ServerPaymentsMiddlewareParams::new( + options, + test_sender(log.clone(), sent.clone(), fail_methods), + )); + Fixture { + harness: Harness::new(middleware, log.clone()), + log, + sent, + create_calls, + } + } + + fn fixture(processor: TestProcessor) -> Fixture { + fixture_with(|o| o, processor, &[]) + } + + // ── the tests ─────────────────────────────────────────────────── + + #[tokio::test] + async fn happy_path_emits_required_then_accepted_then_forwards() { + let log = EventLog::default(); + let fx = fixture(TestProcessor::new("pmi-a", log.clone()).with_ttl(600)); + let ctx = test_ctx("evt-1"); + let ctx_cancel = ctx.cancel.clone(); + fx.harness.dispatch(Arc::new(ctx), call_message()).await; + + // The full ordered sequence, asserted as an order, not a set. + assert_eq!( + fx.log.items(), + vec![ + "create", + "publish:notifications/payment_required", + "verify", + "publish:notifications/payment_accepted", + "forward", + ] + ); + assert_eq!(fx.harness.delivered(), 1); + let sent = fx.sent.items(); + assert_eq!(sent[0].1.get("amount").unwrap(), 21); + assert_eq!(sent[0].1.get("pmi").unwrap(), "pmi-a"); + assert!(sent[0].1.get("pay_req").is_some()); + assert_eq!(sent[1].1.get("amount").unwrap(), 21); + // The per-event context token must survive a completed verify: the middleware + // cancels its own per-verify CHILD, never the event's token, which later + // middlewares on the same event still rely on. + assert!( + !ctx_cancel.is_cancelled(), + "the per-event token must not be cancelled by a completed verify" + ); + } + + #[tokio::test] + async fn reject_emits_payment_rejected_and_does_not_forward() { + // Two processors with distinct PMIs, and the client selects the SECOND, so a + // selection performed after pricing (which would report the default first + // processor) is observable. + let log = EventLog::default(); + let sent = Sent::default(); + let first = TestProcessor::new("pmi-first", log.clone()); + let second = TestProcessor::new("pmi-second", log.clone()); + let options = ServerPaymentsOptions::new( + vec![Arc::new(first), Arc::new(second)], + vec![priced_call(55)], + ) + .with_resolve_price(Arc::new(StaticResolver(ResolvePriceResult::Reject { + message: Some("not today".to_string()), + }))); + let middleware = create_server_payments_middleware(ServerPaymentsMiddlewareParams::new( + options, + test_sender(log.clone(), sent.clone(), &[]), + )); + let harness = Harness::new(middleware, log.clone()); + + let mut ctx = test_ctx("evt-reject"); + ctx.client_pmis = Some(vec!["pmi-second".to_string()]); + harness.dispatch(Arc::new(ctx), call_message()).await; + + let items = sent.items(); + assert_eq!(items.len(), 1, "exactly one notification"); + assert_eq!(items[0].0, "notifications/payment_rejected"); + assert_eq!(items[0].1.get("pmi").unwrap(), "pmi-second"); + assert_eq!(items[0].1.get("amount").unwrap(), 55); + assert_eq!(items[0].1.get("message").unwrap(), "not today"); + assert_eq!(harness.delivered(), 0, "a rejected request is dropped"); + } + + #[tokio::test] + async fn a_redelivered_rejected_request_re_emits_nothing() { + let log = EventLog::default(); + let fx = fixture_with( + |o| { + o.with_resolve_price(Arc::new(StaticResolver(ResolvePriceResult::Reject { + message: None, + }))) + }, + TestProcessor::new("pmi-a", log.clone()), + &[], + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-r")), call_message()) + .await; + assert_eq!(fx.sent.methods(), vec!["notifications/payment_rejected"]); + + fx.harness + .dispatch(Arc::new(test_ctx("evt-r")), call_message()) + .await; + assert_eq!( + fx.sent.methods(), + vec!["notifications/payment_rejected"], + "a redelivery inside the TTL re-emits nothing" + ); + assert_eq!(fx.harness.delivered(), 0); + } + + #[tokio::test] + async fn waive_forwards_without_emitting() { + let log = EventLog::default(); + let fx = fixture_with( + |o| { + o.with_resolve_price(Arc::new(StaticResolver(ResolvePriceResult::Waive { + meta: None, + }))) + }, + TestProcessor::new("pmi-a", log.clone()), + &[], + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-w")), call_message()) + .await; + assert_eq!(fx.sent.items().len(), 0, "a waiver emits nothing"); + assert_eq!(fx.harness.delivered(), 1, "a waived request is forwarded"); + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test(start_paused = true)] + async fn verify_timeout_neither_forwards_nor_accepts() { + let log = EventLog::default(); + let fx = fixture( + TestProcessor::new("pmi-a", log.clone()) + .with_ttl(1) + .with_verify(VerifyPlan::Hang), + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-t")), call_message()) + .await; + assert_eq!(fx.sent.methods(), vec!["notifications/payment_required"]); + assert_eq!( + fx.harness.delivered(), + 0, + "a timed-out payment never forwards" + ); + } + + #[tokio::test(start_paused = true)] + async fn verify_timeout_keeps_the_entry_so_a_retry_does_not_re_charge() { + let log = EventLog::default(); + let fx = fixture( + TestProcessor::new("pmi-a", log.clone()) + .with_ttl(1) // 1 s verify bound, far below the 300 s pending TTL + .with_verify(VerifyPlan::Hang), + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-k")), call_message()) + .await; + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 1); + + // The client may have paid an invoice whose settlement landed just after the + // bound; a redelivery must NOT mint a second invoice. + fx.harness + .dispatch(Arc::new(test_ctx("evt-k")), call_message()) + .await; + assert_eq!( + fx.create_calls.load(Ordering::SeqCst), + 1, + "a redelivery after a verify timeout must not re-charge" + ); + } + + #[tokio::test] + async fn a_pre_invoice_failure_deletes_the_entry_so_a_retry_re_runs() { + let log = EventLog::default(); + let fx = fixture( + TestProcessor::new("pmi-a", log.clone()) + .with_create_plan(vec![CreatePlan::Err, CreatePlan::Ok]), + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-p")), call_message()) + .await; + assert_eq!(fx.harness.delivered(), 0); + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 1); + + // No invoice was ever issued, so the retry is free and must re-run. + fx.harness + .dispatch(Arc::new(test_ctx("evt-p")), call_message()) + .await; + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 2); + assert_eq!(fx.harness.delivered(), 1, "the retry completes normally"); + } + + #[tokio::test] + async fn a_failed_invoice_publish_deletes_the_entry_so_a_retry_re_runs() { + // The invoice never went out (as far as the server can prove), so no money can + // have moved and the retry must be free; keeping the entry would black-hole the + // request for the whole TTL with no invoice out. + let log = EventLog::default(); + let fx = fixture_with( + |o| o, + TestProcessor::new("pmi-a", log.clone()), + &["notifications/payment_required"], + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-pubfail")), call_message()) + .await; + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 1); + assert_eq!(fx.harness.delivered(), 0); + + fx.harness + .dispatch(Arc::new(test_ctx("evt-pubfail")), call_message()) + .await; + assert_eq!( + fx.create_calls.load(Ordering::SeqCst), + 2, + "a retry after a failed invoice publish must re-run the lifecycle" + ); + assert_eq!(fx.harness.delivered(), 0, "still nothing forwarded"); + } + + #[tokio::test] + async fn a_failed_rejection_publish_deletes_the_entry_so_a_retry_re_runs() { + // Same rule on the rejection arm: the refusal never reached the client, no + // invoice exists, so the retry re-runs (and re-attempts the rejection). + let log = EventLog::default(); + let fx = fixture_with( + |o| { + o.with_resolve_price(Arc::new(StaticResolver(ResolvePriceResult::Reject { + message: None, + }))) + }, + TestProcessor::new("pmi-a", log.clone()), + &["notifications/payment_rejected"], + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-rejfail")), call_message()) + .await; + assert_eq!(fx.log.count_of("publish:notifications/payment_rejected"), 1); + + fx.harness + .dispatch(Arc::new(test_ctx("evt-rejfail")), call_message()) + .await; + assert_eq!( + fx.log.count_of("publish:notifications/payment_rejected"), + 2, + "a retry after a failed rejection publish must re-run the lifecycle" + ); + assert_eq!(fx.harness.delivered(), 0); + } + + #[tokio::test] + async fn a_verified_payment_forwards_even_when_the_acceptance_fails_to_publish() { + let log = EventLog::default(); + let fx = fixture_with( + |o| o, + TestProcessor::new("pmi-a", log.clone()), + &["notifications/payment_accepted"], + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-a")), call_message()) + .await; + assert_eq!( + fx.harness.delivered(), + 1, + "the paid request must be delivered even though the acceptance publish failed" + ); + // The publish was attempted (ordered log) but never recorded as sent. + assert!(fx + .log + .items() + .contains(&"publish:notifications/payment_accepted".to_string())); + assert_eq!(fx.sent.methods(), vec!["notifications/payment_required"]); + + // Post-invoice, verified: the entry is kept, so a redelivery does not re-charge. + fx.harness + .dispatch(Arc::new(test_ctx("evt-a")), call_message()) + .await; + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 1); + assert_eq!(fx.harness.delivered(), 0); + } + + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn concurrent_duplicates_charge_once_and_forward_once() { + // Four genuinely concurrent dispatches per event id, iterated, on a multi-thread + // runtime; the settle delay forces a suspension point inside the lifecycle. The + // iteration count matters: a split-critical-section defect is a race, and a + // single two-way attempt can be serialized by scheduler load and miss it. + const ROUNDS: usize = 25; + const RACERS: usize = 4; + + let log = EventLog::default(); + let fx = fixture( + TestProcessor::new("pmi-a", log.clone()) + .with_verify(VerifyPlan::SettleAfter(Duration::from_millis(10))), + ); + let fx = Arc::new(fx); + + for round in 0..ROUNDS { + let event_id = format!("evt-c-{round}"); + let racers: Vec<_> = (0..RACERS) + .map(|_| { + let fx = Arc::clone(&fx); + let event_id = event_id.clone(); + tokio::spawn(async move { + fx.harness + .dispatch(Arc::new(test_ctx(&event_id)), call_message()) + .await; + }) + }) + .collect(); + for racer in racers { + racer.await.unwrap(); + } + assert_eq!( + fx.create_calls.load(Ordering::SeqCst), + round + 1, + "exactly one charge per event id, every round" + ); + } + + // NOTE: no route-survival assertion here on purpose. The duplicates' chain runs + // do not reach the terminal, so the seam pops the delivered request's route; + // that is expected, and the snapshot fallback is what covers the response. + assert_eq!( + fx.create_calls.load(Ordering::SeqCst), + ROUNDS, + "one charge per id" + ); + assert_eq!( + fx.log.count_of("publish:notifications/payment_required"), + ROUNDS, + "one payment_required per id" + ); + assert_eq!(fx.harness.delivered(), ROUNDS, "one forward per id"); + } + + #[tokio::test] + async fn an_unpriced_request_is_forwarded_untouched() { + let log = EventLog::default(); + let fx = fixture(TestProcessor::new("pmi-a", log.clone())); + let message = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("free-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ "name": "free-tool" })), + }); + fx.harness + .dispatch(Arc::new(test_ctx("evt-free")), message) + .await; + assert_eq!(fx.harness.delivered(), 1); + assert_eq!(fx.sent.items().len(), 0); + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn a_non_transparent_session_forwards_a_priced_request() { + let log = EventLog::default(); + let fx = fixture(TestProcessor::new("pmi-a", log.clone())); + let mut ctx = test_ctx("evt-gated"); + ctx.payment_interaction = Some(PaymentInteractionMode::ExplicitGating); + fx.harness.dispatch(Arc::new(ctx), call_message()).await; + assert_eq!(fx.harness.delivered(), 1); + assert_eq!(fx.sent.items().len(), 0); + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test] + async fn an_absent_mode_falls_through_to_the_priced_check() { + // `None` must NOT forward early: it reaches the priced check and gates. + // Reachable only from a hand-built context; the seam always resolves a mode. + let log = EventLog::default(); + let fx = fixture(TestProcessor::new("pmi-a", log.clone())); + let mut ctx = test_ctx("evt-none"); + ctx.payment_interaction = None; + fx.harness.dispatch(Arc::new(ctx), call_message()).await; + assert_eq!( + fx.create_calls.load(Ordering::SeqCst), + 1, + "the request was gated" + ); + assert_eq!(fx.harness.delivered(), 1, "and forwarded after payment"); + } + + #[tokio::test] + async fn a_notification_is_forwarded_even_when_its_method_is_priced() { + let log = EventLog::default(); + let sent = Sent::default(); + let processor = TestProcessor::new("pmi-a", log.clone()); + let create_calls = Arc::clone(&processor.create_calls); + // Price the notification's own method, so only the request gate can explain the + // pass-through. + let options = ServerPaymentsOptions::new( + vec![Arc::new(processor)], + vec![PricedCapability { + method: "notifications/progress".to_string(), + name: None, + amount: 1, + max_amount: None, + currency_unit: "sats".to_string(), + description: None, + }], + ); + let middleware = create_server_payments_middleware(ServerPaymentsMiddlewareParams::new( + options, + test_sender(log.clone(), sent.clone(), &[]), + )); + let harness = Harness::new(middleware, log); + harness + .dispatch( + Arc::new(test_ctx("evt-notif")), + JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: None, + }), + ) + .await; + assert_eq!(harness.delivered(), 1); + assert_eq!(sent.items().len(), 0); + assert_eq!(create_calls.load(Ordering::SeqCst), 0); + } + + #[tokio::test(start_paused = true)] + async fn an_expired_entry_does_not_dedup_a_retry() { + // The entry's expiry rides the pending TTL (10 s), NOT the polling bound the + // processor's 1 s invoice TTL produces; the two clocks are deliberately set + // apart so an entry stamped from the wrong one is caught on both sides. + let log = EventLog::default(); + let fx = fixture_with( + |o| o.with_payment_ttl(Duration::from_secs(10)), + TestProcessor::new("pmi-a", log.clone()).with_ttl(1), + &[], + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-x")), call_message()) + .await; + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 1); + + // Inside the pending TTL (but past the 1 s polling bound): still deduped. + tokio::time::sleep(Duration::from_secs(5)).await; + fx.harness + .dispatch(Arc::new(test_ctx("evt-x")), call_message()) + .await; + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 1); + + // Past the pending TTL: the entry is expired and must NOT dedup the retry. + tokio::time::sleep(Duration::from_secs(6)).await; + fx.harness + .dispatch(Arc::new(test_ctx("evt-x")), call_message()) + .await; + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test(start_paused = true)] + async fn an_expired_entry_beyond_the_purge_window_does_not_dedup() { + // The lookup-time expiry check is the correctness gate; the purge is only + // hygiene. In a small cache the purge removes an expired entry before the + // lookup ever sees it, so this fixture stages the expired entry OUTSIDE the + // purge's 25-deep scan from the least-recently-used end: only the lookup check + // can now stop it from deduping a legitimate retry. + fn entry(expires_at: Instant) -> PendingEntry { + PendingEntry { + expires_at, + in_flight: async { Arc::new(Ok(())) }.boxed().shared(), + } + } + + let log = EventLog::default(); + let sent = Sent::default(); + let processor = TestProcessor::new("pmi-a", log.clone()); + let create_calls = Arc::clone(&processor.create_calls); + let options = ServerPaymentsOptions::new(vec![Arc::new(processor)], vec![priced_call(21)]); + let concrete = Arc::new(ServerPaymentsMiddleware { + processors_by_pmi: Arc::new(build_processors_by_pmi(&options.processors)), + sender: test_sender(log.clone(), sent, &[]), + pending: tokio::sync::Mutex::new(LruCache::new(NonZeroUsize::new(40).unwrap())), + options, + }); + { + let now = Instant::now(); + let mut cache = concrete.pending.lock().await; + for i in 0..26 { + cache.put(format!("live-{i}"), entry(now + Duration::from_secs(60))); + } + // Inserted last: most-recently-used, beyond the 25-entry LRU-end scan. + cache.put( + "expired-target".to_string(), + entry(now - Duration::from_secs(1)), + ); + } + let harness = Harness::new( + Arc::clone(&concrete) as Arc, + log.clone(), + ); + + harness + .dispatch(Arc::new(test_ctx("expired-target")), call_message()) + .await; + assert_eq!( + create_calls.load(Ordering::SeqCst), + 1, + "an expired entry must not dedup the retry, purge window or not" + ); + assert_eq!(harness.delivered(), 1, "the retry runs the full lifecycle"); + } + + #[tokio::test(start_paused = true)] + async fn the_purge_scans_the_least_recently_used_end() { + fn completed_entry(expires_at: Instant) -> PendingEntry { + PendingEntry { + expires_at, + in_flight: async { Arc::new(Ok(())) }.boxed().shared(), + } + } + + let mut cache: LruCache = + LruCache::new(NonZeroUsize::new(40).unwrap()); + let now = Instant::now(); + // One expired entry inserted FIRST (so it sits at the LRU end)... + cache.put( + "old".to_string(), + completed_entry(now - Duration::from_secs(1)), + ); + // ...behind 30 live newer ones. + for i in 0..30 { + cache.put( + format!("live-{i}"), + completed_entry(now + Duration::from_secs(60)), + ); + } + + purge_expired_pending(&mut cache, now); + + // A scan of the 25 most-recently-used entries can never see "old"; only the + // LRU-end scan purges it. + assert!( + cache.peek("old").is_none(), + "the expired LRU entry is purged" + ); + assert_eq!(cache.len(), 30, "every live entry survives"); + } + + #[tokio::test(start_paused = true)] + async fn the_verify_deadline_comes_from_the_payment_ttl_when_it_is_shorter() { + // Invoice TTL 600 s vs pending TTL 200 ms: the verify must be abandoned at + // 200 ms. Asserts WHEN the per-verify cancellation fired (observed by the + // processor's background poller), not merely that a timeout happened. + let log = EventLog::default(); + let processor = TestProcessor::new("pmi-a", log.clone()) + .with_ttl(600) + .with_verify(VerifyPlan::Hang); + let observed = Arc::clone(&processor.cancel_observed_after); + let fx = fixture_with( + |o| o.with_payment_ttl(Duration::from_millis(200)), + processor, + &[], + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-dl1")), call_message()) + .await; + tokio::time::sleep(Duration::from_millis(1)).await; // let the poller record + assert_eq!( + observed + .lock() + .unwrap() + .expect("the cancellation must fire"), + Duration::from_millis(200), + "the deadline is the shorter pending TTL" + ); + assert_eq!(fx.harness.delivered(), 0); + } + + #[tokio::test(start_paused = true)] + async fn the_verify_deadline_comes_from_the_payment_request_ttl_when_it_is_shorter() { + // Invoice TTL 1 s vs pending TTL 300 s: the verify must be abandoned at 1 s. + // Together with the sibling above this pins the deadline from both sides, and + // pins that the timeout derives from the processor's ttl rather than always + // using the default. + let log = EventLog::default(); + let processor = TestProcessor::new("pmi-a", log.clone()) + .with_ttl(1) + .with_verify(VerifyPlan::Hang); + let observed = Arc::clone(&processor.cancel_observed_after); + let fx = fixture_with( + |o| o.with_payment_ttl(Duration::from_secs(300)), + processor, + &[], + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-dl2")), call_message()) + .await; + tokio::time::sleep(Duration::from_millis(1)).await; + assert_eq!( + observed + .lock() + .unwrap() + .expect("the cancellation must fire"), + Duration::from_secs(1), + "the deadline is the invoice's own shorter TTL" + ); + assert_eq!(fx.harness.delivered(), 0); + } + + #[tokio::test] + async fn an_empty_processor_list_drops_without_emitting() { + let log = EventLog::default(); + let sent = Sent::default(); + let options = ServerPaymentsOptions::new(Vec::new(), vec![priced_call(1)]); + let middleware = create_server_payments_middleware(ServerPaymentsMiddlewareParams::new( + options, + test_sender(log.clone(), sent.clone(), &[]), + )); + let harness = Harness::new(middleware, log); + harness + .dispatch(Arc::new(test_ctx("evt-none-proc")), call_message()) + .await; + assert_eq!(harness.delivered(), 0, "a misconfigured gate fails closed"); + assert_eq!(sent.items().len(), 0, "and emits nothing"); + } + + #[tokio::test] + async fn a_paid_request_redelivered_is_not_charged_again() { + let log = EventLog::default(); + let fx = fixture(TestProcessor::new("pmi-a", log.clone())); + fx.harness + .dispatch(Arc::new(test_ctx("evt-paid")), call_message()) + .await; + fx.harness + .dispatch(Arc::new(test_ctx("evt-paid")), call_message()) + .await; + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 1, "one charge"); + assert_eq!(fx.log.count_of("publish:notifications/payment_required"), 1); + assert_eq!(fx.log.count_of("publish:notifications/payment_accepted"), 1); + assert_eq!(fx.harness.delivered(), 1, "one delivery"); + } + + #[tokio::test] + async fn a_waived_request_redelivered_is_not_forwarded_twice() { + let log = EventLog::default(); + let fx = fixture_with( + |o| { + o.with_resolve_price(Arc::new(StaticResolver(ResolvePriceResult::Waive { + meta: None, + }))) + }, + TestProcessor::new("pmi-a", log.clone()), + &[], + ); + fx.harness + .dispatch(Arc::new(test_ctx("evt-w2")), call_message()) + .await; + fx.harness + .dispatch(Arc::new(test_ctx("evt-w2")), call_message()) + .await; + assert_eq!( + fx.harness.delivered(), + 1, + "the waiver forwards exactly once" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_stale_error_cleanup_does_not_pop_a_live_retrys_entry() { + // Timeline (virtual): A inserts at t=0 (TTL 50 ms) and fails slowly at t=100; + // a legitimate retry B inserts a LIVE entry at t=60; A's cleanup at t=100 must + // pop only its OWN entry, so B's stays and dedups C at t=105. + let log = EventLog::default(); + let fx = Arc::new(fixture_with( + |o| o.with_payment_ttl(Duration::from_millis(50)), + TestProcessor::new("pmi-a", log.clone()).with_create_plan(vec![ + CreatePlan::ErrAfter(Duration::from_millis(100)), + CreatePlan::OkAfter(Duration::from_millis(100)), + ]), + &[], + )); + + let a = { + let fx = Arc::clone(&fx); + tokio::spawn(async move { + fx.harness + .dispatch(Arc::new(test_ctx("evt-aba")), call_message()) + .await; + }) + }; + tokio::time::sleep(Duration::from_millis(60)).await; + let b = { + let fx = Arc::clone(&fx); + tokio::spawn(async move { + fx.harness + .dispatch(Arc::new(test_ctx("evt-aba")), call_message()) + .await; + }) + }; + tokio::time::sleep(Duration::from_millis(45)).await; // t = 105 + let c = { + let fx = Arc::clone(&fx); + tokio::spawn(async move { + fx.harness + .dispatch(Arc::new(test_ctx("evt-aba")), call_message()) + .await; + }) + }; + a.await.unwrap(); + b.await.unwrap(); + c.await.unwrap(); + + assert_eq!( + fx.create_calls.load(Ordering::SeqCst), + 2, + "A and B each ran; C joined B's live entry instead of starting a third" + ); + assert_eq!(fx.harness.delivered(), 1, "B's lifecycle forwarded once"); + } + + #[tokio::test(start_paused = true)] + async fn capacity_pressure_never_evicts_a_live_pending_entry() { + let log = EventLog::default(); + let fx = Arc::new(fixture_with( + |o| o.with_max_pending_payments(2), + TestProcessor::new("pmi-a", log.clone()) + .with_verify(VerifyPlan::SettleAfter(Duration::from_millis(100))), + &[], + )); + + let a = { + let fx = Arc::clone(&fx); + tokio::spawn(async move { + fx.harness + .dispatch(Arc::new(test_ctx("evt-cap-a")), call_message()) + .await; + }) + }; + let b = { + let fx = Arc::clone(&fx); + tokio::spawn(async move { + fx.harness + .dispatch(Arc::new(test_ctx("evt-cap-b")), call_message()) + .await; + }) + }; + tokio::time::sleep(Duration::from_millis(10)).await; + + // Cache full, both entries live: the third id is refused, not started, and no + // live entry is evicted for it. + let fx2 = Arc::clone(&fx); + fx2.harness + .dispatch(Arc::new(test_ctx("evt-cap-c")), call_message()) + .await; + + a.await.unwrap(); + b.await.unwrap(); + + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 2, "no third charge"); + assert_eq!( + fx.harness.delivered(), + 2, + "the two live payments both deliver" + ); + + // Both surviving entries still dedup their ids. + fx.harness + .dispatch(Arc::new(test_ctx("evt-cap-a")), call_message()) + .await; + fx.harness + .dispatch(Arc::new(test_ctx("evt-cap-b")), call_message()) + .await; + assert_eq!(fx.create_calls.load(Ordering::SeqCst), 2); + } + + #[tokio::test(start_paused = true)] + async fn a_full_cache_evicts_the_expired_entry_not_the_live_lru_one() { + fn entry(expires_at: Instant) -> PendingEntry { + PendingEntry { + expires_at, + in_flight: async { Arc::new(Ok(())) }.boxed().shared(), + } + } + + // Build the middleware directly so the cache can be staged: 30 slots, all live + // except ONE expired entry positioned among the 5 most-recently-used, which the + // 25-deep LRU-end purge can never reach. + let log = EventLog::default(); + let sent = Sent::default(); + let processor = TestProcessor::new("pmi-a", log.clone()); + let create_calls = Arc::clone(&processor.create_calls); + let options = ServerPaymentsOptions::new(vec![Arc::new(processor)], vec![priced_call(21)]) + .with_max_pending_payments(30); + let concrete = Arc::new(ServerPaymentsMiddleware { + processors_by_pmi: Arc::new(build_processors_by_pmi(&options.processors)), + sender: test_sender(log.clone(), sent.clone(), &[]), + pending: tokio::sync::Mutex::new(LruCache::new(NonZeroUsize::new(30).unwrap())), + options, + }); + { + let now = Instant::now(); + let mut cache = concrete.pending.lock().await; + cache.put("lru-live".to_string(), entry(now + Duration::from_secs(60))); + for i in 0..24 { + cache.put(format!("live-{i}"), entry(now + Duration::from_secs(60))); + } + cache.put( + "expired-mru".to_string(), + entry(now - Duration::from_secs(1)), + ); + for i in 24..28 { + cache.put(format!("live-{i}"), entry(now + Duration::from_secs(60))); + } + assert_eq!(cache.len(), 30); + } + + let harness = Harness::new( + Arc::clone(&concrete) as Arc, + log.clone(), + ); + harness + .dispatch(Arc::new(test_ctx("fresh")), call_message()) + .await; + + { + let cache = concrete.pending.lock().await; + assert!( + cache.peek("lru-live").is_some(), + "the live LRU-end entry must survive the capacity insert" + ); + assert!( + cache.peek("expired-mru").is_none(), + "the expired entry is the one evicted" + ); + assert!(cache.peek("fresh").is_some(), "the new payment was tracked"); + } + assert_eq!(create_calls.load(Ordering::SeqCst), 1); + + // The surviving LRU entry still dedups: its id is never charged. + harness + .dispatch(Arc::new(test_ctx("lru-live")), call_message()) + .await; + assert_eq!( + create_calls.load(Ordering::SeqCst), + 1, + "the live LRU entry's dedup stayed armed" + ); + } + + #[test] + fn purge_scan_limit_matches_ts_sdk() { + // The purge inspects at most 25 entries per lookup, the reference + // implementation's cap. The limit is hygiene, not correctness (the lookup-time + // expiry check carries that), so it is pinned by value rather than by behavior. + assert_eq!(PENDING_PURGE_SCAN_LIMIT, 25); + } + + #[tokio::test(start_paused = true)] + async fn the_lookup_purge_drops_expired_entries() { + // Distinct one-shot ids leave dead entries behind; the purge on the next + // lookup is what keeps them from accumulating until capacity pressure. Built + // concretely so the cache length is observable. + let log = EventLog::default(); + let sent = Sent::default(); + let processor = TestProcessor::new("pmi-a", log.clone()); + let options = ServerPaymentsOptions::new(vec![Arc::new(processor)], vec![priced_call(21)]) + .with_payment_ttl(Duration::from_millis(50)); + let concrete = Arc::new(ServerPaymentsMiddleware { + processors_by_pmi: Arc::new(build_processors_by_pmi(&options.processors)), + sender: test_sender(log.clone(), sent, &[]), + pending: tokio::sync::Mutex::new(LruCache::new(NonZeroUsize::new(1000).unwrap())), + options, + }); + let harness = Harness::new( + Arc::clone(&concrete) as Arc, + log.clone(), + ); + + for i in 0..3 { + harness + .dispatch(Arc::new(test_ctx(&format!("evt-spam-{i}"))), call_message()) + .await; + } + assert_eq!(concrete.pending.lock().await.len(), 3); + tokio::time::sleep(Duration::from_millis(100)).await; + + harness + .dispatch(Arc::new(test_ctx("evt-after")), call_message()) + .await; + assert_eq!( + concrete.pending.lock().await.len(), + 1, + "the lookup purge must drop the expired one-shot entries, not leave them \ + for capacity pressure" + ); + } + + #[tokio::test] + async fn a_panicking_processor_does_not_poison_the_entry_for_the_ttl() { + let log = EventLog::default(); + let fx = fixture( + TestProcessor::new("pmi-a", log.clone()) + .with_create_plan(vec![CreatePlan::Panic, CreatePlan::Ok]), + ); + // The panic happens before any invoice, so the entry is deleted and the retry + // re-runs; neither dispatch propagates a panic into this test. + fx.harness + .dispatch(Arc::new(test_ctx("evt-panic")), call_message()) + .await; + assert_eq!(fx.harness.delivered(), 0); + + fx.harness + .dispatch(Arc::new(test_ctx("evt-panic")), call_message()) + .await; + assert_eq!( + fx.create_calls.load(Ordering::SeqCst), + 2, + "the retry re-ran" + ); + assert_eq!(fx.harness.delivered(), 1, "and completed normally"); + } +} diff --git a/src/payments/server_payments_utils.rs b/src/payments/server_payments_utils.rs new file mode 100644 index 0000000..dc8730d --- /dev/null +++ b/src/payments/server_payments_utils.rs @@ -0,0 +1,695 @@ +//! CEP-8 server-side payment helpers shared by the payment middlewares: +//! processor-map construction, priced-capability matching, verification-timeout +//! arithmetic, PMI selection, and the resolve-and-initiate step that turns a +//! priced request into a rejection, a waiver, or a payment request. +//! +//! Split from the transparent middleware for the same reason the reference +//! implementation splits it: the explicit-gating middleware consumes the same +//! helpers. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use crate::core::types::JsonRpcRequest; +use crate::payments::errors::PaymentError; +use crate::payments::server_payments::ServerPaymentsOptions; +use crate::payments::traits::PaymentProcessor; +use crate::payments::types::{ + Meta, PaymentProcessorCreateParams, PaymentRequiredParams, PricedCapability, + ResolvePriceParams, ResolvePriceResult, +}; + +const LOG_TARGET: &str = "contextvm_sdk::payments::server_payments"; + +/// Default verification timeout when the payment request carries no usable `ttl`: five minutes. +const DEFAULT_VERIFICATION_TIMEOUT: Duration = Duration::from_secs(5 * 60); + +/// Build the PMI-to-processor map, warning once per duplicate PMI (the last +/// registration wins, exactly as a map insert does). +pub(crate) fn build_processors_by_pmi( + processors: &[Arc], +) -> HashMap> { + let mut map: HashMap> = HashMap::new(); + for processor in processors { + if map + .insert(processor.pmi().to_string(), Arc::clone(processor)) + .is_some() + { + tracing::warn!( + target: LOG_TARGET, + pmi = %processor.pmi(), + "duplicate PMI processor registered, last one wins" + ); + } + } + map +} + +/// The first priced-capability entry matching this request, if any. +/// +/// An entry matches when its `method` equals the request method and its `name` +/// is either `None` (which prices every invocation of the method) or equal to +/// the capability name the method addresses. +pub(crate) fn match_priced_capability<'a>( + request: &JsonRpcRequest, + priced: &'a [PricedCapability], +) -> Option<&'a PricedCapability> { + let capability_name = capability_name_for_pricing(request); + priced.iter().find(|p| { + if p.method != request.method { + return false; + } + match &p.name { + None => true, + Some(name) => Some(name.as_str()) == capability_name.as_deref(), + } + }) +} + +/// The name a pricing entry addresses for this request: `params.name` for +/// `tools/call` and `prompts/get`, `params.uri` for `resources/read`, `None` +/// for every other method (and for a non-string value). +pub(crate) fn capability_name_for_pricing(request: &JsonRpcRequest) -> Option { + let params = request.params.as_ref()?; + let key = match request.method.as_str() { + "tools/call" | "prompts/get" => "name", + "resources/read" => "uri", + _ => return None, + }; + params.get(key)?.as_str().map(str::to_string) +} + +/// The verification timeout derived from a payment request's `ttl` (seconds). +/// +/// No `ttl` and a zero `ttl` both give the five-minute default; anything else +/// converts to a duration, falling back to the default if the multiplication +/// overflows. +pub(crate) fn verification_timeout(ttl_seconds: Option) -> Duration { + match ttl_seconds { + None | Some(0) => DEFAULT_VERIFICATION_TIMEOUT, + Some(ttl) => match ttl.checked_mul(1000) { + Some(ms) => Duration::from_millis(ms), + None => DEFAULT_VERIFICATION_TIMEOUT, + }, + } +} + +/// Select the processor for this request: the first client-advertised PMI that +/// has a processor wins; otherwise (including a client list that matches +/// nothing) the first configured processor; an empty processor list is a +/// configuration error. +pub(crate) fn resolve_payment_processor( + client_pmis: Option<&[String]>, + processors_by_pmi: &HashMap>, + processors: &[Arc], +) -> Result, PaymentError> { + let chosen = client_pmis + .and_then(|pmis| pmis.iter().find_map(|pmi| processors_by_pmi.get(pmi))) + .or_else(|| processors.first()); + chosen + .map(Arc::clone) + .ok_or_else(|| PaymentError::Processor("no payment processors configured".to_string())) +} + +/// The outcome of the resolve-and-initiate step for one priced request. +pub(crate) enum InitiationOutcome { + /// The pricing callback refused service: emit `payment_rejected` and drop. + Rejected { + /// The selected processor's PMI (selection happens before pricing, so a + /// rejection can name one). + pmi: String, + /// The capability's listed amount (the rejection reports the listed + /// price, not a resolved quote). + amount: i64, + /// Optional human-readable refusal reason from the callback. + message: Option, + }, + /// The pricing callback waived payment: forward untouched, emit nothing. + Waived, + /// A payment request was issued: emit `payment_required` and verify. + PaymentRequired { + /// The processor that issued (and will verify) the payment. + processor: Arc, + /// The processor-returned payment request, published verbatim. + payment_required: PaymentRequiredParams, + /// Quote metadata merged over processor metadata; `None` only when both + /// are absent. + merged_meta: Option, + /// Verification timeout derived from the payment request's `ttl`. + verify_timeout: Duration, + }, +} + +/// Select a processor, resolve the price (static or via the configured +/// callback), and either report a rejection / waiver or create the payment +/// request. +/// +/// A callback `Err` is a server-side failure and propagates as `Err`; a +/// [`ResolvePriceResult::Reject`] is a business decision and returns +/// [`InitiationOutcome::Rejected`]. The processor is selected before the price +/// resolves so a rejection can name a PMI. +pub(crate) async fn resolve_and_initiate_payment( + request: &JsonRpcRequest, + priced: &PricedCapability, + request_event_id: &str, + client_pubkey: &str, + client_pmis: Option<&[String]>, + options: &ServerPaymentsOptions, + processors_by_pmi: &HashMap>, +) -> Result { + let processor = resolve_payment_processor(client_pmis, processors_by_pmi, &options.processors)?; + + let quote = match &options.resolve_price { + Some(resolver) => { + resolver + .resolve_price(ResolvePriceParams { + capability: priced.clone(), + request: request.clone(), + client_pubkey: client_pubkey.to_string(), + request_event_id: request_event_id.to_string(), + }) + .await? + } + None => ResolvePriceResult::Quote { + amount: priced.amount, + description: priced.description.clone(), + meta: None, + }, + }; + + let (amount, description, quote_meta) = match quote { + ResolvePriceResult::Reject { message } => { + return Ok(InitiationOutcome::Rejected { + pmi: processor.pmi().to_string(), + amount: priced.amount, + message, + }); + } + // The waiver's metadata is dropped: the waive path emits no + // notification at all, matching the reference implementation's + // behavior (its docs promise more than its emitter delivers). + ResolvePriceResult::Waive { meta: _ } => return Ok(InitiationOutcome::Waived), + ResolvePriceResult::Quote { + amount, + description, + meta, + } => (amount, description, meta), + }; + + let payment_required = processor + .create_payment_required(PaymentProcessorCreateParams { + amount, + description, + request_event_id: request_event_id.to_string(), + client_pubkey: client_pubkey.to_string(), + }) + .await?; + + // Quote metadata overrides processor metadata key-by-key; `None` only when + // both are absent. + let merged_meta = match (payment_required.meta.clone(), quote_meta) { + (None, None) => None, + (processor_meta, quote_meta) => { + let mut merged = processor_meta.unwrap_or_default(); + if let Some(quote_meta) = quote_meta { + for (key, value) in quote_meta { + merged.insert(key, value); + } + } + Some(merged) + } + }; + + let verify_timeout = verification_timeout(payment_required.ttl); + + Ok(InitiationOutcome::PaymentRequired { + processor, + payment_required, + merged_meta, + verify_timeout, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::payments::traits::ResolvePrice; + use crate::payments::types::{PaymentProcessorVerifyParams, VerifyOutcome}; + use async_trait::async_trait; + + /// A minimal local processor double: a fixed PMI, a canned payment request. + /// Local to this module so these tests run in every feature configuration. + struct StubProcessor { + pmi: String, + ttl: Option, + meta: Option, + fail_create: bool, + } + + impl StubProcessor { + fn new(pmi: &str) -> Self { + Self { + pmi: pmi.to_string(), + ttl: None, + meta: None, + fail_create: false, + } + } + } + + #[async_trait] + impl PaymentProcessor for StubProcessor { + fn pmi(&self) -> &str { + &self.pmi + } + + async fn create_payment_required( + &self, + params: PaymentProcessorCreateParams, + ) -> Result { + if self.fail_create { + return Err(PaymentError::Processor("create failed".to_string())); + } + Ok(PaymentRequiredParams { + amount: params.amount, + pay_req: format!("invoice-{}", params.request_event_id), + pmi: self.pmi.clone(), + description: params.description, + ttl: self.ttl, + meta: self.meta.clone(), + }) + } + + async fn verify_payment( + &self, + _params: PaymentProcessorVerifyParams, + ) -> Result { + Ok(VerifyOutcome::default()) + } + } + + struct StaticResolver(ResolvePriceResult); + #[async_trait] + impl ResolvePrice for StaticResolver { + async fn resolve_price( + &self, + _params: ResolvePriceParams, + ) -> Result { + Ok(self.0.clone()) + } + } + + struct FailingResolver; + #[async_trait] + impl ResolvePrice for FailingResolver { + async fn resolve_price( + &self, + _params: ResolvePriceParams, + ) -> Result { + Err(PaymentError::Processor("price lookup down".to_string())) + } + } + + fn request(method: &str, params: Option) -> JsonRpcRequest { + JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: method.to_string(), + params, + } + } + + fn priced(method: &str, name: Option<&str>, amount: i64) -> PricedCapability { + PricedCapability { + method: method.to_string(), + name: name.map(str::to_string), + amount, + max_amount: None, + currency_unit: "sats".to_string(), + description: None, + } + } + + fn one_key_meta(key: &str, value: &str) -> Meta { + let mut m = Meta::new(); + m.insert( + key.to_string(), + serde_json::Value::String(value.to_string()), + ); + m + } + + // ── capability matching ───────────────────────────────────────── + + #[test] + fn matches_named_tool_and_wildcard_method() { + let call = request("tools/call", Some(serde_json::json!({ "name": "echo" }))); + let entries = vec![ + priced("tools/call", Some("other"), 1), + priced("tools/call", Some("echo"), 2), + ]; + let hit = match_priced_capability(&call, &entries).expect("named match"); + assert_eq!(hit.amount, 2); + + // `name: None` prices every invocation of the method, first match wins. + let wildcard = vec![ + priced("tools/call", None, 9), + priced("tools/call", Some("echo"), 2), + ]; + let hit = match_priced_capability(&call, &wildcard).expect("wildcard match"); + assert_eq!(hit.amount, 9); + + let miss = request("tools/list", None); + assert!(match_priced_capability(&miss, &entries).is_none()); + } + + #[test] + fn capability_name_reads_the_method_specific_key() { + let call = request("tools/call", Some(serde_json::json!({ "name": "echo" }))); + assert_eq!(capability_name_for_pricing(&call).as_deref(), Some("echo")); + + let prompt = request("prompts/get", Some(serde_json::json!({ "name": "p" }))); + assert_eq!(capability_name_for_pricing(&prompt).as_deref(), Some("p")); + + let resource = request( + "resources/read", + Some(serde_json::json!({ "uri": "file:///x" })), + ); + assert_eq!( + capability_name_for_pricing(&resource).as_deref(), + Some("file:///x") + ); + + // A non-string value and an unaddressed method both yield None. + let numeric = request("tools/call", Some(serde_json::json!({ "name": 7 }))); + assert!(capability_name_for_pricing(&numeric).is_none()); + let other = request("tools/list", Some(serde_json::json!({ "name": "x" }))); + assert!(capability_name_for_pricing(&other).is_none()); + } + + // ── verification timeout arithmetic ───────────────────────────── + + #[test] + fn verification_timeout_arms() { + assert_eq!(verification_timeout(None), Duration::from_secs(300)); + assert_eq!(verification_timeout(Some(0)), Duration::from_secs(300)); + assert_eq!(verification_timeout(Some(90)), Duration::from_secs(90)); + // Multiplication overflow falls back to the default. + assert_eq!( + verification_timeout(Some(u64::MAX)), + Duration::from_secs(300) + ); + } + + // ── PMI selection (all three arms) ────────────────────────────── + + #[test] + fn pmi_selection_prefers_the_first_client_pmi_with_a_processor() { + let processors: Vec> = vec![ + Arc::new(StubProcessor::new("pmi-a")), + Arc::new(StubProcessor::new("pmi-b")), + ]; + let by_pmi = build_processors_by_pmi(&processors); + + // First client PMI with a processor wins (skipping unknown ones). + let client = vec!["pmi-unknown".to_string(), "pmi-b".to_string()]; + let chosen = resolve_payment_processor(Some(&client), &by_pmi, &processors).unwrap(); + assert_eq!(chosen.pmi(), "pmi-b"); + + // A present-but-non-matching list falls through to the first processor. + let unmatched = vec!["pmi-x".to_string()]; + let chosen = resolve_payment_processor(Some(&unmatched), &by_pmi, &processors).unwrap(); + assert_eq!(chosen.pmi(), "pmi-a"); + + // An absent list takes the first processor. + let chosen = resolve_payment_processor(None, &by_pmi, &processors).unwrap(); + assert_eq!(chosen.pmi(), "pmi-a"); + } + + #[test] + fn an_empty_processor_list_is_a_configuration_error() { + let by_pmi = HashMap::new(); + let err = match resolve_payment_processor(None, &by_pmi, &[]) { + Err(err) => err, + Ok(_) => panic!("an empty processor list must fail"), + }; + assert!(err.to_string().contains("no payment processors configured")); + } + + #[test] + fn duplicate_pmi_registration_lets_the_last_one_win() { + let processors: Vec> = vec![ + Arc::new(StubProcessor { + ttl: Some(1), + ..StubProcessor::new("pmi-a") + }), + Arc::new(StubProcessor { + ttl: Some(2), + ..StubProcessor::new("pmi-a") + }), + ]; + let by_pmi = build_processors_by_pmi(&processors); + assert_eq!(by_pmi.len(), 1); + } + + // ── resolve-and-initiate ──────────────────────────────────────── + + fn options_with( + processors: Vec>, + resolver: Option>, + ) -> ServerPaymentsOptions { + let mut options = ServerPaymentsOptions::new(processors, Vec::new()); + if let Some(resolver) = resolver { + options = options.with_resolve_price(resolver); + } + options + } + + #[tokio::test] + async fn static_pricing_with_no_resolver_charges_the_capability_amount() { + let processors: Vec> = + vec![Arc::new(StubProcessor::new("pmi-a"))]; + let by_pmi = build_processors_by_pmi(&processors); + let options = options_with(processors, None); + let capability = priced("tools/call", Some("echo"), 42); + let outcome = resolve_and_initiate_payment( + &request("tools/call", Some(serde_json::json!({ "name": "echo" }))), + &capability, + "event-1", + "client-pk", + None, + &options, + &by_pmi, + ) + .await + .unwrap(); + match outcome { + InitiationOutcome::PaymentRequired { + payment_required, .. + } => { + assert_eq!(payment_required.amount, 42); + assert_eq!(payment_required.pmi, "pmi-a"); + } + _ => panic!("expected a payment request"), + } + } + + #[tokio::test] + async fn a_resolver_error_is_a_server_failure_not_a_rejection() { + let processors: Vec> = + vec![Arc::new(StubProcessor::new("pmi-a"))]; + let by_pmi = build_processors_by_pmi(&processors); + let options = options_with(processors, Some(Arc::new(FailingResolver))); + let capability = priced("tools/call", None, 1); + let result = resolve_and_initiate_payment( + &request("tools/call", None), + &capability, + "event-1", + "client-pk", + None, + &options, + &by_pmi, + ) + .await; + assert!(result.is_err(), "an Err quote must propagate as Err"); + } + + #[tokio::test] + async fn a_rejection_names_the_selected_pmi_and_the_listed_amount() { + let processors: Vec> = vec![ + Arc::new(StubProcessor::new("pmi-a")), + Arc::new(StubProcessor::new("pmi-b")), + ]; + let by_pmi = build_processors_by_pmi(&processors); + let options = options_with( + processors, + Some(Arc::new(StaticResolver(ResolvePriceResult::Reject { + message: Some("too rich".to_string()), + }))), + ); + let capability = priced("tools/call", None, 55); + // The client selects the non-first processor, so the rejection PMI + // proves selection happened before pricing. + let client = vec!["pmi-b".to_string()]; + let outcome = resolve_and_initiate_payment( + &request("tools/call", None), + &capability, + "event-1", + "client-pk", + Some(&client), + &options, + &by_pmi, + ) + .await + .unwrap(); + match outcome { + InitiationOutcome::Rejected { + pmi, + amount, + message, + } => { + assert_eq!(pmi, "pmi-b"); + assert_eq!(amount, 55, "the rejection reports the listed price"); + assert_eq!(message.as_deref(), Some("too rich")); + } + _ => panic!("expected a rejection"), + } + } + + #[tokio::test] + async fn a_waiver_initiates_nothing() { + let processors: Vec> = + vec![Arc::new(StubProcessor::new("pmi-a"))]; + let by_pmi = build_processors_by_pmi(&processors); + let options = options_with( + processors, + Some(Arc::new(StaticResolver(ResolvePriceResult::Waive { + meta: Some(one_key_meta("covered_by", "promo")), + }))), + ); + let capability = priced("tools/call", None, 1); + let outcome = resolve_and_initiate_payment( + &request("tools/call", None), + &capability, + "event-1", + "client-pk", + None, + &options, + &by_pmi, + ) + .await + .unwrap(); + assert!(matches!(outcome, InitiationOutcome::Waived)); + } + + #[tokio::test] + async fn meta_from_the_quote_overrides_the_processor_meta() { + let mut processor_meta = one_key_meta("source", "processor"); + processor_meta.insert( + "kept".to_string(), + serde_json::Value::String("yes".to_string()), + ); + let processors: Vec> = vec![Arc::new(StubProcessor { + meta: Some(processor_meta), + ..StubProcessor::new("pmi-a") + })]; + let by_pmi = build_processors_by_pmi(&processors); + let options = options_with( + processors.clone(), + Some(Arc::new(StaticResolver(ResolvePriceResult::Quote { + amount: 5, + description: None, + meta: Some(one_key_meta("source", "quote")), + }))), + ); + let capability = priced("tools/call", None, 1); + let outcome = resolve_and_initiate_payment( + &request("tools/call", None), + &capability, + "event-1", + "client-pk", + None, + &options, + &by_pmi, + ) + .await + .unwrap(); + match outcome { + InitiationOutcome::PaymentRequired { merged_meta, .. } => { + let merged = merged_meta.expect("merged"); + assert_eq!(merged.get("source").unwrap(), "quote"); + assert_eq!(merged.get("kept").unwrap(), "yes"); + } + _ => panic!("expected a payment request"), + } + + // Both absent gives None, not an empty object. + let plain: Vec> = vec![Arc::new(StubProcessor::new("pmi-a"))]; + let by_pmi = build_processors_by_pmi(&plain); + let options = options_with(plain, None); + let outcome = resolve_and_initiate_payment( + &request("tools/call", None), + &capability, + "event-1", + "client-pk", + None, + &options, + &by_pmi, + ) + .await + .unwrap(); + match outcome { + InitiationOutcome::PaymentRequired { merged_meta, .. } => { + assert!(merged_meta.is_none()); + } + _ => panic!("expected a payment request"), + } + } + + #[tokio::test] + async fn the_verify_timeout_derives_from_the_payment_requests_ttl() { + let processors: Vec> = vec![Arc::new(StubProcessor { + ttl: Some(90), + ..StubProcessor::new("pmi-a") + })]; + let by_pmi = build_processors_by_pmi(&processors); + let options = options_with(processors, None); + let capability = priced("tools/call", None, 1); + let outcome = resolve_and_initiate_payment( + &request("tools/call", None), + &capability, + "event-1", + "client-pk", + None, + &options, + &by_pmi, + ) + .await + .unwrap(); + match outcome { + InitiationOutcome::PaymentRequired { verify_timeout, .. } => { + assert_eq!(verify_timeout, Duration::from_secs(90)); + } + _ => panic!("expected a payment request"), + } + } + + #[tokio::test] + async fn an_empty_processor_list_fails_before_pricing() { + let by_pmi = HashMap::new(); + let options = options_with(Vec::new(), None); + let capability = priced("tools/call", None, 1); + let result = resolve_and_initiate_payment( + &request("tools/call", None), + &capability, + "event-1", + "client-pk", + None, + &options, + &by_pmi, + ) + .await; + assert!(result.is_err()); + } +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 461599e..6810ca6 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -17,5 +17,6 @@ pub use open_stream::{ OpenStreamRegistryPolicy, OpenStreamSession, OpenStreamWriter, }; pub use server::{ - NostrServerTransport, NostrServerTransportConfig, ServerEventRouteStore, TargetedResponseSender, + NostrServerTransport, NostrServerTransportConfig, PaymentNotificationSender, + ServerEventRouteStore, TargetedResponseSender, }; diff --git a/src/transport/server/middleware.rs b/src/transport/server/middleware.rs index b46ec69..ec117cb 100644 --- a/src/transport/server/middleware.rs +++ b/src/transport/server/middleware.rs @@ -14,8 +14,9 @@ use async_trait::async_trait; use futures::future::FutureExt; // .catch_unwind() use nostr_sdk::prelude::Event; use tokio::sync::mpsc::UnboundedSender; +use tokio_util::sync::CancellationToken; -use super::{IncomingRequest, ServerEventRouteStore, LOG_TARGET}; +use super::{IncomingRequest, ServerEventRouteStore, ServerOpenStreamState, LOG_TARGET}; use crate::core::types::{JsonRpcMessage, PaymentInteractionMode}; /// Per-event context handed to every inbound middleware. @@ -44,6 +45,13 @@ pub struct InboundContext { /// Unlike [`client_pmis`](Self::client_pmis) this persists across the session, and an /// unnegotiated session resolves to [`PaymentInteractionMode::Transparent`] rather than `None`. pub payment_interaction: Option, + /// Per-event cancellation token, a child of the transport's shutdown token. + /// + /// A middleware doing long-running work (e.g. a payment verification) should derive its own + /// child from this and select on it, so closing the transport aborts the work instead of + /// leaving it running against cleared state. Cancelling a child never propagates upward, so + /// aborting one event's work cannot tear down the transport. + pub cancel: CancellationToken, } /// A general-purpose inbound middleware, run as the final inbound stage before delivery to the MCP @@ -124,6 +132,8 @@ pub(crate) fn dispatch_inbound( middlewares: &Arc<[Arc]>, tx: &UnboundedSender, event_routes: &ServerEventRouteStore, + open_stream: &ServerOpenStreamState, + cancel: &CancellationToken, message: JsonRpcMessage, client_pubkey: String, event_id: String, @@ -152,12 +162,16 @@ pub(crate) fn dispatch_inbound( mirrored_wrap_kind, client_pmis, payment_interaction, + // A per-event child of the transport's shutdown token: closing the transport cancels + // every in-flight event's work, while cancelling one event's token affects nothing else. + cancel: cancel.child_token(), }); spawn_inbound_chain( Arc::clone(middlewares), ctx, tx.clone(), event_routes.clone(), + open_stream.clone(), message, event, ); @@ -170,6 +184,7 @@ fn spawn_inbound_chain( ctx: Arc, tx: UnboundedSender, event_routes: ServerEventRouteStore, + open_stream: ServerOpenStreamState, message: JsonRpcMessage, event: Option, ) { @@ -178,17 +193,19 @@ fn spawn_inbound_chain( ctx, tx, event_routes, + open_stream, message, event, )); } /// The awaitable chain runner. Awaited directly in tests; spawned in production. -async fn run_inbound_chain( +pub(crate) async fn run_inbound_chain( chain: Arc<[Arc]>, ctx: Arc, tx: UnboundedSender, event_routes: ServerEventRouteStore, + open_stream: ServerOpenStreamState, message: JsonRpcMessage, event: Option, ) { @@ -229,6 +246,22 @@ async fn run_inbound_chain( // there `pop` is a real (harmless) removal. if !reached.load(Ordering::SeqCst) { event_routes.pop(&event_id).await; + + // A gated (dropped) request also releases the open-stream slot the transport reserved for + // it before dispatch: `send_response` never runs for a dropped request, so no arm of its + // deferral decision can release the slot, the keepalive sweep cannot reap a never-started + // writer, and the slots map is unbounded. The token index is keyed by + // `(client_pubkey, progress_token)`, not by event id, so both keys are recovered from the + // slot itself before it is dropped. + let slot = open_stream.lock_slots().remove(&event_id); + if let Some(slot) = slot { + let token = slot.writer.progress_token().to_string(); + let client = slot.snapshot.client_pubkey.to_hex(); + open_stream + .lock_token_index() + .remove(&ServerOpenStreamState::client_token_key(&client, &token)); + slot.writer.dispose(); + } } } @@ -236,9 +269,14 @@ async fn run_inbound_chain( mod tests { use super::*; use crate::core::types::{JsonRpcNotification, JsonRpcRequest}; + use crate::transport::open_stream::OpenStreamConfig; use std::sync::Mutex; use tokio::sync::mpsc; + fn open_stream_state() -> ServerOpenStreamState { + ServerOpenStreamState::new(&OpenStreamConfig::default(), 10) + } + fn req(id: &str, method: &str) -> JsonRpcMessage { JsonRpcMessage::Request(JsonRpcRequest { jsonrpc: "2.0".to_string(), @@ -276,8 +314,18 @@ mod tests { mirrored_wrap_kind: None, client_pmis: None, payment_interaction: None, + cancel: CancellationToken::new(), }); - run_inbound_chain(chain_of(mws), ctx, tx, event_routes.clone(), message, None).await; + run_inbound_chain( + chain_of(mws), + ctx, + tx, + event_routes.clone(), + open_stream_state(), + message, + None, + ) + .await; rx.try_recv().ok() } @@ -372,6 +420,8 @@ mod tests { &empty, &tx, &routes, + &open_stream_state(), + &CancellationToken::new(), req("1", "tools/call"), "client_pk".to_string(), "e1".to_string(), diff --git a/src/transport/server/mod.rs b/src/transport/server/mod.rs index 8753162..528145b 100644 --- a/src/transport/server/mod.rs +++ b/src/transport/server/mod.rs @@ -31,7 +31,9 @@ use crate::core::error::{Error, Result}; use crate::core::types::*; use crate::core::validation; use crate::encryption; -use crate::payments::constants::UNSUPPORTED_PAYMENT_INTERACTION_ERROR_CODE; +use crate::payments::constants::{ + PAYMENT_REQUIRED_METHOD, UNSUPPORTED_PAYMENT_INTERACTION_ERROR_CODE, +}; use crate::payments::{PaymentInteractionPolicy, UnsupportedPaymentInteractionData}; use crate::relay::{RelayPool, RelayPoolTrait}; use crate::transport::base::BaseTransport; @@ -129,6 +131,15 @@ struct RouteSnapshot { mirrored_wrap_kind: Option, } +/// CEP-8: a [`RouteSnapshot`] captured for a payment-gated request, plus its expiry stamp. +/// +/// `expires_at` is a [`std::time::Instant`] because the purge runs on the real-time cleanup +/// task, alongside the stale-route sweep. +struct PaymentRouteSnapshot { + snapshot: RouteSnapshot, + expires_at: Instant, +} + /// CEP-41: the per-stream coordination slot for a server→client writer, keyed by /// request `event_id` in [`ServerOpenStreamState::slots`]. /// @@ -149,8 +160,10 @@ struct OpenStreamSlot { /// CEP-41: the open-stream runtime state shared between the server transport and /// its spawned event loop. Bundled so the event-loop signature stays manageable. +/// `pub(crate)` because the inbound middleware seam's drop-cleanup releases a +/// gated request's writer slot, and the seam lives in a sibling module. #[derive(Clone)] -struct ServerOpenStreamState { +pub(crate) struct ServerOpenStreamState { /// Master gate (`config.open_stream.enabled`). enabled: bool, /// Reader admission/buffering/keepalive policy projected from config. @@ -169,7 +182,7 @@ struct ServerOpenStreamState { } impl ServerOpenStreamState { - fn new(config: &OpenStreamConfig, max_sessions: usize) -> Self { + pub(crate) fn new(config: &OpenStreamConfig, max_sessions: usize) -> Self { Self { enabled: config.enabled, policy: config.into(), @@ -272,6 +285,32 @@ pub type TargetedResponseSender = Arc< dyn Fn(String, String, JsonRpcMessage) -> BoxFuture<'static, crate::Result<()>> + Send + Sync, >; +/// An injected CEP-8 payment-notification publish, for callers that have no `&self`. +/// +/// The arguments are the recipient's public key in hex, the hex id of the request event the +/// notification correlates to, the request's mirrored gift-wrap kind, and the notification to +/// publish. Every CEP-8 payment notification MUST carry the correlating `e` tag, so unlike +/// [`send_notification`](NostrServerTransport::send_notification) the event id here is not +/// optional. The wrap kind is threaded from the inbound context (`None` for a plaintext +/// request) rather than looked up, because this sender outlives the request's route: a map +/// lookup after the stale-route sweep falls back to session state and can select a different +/// wrap kind than the request used. +/// +/// When the notification is `notifications/payment_required`, the publish also captures the +/// request's routing fields while the route is still fresh, so the eventual result of a payment +/// that outlives the sweep is delivered from that capture. That side effect is keyed on the +/// notification method and exists only on this sender, which is why it is named for payment +/// notifications rather than as a general correlated-notification sender. +/// +/// Cheaply clonable (`Arc`). Obtained from +/// [`payment_notification_sender`](NostrServerTransport::payment_notification_sender), which is +/// also where the construction-order rule is documented. +pub type PaymentNotificationSender = Arc< + dyn Fn(String, String, Option, JsonRpcMessage) -> BoxFuture<'static, crate::Result<()>> + + Send + + Sync, +>; + /// CEP-8: the outcome of one payment-interaction negotiation. #[derive(Debug, PartialEq)] enum NegotiationOutcome { @@ -390,6 +429,14 @@ pub struct NostrServerTransport { event_routes: ServerEventRouteStore, /// CEP-19: Track the incoming gift-wrap kind per request for mirroring. request_wrap_kinds: Arc>>>, + /// CEP-8: routing snapshots for requests whose payment can outlive the 60 s stale-route + /// sweep. Written by the injected payment-notification sender when it publishes + /// `payment_required` (while the request's route is still fresh); taken by + /// [`send_response`](Self::send_response), which delivers from the snapshot when the route + /// is gone. Entries are stamped with an expiry at capture and dropped by the cleanup task's + /// tick, so a timed-out payment's snapshot lingers until that tick (or LRU eviction at the + /// 5000-entry bound) rather than being removed the moment the payment fails. + payment_route_snapshots: Arc>>, /// Outer gift-wrap event IDs successfully decrypted and verified (inner `verify()`). /// Duplicate outer ids are skipped before decrypt; ids are inserted only after success /// so failed decrypt/verify can be retried on redelivery. @@ -651,6 +698,9 @@ impl NostrServerTransport { config, event_routes: ServerEventRouteStore::new(), request_wrap_kinds: Arc::new(RwLock::new(HashMap::new())), + payment_route_snapshots: Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"), + ))), seen_gift_wrap_ids, inbound_middlewares: Vec::new(), supported_payment_interaction: None, @@ -705,6 +755,9 @@ impl NostrServerTransport { open_stream: ServerOpenStreamState::new(&config.open_stream, config.max_sessions), config, request_wrap_kinds: Arc::new(RwLock::new(HashMap::new())), + payment_route_snapshots: Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"), + ))), event_routes: ServerEventRouteStore::new(), seen_gift_wrap_ids, inbound_middlewares: Vec::new(), @@ -869,6 +922,7 @@ impl NostrServerTransport { let sessions_cleanup = self.sessions.clone(); let event_routes_cleanup = self.event_routes.clone(); let request_wrap_kinds_cleanup = self.request_wrap_kinds.clone(); + let payment_snapshots_cleanup = Arc::clone(&self.payment_route_snapshots); let cleanup_interval = self.config.cleanup_interval; let session_timeout = self.config.session_timeout; let request_timeout = self.config.request_timeout; @@ -920,6 +974,24 @@ impl NostrServerTransport { "Swept stale event routes (rmcp handles timeout errors)" ); } + + // CEP-8: drop expired payment route snapshots (stamped at capture). Unlike + // `event_routes`, this map's entries must OUTLIVE ordinary traffic for the + // whole payment, so age-based expiry here is the intended lifetime and the + // LRU bound is only a memory backstop. + { + let mut snapshots = + Self::lock_payment_route_snapshots(&payment_snapshots_cleanup); + let now = Instant::now(); + let expired: Vec = snapshots + .iter() + .filter(|(_, entry)| entry.expires_at <= now) + .map(|(key, _)| key.clone()) + .collect(); + for key in expired { + snapshots.pop(&key); + } + } } }); @@ -961,11 +1033,37 @@ impl NostrServerTransport { slot.writer.dispose(); } self.open_stream.lock_token_index().clear(); + // CEP-8: drop any remaining payment route snapshots. + Self::lock_payment_route_snapshots(&self.payment_route_snapshots).clear(); Ok(()) } + /// Whether a live response route exists for `event_id`. + /// + /// Test-only: lets a test assert the stale-route sweep (or a drop-cleanup) has really + /// removed the route before a response is sent, so a delivery observed afterwards is + /// attributable to the snapshot path rather than the ordinary one. + #[cfg(feature = "test-utils")] + pub async fn has_event_route(&self, event_id: &str) -> bool { + self.event_routes.has_event_route(event_id).await + } + /// Send a response back to the client that sent the original request. + /// + /// Call this at most once per request. For an unpaid request the consumed route makes a + /// second concurrent call fail cleanly; for a payment-gated request the route and the payment + /// snapshot are two separate one-shot authorities, so two responders racing for the same + /// event id in the narrow window where both are live can each win one and publish twice. The + /// client's correlation consumes only the first, but the duplicate still reaches the relay. pub async fn send_response(&self, event_id: &str, mut response: JsonRpcMessage) -> Result<()> { + // CEP-8: take the payment route snapshot up front, unconditionally. For a request that + // was never payment-gated the map has no entry, the take yields `None`, and everything + // below behaves exactly as it did before payments existed. When both a payments snapshot + // and an open-stream slot exist, the open-stream arm wins (it returns from this + // function), and this take has already dropped the payments snapshot, so the two owners + // can never both deliver. + let payment_snapshot = self.take_payment_route_snapshot(event_id); + // CEP-41: response deferral. Decide BEFORE consuming the route — for a // started stream the final response rides the captured snapshot, not the // (possibly-swept) event route. @@ -986,14 +1084,35 @@ impl NostrServerTransport { // Consume the route up-front so only one concurrent responder can proceed // for a given event_id. - let route = self.event_routes.pop(event_id).await.ok_or_else(|| { - tracing::error!( - target: LOG_TARGET, - event_id = %event_id, - "No client found for response correlation" - ); - Error::Other(format!("No client found for event {event_id}")) - })?; + let route = match self.event_routes.pop(event_id).await { + Some(route) => route, + None => { + // CEP-8: the route is gone (swept during a long payment, or popped by a + // duplicate delivery's chain run) but a payment route snapshot was captured + // when `payment_required` was published; deliver from it, the same way a + // deferred open-stream response is delivered. + if let Some(entry) = payment_snapshot { + let result = self + .send_open_stream_deferred_response(event_id, &entry.snapshot, response) + .await; + if result.is_err() { + // Keep a PAID delivery retryable: the normal path re-registers the + // route on a publish failure, so this path re-inserts the snapshot + // (with its original expiry) for the same reason. + self.reinsert_payment_route_snapshot(event_id, entry); + } + return result; + } + tracing::error!( + target: LOG_TARGET, + event_id = %event_id, + "No client found for response correlation" + ); + return Err(Error::Other(format!( + "No client found for event {event_id}" + ))); + } + }; let client_pubkey_hex = route.client_pubkey; let original_request_id = route.original_request_id; @@ -1346,6 +1465,82 @@ impl NostrServerTransport { self.open_stream.writer_for(event_id) } + /// Lock-poison-tolerant access to the payment route snapshot map. An associated function + /// (not `&self`) so the injected sender's detached closure can share it. + fn lock_payment_route_snapshots( + map: &Mutex>, + ) -> std::sync::MutexGuard<'_, LruCache> { + match map.lock() { + Ok(guard) => guard, + Err(poisoned) => poisoned.into_inner(), + } + } + + /// CEP-8: remove and return the payment route snapshot for `event_id`, if any. + /// A single-lock map operation; called unconditionally at the top of `send_response`. + fn take_payment_route_snapshot(&self, event_id: &str) -> Option { + Self::lock_payment_route_snapshots(&self.payment_route_snapshots).pop(event_id) + } + + /// CEP-8: put a taken snapshot back after a failed delivery, keeping its original expiry so + /// the failed publish does not extend the entry's life. The key was popped moments ago, so + /// the plain `put` cannot evict anything at capacity. + fn reinsert_payment_route_snapshot(&self, event_id: &str, entry: PaymentRouteSnapshot) { + Self::lock_payment_route_snapshots(&self.payment_route_snapshots) + .put(event_id.to_string(), entry); + } + + /// CEP-8: record a payment route snapshot when `payment_required` is published. + /// + /// First write wins for the routing fields: a redelivery that re-runs the lifecycle + /// captures identical fields, so only the expiry is extended, and the entry can never be + /// clobbered mid-payment. At capacity the insert pops one expired entry first and refuses + /// (logging at `warn`) when every entry is live: `LruCache::put`'s recency eviction must + /// never remove a live snapshot, because a live snapshot is the only thing standing between + /// a multi-minute payment and a lost response. + fn record_payment_route_snapshot( + map: &Mutex>, + event_id: &str, + snapshot: RouteSnapshot, + expires_at: Instant, + ) { + let mut cache = Self::lock_payment_route_snapshots(map); + if let Some(existing) = cache.peek_mut(event_id) { + if expires_at > existing.expires_at { + existing.expires_at = expires_at; + } + return; + } + if cache.len() == cache.cap().get() { + let now = Instant::now(); + let expired_key = cache + .iter() + .find(|(_, entry)| entry.expires_at <= now) + .map(|(key, _)| key.clone()); + match expired_key { + Some(key) => { + cache.pop(&key); + } + None => { + tracing::warn!( + target: LOG_TARGET, + event_id = %event_id, + "payment route snapshot capacity reached with every entry live; \ + skipping this capture" + ); + return; + } + } + } + cache.put( + event_id.to_string(), + PaymentRouteSnapshot { + snapshot, + expires_at, + }, + ); + } + /// CEP-41: decide how `send_response` should handle the final response for a /// (possibly) streaming request. Run under the slots lock so the stash/flush /// decision is consistent against the close/abort hook's `terminated` flag. @@ -1714,22 +1909,78 @@ impl NostrServerTransport { } /// Send a notification to a specific client. + /// + /// A thin wrapper over the shared notification publish: it performs the wrap-kind map + /// lookup itself, then delegates, so this method and the injected + /// [`PaymentNotificationSender`] cannot drift on tag composition or wrap-kind policy. pub async fn send_notification( &self, client_pubkey_hex: &str, notification: &JsonRpcMessage, correlated_event_id: Option<&str>, ) -> Result<()> { - let mut sessions = self.sessions.write().await; - let session = sessions - .get_mut(client_pubkey_hex) - .ok_or_else(|| Error::Other(format!("No session for {client_pubkey_hex}")))?; - let is_encrypted = session.is_encrypted; - let supports_ephemeral = session.supports_ephemeral_gift_wrap; + // CEP-19: Look up mirrored wrap kind from correlated request + let correlated_wrap_kind = if let Some(event_id) = correlated_event_id { + self.request_wrap_kinds + .read() + .await + .get(event_id) + .copied() + .flatten() + } else { + None + }; + Self::publish_payment_notification( + &self.base, + self.config.gift_wrap_mode, + &self.sessions, + correlated_wrap_kind, + &self.announcement_manager.get_common_tags(), + client_pubkey_hex, + correlated_event_id, + notification, + ) + .await + } - // CEP-35: include discovery tags on first message to this client - let discovery_tags = self.take_pending_server_discovery_tags(session); - drop(sessions); + /// The notification publish itself, shared by [`send_notification`](Self::send_notification) + /// and the injected [`PaymentNotificationSender`] so the two forms cannot drift apart. + /// Static for the same reason the deferred and targeted publishes are: a detached caller has + /// no `self` in scope. The caller supplies the mirrored wrap kind; the `&self` wrapper looks + /// it up in the wrap-kind map, while the injected sender threads the value captured on the + /// inbound context, which stays correct after the stale-route sweep has reaped the map entry. + // Eight parameters is what a `&self`-less publish needs: the two transport handles, the + // session store, the threaded wrap kind, the captured discovery tag set, the two routing + // values, and the payload. + #[allow(clippy::too_many_arguments)] + async fn publish_payment_notification( + base: &BaseTransport, + gift_wrap_mode: GiftWrapMode, + sessions: &SessionStore, + correlated_wrap_kind: Option, + common_tags: &[Tag], + client_pubkey_hex: &str, + correlated_event_id: Option<&str>, + notification: &JsonRpcMessage, + ) -> Result<()> { + let (is_encrypted, supports_ephemeral, discovery_tags) = { + let mut sessions_w = sessions.write().await; + let session = sessions_w + .get_mut(client_pubkey_hex) + .ok_or_else(|| Error::Other(format!("No session for {client_pubkey_hex}")))?; + let is_encrypted = session.is_encrypted; + let supports_ephemeral = session.supports_ephemeral_gift_wrap; + + // CEP-35: include discovery tags on first message to this client (one-shot latch, + // read and flipped under the same session guard the other fields are read under). + let discovery_tags = if session.has_sent_common_tags { + Vec::new() + } else { + session.has_sent_common_tags = true; + common_tags.to_vec() + }; + (is_encrypted, supports_ephemeral, discovery_tags) + }; let client_pubkey = PublicKey::from_hex(client_pubkey_hex).map_err(|e| Error::Other(e.to_string()))?; @@ -1742,37 +1993,131 @@ impl NostrServerTransport { let tags = BaseTransport::compose_outbound_tags(&base_tags, &discovery_tags, &[]); - // CEP-19: Look up mirrored wrap kind from correlated request - let correlated_wrap_kind = if let Some(event_id) = correlated_event_id { - self.request_wrap_kinds - .read() - .await - .get(event_id) - .copied() - .flatten() - } else { - None - }; - - self.base - .send_mcp_message( - notification, - &client_pubkey, - CTXVM_MESSAGES_KIND, - tags, - Some(is_encrypted), - Self::select_outbound_notification_gift_wrap_kind( - self.config.gift_wrap_mode, - is_encrypted, - correlated_wrap_kind, - supports_ephemeral, - ), - ) - .await?; + base.send_mcp_message( + notification, + &client_pubkey, + CTXVM_MESSAGES_KIND, + tags, + Some(is_encrypted), + Self::select_outbound_notification_gift_wrap_kind( + gift_wrap_mode, + is_encrypted, + correlated_wrap_kind, + supports_ephemeral, + ), + ) + .await?; Ok(()) } + /// The same publish as [`send_notification`](Self::send_notification), as an injectable + /// closure for callers that have no `&self`, such as a payment middleware running on a + /// detached task. Both forms delegate to one publish, so the tag-composition policy and the + /// wrap-kind selection cannot diverge between them. + /// + /// When the notification is `notifications/payment_required`, the returned sender also + /// captures the request's routing fields (while the route is still fresh) into the payment + /// snapshot map, so the eventual result of a payment that outlives the 60 s stale-route + /// sweep is still delivered. If the route is already gone at capture time it logs at `warn` + /// and captures nothing; the request then fails exactly as an unpaid swept request does. + /// + /// `snapshot_ttl` is how long a captured snapshot stays deliverable: pass the same payment + /// TTL the middleware holding this sender is configured with, so the snapshot outlives every + /// payment that middleware can still be waiting on. Snapshot delivery does not need a live + /// session (a missing session only skips the one-shot discovery/disclosure latches), so a TTL + /// above the transport's session timeout still delivers the paid result; only the acceptance + /// notification dies with the session. + /// + /// **Call this after the announcement extra tags are set.** The returned sender captures + /// the server's discovery tag set at the moment it is built (the `&self` method reads it + /// live), so a sender built before those tags exist ships an empty discovery replay on a + /// session's first outbound event. Nothing enforces the order at compile time. + pub fn payment_notification_sender(&self, snapshot_ttl: Duration) -> PaymentNotificationSender { + let relay_pool = Arc::clone(&self.base.relay_pool); + let encryption_mode = self.base.encryption_mode; + let gift_wrap_mode = self.config.gift_wrap_mode; + let sessions = self.sessions.clone(); + let event_routes = self.event_routes.clone(); + let snapshots = Arc::clone(&self.payment_route_snapshots); + let common_tags = self.announcement_manager.get_common_tags(); + + Arc::new( + move |client_pubkey_hex, event_id, mirrored_wrap_kind, notification| { + let relay_pool = Arc::clone(&relay_pool); + let sessions = sessions.clone(); + let event_routes = event_routes.clone(); + let snapshots = Arc::clone(&snapshots); + let common_tags = common_tags.clone(); + Box::pin(async move { + let base = BaseTransport { + relay_pool, + encryption_mode, + is_connected: true, + }; + + // CEP-8: capture the route snapshot at the moment of first emission, so the + // eventual result survives the stale-route sweep (and a duplicate + // delivery's route pop). + if notification.method() == Some(PAYMENT_REQUIRED_METHOD) { + match event_routes.get_route(&event_id).await { + Some(route) => { + let session_encrypted = sessions + .get_session(&client_pubkey_hex) + .await + .map(|s| s.is_encrypted); + match (PublicKey::from_hex(&client_pubkey_hex), session_encrypted) { + (Ok(client_pubkey), Some(is_encrypted)) => { + Self::record_payment_route_snapshot( + &snapshots, + &event_id, + RouteSnapshot { + client_pubkey, + original_request_id: route.original_request_id, + is_encrypted, + mirrored_wrap_kind, + }, + Instant::now() + snapshot_ttl, + ); + } + _ => { + tracing::warn!( + target: LOG_TARGET, + event_id = %event_id, + "cannot capture payment route snapshot \ + (invalid pubkey or no session)" + ); + } + } + } + None => { + tracing::warn!( + target: LOG_TARGET, + event_id = %event_id, + "request route already gone when payment_required was \ + published; no snapshot captured, the response will not \ + survive the sweep" + ); + } + } + } + + Self::publish_payment_notification( + &base, + gift_wrap_mode, + &sessions, + mirrored_wrap_kind, + &common_tags, + &client_pubkey_hex, + Some(&event_id), + ¬ification, + ) + .await + }) + }, + ) + } + /// Broadcast a notification to all initialized clients. pub async fn broadcast_notification(&self, notification: &JsonRpcMessage) -> Result<()> { let sessions = self.sessions.read().await; @@ -2695,6 +3040,7 @@ impl NostrServerTransport { effective_payment_interaction, &sessions, &tag_sources, + &cancel, ) .await; continue; @@ -2825,6 +3171,8 @@ impl NostrServerTransport { &middlewares, &tx, &event_routes, + &open_stream, + &cancel, mcp_msg, sender_pubkey, event_id, @@ -2873,6 +3221,7 @@ impl NostrServerTransport { payment_interaction: Option, sessions: &SessionStore, tag_sources: &ResponseTagSources, + cancel: &CancellationToken, ) { // The outer progressToken keys the transfer (needed for accept + route). // String or number — defensive only: every known sender stringifies @@ -2997,6 +3346,8 @@ impl NostrServerTransport { chain, tx, event_routes, + open_stream, + cancel, message, sender_pubkey.to_string(), event_id.to_string(), @@ -5839,6 +6190,491 @@ mod tests { transport.close().await.expect("close the server"); } + #[tokio::test] + async fn a_gated_request_releases_its_open_stream_slot() { + // A `tools/call` carrying a progressToken gets a writer slot created BEFORE dispatch, and + // `send_response` (whose deferral decision is the only other place that releases it) never + // runs for a request a middleware drops. The chain's drop-cleanup must therefore release + // the slot and its `(client, token)` index entry, or every gated streaming call leaks one + // of each until `close()`. + // + // The middleware records that it ran, and the test waits on that flag BEFORE inspecting + // the maps: without the gate, a poll racing ahead of the event loop sees the maps empty + // for the trivial reason that nothing has been processed yet, and the test proves nothing. + struct DropAll(Arc); + #[async_trait::async_trait] + impl InboundMiddleware for DropAll { + async fn handle( + &self, + _message: JsonRpcMessage, + _ctx: &InboundContext, + _next: middleware::Next, + ) -> bool { + self.0.store(true, Ordering::SeqCst); + false + } + } + + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let s_pool = Arc::new(server_pool); + + let mut transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_open_stream(OpenStreamConfig::default().with_enabled(true)), + Arc::clone(&s_pool) as Arc, + ) + .await + .expect("server transport"); + let dropped = Arc::new(std::sync::atomic::AtomicBool::new(false)); + transport.add_inbound_middleware(Arc::new(DropAll(Arc::clone(&dropped)))); + + let mut server_rx = transport + .take_message_receiver() + .expect("server message receiver"); + transport.start().await.expect("server start"); + tokio::time::sleep(Duration::from_millis(20)).await; + + let client_keys = Keys::generate(); + let call_event = streaming_call_event( + &client_keys, + server_pubkey, + serde_json::json!("gated-1"), + "gated-tok", + Vec::new(), + ); + let call_event_id = call_event.id.to_hex(); + client_pool + .publish_event(&call_event) + .await + .expect("publish the streaming call"); + + // Positive control first: wait until the middleware has really run (and dropped). + // The writer slot is created before dispatch, so once this flag is up the slot HAD + // existed and an empty map below can only mean the drop-cleanup released it. + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while !dropped.load(Ordering::SeqCst) { + assert!( + tokio::time::Instant::now() < deadline, + "the gating middleware must have processed the request" + ); + tokio::time::sleep(Duration::from_millis(10)).await; + } + + // The chain runs on a detached task; poll until the drop-cleanup has run. + let token_key = ServerOpenStreamState::client_token_key( + &client_keys.public_key().to_hex(), + "gated-tok", + ); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + loop { + let slots_empty = transport.open_stream.lock_slots().is_empty(); + let token_gone = !transport + .open_stream + .lock_token_index() + .contains_key(&token_key); + if slots_empty && token_gone { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the dropped request's writer slot / token-index entry must be released \ + (slots_empty={slots_empty}, token_gone={token_gone})" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + // The request never reached the handler. + assert!( + server_rx.try_recv().is_err(), + "a dropped request must not reach the handler" + ); + // The cleanup pops the route before the slot, so by this point it must be gone. + assert!( + !transport.event_routes.has_event_route(&call_event_id).await, + "a dropped request must not keep a live route" + ); + + transport.close().await.expect("close the server"); + } + + /// The open-stream arm wins over the payments snapshot when a paid stream really + /// streams: the first responder delivers through the slot, the payments snapshot is + /// consumed unused, and a second responder for the same event id errors instead of + /// publishing a duplicate. Needs the `test-utils` fake processor. + #[cfg(feature = "test-utils")] + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_paid_stream_request_that_streams_delivers_through_the_open_stream_path() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let s_pool = Arc::new(server_pool); + + let mut transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_request_timeout(Duration::from_millis(100)) + .with_cleanup_interval(Duration::from_millis(50)) + .with_open_stream(OpenStreamConfig::default().with_enabled(true)), + Arc::clone(&s_pool) as Arc, + ) + .await + .expect("server transport"); + let sender = transport.payment_notification_sender(Duration::from_secs(300)); + let options = crate::payments::ServerPaymentsOptions::new( + vec![Arc::new( + crate::payments::fakes::FakePaymentProcessor::with_options( + crate::payments::fakes::FakePaymentProcessorOptions { + pmi: "fake".to_string(), + verify_delay_ms: 300, + create_delay_ms: 0, + ttl: None, + }, + ), + )], + vec![crate::payments::types::PricedCapability { + method: "tools/call".to_string(), + name: Some("streamer".to_string()), + amount: 21, + max_amount: None, + currency_unit: "sats".to_string(), + description: None, + }], + ); + transport.add_inbound_middleware(crate::payments::create_server_payments_middleware( + crate::payments::ServerPaymentsMiddlewareParams::new(options, sender), + )); + + let mut server_rx = transport + .take_message_receiver() + .expect("server message receiver"); + transport.start().await.expect("server start"); + tokio::time::sleep(Duration::from_millis(20)).await; + + let client_keys = Keys::generate(); + client_pool + .publish_event(&streaming_call_event( + &client_keys, + server_pubkey, + serde_json::json!("paid-stream-1"), + "tok-17", + Vec::new(), + )) + .await + .expect("publish the paid streaming call"); + + // The request reaches the handler only after the 300 ms payment; the route was + // swept at 100 ms, but the writer slot (created before dispatch) survives. + let incoming = tokio::time::timeout(Duration::from_secs(3), server_rx.recv()) + .await + .expect("the paid streaming request must reach the handler") + .expect("channel closed"); + let event_id = incoming.event_id.clone(); + + let writer = transport + .get_open_stream_writer(&event_id) + .expect("the writer slot must survive the payment"); + writer.start().await.expect("start the stream"); + writer.write("chunk".to_string()).await.expect("stream"); + writer.close().await.expect("close the stream"); + + // The route must be gone before the first response, or either responder below + // could take the ordinary path and the precedence claim would go untested. + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while transport.event_routes.has_event_route(&event_id).await { + assert!( + tokio::time::Instant::now() < deadline, + "the sweep must reap the route during the payment" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + transport + .send_response( + &event_id, + list_result_response(serde_json::json!("paid-stream-1")), + ) + .await + .expect("the streamed response delivers through the open-stream slot"); + + let delivered = s_pool + .stored_events() + .await + .into_iter() + .filter(|e| { + e.kind == Kind::Custom(CTXVM_MESSAGES_KIND) + && e.pubkey == server_pubkey + && e.content.contains("paid-stream-1") + }) + .count(); + assert_eq!(delivered, 1, "exactly one response delivery"); + + // A second responder finds no slot, no route, and no payments snapshot (it was + // consumed by the first call): it must error, never publish a duplicate. + let second = transport + .send_response( + &event_id, + list_result_response(serde_json::json!("paid-stream-1")), + ) + .await; + assert!( + second.is_err(), + "a second responder must not double-deliver" + ); + let delivered_after = s_pool + .stored_events() + .await + .into_iter() + .filter(|e| { + e.kind == Kind::Custom(CTXVM_MESSAGES_KIND) + && e.pubkey == server_pubkey + && e.content.contains("paid-stream-1") + }) + .count(); + assert_eq!(delivered_after, 1, "still exactly one response delivery"); + + transport.close().await.expect("close the server"); + } + + /// A failed snapshot delivery re-inserts the snapshot (with its original expiry), + /// keeping a PAID delivery retryable the same way the normal path's route + /// re-registration keeps an unpaid one retryable. + #[tokio::test] + async fn a_failed_snapshot_delivery_reinserts_the_snapshot() { + let pool: Arc = Arc::new(MockRelayPool::new()); + let transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + pool, + ) + .await + .expect("server transport"); + + // A snapshot stored under a non-hex event id makes the delivery's own id parse + // fail, which is the only publish-adjacent failure reachable without a relay + // that can fail on command. + NostrServerTransport::record_payment_route_snapshot( + &transport.payment_route_snapshots, + "not-a-hex-event-id", + RouteSnapshot { + client_pubkey: Keys::generate().public_key(), + original_request_id: serde_json::json!("orig-1"), + is_encrypted: false, + mirrored_wrap_kind: None, + }, + Instant::now() + Duration::from_secs(60), + ); + + let result = transport + .send_response( + "not-a-hex-event-id", + list_result_response(serde_json::json!("orig-1")), + ) + .await; + assert!(result.is_err(), "the delivery must fail"); + assert!( + transport + .take_payment_route_snapshot("not-a-hex-event-id") + .is_some(), + "the snapshot must be re-inserted so a paid delivery stays retryable" + ); + } + + /// A correlated notification mirrors the wrap kind of the request it answers, not the + /// session's learned capabilities. The fixture is the one case where the two disagree: + /// a mixed-wrap client, whose session learned ephemeral support while this particular + /// request arrived on a persistent wrap. Falling back to session state would pick the ephemeral + /// kind, and an ephemeral wrap to a briefly-offline client is silently lost (relays do + /// not store it), so the mirror is load-bearing, not cosmetic. + #[tokio::test] + async fn a_correlated_notification_mirrors_the_requests_wrap_kind() { + let pool = Arc::new(MockRelayPool::new()); + let transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default(), + Arc::clone(&pool) as Arc, + ) + .await + .expect("server transport"); + + let client_keys = Keys::generate(); + let client_hex = client_keys.public_key().to_hex(); + { + let mut sessions_w = transport.sessions.write().await; + let mut session = ClientSession::new(true); + session.supports_ephemeral_gift_wrap = true; + sessions_w.put(client_hex.clone(), session); + } + let mirrored_id = "ab".repeat(32); + { + let mut kinds_w = transport.request_wrap_kinds.write().await; + kinds_w.insert(mirrored_id.clone(), Some(GIFT_WRAP_KIND)); + } + + let notification = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: None, + }); + + // Correlated to a persistent-wrap request: the notification must mirror 1059. + transport + .send_notification(&client_hex, ¬ification, Some(&mirrored_id)) + .await + .expect("send the correlated notification"); + let mirrored_kind = pool + .stored_events() + .await + .last() + .expect("published") + .kind + .as_u16(); + assert_eq!( + mirrored_kind, GIFT_WRAP_KIND, + "a correlated notification must mirror the request's own wrap kind" + ); + + // Control: with no correlated request to mirror, the session's learned ephemeral + // support decides, so a wrapper that dropped the lookup is distinguishable. + transport + .send_notification(&client_hex, ¬ification, Some(&"cd".repeat(32))) + .await + .expect("send the uncorrelated notification"); + let fallback_kind = pool + .stored_events() + .await + .last() + .expect("published") + .kind + .as_u16(); + assert_eq!( + fallback_kind, EPHEMERAL_GIFT_WRAP_KIND, + "without a mirrored kind the learned-ephemeral fallback applies" + ); + } + + /// A snapshot re-capture (a redelivery whose pending entry expired re-runs the + /// lifecycle) keeps the first capture's routing fields but extends the expiry to the + /// later stamp, so a re-run near the original expiry cannot have its snapshot purged + /// mid-payment. An earlier stamp never shortens it. + #[tokio::test] + async fn a_snapshot_recapture_extends_the_expiry() { + let pool: Arc = Arc::new(MockRelayPool::new()); + let transport = + NostrServerTransport::with_relay_pool(NostrServerTransportConfig::default(), pool) + .await + .expect("server transport"); + let snapshot = || RouteSnapshot { + client_pubkey: Keys::generate().public_key(), + original_request_id: serde_json::json!("re-1"), + is_encrypted: false, + mirrored_wrap_kind: None, + }; + let event_id = "ef".repeat(32); + let base = Instant::now(); + NostrServerTransport::record_payment_route_snapshot( + &transport.payment_route_snapshots, + &event_id, + snapshot(), + base + Duration::from_secs(60), + ); + NostrServerTransport::record_payment_route_snapshot( + &transport.payment_route_snapshots, + &event_id, + snapshot(), + base + Duration::from_secs(120), + ); + { + let cache = NostrServerTransport::lock_payment_route_snapshots( + &transport.payment_route_snapshots, + ); + assert_eq!( + cache.peek(&event_id).expect("entry").expires_at, + base + Duration::from_secs(120), + "a later re-capture must extend the expiry" + ); + } + NostrServerTransport::record_payment_route_snapshot( + &transport.payment_route_snapshots, + &event_id, + snapshot(), + base + Duration::from_secs(30), + ); + { + let cache = NostrServerTransport::lock_payment_route_snapshots( + &transport.payment_route_snapshots, + ); + assert_eq!( + cache.peek(&event_id).expect("entry").expires_at, + base + Duration::from_secs(120), + "an earlier re-capture must never shorten the expiry" + ); + } + } + + /// The snapshot map shares the crate's LRU default. The bound is a memory backstop + /// only (entries are meant to outlive traffic for the whole payment window), so it + /// is pinned by value; no test drives real eviction at this size. + #[tokio::test] + async fn payment_snapshot_map_bound_matches_the_shared_lru_default() { + let pool: Arc = Arc::new(MockRelayPool::new()); + let transport = + NostrServerTransport::with_relay_pool(NostrServerTransportConfig::default(), pool) + .await + .expect("server transport"); + assert_eq!( + NostrServerTransport::lock_payment_route_snapshots(&transport.payment_route_snapshots) + .cap() + .get(), + DEFAULT_LRU_SIZE + ); + } + + /// A snapshot whose payment window has passed is dropped by the cleanup task's + /// tick, so a timed-out payment's residue does not sit until LRU eviction. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_timed_out_payments_snapshot_is_purged_on_a_cleanup_tick() { + let pool: Arc = Arc::new(MockRelayPool::new()); + let mut transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_cleanup_interval(Duration::from_millis(50)), + pool, + ) + .await + .expect("server transport"); + + let expired_id = "ab".repeat(32); + NostrServerTransport::record_payment_route_snapshot( + &transport.payment_route_snapshots, + &expired_id, + RouteSnapshot { + client_pubkey: Keys::generate().public_key(), + original_request_id: serde_json::json!("stale-1"), + is_encrypted: false, + mirrored_wrap_kind: None, + }, + Instant::now() - Duration::from_secs(1), + ); + + transport.start().await.expect("start"); + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + loop { + let present = NostrServerTransport::lock_payment_route_snapshots( + &transport.payment_route_snapshots, + ) + .peek(&expired_id) + .is_some(); + if !present { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the cleanup tick must purge an expired snapshot" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + transport.close().await.expect("close"); + } + // ── Targeted response sender ───────────────────────────────────────────── /// A server transport wired to `pool`, with the CEP-8 knobs the targeted-send tests need. diff --git a/tests/payments_transparent_e2e.rs b/tests/payments_transparent_e2e.rs new file mode 100644 index 0000000..5f8f218 --- /dev/null +++ b/tests/payments_transparent_e2e.rs @@ -0,0 +1,1074 @@ +//! CEP-8 transparent payment lifecycle, end to end over `MockRelayPool`. +//! +//! A real server transport with the payment middleware registered before `start()`, a +//! real client (a client transport or raw signed events, per test), and every emission +//! asserted on the wire: published events and their tags, never a sender spy. +//! +//! Clock discipline: the stale-route sweep and session expiry run on +//! `std::time::Instant`, which paused tokio time does not advance, so every test here +//! runs on the real clock with tiny configured timeouts. The test clients raise their +//! own timeout above the payment duration: client-side survival of a long payment is a +//! separate concern (the client transport sweeps its own pending store), and these +//! tests must not appear to prove it. + +use std::sync::Arc; +use std::sync::Mutex as StdMutex; +use std::time::Duration; + +use async_trait::async_trait; +use contextvm_sdk::core::constants::CTXVM_MESSAGES_KIND; +use contextvm_sdk::core::types::EncryptionMode; +use contextvm_sdk::payments::fakes::{FakePaymentProcessor, FakePaymentProcessorOptions}; +use contextvm_sdk::payments::tags::payment_interaction_tag; +use contextvm_sdk::payments::types::PricedCapability; +use contextvm_sdk::payments::{ + create_server_payments_middleware, ServerPaymentsMiddlewareParams, ServerPaymentsOptions, +}; +use contextvm_sdk::relay::mock::MockRelayPool; +use contextvm_sdk::transport::base::BaseTransport; +use contextvm_sdk::transport::client::{NostrClientTransport, NostrClientTransportConfig}; +use contextvm_sdk::transport::open_stream::OpenStreamConfig; +use contextvm_sdk::transport::server::{ + InboundContext, InboundMiddleware, IncomingRequest, Next, NostrServerTransport, + NostrServerTransportConfig, +}; +use contextvm_sdk::{ + JsonRpcMessage, JsonRpcRequest, JsonRpcResponse, PaymentInteractionMode, RelayPoolTrait, +}; +use nostr_sdk::prelude::*; + +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 paid_streaming_call(id: &str, token: &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", + "_meta": { "progressToken": token }, + })), + }) +} + +fn result_response(id: &str) -> JsonRpcMessage { + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(id), + result: serde_json::json!({ "content": [] }), + }) +} + +fn priced_tool(amount: i64) -> PricedCapability { + PricedCapability { + method: "tools/call".to_string(), + name: Some("paid-tool".to_string()), + amount, + max_amount: None, + currency_unit: "sats".to_string(), + description: None, + } +} + +fn all_tags(event: &Event) -> Vec> { + event.tags.iter().map(|t| t.clone().to_vec()).collect() +} + +/// Every stored server-authored ContextVM event whose content contains `needle`. +async fn server_events_containing( + pool: &Arc, + server: PublicKey, + needle: &str, +) -> Vec { + pool.stored_events() + .await + .into_iter() + .filter(|e| { + e.kind == Kind::Custom(CTXVM_MESSAGES_KIND) + && e.pubkey == server + && e.content.contains(needle) + }) + .collect() +} + +/// Poll for one server event containing `needle`, within `deadline`. +async fn wait_for_server_event( + pool: &Arc, + server: PublicKey, + needle: &str, + deadline: Duration, +) -> Event { + let end = tokio::time::Instant::now() + deadline; + loop { + let found = server_events_containing(pool, server, needle).await; + if let Some(event) = found.into_iter().next() { + return event; + } + assert!( + tokio::time::Instant::now() < end, + "no server event containing {needle:?} within {deadline:?}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } +} + +/// Register the transparent payment middleware on `server` with one fake processor. +fn register_payments(server: &mut NostrServerTransport, verify_delay_ms: u64, amount: i64) { + let sender = server.payment_notification_sender(Duration::from_secs(300)); + let options = ServerPaymentsOptions::new( + vec![Arc::new(FakePaymentProcessor::with_options( + FakePaymentProcessorOptions { + pmi: "fake".to_string(), + verify_delay_ms, + create_delay_ms: 0, + ttl: None, + }, + ))], + vec![priced_tool(amount)], + ); + server.add_inbound_middleware(create_server_payments_middleware( + ServerPaymentsMiddlewareParams::new(options, sender), + )); +} + +struct Fx { + server: NostrServerTransport, + server_rx: tokio::sync::mpsc::UnboundedReceiver, + client: NostrClientTransport, + client_rx: tokio::sync::mpsc::UnboundedReceiver, + pool: Arc, + client_pubkey: PublicKey, + server_pubkey: PublicKey, +} + +/// A paired client/server with the payment middleware registered before `start()`. +/// The client timeout is raised above every payment duration used here (see the module +/// doc for why). +async fn fixture( + verify_delay_ms: u64, + configure_config: impl FnOnce(NostrServerTransportConfig) -> NostrServerTransportConfig, + encryption: EncryptionMode, +) -> Fx { + fixture_modes(verify_delay_ms, configure_config, encryption, encryption).await +} + +/// Like [`fixture`], with independent server and client encryption modes (an encrypted +/// client on an `Optional` server is the one configuration where a per-message +/// encryption flag decides the wire, rather than the transport mode overriding it). +async fn fixture_modes( + verify_delay_ms: u64, + configure_config: impl FnOnce(NostrServerTransportConfig) -> NostrServerTransportConfig, + server_encryption: EncryptionMode, + client_encryption: EncryptionMode, +) -> Fx { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let client_pubkey = client_pool.mock_public_key(); + let pool = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + configure_config( + NostrServerTransportConfig::default().with_encryption_mode(server_encryption), + ), + as_pool(&pool), + ) + .await + .expect("server transport"); + register_payments(&mut server, verify_delay_ms, 21); + + 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(client_encryption) + .with_timeout(Duration::from_secs(30)), + Arc::new(client_pool), + ) + .await + .expect("client transport"); + + 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(20)).await; + + Fx { + server, + server_rx, + client, + client_rx, + pool, + client_pubkey, + server_pubkey, + } +} + +/// The client request event carrying `id`, as stored on the shared mock network. +async fn client_request_event(pool: &Arc, client: PublicKey, id: &str) -> Event { + let needle = format!("\"{id}\""); + pool.stored_events() + .await + .into_iter() + .find(|e| { + e.kind == Kind::Custom(CTXVM_MESSAGES_KIND) + && e.pubkey == client + && e.content.contains(&needle) + }) + .unwrap_or_else(|| panic!("client request {id} missing from the relay store")) +} + +// ── sweep survival ────────────────────────────────────────────────────────── + +/// The core sweep-survival property: a payment that outlives the stale-route sweep +/// still delivers its result, with the original request id and the correct `e` tag. +/// Open-stream stays disabled here, so the delivery is attributable to the payment +/// snapshot fallback alone. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_payment_outliving_the_route_sweep_still_delivers_its_result() { + let mut fx = fixture( + 400, // settles well after the 100 ms route TTL below + |c| { + c.with_request_timeout(Duration::from_millis(100)) + .with_cleanup_interval(Duration::from_millis(50)) + }, + EncryptionMode::Disabled, + ) + .await; + + fx.client.send(&paid_call("pay-1")).await.expect("send"); + let request_event = client_request_event(&fx.pool, fx.client_pubkey, "pay-1").await; + let request_event_id = request_event.id.to_hex(); + + // The invoice goes out while the route is fresh. + let required = wait_for_server_event( + &fx.pool, + fx.server_pubkey, + "payment_required", + Duration::from_secs(2), + ) + .await; + assert!( + all_tags(&required).contains(&vec!["e".to_string(), request_event_id.clone()]), + "payment_required must correlate to the request event" + ); + + // The request reaches the handler only after settlement. + let incoming = tokio::time::timeout(Duration::from_secs(3), fx.server_rx.recv()) + .await + .expect("the paid request must reach the handler") + .expect("server channel open"); + assert_eq!(incoming.event_id, request_event_id); + + // The route must be genuinely gone before the response is sent, so the delivery + // below is attributable to the snapshot path, not the ordinary one. + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while fx.server.has_event_route(&request_event_id).await { + assert!( + tokio::time::Instant::now() < deadline, + "the stale-route sweep must have reaped the route during the payment" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + fx.server + .send_response(&request_event_id, result_response("pay-1")) + .await + .expect("the swept-route response must deliver from the snapshot"); + + let response = wait_for_server_event( + &fx.pool, + fx.server_pubkey, + "\"content\"", + Duration::from_secs(2), + ) + .await; + let tags = all_tags(&response); + assert!( + tags.contains(&vec!["e".to_string(), request_event_id.clone()]), + "the delivered response must carry the request's e tag, got {tags:?}" + ); + assert!( + tags.contains(&vec!["p".to_string(), fx.client_pubkey.to_hex()]), + "the delivered response must address the client" + ); + assert!( + response.content.contains("\"pay-1\""), + "the delivered response must restore the client's own request id" + ); + let accepted = server_events_containing(&fx.pool, fx.server_pubkey, "payment_accepted").await; + assert_eq!(accepted.len(), 1, "settlement was acknowledged"); + + fx.server.close().await.expect("close"); +} + +/// A priced `tools/call` that carries a progressToken but never streams lands in the +/// open-stream deferral's passthrough branch with its writer slot deleted; the payment +/// snapshot at the route miss is then the only delivery path. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_paid_stream_request_that_never_streams_still_delivers() { + let mut fx = fixture( + 400, + |c| { + c.with_request_timeout(Duration::from_millis(100)) + .with_cleanup_interval(Duration::from_millis(50)) + .with_open_stream(OpenStreamConfig::default().with_enabled(true)) + }, + EncryptionMode::Disabled, + ) + .await; + + fx.client + .send(&paid_streaming_call("stream-none", "tok-16")) + .await + .expect("send"); + let request_event = client_request_event(&fx.pool, fx.client_pubkey, "stream-none").await; + let request_event_id = request_event.id.to_hex(); + + let incoming = tokio::time::timeout(Duration::from_secs(3), fx.server_rx.recv()) + .await + .expect("the paid request must reach the handler") + .expect("server channel open"); + assert_eq!(incoming.event_id, request_event_id); + + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while fx.server.has_event_route(&request_event_id).await { + assert!( + tokio::time::Instant::now() < deadline, + "route must be swept" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + // The tool "returns" without ever touching its writer. + fx.server + .send_response(&request_event_id, result_response("stream-none")) + .await + .expect("the never-streamed paid response must deliver"); + + let response = wait_for_server_event( + &fx.pool, + fx.server_pubkey, + "\"content\"", + Duration::from_secs(2), + ) + .await; + assert!(response.content.contains("\"stream-none\"")); + assert!(all_tags(&response).contains(&vec!["e".to_string(), request_event_id])); + + fx.server.close().await.expect("close"); +} + +// ── notification shape and disclosure ─────────────────────────────────────── + +/// `payment_required` carries the correlating `e` tag and never the effective-mode +/// disclosure; the disclosure still rides the next *response*. The latch state is +/// pinned: no server info and no availability advertisement are configured, so the +/// notification's expected tag list is exactly recipient plus correlation. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn payment_required_carries_the_e_tag_and_no_disclosure() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let pool = Arc::new(server_pool); + let client_pool = Arc::new(client_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(&pool), + ) + .await + .expect("server transport"); + register_payments(&mut server, 50, 21); + let mut server_rx = server.take_message_receiver().expect("rx"); + server.start().await.expect("start"); + tokio::time::sleep(Duration::from_millis(20)).await; + + let client_keys = Keys::generate(); + let client_pubkey = client_keys.public_key(); + + // A notification carrying the gating request to a server that does not support it: + // the session downgrades to transparent with the disclosure armed (a request would + // have drawn an invalid-params error instead; a notification cannot). + let init_notif = JsonRpcMessage::Notification(contextvm_sdk::JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/initialized".to_string(), + params: None, + }); + let mut tags = BaseTransport::create_recipient_tags(&server_pubkey); + tags.push(payment_interaction_tag( + PaymentInteractionMode::ExplicitGating, + )); + let notif_event = contextvm_sdk::core::serializers::mcp_to_nostr_event( + &init_notif, + CTXVM_MESSAGES_KIND, + tags, + ) + .expect("serialize") + .sign_with_keys(&client_keys) + .expect("sign"); + client_pool + .publish_event(¬if_event) + .await + .expect("publish"); + tokio::time::sleep(Duration::from_millis(30)).await; + + // The priced request itself carries no negotiation tags. + let call_event = contextvm_sdk::core::serializers::mcp_to_nostr_event( + &paid_call("disc-1"), + CTXVM_MESSAGES_KIND, + BaseTransport::create_recipient_tags(&server_pubkey), + ) + .expect("serialize") + .sign_with_keys(&client_keys) + .expect("sign"); + let request_event_id = call_event.id.to_hex(); + client_pool + .publish_event(&call_event) + .await + .expect("publish"); + + let required = wait_for_server_event( + &pool, + server_pubkey, + "payment_required", + Duration::from_secs(2), + ) + .await; + // The FULL tag list, latch state pinned: this is the session's first outbound + // event, so the one-shot discovery replay rides it, and with no server info and no + // availability advertisement configured that replay is exactly the internal + // oversized-support capability tag. In particular there is NO payment_interaction + // tag: a notification discharges no disclosure obligation. + assert_eq!( + all_tags(&required), + vec![ + vec!["p".to_string(), client_pubkey.to_hex()], + vec!["e".to_string(), request_event_id.clone()], + vec!["support_oversized_transfer".to_string()], + ], + "payment_required must carry exactly recipient, correlation, and the discovery replay" + ); + + // Drain the initialized notification and receive the paid request. + let incoming = loop { + let msg = tokio::time::timeout(Duration::from_secs(3), server_rx.recv()) + .await + .expect("the paid request must reach the handler") + .expect("channel open"); + if matches!(msg.message, JsonRpcMessage::Request(_)) { + break msg; + } + }; + server + .send_response(&incoming.event_id, result_response("disc-1")) + .await + .expect("respond"); + + let response = + wait_for_server_event(&pool, server_pubkey, "\"content\"", Duration::from_secs(2)).await; + assert!( + all_tags(&response).contains(&vec![ + "payment_interaction".to_string(), + "transparent".to_string() + ]), + "the next response still owes and carries the effective-mode disclosure, got {:?}", + all_tags(&response) + ); + + server.close().await.expect("close"); +} + +// ── duplicates and concurrency ────────────────────────────────────────────── + +/// A redelivered request event while the payment is in flight: the duplicate joins the +/// in-flight payment, its chain run pops the route, and the result is still delivered +/// from the snapshot. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_redelivered_request_event_still_gets_its_result() { + let mut fx = fixture( + 400, + |c| c.with_cleanup_interval(Duration::from_millis(50)), + EncryptionMode::Disabled, + ) + .await; + + fx.client.send(&paid_call("dup-1")).await.expect("send"); + let request_event = client_request_event(&fx.pool, fx.client_pubkey, "dup-1").await; + let request_event_id = request_event.id.to_hex(); + + // Redeliver the identical event while the payment is in flight. + tokio::time::sleep(Duration::from_millis(100)).await; + fx.pool + .publish_event(&request_event) + .await + .expect("redeliver"); + + // Exactly one delivery reaches the handler. + let incoming = tokio::time::timeout(Duration::from_secs(3), fx.server_rx.recv()) + .await + .expect("the paid request must reach the handler") + .expect("channel open"); + assert_eq!(incoming.event_id, request_event_id); + + // One charge on the wire. + let required = server_events_containing(&fx.pool, fx.server_pubkey, "payment_required").await; + assert_eq!(required.len(), 1, "the duplicate must not re-invoice"); + + // The duplicate's chain run pops the delivered request's route. + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while fx.server.has_event_route(&request_event_id).await { + assert!( + tokio::time::Instant::now() < deadline, + "the duplicate's cleanup must pop the route" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + fx.server + .send_response(&request_event_id, result_response("dup-1")) + .await + .expect("the response must deliver from the snapshot after the duplicate popped the route"); + + let response = wait_for_server_event( + &fx.pool, + fx.server_pubkey, + "\"content\"", + Duration::from_secs(2), + ) + .await; + assert!(response.content.contains("\"dup-1\"")); + assert!(all_tags(&response).contains(&vec!["e".to_string(), request_event_id])); + + fx.server.close().await.expect("close"); +} + +/// Two priced requests from one client overlap in wall time: the positive control for +/// the no-guard-across-await rule. An implementation that holds the pending-cache lock +/// across the verify serializes them and fails the bound below. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn two_priced_requests_from_one_client_overlap_in_wall_time() { + let mut fx = fixture(400, |c| c, EncryptionMode::Disabled).await; + + let started = tokio::time::Instant::now(); + fx.client.send(&paid_call("par-1")).await.expect("send 1"); + fx.client.send(&paid_call("par-2")).await.expect("send 2"); + + let first = tokio::time::timeout(Duration::from_secs(3), fx.server_rx.recv()) + .await + .expect("first paid request answered") + .expect("channel open"); + let second = tokio::time::timeout(Duration::from_secs(3), fx.server_rx.recv()) + .await + .expect("second paid request answered") + .expect("channel open"); + let elapsed = started.elapsed(); + + assert_ne!(first.event_id, second.event_id); + assert!( + elapsed < Duration::from_millis(700), + "two 400 ms payments must overlap, not serialize; took {elapsed:?}" + ); + + fx.server.close().await.expect("close"); +} + +// ── shutdown ──────────────────────────────────────────────────────────────── + +/// Closing the transport aborts an in-flight verification: the processor observes the +/// cancellation promptly and nothing is forwarded. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn closing_the_transport_aborts_an_in_flight_verify() { + let mut fx = fixture(10_000, |c| c, EncryptionMode::Disabled).await; + + fx.client.send(&paid_call("shut-1")).await.expect("send"); + // The invoice is out: the lifecycle is parked in the 10 s verify. + wait_for_server_event( + &fx.pool, + fx.server_pubkey, + "payment_required", + Duration::from_secs(2), + ) + .await; + + let close_started = tokio::time::Instant::now(); + fx.server.close().await.expect("close"); + assert!( + close_started.elapsed() < Duration::from_secs(5), + "close must not wait out a 10 s verify" + ); + + // The cancelled verify fails the lifecycle: nothing was (or will be) forwarded. + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + fx.server_rx.try_recv().is_err(), + "a request whose verify was aborted by shutdown must never reach the handler" + ); + let accepted = server_events_containing(&fx.pool, fx.server_pubkey, "payment_accepted").await; + assert!(accepted.is_empty(), "no settlement was acknowledged"); +} + +// ── the oversized re-inject dispatch site ─────────────────────────────────── + +/// One recorded dispatch: the correlation event id and the context's client PMIs. +type RecordedCtx = (String, Option>); + +/// Records the inbound context the (reassembled) request is dispatched with. +#[derive(Clone, Default)] +struct CtxRecorder(Arc>>); + +#[async_trait] +impl InboundMiddleware for CtxRecorder { + async fn handle(&self, message: JsonRpcMessage, ctx: &InboundContext, next: Next) -> bool { + if matches!(message, JsonRpcMessage::Request(_)) { + self.0 + .lock() + .unwrap() + .push((ctx.request_event_id.clone(), ctx.client_pmis.clone())); + } + next.run(message).await + } +} + +/// The oversized re-inject path is the second dispatch site, and it is gated exactly +/// like the primary one. The oversized send is the client's FIRST send (a small control +/// request first would spend the discovery tags, suppress the server's accept, and +/// abort the transfer), it carries a progressToken (nothing fragments without one), and +/// the re-injected context presents no client PMIs even though the client advertises +/// them (they ride the start frame, not the end frame the server keys identity from). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_oversized_priced_request_is_gated_on_the_re_inject_path() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let pool = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(&pool), + ) + .await + .expect("server transport"); + let recorder = CtxRecorder::default(); + server.add_inbound_middleware(Arc::new(recorder.clone())); + register_payments(&mut server, 50, 21); + let mut server_rx = server.take_message_receiver().expect("rx"); + server.start().await.expect("start"); + + 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_pmis(vec!["fake".to_string()]) + .with_timeout(Duration::from_secs(30)), + Arc::new(client_pool), + ) + .await + .expect("client transport"); + client.start().await.expect("client start"); + tokio::time::sleep(Duration::from_millis(20)).await; + + // An oversized priced request as the FIRST send, with a progressToken. + let blob = "x".repeat(200_000); + let oversized = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("big-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "name": "paid-tool", + "arguments": { "blob": blob }, + "_meta": { "progressToken": "tok-big" }, + })), + }); + client.send(&oversized).await.expect("oversized send"); + + // The reassembled request is gated: the invoice correlates to the END frame's + // carrying event, which is also the id the request is dispatched under. + let incoming = tokio::time::timeout(Duration::from_secs(5), server_rx.recv()) + .await + .expect("the reassembled paid request must reach the handler") + .expect("channel open"); + let end_frame_event_id = incoming.event_id.clone(); + + let required = wait_for_server_event( + &pool, + server_pubkey, + "payment_required", + Duration::from_secs(2), + ) + .await; + assert!( + all_tags(&required).contains(&vec!["e".to_string(), end_frame_event_id.clone()]), + "the invoice must correlate to the end frame's event id" + ); + + // The re-injected context presents no PMIs, even though the client advertised one: + // they rode the start frame. + let recorded = recorder.0.lock().unwrap().clone(); + let (recorded_id, recorded_pmis) = recorded + .iter() + .find(|(id, _)| *id == end_frame_event_id) + .expect("the re-injected request must pass through the chain") + .clone(); + assert_eq!(recorded_id, end_frame_event_id); + assert_eq!( + recorded_pmis, None, + "the end frame carries no pmi tags for this SDK's client" + ); + + server + .send_response(&end_frame_event_id, result_response("big-1")) + .await + .expect("respond"); + let response = + wait_for_server_event(&pool, server_pubkey, "\"content\"", Duration::from_secs(2)).await; + assert!(response.content.contains("\"big-1\"")); + + server.close().await.expect("close"); +} + +// ── encryption ────────────────────────────────────────────────────────────── + +/// The whole lifecycle under required encryption: the invoice, the acceptance and the +/// result all reach the client through gift wraps, and the server publishes nothing in +/// plaintext. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_encrypted_session_gates_and_delivers_end_to_end() { + let mut fx = fixture(50, |c| c, EncryptionMode::Required).await; + let client_rx = &mut fx.client_rx; + + fx.client.send(&paid_call("enc-1")).await.expect("send"); + + // The paid request reaches the handler after settlement; answer it. + let incoming = tokio::time::timeout(Duration::from_secs(3), fx.server_rx.recv()) + .await + .expect("the paid request must reach the handler") + .expect("channel open"); + fx.server + .send_response(&incoming.event_id, result_response("enc-1")) + .await + .expect("respond"); + + // The client decrypts the invoice off the gift wrap: the inner-tag correlation and + // the wrap path work end to end for a payment notification. + // + // Only the invoice is asserted through the client channel. The client transport + // currently consumes the response-correlation entry on the FIRST correlated + // message of any kind, so the acceptance notification and the paid response are + // dropped client-side as uncorrelated; delivering them to a paying client is the + // client-side payment work's to fix, and this test must not paper over it. Their + // publication is asserted on the wire below instead. + let invoice = tokio::time::timeout(Duration::from_secs(3), client_rx.recv()) + .await + .expect("the client must decrypt and receive the invoice") + .expect("client channel open"); + match invoice { + JsonRpcMessage::Notification(n) => { + assert_eq!(n.method, "notifications/payment_required"); + let params = n.params.expect("invoice params"); + assert_eq!(params.get("amount").unwrap(), 21); + assert_eq!(params.get("pmi").unwrap(), "fake"); + } + other => panic!("expected the invoice first, got {other:?}"), + } + + // Everything on the wire rides a gift wrap: no plaintext ContextVM event at all, + // and at least one wrap per lifecycle emission (the request, the invoice, the + // acceptance, the result). + let plaintext: Vec = fx + .pool + .stored_events() + .await + .into_iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .collect(); + assert!( + plaintext.is_empty(), + "an encrypted session must never emit plaintext payment traffic" + ); + let wraps = fx + .pool + .stored_events() + .await + .into_iter() + .filter(|e| e.kind == Kind::Custom(1059) || e.kind == Kind::Custom(21059)) + .count(); + assert!( + wraps >= 4, + "the request, the invoice, the acceptance and the result must each ride a \ + gift wrap; saw {wraps}" + ); + + fx.server.close().await.expect("close"); +} + +/// The sweep-survival path for an encrypted client: the snapshot must carry the +/// session's encryption state, or the paid result is published in plaintext after the +/// sweep. The server runs `EncryptionMode::Optional` deliberately: under `Required` the +/// transport mode forces encryption no matter what the snapshot says, so `Optional` +/// plus an encrypted client is the one configuration where the snapshot's own field +/// decides the wire. Every other sweep test runs plaintext, so this is the only +/// coverage of the snapshot's encrypted-delivery fields. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_encrypted_payment_outliving_the_sweep_still_delivers_encrypted() { + let mut fx = fixture_modes( + 400, + |c| { + c.with_request_timeout(Duration::from_millis(100)) + .with_cleanup_interval(Duration::from_millis(50)) + }, + EncryptionMode::Optional, + EncryptionMode::Required, + ) + .await; + let client_rx = &mut fx.client_rx; + + fx.client + .send(&paid_call("enc-sweep-1")) + .await + .expect("send"); + + // Positive control that the encrypted lifecycle is really running: the client + // decrypts the invoice off its gift wrap. + let invoice = tokio::time::timeout(Duration::from_secs(3), client_rx.recv()) + .await + .expect("the client must receive the invoice") + .expect("client channel open"); + match invoice { + JsonRpcMessage::Notification(n) => { + assert_eq!(n.method, "notifications/payment_required") + } + other => panic!("expected the invoice, got {other:?}"), + } + + let incoming = tokio::time::timeout(Duration::from_secs(3), fx.server_rx.recv()) + .await + .expect("the paid request must reach the handler") + .expect("channel open"); + + // The route must be genuinely gone, so the delivery below is the snapshot's. + let deadline = tokio::time::Instant::now() + Duration::from_secs(2); + while fx.server.has_event_route(&incoming.event_id).await { + assert!( + tokio::time::Instant::now() < deadline, + "route must be swept" + ); + tokio::time::sleep(Duration::from_millis(25)).await; + } + + let wraps_before = fx + .pool + .stored_events() + .await + .into_iter() + .filter(|e| e.kind == Kind::Custom(1059) || e.kind == Kind::Custom(21059)) + .count(); + + fx.server + .send_response(&incoming.event_id, result_response("enc-sweep-1")) + .await + .expect("the swept encrypted response must deliver from the snapshot"); + tokio::time::sleep(Duration::from_millis(50)).await; + + // The delivered result rides a gift wrap, and nothing the session produced is + // plaintext ContextVM. + let events = fx.pool.stored_events().await; + let wraps_after = events + .iter() + .filter(|e| e.kind == Kind::Custom(1059) || e.kind == Kind::Custom(21059)) + .count(); + assert!( + wraps_after > wraps_before, + "the snapshot delivery must publish a gift wrap" + ); + assert!( + !events + .iter() + .any(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)), + "an encrypted session's paid result must never be published in plaintext" + ); + + fx.server.close().await.expect("close"); +} + +// ── observability ─────────────────────────────────────────────────────────── + +/// The warn on a route-less snapshot capture is the only signal that a +/// `payment_required` was published for a request whose result can no longer be +/// delivered; pin that it fires. +#[tokio::test] +async fn a_missing_route_at_snapshot_time_warns() { + use std::io::Write; + use tracing_subscriber::fmt::MakeWriter; + + #[derive(Clone, Default)] + struct Capture(Arc>>); + impl Write for Capture { + fn write(&mut self, buf: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(buf); + Ok(buf.len()) + } + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } + } + impl<'a> MakeWriter<'a> for Capture { + type Writer = Capture; + fn make_writer(&'a self) -> Capture { + self.clone() + } + } + + let capture = Capture::default(); + let subscriber = tracing_subscriber::fmt() + .with_max_level(tracing::Level::WARN) + .with_writer(capture.clone()) + .finish(); + let _guard = tracing::subscriber::set_default(subscriber); + + let mut fx = fixture(50, |c| c, EncryptionMode::Disabled).await; + + // Establish a session (and then answer the request, so its route is gone). + fx.client.send(&paid_call("warm-1")).await.expect("send"); + let incoming = tokio::time::timeout(Duration::from_secs(3), fx.server_rx.recv()) + .await + .expect("request") + .expect("channel"); + fx.server + .send_response(&incoming.event_id, result_response("warm-1")) + .await + .expect("respond"); + + // A payment_required for an event id with no live route: the sender must warn and + // capture nothing, then still publish. + let sender = fx + .server + .payment_notification_sender(Duration::from_secs(300)); + let orphan = JsonRpcMessage::Notification(contextvm_sdk::JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/payment_required".to_string(), + params: Some(serde_json::json!({ "amount": 1, "pay_req": "inv", "pmi": "fake" })), + }); + sender(fx.client_pubkey.to_hex(), "ab".repeat(32), None, orphan) + .await + .expect("the publish itself succeeds"); + + let logged = String::from_utf8_lossy(&capture.0.lock().unwrap()).to_string(); + assert!( + logged.contains("route already gone"), + "the route-less capture must be observable; captured: {logged}" + ); + + fx.server.close().await.expect("close"); +} + +// ── liveness through the real worker ──────────────────────────────────────── + +#[cfg(feature = "rmcp")] +mod worker_liveness { + use super::*; + use rmcp::model::{ + Implementation, ProtocolVersion, ServerCapabilities, ServerInfo as RmcpServerInfo, + }; + use rmcp::{ServerHandler, ServiceExt}; + + struct LivenessServer; + impl ServerHandler for LivenessServer { + fn get_info(&self) -> RmcpServerInfo { + let mut info = + RmcpServerInfo::new(ServerCapabilities::builder().enable_tools().build()); + info.protocol_version = ProtocolVersion::LATEST; + info.server_info = Implementation::new("payments-liveness-server", "0.1.0"); + info + } + } + + fn wire_event(keys: &Keys, server: PublicKey, message: &JsonRpcMessage) -> Event { + contextvm_sdk::core::serializers::mcp_to_nostr_event( + message, + CTXVM_MESSAGES_KIND, + BaseTransport::create_recipient_tags(&server), + ) + .expect("serialize") + .sign_with_keys(keys) + .expect("sign") + } + + /// A priced request parked in a multi-minute verify must not delay an unpriced + /// request from the same client reaching the handler and being answered. + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] + async fn a_held_payment_does_not_stall_other_requests() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let pool = Arc::new(server_pool); + let client_pool = Arc::new(client_pool); + + let mut transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(&pool), + ) + .await + .expect("server transport"); + register_payments(&mut transport, 30_000, 21); + + let server_task = tokio::spawn(async move { + match LivenessServer.serve(transport).await { + Ok(running) => { + let _ = running.waiting().await; + } + Err(error) => panic!("serve failed: {error}"), + } + }); + tokio::time::sleep(Duration::from_millis(100)).await; + + let client_keys = Keys::generate(); + // The priced request parks in a 30 s verify... + client_pool + .publish_event(&wire_event( + &client_keys, + server_pubkey, + &paid_call("held-1"), + )) + .await + .expect("publish paid call"); + wait_for_server_event( + &pool, + server_pubkey, + "payment_required", + Duration::from_secs(3), + ) + .await; + + // ...while an unpriced request from the same client is answered promptly. + let free = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("free-1"), + method: "tools/list".to_string(), + params: Some(serde_json::json!({})), + }); + client_pool + .publish_event(&wire_event(&client_keys, server_pubkey, &free)) + .await + .expect("publish free call"); + + let answered = + wait_for_server_event(&pool, server_pubkey, "\"free-1\"", Duration::from_secs(3)).await; + assert!( + answered.content.contains("\"tools\""), + "the unpriced request must be answered while the payment is held; got {}", + answered.content + ); + + server_task.abort(); + } +}