From 33c31b972cfa30a9959fd79ad1504d8724435704 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Thu, 15 Oct 2026 00:42:38 +0530 Subject: [PATCH 1/6] fix(payments): harden client payment attempts and retries --- src/__mocks__/client-payments-harness.ts | 143 ++ src/payments/client-payment-attempts.ts | 89 ++ src/payments/client-payment-lifecycle.ts | 218 +++ src/payments/client-payment-options.ts | 137 ++ src/payments/client-payment-progress.ts | 56 + src/payments/client-payment-validation.ts | 144 ++ src/payments/client-payments-limits.test.ts | 334 +++++ .../client-payments-reliability.test.ts | 597 ++++++++ src/payments/client-payments.test.ts | 20 +- src/payments/client-payments.ts | 1266 +++++++---------- src/payments/types.ts | 2 + src/transport/nostr-client-transport.ts | 11 +- .../nostr-client/correlation-store.test.ts | 21 + .../nostr-client/correlation-store.ts | 8 + .../payment-client-reliability.e2e.test.ts | 149 ++ src/transport/payments-flow.test.ts | 53 +- 16 files changed, 2438 insertions(+), 810 deletions(-) create mode 100644 src/__mocks__/client-payments-harness.ts create mode 100644 src/payments/client-payment-attempts.ts create mode 100644 src/payments/client-payment-lifecycle.ts create mode 100644 src/payments/client-payment-options.ts create mode 100644 src/payments/client-payment-progress.ts create mode 100644 src/payments/client-payment-validation.ts create mode 100644 src/payments/client-payments-limits.test.ts create mode 100644 src/payments/client-payments-reliability.test.ts create mode 100644 src/transport/payment-client-reliability.e2e.test.ts diff --git a/src/__mocks__/client-payments-harness.ts b/src/__mocks__/client-payments-harness.ts new file mode 100644 index 0000000..c3a68f2 --- /dev/null +++ b/src/__mocks__/client-payments-harness.ts @@ -0,0 +1,143 @@ +import type { Transport } from '@contextvm/mcp-sdk/shared/transport'; +import type { + JSONRPCMessage, + JSONRPCRequest, +} from '@contextvm/mcp-sdk/types.js'; +import { + withClientPayments, + type ClientPaymentsOptions, +} from '../payments/client-payments.js'; +import type { PaymentOption } from '../payments/types.js'; +import { waitFor as waitForValue } from '../core/utils/test.utils.js'; + +/** Coordinates isolated test work without changing the global clock. */ +export async function waitFor( + condition: () => boolean, + options: { timeoutMs?: number } = {}, +): Promise { + await waitForValue({ + produce: () => (condition() ? true : undefined), + timeoutMs: options.timeoutMs ?? 1000, + intervalMs: 2, + }); +} + +/** Builds or advances isolated payment test state. */ +export const tick = (): Promise => + new Promise((resolve) => setTimeout(resolve, 0)); +/** Builds or advances isolated payment test state. */ +export const wait = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); +/** Builds or advances isolated payment test state. */ +export const request = (id: string | number = 1): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'paid', arguments: { value: id } }, +}); +/** Builds or advances isolated payment test state. */ +export const option = ( + pay_req = 'invoice', + amount = 5, + pmi = 'fake', +): PaymentOption => ({ amount, pmi, pay_req }); +/** Builds or advances isolated payment test state. */ +export const offer = (input: unknown = option()): JSONRPCMessage => + ({ + jsonrpc: '2.0', + method: 'notifications/payment_required', + params: input, + }) as JSONRPCMessage; +/** Builds or advances isolated payment test state. */ +export const required = ( + id: string | number = 1, + options: unknown = [option()], +): JSONRPCMessage => ({ + jsonrpc: '2.0', + id, + error: { + code: -32042, + message: 'Payment Required', + data: { payment_options: options }, + }, +}); +/** Builds or advances isolated payment test state. */ +export const pending = ( + id: string | number = 1, + retry_after?: unknown, +): JSONRPCMessage => ({ + jsonrpc: '2.0', + id, + error: { + code: -32043, + message: 'Payment Pending', + data: retry_after === undefined ? {} : { retry_after }, + }, +}); +/** Lets a test decide when an external payment action completes. */ +export function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +/** One transport, its wrapper, and all observable test outcomes. */ +export interface Harness { + paid: Transport; + base: Transport; + sent: JSONRPCMessage[]; + received: JSONRPCMessage[]; + errors: Error[]; + deliver: (message: JSONRPCMessage) => void; +} +/** Coordinates isolated test work without changing the global clock. */ +export async function run( + options: ClientPaymentsOptions, + body: (harness: Harness) => Promise, +): Promise { + const sent: JSONRPCMessage[] = []; + const received: JSONRPCMessage[] = []; + const errors: Error[] = []; + const base: Transport = { + async start(): Promise {}, + async send(message): Promise { + sent.push(message); + }, + async close(): Promise {}, // Deliberately does not fire onclose. + }; + const paid = withClientPayments(base, { minRetryDelayMs: 2, ...options }); + paid.onmessage = (message): void => { + received.push(message); + }; + paid.onerror = (error): void => { + errors.push(error); + }; + await paid.start(); + try { + await body({ + paid, + base, + sent, + received, + errors, + deliver: (message): void => base.onmessage?.(message), + }); + } finally { + await paid.close(); + } +} +/** Collects terminal errors observed by the test caller. */ +export function failures( + harness: Harness, +): Array<{ error: { data?: unknown } }> { + return harness.received.filter((message) => 'error' in message) as Array<{ + error: { data?: unknown }; + }>; +} diff --git a/src/payments/client-payment-attempts.ts b/src/payments/client-payment-attempts.ts new file mode 100644 index 0000000..a8ac981 --- /dev/null +++ b/src/payments/client-payment-attempts.ts @@ -0,0 +1,89 @@ +import type { PaymentOption } from './types.js'; +import { ClientPaymentError } from './client-payment-validation.js'; + +type AttemptOutcome = 'in_flight' | 'handled' | 'declined' | 'unknown'; + +/** A local payment action; handled means wallet callback returned, not settlement. */ +export interface ClientPaymentAttempt { + outcome: AttemptOutcome; + completion: Promise; + complete: (outcome: Exclude) => void; +} + +/** + * Wrapper-scoped attempt history. A Nostr wrapper is bound to one authenticated + * server; generic wrappers use their transport instance as the peer boundary. + * No live/completed/unknown entry is evicted and silently made payable again. + */ +export class ClientPaymentAttempts { + private readonly entries = new Map< + string, + { amount: number; attempt: ClientPaymentAttempt } + >(); + + public constructor(private readonly maxEntries: number) {} + + private key(option: PaymentOption): string { + return JSON.stringify([option.pmi, option.pay_req]); + } + + /** Finds a prior action without reserving or paying any new option. */ + public find( + options: readonly PaymentOption[], + ): ClientPaymentAttempt | undefined { + let found: ClientPaymentAttempt | undefined; + for (const option of options) { + const prior = this.entries.get(this.key(option)); + if (prior) { + if (prior.amount !== option.amount) { + throw new ClientPaymentError( + 'payment_offer_changed', + 'Payment amount changed for an existing payment identity', + ); + } + if ( + !found || + (found.outcome !== 'unknown' && + (prior.attempt.outcome === 'unknown' || + prior.attempt.outcome === 'in_flight')) + ) + found = prior.attempt; + } + } + return found; + } + + /** Atomically reserves all selectable options before external approval work. */ + public reserve( + options: readonly PaymentOption[], + signal: AbortSignal, + ): ClientPaymentAttempt { + const unique = new Map(options.map((option) => [this.key(option), option])); + if (this.entries.size + unique.size > this.maxEntries) { + throw new ClientPaymentError( + 'payment_attempt_capacity', + 'Client payment attempt capacity reached', + ); + } + let resolve!: () => void; + const attempt: ClientPaymentAttempt = { + outcome: 'in_flight', + completion: new Promise((done) => { + resolve = done; + }), + complete: (outcome): void => { + if (attempt.outcome !== 'in_flight') return; + attempt.outcome = outcome; + signal.removeEventListener('abort', onAbort); + resolve(); + }, + }; + const onAbort = (): void => attempt.complete('unknown'); + for (const [key, option] of unique) { + this.entries.set(key, { amount: option.amount, attempt }); + } + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + return attempt; + } +} diff --git a/src/payments/client-payment-lifecycle.ts b/src/payments/client-payment-lifecycle.ts new file mode 100644 index 0000000..ccc4cd2 --- /dev/null +++ b/src/payments/client-payment-lifecycle.ts @@ -0,0 +1,218 @@ +import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; +import { LruCache } from '../core/utils/lru-cache.js'; +import type { ClientPaymentLimits } from './client-payment-options.js'; +import type { PaymentOption } from './types.js'; +import { ClientPaymentError } from './client-payment-validation.js'; + +/** One caller request generation, including its automatic payment retries. */ +export interface ClientPaymentRequest { + key: string; + id?: string | number; + request?: JSONRPCRequest; + controller: AbortController; + active: boolean; + retries: number; + attempts: number; + paymentInFlight: boolean; + amounts: Map; + deadline?: number; + deadlineTimer?: ReturnType; + retryTimer?: ReturnType; +} + +/** Owns request generations, deadlines, limits, and retry timer cancellation. */ +export class ClientPaymentLifecycle { + private readonly requests: LruCache; + private closed = false; + + public constructor( + private readonly limits: ClientPaymentLimits | undefined, + private readonly onFailure: ( + request: ClientPaymentRequest, + error: ClientPaymentError, + ) => void, + private readonly onFinish: (request: ClientPaymentRequest) => void, + ) { + this.requests = new LruCache(1000, (_key, request) => { + if (!request.active) return; + this.finish(request); + this.onFailure( + request, + new ClientPaymentError( + 'payment_request_capacity', + 'Client request capacity reached', + ), + ); + }); + } + + /** Makes future generations usable; closed generations remain inactive. */ + public open(): void { + this.closed = false; + } + + /** Whether the wrapper has begun closing. */ + public get isClosed(): boolean { + return this.closed; + } + + /** Finds the current generation using a type-aware JSON-RPC identifier. */ + public get(id: string | number): ClientPaymentRequest | undefined { + return this.requests.get(JSON.stringify(['request', id])); + } + + private create( + key: string, + id?: string | number, + request?: JSONRPCRequest, + ): ClientPaymentRequest { + const prior = this.requests.get(key); + if (prior) this.finish(prior); + const state: ClientPaymentRequest = { + key, + id, + request, + active: true, + controller: new AbortController(), + retries: 0, + attempts: 0, + paymentInFlight: false, + amounts: new Map(), + }; + this.requests.set(key, state); + return state; + } + + /** Records the wire-shaped request before publishing and isolates it from callbacks. */ + public register(request: JSONRPCRequest): ClientPaymentRequest { + if (this.closed) throw new Error('Client payments transport is closed'); + const copy = JSON.parse(JSON.stringify(request)) as JSONRPCRequest; + return this.create( + JSON.stringify(['request', request.id]), + request.id, + copy, + ); + } + + /** Adopts correlation supplied by a transport when no original request was cached. */ + public correlated(id: string | number): ClientPaymentRequest { + return this.get(id) ?? this.create(JSON.stringify(['request', id]), id); + } + + /** Returns the sole live invocation for an uncorrelated generic transport. */ + public onlyRequest(): ClientPaymentRequest | undefined { + const active = [...this.requests.entries()] + .map(([, state]) => state) + .filter((state) => state.active && state.request); + return active.length === 1 ? active[0] : undefined; + } + + /** Whether a generic notification is ambiguous between multiple invocations. */ + public hasRequests(): boolean { + return [...this.requests.entries()].some( + ([, state]) => state.active && state.request !== undefined, + ); + } + + /** Tracks legacy standalone notifications in the generic transport's scope. */ + public detached(eventId: string): ClientPaymentRequest { + const key = JSON.stringify(['event', eventId]); + return this.requests.get(key) ?? this.create(key); + } + + /** Checks generation ownership and expires an elapsed payment deadline. */ + public isActive(state: ClientPaymentRequest): boolean { + if (this.closed || !state.active || this.requests.get(state.key) !== state) + return false; + if (state.deadline !== undefined && Date.now() >= state.deadline) { + this.expire(state); + return false; + } + return true; + } + + private expire(state: ClientPaymentRequest): void { + if (!state.active) return; + this.finish(state); + this.onFailure( + state, + new ClientPaymentError( + 'payment_deadline_exceeded', + 'Client payment deadline exceeded', + ), + ); + } + + /** Starts one deadline spanning every offer and pending response for this invocation. */ + public startPayment(state: ClientPaymentRequest): void { + if (this.limits?.timeoutMs === undefined || state.deadline !== undefined) + return; + state.deadline = Date.now() + this.limits.timeoutMs; + state.deadlineTimer = setTimeout( + () => this.expire(state), + this.limits.timeoutMs, + ); + } + + /** Reserves a conservative per-PMI approval budget before external code runs. */ + public approve( + state: ClientPaymentRequest, + options: readonly PaymentOption[], + ): PaymentOption[] { + const limits = this.limits; + if ( + limits?.maxAttempts !== undefined && + state.attempts >= limits.maxAttempts + ) { + throw new ClientPaymentError( + 'payment_limit_exceeded', + 'Client payment attempt limit exceeded', + ); + } + const allowed = options.filter((option) => { + if (limits?.maxAmountByPmi === undefined) return true; + const bound = limits.maxAmountByPmi[option.pmi]; + return ( + bound !== undefined && + option.amount <= bound - (state.amounts.get(option.pmi) ?? 0) + ); + }); + if (allowed.length === 0) { + throw new ClientPaymentError( + 'payment_limit_exceeded', + 'Client payment amount limit exceeded', + ); + } + const reserved = new Map(); + for (const option of allowed) + reserved.set( + option.pmi, + Math.max(reserved.get(option.pmi) ?? 0, option.amount), + ); + for (const [pmi, amount] of reserved) + state.amounts.set(pmi, (state.amounts.get(pmi) ?? 0) + amount); + state.attempts++; + return allowed; + } + + /** Reaps request-owned timers and signals before any terminal callback is delivered. */ + public finish(state: ClientPaymentRequest): void { + if (!state.active) return; + state.active = false; + clearTimeout(state.retryTimer); + clearTimeout(state.deadlineTimer); + state.retryTimer = undefined; + state.controller.abort(); + // Retain an inactive generation so late offers cannot revive a terminal id. + state.request = undefined; + state.amounts.clear(); + this.onFinish(state); + } + + /** Stops every current generation synchronously, even when transport close awaits I/O. */ + public close(): void { + this.closed = true; + for (const [, state] of [...this.requests.entries()]) this.finish(state); + this.requests.clear(); + } +} diff --git a/src/payments/client-payment-options.ts b/src/payments/client-payment-options.ts new file mode 100644 index 0000000..05b5d41 --- /dev/null +++ b/src/payments/client-payment-options.ts @@ -0,0 +1,137 @@ +import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; +import type { OriginalRequestContext } from '../transport/nostr-client/correlation-store.js'; +import type { + PaymentHandler, + PaymentHandlerRequest, + PaymentInteractionMode, + PaymentOption, +} from './types.js'; + +/** Limits for one request lifecycle, including replacement invoices. */ +export interface ClientPaymentLimits { + /** + * Cumulative advertised amount approved per PMI, in that PMI's amount unit. + * When set, unlisted PMIs cannot be paid automatically. Multi-option explicit + * callbacks reserve the largest offered amount for each PMI conservatively. + * Wallet handlers must verify the settlement payload amount; this generic + * policy cannot decode or constrain arbitrary PMI payloads or external wallets. + */ + maxAmountByPmi?: Readonly>; + /** Maximum distinct offers passed to policy/approval work. Zero refuses payment. */ + maxAttempts?: number; + /** Payment deadline from the first offer/pending response, in milliseconds. */ + timeoutMs?: number; +} + +/** Client-side CEP-8 payment handlers, approval policy, and lifecycle limits. */ +export interface ClientPaymentsOptions { + /** + * Payment handlers for in-band (programmatic) payment, indexed by their + * {@link PaymentHandler.pmi}. Each handler's `pmi` is advertised to the + * server so it can pick a matching rail (the wallet-client fast path). + * + * **Omit entirely** for a PMI-agnostic client that pays out-of-band: no PMIs + * are advertised, so per CEP-8 the server sends `payment_required` for any of + * its processors. The notification is forwarded to the application via + * `onmessage`, synthetic progress keeps the original MCP request alive while + * the payment settles out-of-band, and the server's TTL is the timeout. + */ + handlers?: readonly PaymentHandler[]; + /** Optional limits across all automatic payment offers for one invocation. */ + limits?: ClientPaymentLimits; + /** + * Maximum distinct payment identities retained by this wrapper. Completed and + * uncertain attempts are never evicted to make room for a new payment. + * At capacity, new attempts fail without invoking a wallet. @default 1000 + * Explicit callbacks reserve every offered identity because option selection + * is opaque; they must pay only one of the supplied options. + */ + maxTrackedPaymentAttempts?: number; + /** + * Interval for periodic synthetic progress heartbeats (milliseconds). + * + * One heartbeat is also emitted immediately when `payment_required` is + * received so the MCP timeout is always reset as soon as the payment flow + * begins. + * @default DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS (30_000 ms) + */ + syntheticProgressIntervalMs?: number; + /** + * Duration in ms for synthetic progress when `payment_required` carries no `ttl`. + * + * Mirrors the server-side `paymentTtlMs` default so the client keeps the MCP + * request alive for at least as long as the server will wait. + * @default DEFAULT_PAYMENT_TTL_MS (300_000 ms) + */ + defaultPaymentTtlMs?: number; + /** + * Minimum delay before an explicit-payment retry is re-sent + * (milliseconds), applied after the server-provided `retry_after` backoff. + * + * Guards against `retry_after: 0`: an immediate retry within the same second + * can produce a byte-identical Nostr event (same content, same tags, same + * `created_at` at second resolution), which relays and servers swallow as a + * duplicate — silently losing the retry. + * Nostr transports always enforce at least 1000 ms, including paid retries. + * Shorter delays are supported only by other transports. + * @default 1000 + */ + minRetryDelayMs?: number; + + /** + * Optional policy hook invoked when a `payment_required` notification is received. + * + * This is evaluated before checking rail-specific `canHandle` methods. + * + * When the underlying transport supports request correlation (e.g. {@link NostrClientTransport}), + * `originalRequestContext` provides minimal details about the original JSON-RPC request that + * triggered the payment. + */ + paymentPolicy?: ( + req: PaymentHandlerRequest, + originalRequestContext?: OriginalRequestContext, + ) => boolean | Promise; + + /** Requested payment interaction mode. @default 'transparent' */ + paymentInteraction?: PaymentInteractionMode; + + /** + * Maximum number of -32043 (Payment Pending) retries before giving up. + * + * With retry_after=2 and 1.5× exponential backoff capped at 10s, the default + * of 10 retries gives ~76s of cumulative wait — enough for typical verification + * flows. Increase for slow payment processors (e.g. on-chain confirmation). + * @default 10 + */ + maxPendingRetries?: number; + + /** + * Handler for explicit-gating -32042 errors. + * Called when a priced invocation returns Payment Required. + * The handler should pay one option and signal completion. + * + * **Error handling contract**: + * - If the promise resolves with `{ paid: true }`, the wrapper auto-retries the + * original request with the same `method` and `params`. + * - If the promise resolves with `{ paid: false, reason }`, the wrapper synthesizes + * a JSON-RPC error to the caller with code `-32042` and `data: { reason }`. + * Use `reason: 'user_cancelled'` for user-initiated cancellations. + * - If the promise **rejects**, the wrapper MUST NOT silently fall back. + * It synthesizes a JSON-RPC error with code `-32042` and + * `data: { reason: error.message, type: 'payment_handler_error' }`. + * - Transient payment-provider failures should reject with an Error whose + * `message` contains the provider error details. + * + * **Verify-timeout window**: if the server's verification times out or fails + * after the client paid, its pending state is cleared and the client's retry + * can receive a fresh `-32042` with a new invoice. Distinct invoices still + * pass through the invocation limits; an uncertain wallet outcome is terminal. + */ + onPaymentRequired?: (params: { + options: PaymentOption[]; + instructions?: string; + originalRequest: JSONRPCRequest; + /** Aborted on completion, cancellation, deadline, or close. Check before paying. */ + abortSignal: AbortSignal; + }) => Promise<{ paid: boolean; reason?: string }>; +} diff --git a/src/payments/client-payment-progress.ts b/src/payments/client-payment-progress.ts new file mode 100644 index 0000000..71bc2ab --- /dev/null +++ b/src/payments/client-payment-progress.ts @@ -0,0 +1,56 @@ +import type { JSONRPCMessage } from '@contextvm/mcp-sdk/types.js'; + +/** Maintains bounded synthetic MCP progress while transparent settlement is pending. */ +export class ClientPaymentProgress { + private readonly entries = new Map< + string, + { token: string | number; stopAt: number } + >(); + private timer?: ReturnType; + + public constructor( + private readonly intervalMs: number, + private readonly emit: (message: JSONRPCMessage) => void, + ) {} + + private tick(): void { + for (const [key, entry] of this.entries) { + if (Date.now() >= entry.stopAt) this.stop(key); + else this.heartbeat(entry.token); + } + } + + private heartbeat(token: string | number): void { + this.emit({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: token, progress: 0 }, + }); + } + + /** Starts an immediate heartbeat and periodic resets for the request's wire token. */ + public start(key: string, token: string, ttlMs: number): void { + if (this.entries.has(key)) return; + const wireToken = Number.isFinite(Number(token)) ? Number(token) : token; + this.entries.set(key, { token: wireToken, stopAt: Date.now() + ttlMs }); + if (!this.timer) + this.timer = setInterval(() => this.tick(), this.intervalMs); + this.heartbeat(wireToken); + } + + /** Stops only this generation's progress and reaps an idle scheduler. */ + public stop(key: string): void { + this.entries.delete(key); + if (this.entries.size === 0 && this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + /** Reaps every heartbeat during wrapper teardown. */ + public clear(): void { + this.entries.clear(); + clearInterval(this.timer); + this.timer = undefined; + } +} diff --git a/src/payments/client-payment-validation.ts b/src/payments/client-payment-validation.ts new file mode 100644 index 0000000..e86c555 --- /dev/null +++ b/src/payments/client-payment-validation.ts @@ -0,0 +1,144 @@ +import { z } from 'zod'; +import type { ClientPaymentsOptions } from './client-payment-options.js'; +import type { + PaymentOption, + PaymentPendingErrorData, + PaymentRequiredErrorData, +} from './types.js'; + +const MAX_TIMER_MS = 2_147_483_647; +const amountSchema = z + .number() + .finite() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER); +const pmiSchema = z.string().regex(/^[a-z0-9-]+$/); +const timerSchema = z.number().finite().positive().max(MAX_TIMER_MS); + +/** Internal payment failure with a stable application-visible reason. */ +export class ClientPaymentError extends Error { + public constructor( + public readonly reason: string, + message: string, + ) { + super(message); + this.name = 'ClientPaymentError'; + } +} + +/** Runtime offer schema; unknown extension metadata is preserved. */ +export const clientPaymentOptionSchema = z.object({ + amount: amountSchema, + pmi: pmiSchema, + pay_req: z + .string() + .min(1) + .refine((value) => value.trim().length > 0), + description: z.string().optional(), + ttl: z + .number() + .finite() + .positive() + .max(Number.MAX_SAFE_INTEGER / 1000) + .optional(), + _meta: z.record(z.string(), z.unknown()).optional(), +}); + +const requiredSchema = z.object({ + instructions: z.string().optional(), + payment_options: z + .array(clientPaymentOptionSchema) + .min(1) + .max(100) + .refine((options) => { + const amounts = new Map(); + for (const option of options) { + const key = JSON.stringify([option.pmi, option.pay_req]); + if (amounts.has(key) && amounts.get(key) !== option.amount) + return false; + amounts.set(key, option.amount); + } + return true; + }), +}); +const pendingSchema = z.object({ + instructions: z.string().optional(), + retry_after: z + .number() + .finite() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER / 1000) + .optional(), +}); + +function parse(schema: z.ZodType, input: unknown): T { + const result = schema.safeParse(input); + if (!result.success) { + throw new ClientPaymentError('invalid_payment', 'Invalid payment message'); + } + return result.data; +} + +/** Validates a transparent payment offer before invoking policy or wallet code. */ +export function parseClientPaymentOption(input: unknown): PaymentOption { + return parse(clientPaymentOptionSchema, input); +} + +/** Validates all explicit options, including options that might not be selected. */ +export function parseClientPaymentRequired( + input: unknown, +): PaymentRequiredErrorData { + return parse(requiredSchema, input); +} + +/** Validates pending backoff; omitted hints use the client's default. */ +export function parseClientPaymentPending( + input: unknown, +): PaymentPendingErrorData { + return parse(pendingSchema, input === undefined ? {} : input); +} + +/** Rejects invalid client configuration synchronously, before transport mutation. */ +export function validateClientPaymentOptions( + options: ClientPaymentsOptions, +): void { + const schema = z.object({ + syntheticProgressIntervalMs: timerSchema.optional(), + defaultPaymentTtlMs: timerSchema.optional(), + minRetryDelayMs: z + .number() + .finite() + .nonnegative() + .max(MAX_TIMER_MS) + .optional(), + maxPendingRetries: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .optional(), + maxTrackedPaymentAttempts: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .optional(), + paymentInteraction: z.enum(['transparent', 'explicit_gating']).optional(), + handlers: z.array(z.object({ pmi: pmiSchema })).optional(), + limits: z + .object({ + maxAmountByPmi: z.record(pmiSchema, amountSchema).optional(), + maxAttempts: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .optional(), + timeoutMs: timerSchema.optional(), + }) + .optional(), + }); + if (!schema.safeParse(options).success) { + throw new TypeError('Invalid client payment options'); + } +} diff --git a/src/payments/client-payments-limits.test.ts b/src/payments/client-payments-limits.test.ts new file mode 100644 index 0000000..a53c91b --- /dev/null +++ b/src/payments/client-payments-limits.test.ts @@ -0,0 +1,334 @@ +import { describe, expect, test } from 'bun:test'; +import type { Transport } from '@contextvm/mcp-sdk/shared/transport'; +import { withClientPayments } from './client-payments.js'; +import type { PaymentOption } from './types.js'; +import { + run, + waitFor, + tick, + wait, + request, + option, + offer, + required, + pending, + deferred, + failures, +} from '../__mocks__/client-payments-harness.js'; + +describe('client payment limits and validation', () => { + test('replacement invoices share an attempt allowance', async () => { + let calls = 0; + await run( + { + limits: { maxAttempts: 1 }, + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + h.deliver(required(1, [option('replacement')])); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_limit_exceeded', + }); + }, + ); + }); + + test('replacement invoices share a cumulative per-PMI amount limit', async () => { + let calls = 0; + await run( + { + limits: { maxAmountByPmi: { fake: 8 } }, + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + h.deliver(required(1, [option('replacement', 4)])); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(1); + }, + ); + }); + + test('multi-option offers reserve a conservative maximum per PMI and exclude unlisted rails', async () => { + const approved: PaymentOption[][] = []; + await run( + { + limits: { maxAmountByPmi: { fake: 10 } }, + onPaymentRequired: async ({ options }) => { + approved.push(options); + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver( + required(1, [ + option('low', 2), + option('high', 8), + option('other', 1, 'other'), + ]), + ); + await waitFor(() => h.sent.length === 2); + expect(approved[0]).toEqual([option('low', 2), option('high', 8)]); + h.deliver(required(1, [option('replacement', 3)])); + await waitFor(() => failures(h).length === 1); + expect(approved).toHaveLength(1); + }, + ); + }); + + test('limits are independent for genuinely new invocations', async () => { + let calls = 0; + await run( + { + limits: { maxAttempts: 1, maxAmountByPmi: { fake: 5 } }, + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + h.deliver({ jsonrpc: '2.0', id: 1, result: {} }); + await h.paid.send(request(2)); + h.deliver(required(2, [option('independent')])); + await waitFor(() => calls === 2); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('zero limits refuse wallet work before policy approval', async () => { + let policies = 0; + let payments = 0; + await run( + { + limits: { maxAttempts: 0 }, + paymentPolicy: () => { + policies++; + return true; + }, + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + payments++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + await waitFor(() => failures(h).length === 1); + expect(policies).toBe(0); + expect(payments).toBe(0); + }, + ); + }); + + test('deadline aborts awaiting approval and prevents a later retry', async () => { + const decision = deferred<{ paid: boolean }>(); + let signal: AbortSignal | undefined; + await run( + { + limits: { timeoutMs: 10 }, + onPaymentRequired: async ({ abortSignal }) => { + signal = abortSignal; + return decision.promise; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => failures(h).length === 1); + expect(signal?.aborted).toBe(true); + decision.resolve({ paid: true }); + await wait(10); + expect(h.sent).toHaveLength(1); + expect(failures(h)).toHaveLength(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_deadline_exceeded', + }); + }, + ); + }); + + for (const invalid of [ + { ...option(), amount: -1 }, + { ...option(), amount: NaN }, + { ...option(), amount: Infinity }, + { ...option(), amount: Number.MAX_SAFE_INTEGER + 1 }, + { ...option(), amount: '5' }, + { ...option(), pmi: 'INVALID:PMI' }, + { ...option(), pay_req: '' }, + { ...option(), pay_req: ' ' }, + { ...option(), ttl: 0 }, + { ...option(), ttl: -1 }, + { ...option(), ttl: Infinity }, + { ...option(), _meta: [] }, + null, + ]) { + test(`rejects malformed transparent offer ${JSON.stringify(invalid)}`, async () => { + let calls = 0; + await run( + { + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer(invalid)); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(0); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'invalid_payment', + }); + }, + ); + }); + } + + for (const invalid of [ + [], + [option(), { ...option('invalid'), amount: -1 }], + [option(), option('invoice', 6)], + ]) { + test(`validates every explicit option ${JSON.stringify(invalid)}`, async () => { + let calls = 0; + await run( + { + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required(1, invalid)); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(0); + }, + ); + }); + } + + for (const invalid of [-1, NaN, Infinity, '1', null]) { + test(`invalid retry hint ${String(invalid)} cannot schedule a send`, async () => { + await run({}, async (h) => { + await h.paid.send(request()); + h.deliver(pending(1, invalid)); + await waitFor(() => failures(h).length === 1); + await wait(5); + expect(h.sent).toHaveLength(1); + }); + }); + } + + test('omitted retry hint uses a finite default delay', async () => { + await run({}, async (h) => { + await h.paid.send(request()); + h.deliver(pending()); + await waitFor(() => h.sent.length === 2, { timeoutMs: 1500 }); + expect(failures(h)).toHaveLength(0); + }); + }); + + test('unknown metadata is preserved without influencing validation', async () => { + let metadata: unknown; + await run( + { + handlers: [ + { + pmi: 'fake', + async handle(req): Promise { + metadata = req._meta; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver( + offer({ + ...option(), + _meta: { custom: { future: true } }, + extra: 'ignored', + }), + ); + await waitFor(() => metadata !== undefined); + expect(metadata).toEqual({ custom: { future: true } }); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('ambiguous uncorrelated notifications never choose between concurrent requests', async () => { + let calls = 0; + await run( + { + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + await h.paid.send(request(2)); + h.deliver(offer()); + await tick(); + expect(calls).toBe(0); + expect(h.errors).toHaveLength(1); + }, + ); + }); + + for (const options of [ + { minRetryDelayMs: -1 }, + { minRetryDelayMs: NaN }, + { maxPendingRetries: 0.5 }, + { maxTrackedPaymentAttempts: -1 }, + { syntheticProgressIntervalMs: 0 }, + { limits: { timeoutMs: 0 } }, + { limits: { timeoutMs: 2_147_483_648 } }, + { limits: { maxAttempts: -1 } }, + { limits: { maxAmountByPmi: { fake: Infinity } } }, + ]) { + test(`configuration validation rejects ${JSON.stringify(options)} before wrapping`, () => { + const base: Transport = { + async start(): Promise {}, + async send(): Promise {}, + async close(): Promise {}, + }; + expect(() => withClientPayments(base, options)).toThrow( + 'Invalid client payment options', + ); + expect(() => withClientPayments(base, {})).not.toThrow(); + }); + } +}); diff --git a/src/payments/client-payments-reliability.test.ts b/src/payments/client-payments-reliability.test.ts new file mode 100644 index 0000000..27aa664 --- /dev/null +++ b/src/payments/client-payments-reliability.test.ts @@ -0,0 +1,597 @@ +import { describe, expect, test } from 'bun:test'; +import type { JSONRPCMessage } from '@contextvm/mcp-sdk/types.js'; +import { + run, + waitFor, + tick, + wait, + request, + option, + offer, + required, + pending, + deferred, + failures, +} from '../__mocks__/client-payments-harness.js'; + +describe('client payment attempt reliability', () => { + test('retains transparent attempt history after handler return and invoice TTL', async () => { + let calls = 0; + await run( + { + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer({ ...option(), ttl: 1 })); + await waitFor(() => calls === 1); + await wait(1100); + h.deliver(offer(option())); + await tick(); + expect(calls).toBe(1); + }, + ); + }); + + test('deduplicates concurrent and sequential explicit approvals', async () => { + let calls = 0; + const decision = deferred<{ paid: boolean }>(); + await run( + { + onPaymentRequired: async () => { + calls++; + return decision.promise; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + h.deliver(required()); + expect(calls).toBe(1); + decision.resolve({ paid: true }); + await waitFor(() => h.sent.length === 2); + h.deliver(required()); + await waitFor(() => h.sent.length === 3); + expect(calls).toBe(1); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('an uncertain wallet result cannot be paid again on another request id', async () => { + let calls = 0; + await run( + { + onPaymentRequired: async () => { + calls++; + throw new Error('wallet response lost'); + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => failures(h).length === 1); + await h.paid.send(request(2)); + h.deliver(required(2)); + await waitFor(() => failures(h).length === 2); + expect(calls).toBe(1); + expect(failures(h)[1]?.error.data).toMatchObject({ + reason: 'payment_outcome_unknown', + }); + }, + ); + }); + + test('different PMIs with the same opaque request remain independent', async () => { + let calls = 0; + await run( + { + handlers: ['fake', 'other'].map((pmi) => ({ + pmi, + async handle(): Promise { + calls++; + }, + })), + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + await waitFor(() => calls === 1); + h.deliver({ jsonrpc: '2.0', id: 1, result: {} }); + await h.paid.send(request(2)); + h.deliver(offer(option('invoice', 5, 'other'))); + await waitFor(() => calls === 2); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('attempt history is isolated between stable transport peers', async () => { + let calls = 0; + const options = { + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }; + await run(options, async (one) => { + await run(options, async (two) => { + await one.paid.send(request()); + await two.paid.send(request()); + one.deliver(offer()); + two.deliver(offer()); + await waitFor(() => calls === 2); + expect(failures(one)).toHaveLength(0); + expect(failures(two)).toHaveLength(0); + }); + }); + }); + + test('attempt capacity refuses a new invoice without forgetting a completed payment', async () => { + let calls = 0; + await run( + { + maxTrackedPaymentAttempts: 1, + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + h.deliver({ jsonrpc: '2.0', id: 1, result: {} }); + await h.paid.send(request(2)); + h.deliver(required(2, [option('new-invoice')])); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_attempt_capacity', + }); + await h.paid.send(request(3)); + h.deliver(required(3)); + await waitFor(() => h.sent.length === 5); + expect(calls).toBe(1); + }, + ); + }); + + test('changed amount for a known invoice never triggers another approval', async () => { + let calls = 0; + await run( + { + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + h.deliver(required(1, [option('invoice', 6)])); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_offer_changed', + }); + }, + ); + }); + + test('different offers cannot run approval callbacks concurrently for one invocation', async () => { + let calls = 0; + const decision = deferred<{ paid: boolean }>(); + await run( + { + onPaymentRequired: async () => { + calls++; + return decision.promise; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + h.deliver(required(1, [option('replacement')])); + await waitFor(() => failures(h).length === 1); + decision.resolve({ paid: true }); + await tick(); + expect(calls).toBe(1); + expect(h.sent).toHaveLength(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_outcome_unknown', + }); + }, + ); + }); +}); + +test('an unknown option takes precedence over another pending payment attempt', async () => { + const pendingDecision = deferred<{ paid: boolean }>(); + let approvals = 0; + await run( + { + onPaymentRequired: async ({ options }) => { + approvals++; + if (options[0]?.pay_req === 'unknown') + throw new Error('lost wallet result'); + return pendingDecision.promise; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required(1, [option('unknown')])); + await waitFor(() => failures(h).length === 1); + await h.paid.send(request(2)); + h.deliver(required(2, [option('pending')])); + await h.paid.send(request(3)); + h.deliver(required(3, [option('unknown'), option('pending')])); + await waitFor(() => failures(h).length === 2); + expect(failures(h)[1]?.error.data).toMatchObject({ + reason: 'payment_outcome_unknown', + }); + expect(approvals).toBe(2); + pendingDecision.resolve({ paid: false }); + await tick(); + }, + ); +}); + +describe('wallet eligibility and attempt retention', () => { + test('policy mutations cannot change the validated invoice or amount sent to a handler', async () => { + let handled: unknown; + await run( + { + paymentPolicy: (req) => { + req.amount = 100; + req.pay_req = 'changed'; + return true; + }, + handlers: [ + { + pmi: 'fake', + async handle(req): Promise { + handled = req; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + await waitFor(() => handled !== undefined); + expect(handled).toMatchObject(option()); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('expiry during an eligibility check prevents a wallet action', async () => { + const eligibility = deferred(); + let calls = 0; + await run( + { + handlers: [ + { + pmi: 'fake', + canHandle: () => eligibility.promise, + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer({ ...option(), ttl: 0.001 })); + await wait(10); + eligibility.resolve(true); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(0); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_offer_expired', + }); + }, + ); + }); + + test('a failed initial send releases request state before late payment offers', async () => { + let calls = 0; + await run( + { + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + h.base.send = async (): Promise => { + throw new Error('publish failed'); + }; + await expect(h.paid.send(request())).rejects.toThrow('publish failed'); + h.deliver(required()); + await tick(); + expect(calls).toBe(0); + expect(h.received).toHaveLength(0); + }, + ); + }); + + test('closed/restarted wrappers retain completed offer history', async () => { + let calls = 0; + await run( + { + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + await h.paid.close(); + await h.paid.start(); + await h.paid.send(request(2)); + h.deliver(required(2)); + await waitFor(() => h.sent.length === 4); + expect(calls).toBe(1); + }, + ); + }); + + test('standalone generic notifications cannot bypass per-invocation limits', async () => { + let calls = 0; + await run( + { + limits: { maxAttempts: 1 }, + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + h.deliver(offer()); + await waitFor(() => h.errors.length === 1); + expect(calls).toBe(0); + expect(h.errors[0]).toMatchObject({ + reason: 'payment_request_uncorrelated', + }); + }, + ); + }); + + test('zero attempt-history capacity refuses a fresh offer before approval', async () => { + let calls = 0; + await run( + { + maxTrackedPaymentAttempts: 0, + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(0); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_attempt_capacity', + }); + }, + ); + }); +}); + +describe('client payment request lifecycle', () => { + for (const terminal of [ + { jsonrpc: '2.0', id: 1, result: {} }, + { jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'done' } }, + ] as JSONRPCMessage[]) { + test(`terminal ${'result' in terminal ? 'result' : 'error'} cancels a queued retry`, async () => { + await run({ minRetryDelayMs: 20 }, async (h) => { + await h.paid.send(request()); + h.deliver(pending(1, 0)); + h.deliver(terminal); + await wait(40); + expect(h.sent).toHaveLength(1); + expect(h.received).toEqual([terminal]); + }); + }); + } + + test('outbound MCP cancellation stops retries for numeric id zero only', async () => { + await run({ minRetryDelayMs: 20 }, async (h) => { + await h.paid.send(request(0)); + await h.paid.send(request('0')); + h.deliver(pending(0, 0)); + h.deliver(pending('0', 0)); + await h.paid.send({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 0 }, + }); + await wait(40); + const retries = h.sent.slice(3); + expect(retries).toHaveLength(1); + expect(retries[0]).toMatchObject({ id: '0' }); + }); + }); + + test('inbound cancellation prevents a delayed policy from starting the wallet', async () => { + let calls = 0; + const policy = deferred(); + await run( + { + paymentPolicy: () => policy.promise, + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + h.deliver({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 1 }, + }); + policy.resolve(true); + await tick(); + expect(calls).toBe(0); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('close fences approval even when the underlying transport never fires onclose', async () => { + const decision = deferred<{ paid: boolean }>(); + let signal: AbortSignal | undefined; + await run( + { + onPaymentRequired: async ({ abortSignal }) => { + signal = abortSignal; + return decision.promise; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await h.paid.close(); + expect(signal?.aborted).toBe(true); + decision.resolve({ paid: true }); + await wait(10); + expect(h.sent).toHaveLength(1); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('terminal result fences an awaiting handler eligibility check', async () => { + let calls = 0; + const eligibility = deferred(); + await run( + { + handlers: [ + { + pmi: 'fake', + canHandle: () => eligibility.promise, + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + h.deliver({ jsonrpc: '2.0', id: 1, result: {} }); + eligibility.resolve(true); + await tick(); + expect(calls).toBe(0); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('request-id reuse does not allow the previous generation to pay or fail the replacement', async () => { + const oldPolicy = deferred(); + let policies = 0; + let payments = 0; + await run( + { + paymentPolicy: () => (++policies === 1 ? oldPolicy.promise : true), + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + payments++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + await h.paid.send({ ...request(), params: { name: 'replacement' } }); + h.deliver(offer(option('new-invoice'))); + oldPolicy.resolve(false); + await waitFor(() => payments === 1); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('approval cannot mutate the cached retry invocation', async () => { + await run( + { + onPaymentRequired: async ({ originalRequest }) => { + originalRequest.id = 'changed'; + originalRequest.params = { name: 'changed' }; + return { paid: true }; + }, + }, + async (h) => { + const original = request(); + await h.paid.send(original); + original.params = { name: 'also-changed' }; + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + expect(h.sent[1]).toEqual(request()); + }, + ); + }); + + test('duplicate pending responses queue one retry and consume one retry allowance', async () => { + await run({ maxPendingRetries: 1, minRetryDelayMs: 20 }, async (h) => { + await h.paid.send(request()); + h.deliver(pending(1, 0)); + h.deliver(pending(1, 0)); + await waitFor(() => h.sent.length === 2); + expect(failures(h)).toHaveLength(0); + h.deliver(pending(1, 0)); + expect(failures(h)).toHaveLength(1); + await wait(30); + expect(h.sent).toHaveLength(2); + }); + }); + + test('a retry publish failure terminates the request once', async () => { + await run({}, async (h) => { + await h.paid.send(request()); + h.base.send = async (): Promise => { + throw new Error('relay failed'); + }; + h.deliver(pending(1, 0)); + await waitFor(() => failures(h).length === 1); + h.deliver(required()); + await tick(); + expect(failures(h)).toHaveLength(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_retry_failed', + }); + }); + }); +}); diff --git a/src/payments/client-payments.test.ts b/src/payments/client-payments.test.ts index fb039c8..b95bd01 100644 --- a/src/payments/client-payments.test.ts +++ b/src/payments/client-payments.test.ts @@ -66,6 +66,7 @@ describe('withClientPayments()', () => { pmi: 'fake', description: undefined, requestEventId: 'req-id', + abortSignal: expect.any(AbortSignal), }); }); @@ -396,6 +397,7 @@ describe('withClientPayments()', () => { amount: 1, method: 'tools/call', capability: 'tool:add', + reason: 'payment_declined', }); }); @@ -457,6 +459,7 @@ describe('withClientPayments()', () => { amount: 2, method: 'prompts/get', capability: 'prompt:hi', + reason: 'payment_declined', }); }); @@ -525,6 +528,7 @@ describe('withClientPayments()', () => { amount: 1, method: 'tools/call', capability: 'tool:paid', + reason: 'payment_negotiation_failed', }); }); @@ -883,8 +887,8 @@ describe('withClientPayments()', () => { { eventId: 'evt4', correlatedEventId: 'req-event-id-3' }, ); - // Wait for async processing - await new Promise((r) => setTimeout(r, 0)); + // Wait for the Nostr retry floor. + await new Promise((r) => setTimeout(r, 1100)); // Error should not be delivered to caller expect(observed).toHaveLength(0); @@ -1019,8 +1023,8 @@ describe('withClientPayments()', () => { expect(observed).toHaveLength(0); expect(sentMessage).toBeUndefined(); - // Wait for retry_after timer to fire - await new Promise((r) => setTimeout(r, 60)); + // Nostr transport enforces a one-second floor despite a shorter hint. + await new Promise((r) => setTimeout(r, 1100)); // Error should not be delivered to caller expect(observed).toHaveLength(0); @@ -1148,11 +1152,11 @@ describe('withClientPayments()', () => { // First two: intercepted and retried (not observed by caller). deliverPending(); - await new Promise((r) => setTimeout(r, 20)); + await new Promise((r) => setTimeout(r, 1100)); expect(observed).toHaveLength(0); deliverPending(); - await new Promise((r) => setTimeout(r, 25)); + await new Promise((r) => setTimeout(r, 1100)); expect(observed).toHaveLength(0); // Third: retry budget exhausted → -32043 reaches the caller. @@ -1230,8 +1234,8 @@ describe('withClientPayments()', () => { await new Promise((r) => setTimeout(r, 10)); expect(sentMessage).toBeUndefined(); - // After the floor: the original request is retried. - await new Promise((r) => setTimeout(r, 120)); + // Nostr retries always wait at least one second. + await new Promise((r) => setTimeout(r, 1100)); expect(sentMessage as unknown).toEqual({ jsonrpc: '2.0', id: 77, diff --git a/src/payments/client-payments.ts b/src/payments/client-payments.ts index 916a305..058b7d0 100644 --- a/src/payments/client-payments.ts +++ b/src/payments/client-payments.ts @@ -1,32 +1,41 @@ import type { Transport } from '@contextvm/mcp-sdk/shared/transport'; import { isJSONRPCNotification, + isJSONRPCRequest, isJSONRPCResultResponse, isJSONRPCErrorResponse, - type JSONRPCNotification, type JSONRPCMessage, - type JSONRPCRequest, type JSONRPCErrorResponse, } from '@contextvm/mcp-sdk/types.js'; -import { NostrClientTransport } from '../transport/nostr-client-transport.js'; -import type { TransportWithContext } from '../transport/nostr-client-transport.js'; +import { + NostrClientTransport, + type TransportWithContext, +} from '../transport/nostr-client-transport.js'; +import type { PendingRequest } from '../transport/nostr-client/correlation-store.js'; +import { createLogger } from '../core/utils/logger.js'; import type { PaymentHandler, - PaymentRejectedNotification, - PaymentRequiredNotification, PaymentHandlerRequest, - PaymentInteractionMode, PaymentOption, - PaymentRequiredErrorData, - PaymentPendingErrorData, + PaymentRequiredNotification, } from './types.js'; -import { LruCache } from '../core/utils/lru-cache.js'; -import { createLogger } from '../core/utils/logger.js'; -import type { - OriginalRequestContext, - PendingRequest, -} from '../transport/nostr-client/correlation-store.js'; - +import type { ClientPaymentsOptions } from './client-payment-options.js'; +import { + ClientPaymentAttempts, + type ClientPaymentAttempt, +} from './client-payment-attempts.js'; +import { + ClientPaymentLifecycle, + type ClientPaymentRequest, +} from './client-payment-lifecycle.js'; +import { ClientPaymentProgress } from './client-payment-progress.js'; +import { + ClientPaymentError, + parseClientPaymentOption, + parseClientPaymentRequired, + parseClientPaymentPending, + validateClientPaymentOptions, +} from './client-payment-validation.js'; import { DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS, DEFAULT_PAYMENT_TTL_MS, @@ -37,749 +46,529 @@ import { PAYMENT_PENDING_ERROR_CODE, } from './constants.js'; -export interface ClientPaymentsOptions { - /** - * Payment handlers for in-band (programmatic) payment, indexed by their - * {@link PaymentHandler.pmi}. Each handler's `pmi` is advertised to the - * server so it can pick a matching rail (the wallet-client fast path). - * - * **Omit entirely** for a PMI-agnostic client that pays out-of-band: no PMIs - * are advertised, so per CEP-8 the server sends `payment_required` for any of - * its processors. The notification is forwarded to the application via - * `onmessage`, synthetic progress keeps the original MCP request alive while - * the payment settles out-of-band, and the server's TTL is the timeout. - */ - handlers?: readonly PaymentHandler[]; - /** - * Interval for periodic synthetic progress heartbeats (milliseconds). - * - * One heartbeat is also emitted immediately when `payment_required` is - * received so the MCP timeout is always reset as soon as the payment flow - * begins. - * @default DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS (30_000 ms) - */ - syntheticProgressIntervalMs?: number; - /** - * Duration in ms for synthetic progress when `payment_required` carries no `ttl`. - * - * Mirrors the server-side `paymentTtlMs` default so the client keeps the MCP - * request alive for at least as long as the server will wait. - * @default DEFAULT_PAYMENT_TTL_MS (300_000 ms) - */ - defaultPaymentTtlMs?: number; - /** - * Minimum delay before a `-32043` Payment Pending retry is re-sent - * (milliseconds), applied after the server-provided `retry_after` backoff. - * - * Guards against `retry_after: 0`: an immediate retry within the same second - * can produce a byte-identical Nostr event (same content, same tags, same - * `created_at` at second resolution), which relays and servers swallow as a - * duplicate — silently losing the retry. - * @default 1000 - */ - minRetryDelayMs?: number; - - /** - * Optional policy hook invoked when a `payment_required` notification is received. - * - * This is evaluated before checking rail-specific `canHandle` methods. - * - * When the underlying transport supports request correlation (e.g. {@link NostrClientTransport}), - * `originalRequestContext` provides minimal details about the original JSON-RPC request that - * triggered the payment. - */ - paymentPolicy?: ( - req: PaymentHandlerRequest, - originalRequestContext?: OriginalRequestContext, - ) => boolean | Promise; - - /** Requested payment interaction mode. @default 'transparent' */ - paymentInteraction?: PaymentInteractionMode; - - /** - * Maximum number of -32043 (Payment Pending) retries before giving up. - * - * With retry_after=2 and 1.5× exponential backoff capped at 10s, the default - * of 10 retries gives ~45s of cumulative wait — enough for typical verification - * flows. Increase for slow payment processors (e.g. on-chain confirmation). - * @default 10 - */ - maxPendingRetries?: number; - - /** - * Handler for explicit-gating -32042 errors. - * Called when a priced invocation returns Payment Required. - * The handler should pay one option and signal completion. - * - * **Error handling contract**: - * - If the promise resolves with `{ paid: true }`, the wrapper auto-retries the - * original request with the same `method` and `params`. - * - If the promise resolves with `{ paid: false, reason }`, the wrapper synthesizes - * a JSON-RPC error to the caller with code `-32042` and `data: { reason }`. - * Use `reason: 'user_cancelled'` for user-initiated cancellations. - * - If the promise **rejects**, the wrapper MUST NOT silently fall back. - * It synthesizes a JSON-RPC error with code `-32042` and - * `data: { reason: error.message, type: 'payment_handler_error' }`. - * - Transient payment-provider failures should reject with an Error whose - * `message` contains the provider error details. - * - * **Verify-timeout window**: if the server's verification times out or fails - * after the client paid, its pending state is cleared and the client's retry - * receives a fresh `-32042` with a new invoice (CEP-8-compliant). The wrapper - * does not dedup across distinct `pay_req` values. - */ - onPaymentRequired?: (params: { - options: PaymentOption[]; - instructions?: string; - originalRequest: JSONRPCRequest; - }) => Promise<{ paid: boolean; reason?: string }>; -} - -type ProgressToken = string; +export type { + ClientPaymentsOptions, + ClientPaymentLimits, +} from './client-payment-options.js'; -type SyntheticProgressEntry = { - stopAtMs: number; - wireProgressToken: string | number; -}; - -function supportsOnmessageWithContext( - transport: Transport, -): transport is TransportWithContext { - // Avoid using the `in` operator here; prefer an ES3-compatible check. - return Object.prototype.hasOwnProperty.call( - transport, - 'onmessageWithContext', - ); -} +const clientPaymentsWrapped = new WeakSet(); -function isExplicitPaymentRequiredError( - msg: JSONRPCMessage, -): msg is JSONRPCErrorResponse { +function isPaymentOffer( + message: JSONRPCMessage, +): message is PaymentRequiredNotification { + // Classify by envelope before schema helpers can reject malformed params. return ( - isJSONRPCErrorResponse(msg) && - msg.error.code === PAYMENT_REQUIRED_ERROR_CODE && - typeof msg.error.data === 'object' && - msg.error.data !== null && - Array.isArray( - (msg.error.data as { payment_options?: unknown }).payment_options, - ) && - (msg.error.data as { payment_options: unknown[] }).payment_options.length > - 0 + 'method' in message && + message.method === PAYMENT_REQUIRED_METHOD && + !('id' in message) ); } -function isExplicitPaymentPendingError( - msg: JSONRPCMessage, -): msg is JSONRPCErrorResponse { +function isExplicitPaymentError( + message: JSONRPCMessage, +): message is JSONRPCErrorResponse { return ( - isJSONRPCErrorResponse(msg) && - msg.error.code === PAYMENT_PENDING_ERROR_CODE && - typeof msg.error.data === 'object' && - msg.error.data !== null && - typeof (msg.error.data as { retry_after?: unknown }).retry_after === - 'number' + isJSONRPCErrorResponse(message) && + (message.error.code === PAYMENT_REQUIRED_ERROR_CODE || + message.error.code === PAYMENT_PENDING_ERROR_CODE) ); } -function isPaymentRequiredNotification( - msg: JSONRPCMessage, -): msg is PaymentRequiredNotification { - return isJSONRPCNotification(msg) && msg.method === PAYMENT_REQUIRED_METHOD; +function cancelledRequestId( + message: JSONRPCMessage, +): string | number | undefined { + if ( + !isJSONRPCNotification(message) || + message.method !== 'notifications/cancelled' + ) + return undefined; + const id = message.params?.requestId; + return typeof id === 'string' || typeof id === 'number' ? id : undefined; } -/** Transports already wrapped by {@link withClientPayments}. */ -const clientPaymentsWrapped = new WeakSet(); - /** - * Wraps a transport to automatically handle CEP-8 payment requests. + * Wraps a transport with validated CEP-8 offers, bounded payment attempts, and + * request-owned retries. Nostr peers are authenticated by their transport; + * generic transports isolate attempt history per wrapper and must keep their + * peer identity stable. History is retained for the lifetime of the wrapper. * - * When `transport` is a {@link NostrClientTransport}, PMI advertisement and - * synthetic progress injection are enabled automatically. - * - * Note: `payment_rejected` → JSON-RPC error synthesis requires a - * {@link NostrClientTransport} because correlation (requestEventId → MCP request id) - * depends on Nostr event tagging. On plain transports a rejected payment surfaces - * as an unhandled notification and the MCP request times out naturally. + * Unmatched handlers leave transparent payment to the application. Generic + * notifications without request correlation support legacy handler execution, + * but per-invocation limits require an identifiable original request. */ export function withClientPayments( transport: Transport, options: ClientPaymentsOptions, ): Transport { - // A second wrap silently double-pays every offer (each wrapper runs its own - // pipeline with its own dedup on separate closures) or, for sibling wraps, - // silently kills the first wrapper's trampolines. Refuse outright. - if (clientPaymentsWrapped.has(transport)) { + if (clientPaymentsWrapped.has(transport)) throw new Error('withClientPayments already called on this transport'); - } + validateClientPaymentOptions(options); const logger = createLogger('client-payments'); - - const syntheticProgressIntervalMs = - options.syntheticProgressIntervalMs ?? - DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS; - - const defaultPaymentTtlMs = - options.defaultPaymentTtlMs ?? DEFAULT_PAYMENT_TTL_MS; - - const minRetryDelayMs = options.minRetryDelayMs ?? 1000; - - const syntheticProgress = new Map(); - - let syntheticProgressScheduler: ReturnType | undefined; - - const maybeStopScheduler = (): void => { - if (syntheticProgress.size > 0) return; - if (!syntheticProgressScheduler) return; - clearInterval(syntheticProgressScheduler); - syntheticProgressScheduler = undefined; + const nostr = + transport instanceof NostrClientTransport ? transport : undefined; + const minRetryDelayMs = Math.max( + options.minRetryDelayMs ?? 1000, + nostr ? 1000 : 0, + ); + const maxRetries = options.maxPendingRetries ?? 10; + const defaultTtlMs = options.defaultPaymentTtlMs ?? DEFAULT_PAYMENT_TTL_MS; + const limits = options.limits && { + ...options.limits, + maxAmountByPmi: options.limits.maxAmountByPmi && { + ...options.limits.maxAmountByPmi, + }, }; + let onmessage: Transport['onmessage']; + let onerror: Transport['onerror']; + let onclose: Transport['onclose']; + const progress = new ClientPaymentProgress( + options.syntheticProgressIntervalMs ?? + DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS, + (message) => onmessage?.(message), + ); - const tickSyntheticProgress = (): void => { - if (syntheticProgress.size === 0) { - maybeStopScheduler(); + const emitFailure = ( + state: ClientPaymentRequest, + error: ClientPaymentError, + code = -32000, + data: Record = {}, + ): void => { + if (state.id === undefined) { + onerror?.(error); return; } - - const now = Date.now(); - for (const [token, entry] of syntheticProgress) { - if (now >= entry.stopAtMs) { - syntheticProgress.delete(token); - continue; - } - - onmessage?.({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken: entry.wireProgressToken, - progress: 0, - }, - } as JSONRPCNotification); - } - - maybeStopScheduler(); - }; - - const ensureSchedulerStarted = (): void => { - if (syntheticProgressScheduler) return; - syntheticProgressScheduler = setInterval( - tickSyntheticProgress, - syntheticProgressIntervalMs, - ); - }; - - const stopSyntheticProgress = (token: ProgressToken): void => { - if (!syntheticProgress.delete(token)) return; - maybeStopScheduler(); - }; - - const pendingTimers = new Set>(); - // Bounded together with rawRequestCache: an abandoned request's counter must - // not accumulate forever (terminal responses and close() clear these, but a - // request that never terminates otherwise would leak). - const retryCounts = new LruCache(1000); - const rawRequestCache = new LruCache(1000); - const MAX_RETRIES = options.maxPendingRetries ?? 10; - - /** - * Disposes all client-side payment state: synthetic progress, pending retry - * timers, retry counters, and the raw-request cache. Called on transport close. - */ - const disposeClientState = (): void => { - syntheticProgress.clear(); - if (syntheticProgressScheduler) { - clearInterval(syntheticProgressScheduler); - syntheticProgressScheduler = undefined; - } - for (const timer of pendingTimers) { - clearTimeout(timer); - } - pendingTimers.clear(); - retryCounts.clear(); - rawRequestCache.clear(); - }; - - // Ensure CEP-8 discovery/negotiation: when using Nostr transports, always - // advertise the handler PMIs in preference order. Omitting handlers entirely - // advertises no PMI, so the server sends payment_required for any processor - // (CEP-8: client that specified no PMI). - const advertisedPmis = (options.handlers ?? []).map((h) => h.pmi); - if (transport instanceof NostrClientTransport) { - transport.setClientPmis(advertisedPmis); - logger.debug('advertised client PMIs', { pmis: advertisedPmis }); - if (options.paymentInteraction) { - transport.setPaymentInteraction(options.paymentInteraction); - logger.debug('advertised requested payment interaction mode', { - mode: options.paymentInteraction, - }); - } - } - - // Index handlers by PMI. Warn on duplicates — Map construction silently keeps - // only the last. - const handlersByPmi = new Map(); - for (const h of options.handlers ?? []) { - if (handlersByPmi.has(h.pmi)) { - logger.warn('duplicate PMI handler registered, last one wins', { - pmi: h.pmi, - }); - } - handlersByPmi.set(h.pmi, h); - } - - // Prevent double-paying if relays or servers deliver duplicate payment_required notifications. - const inFlightPayReqs = new Set(); - - let onmessage: ((message: JSONRPCMessage) => void) | undefined; - let onerror: ((error: Error) => void) | undefined; - let onclose: (() => void) | undefined; - - /** Emits a synthesized JSON-RPC error to the upstream consumer via `onmessage`. */ - const synthesizePaymentError = (params: { - id: string | number | undefined; - code: number; - message: string; - data: Record; - }): void => { onmessage?.({ jsonrpc: '2.0', - id: params.id, + id: state.id, error: { - code: params.code, - message: params.message, - data: params.data, + code, + message: error.message, + data: { reason: error.reason, ...data }, }, - } as JSONRPCMessage); + }); + }; + const lifecycle = new ClientPaymentLifecycle(limits, emitFailure, (state) => { + progress.stop(state.key); + if (state.id !== undefined) + nostr?.forgetPendingRequestsForRequestId(state.id); + }); + const attempts = new ClientPaymentAttempts( + options.maxTrackedPaymentAttempts ?? 1000, + ); + const handlers = new Map(); + for (const handler of options.handlers ?? []) { + if (handlers.has(handler.pmi)) + logger.warn('duplicate PMI handler registered, last one wins', { + pmi: handler.pmi, + }); + handlers.set(handler.pmi, handler); + } + nostr?.setClientPmis([...handlers.keys()]); + if (options.paymentInteraction) + nostr?.setPaymentInteraction(options.paymentInteraction); + + const fail = ( + state: ClientPaymentRequest, + error: ClientPaymentError, + code = -32000, + data: Record = {}, + ): void => { + if (!lifecycle.isActive(state)) return; + lifecycle.finish(state); + emitFailure(state, error, code, data); }; - /** - * Synthesize a generic `-32000` decline for the original request, carrying the - * PMI/amount and the original request's method/capability. No-op when there - * is no correlated pending request to fail. Shared by the no-handler, - * explicit-gating-rejected, and payment_rejected paths. - */ - const synthesizePaymentDecline = ( - pending: PendingRequest | undefined, - message: string, - pmi: string, - amount: number, + const scheduleRetry = ( + state: ClientPaymentRequest, + delayMs: number, + code: number, ): void => { - if (pending?.originalRequestId == null) { + if ( + !lifecycle.isActive(state) || + !state.request || + state.retryTimer !== undefined + ) return; - } - synthesizePaymentError({ - id: pending.originalRequestId, - code: -32000, - message, - data: { - pmi, - amount, - method: pending.originalRequestContext?.method, - capability: pending.originalRequestContext?.capability, + state.retryTimer = setTimeout( + () => { + state.retryTimer = undefined; + if (!lifecycle.isActive(state) || !state.request) return; + void transport.send(state.request).catch((error: unknown) => { + fail( + state, + new ClientPaymentError( + 'payment_retry_failed', + 'Failed to retry payment request', + ), + code, + { detail: error instanceof Error ? error.message : String(error) }, + ); + }); }, - }); + Math.max(delayMs, minRetryDelayMs), + ); }; - /** - * Handles explicit-gating -32042 (invoke `onPaymentRequired`, then retry) and - * -32043 (backoff, then retry). Both are intercepted here, never forwarded. - */ - async function handleExplicitPaymentError( - message: JSONRPCMessage, - requestEventId: string, - ): Promise { - if (isExplicitPaymentRequiredError(message)) { - const errorMsg = message; - const data = errorMsg.error.data as PaymentRequiredErrorData; + const priorOutcome = async ( + state: ClientPaymentRequest, + attempt: ClientPaymentAttempt, + explicit: boolean, + ): Promise => { + await attempt.completion; + if (!lifecycle.isActive(state)) return; + if (attempt.outcome === 'handled') { + if (explicit) + scheduleRetry(state, minRetryDelayMs, PAYMENT_PENDING_ERROR_CODE); + } else { + fail( + state, + new ClientPaymentError( + attempt.outcome === 'unknown' + ? 'payment_outcome_unknown' + : 'payment_previously_declined', + attempt.outcome === 'unknown' + ? 'Payment outcome is uncertain; reconcile before paying again' + : 'Payment was previously declined', + ), + explicit ? PAYMENT_REQUIRED_ERROR_CODE : -32000, + { outcome: attempt.outcome }, + ); + } + }; - if (!options.onPaymentRequired) { + async function explicitPayment(message: JSONRPCErrorResponse): Promise { + const state = message.id != null ? lifecycle.get(message.id) : undefined; + if (!state) { + onmessage?.(message); + return; + } + if (!lifecycle.isActive(state)) return; + lifecycle.startPayment(state); + if (message.error.code === PAYMENT_PENDING_ERROR_CODE) { + const data = parseClientPaymentPending(message.error.data); + if (!state.request) { + lifecycle.finish(state); onmessage?.(message); return; } - - const requestId = errorMsg.id; - const rawRequest = - requestId != null - ? rawRequestCache.get(JSON.stringify(requestId)) - : undefined; - if (!rawRequest) { - logger.warn( - 'missing raw original request, cannot retry explicit payment', - { requestEventId }, - ); + if (state.retryTimer !== undefined) return; + if (state.retries >= maxRetries) { + lifecycle.finish(state); onmessage?.(message); return; } - - logger.info('invoking onPaymentRequired for explicit gating', { - requestEventId, - optionsCount: data.payment_options.length, - }); - - try { - const result = await options.onPaymentRequired({ - options: data.payment_options, - instructions: data.instructions, - originalRequest: rawRequest, - }); - - if (result.paid) { - logger.info('explicit payment satisfied, retrying original request', { - requestEventId, - method: rawRequest.method, - }); - await transport.send(rawRequest); - return; - } - - logger.debug('onPaymentRequired returned paid=false', { - requestEventId, - reason: result.reason, - }); - synthesizePaymentError({ - id: errorMsg.id, - code: PAYMENT_REQUIRED_ERROR_CODE, - message: 'Payment Required', - data: { reason: result.reason || 'user_cancelled' }, - }); - } catch (err) { - logger.error('onPaymentRequired callback failed', { - requestEventId, - error: err instanceof Error ? err.message : String(err), - }); - synthesizePaymentError({ - id: errorMsg.id, - code: PAYMENT_REQUIRED_ERROR_CODE, - message: 'Payment Required', - data: { - reason: err instanceof Error ? err.message : String(err), - type: 'payment_handler_error', - }, - }); - } + const multiplier = Math.pow(1.5, state.retries++); + const baseDelay = (data.retry_after ?? 1) * 1000; + const delay = + baseDelay === 0 ? 0 : Math.min(baseDelay * multiplier, 10000); + scheduleRetry(state, delay, PAYMENT_PENDING_ERROR_CODE); return; } - - // -32043 Payment Pending - if (isExplicitPaymentPendingError(message)) { - const errorMsg = message; - const data = errorMsg.error.data as PaymentPendingErrorData; - const retryAfterSeconds = data.retry_after; - - const requestId = errorMsg.id; - const rawRequest = - requestId != null - ? rawRequestCache.get(JSON.stringify(requestId)) - : undefined; - if (!rawRequest) { - logger.warn( - 'missing raw original request, cannot retry explicit payment pending', - { requestEventId }, + const data = parseClientPaymentRequired(message.error.data); + if (!options.onPaymentRequired || !state.request) { + lifecycle.finish(state); + onmessage?.(message); + return; + } + const prior = attempts.find(data.payment_options); + if (prior) { + await priorOutcome(state, prior, true); + return; + } + if (state.paymentInFlight) { + throw new ClientPaymentError( + 'payment_outcome_unknown', + 'Another payment decision is still in progress for this invocation', + ); + } + const allowed = lifecycle.approve(state, data.payment_options); + const attempt = attempts.reserve(allowed, state.controller.signal); + state.paymentInFlight = true; + try { + const result = await options.onPaymentRequired({ + options: allowed, + instructions: data.instructions, + originalRequest: JSON.parse( + JSON.stringify(state.request), + ) as NonNullable, + abortSignal: state.controller.signal, + }); + if (!lifecycle.isActive(state)) return; + if (result.paid) { + attempt.complete('handled'); + scheduleRetry(state, minRetryDelayMs, PAYMENT_REQUIRED_ERROR_CODE); + } else { + attempt.complete('declined'); + fail( + state, + new ClientPaymentError( + result.reason || 'user_cancelled', + 'Payment Required', + ), + PAYMENT_REQUIRED_ERROR_CODE, ); - onmessage?.(message); - return; } + } catch (error: unknown) { + attempt.complete('unknown'); + if (lifecycle.isActive(state)) + fail( + state, + new ClientPaymentError( + error instanceof Error ? error.message : String(error), + 'Payment Required', + ), + PAYMENT_REQUIRED_ERROR_CODE, + { type: 'payment_handler_error', outcome: 'unknown' }, + ); + } finally { + state.paymentInFlight = false; + } + } - // Type-aware key: JSON.stringify keeps numeric 5 and string "5" - // distinct, and must be used consistently for both maps below. - const requestIdKey = JSON.stringify(errorMsg.id); - const retries = retryCounts.get(requestIdKey) ?? 0; - if (retries >= MAX_RETRIES) { - logger.error('max explicit payment retries exceeded', { - requestEventId, - id: requestIdKey, - maxRetries: MAX_RETRIES, - }); - onmessage?.(message); - return; - } - - retryCounts.set(requestIdKey, retries + 1); - - logger.info('payment pending, retrying after backoff', { - requestEventId, - retryAfterSeconds, - retryCount: retries + 1, - }); - - const baseDelayMs = (retryAfterSeconds ?? 1) * 1000; - const exponentialMultiplier = Math.pow(1.5, retries); - // Floor at minRetryDelayMs: a sub-second retry can produce a - // byte-identical Nostr event (created_at has second resolution), which - // relays and servers swallow as a duplicate. - const delayMs = Math.min( - Math.max(baseDelayMs * exponentialMultiplier, minRetryDelayMs), - 10000, + function transparentState( + eventId: string, + pending?: PendingRequest, + ): ClientPaymentRequest | undefined { + if (nostr && !pending) return undefined; + if (pending?.originalRequestId != null) + return lifecycle.correlated(pending.originalRequestId); + const state = lifecycle.onlyRequest(); + if (state) return state; + if (lifecycle.hasRequests()) { + onerror?.( + new ClientPaymentError( + 'payment_request_uncorrelated', + 'Payment notification is ambiguous between active requests', + ), ); - - const timer = setTimeout(() => { - pendingTimers.delete(timer); - transport.send(rawRequest).catch((err) => { - logger.error('failed to retry pending request', { - requestEventId, - error: err instanceof Error ? err.message : String(err), - }); - synthesizePaymentError({ - id: rawRequest.id, - code: PAYMENT_PENDING_ERROR_CODE, - message: 'Failed to retry pending request', - data: { - reason: err instanceof Error ? err.message : String(err), - }, - }); - }); - }, delayMs); - pendingTimers.add(timer); + return undefined; } + return lifecycle.detached(eventId); } - /** - * Handles transparent `notifications/payment_required`: satisfies the request - * in-band via configured handlers, gated by `paymentPolicy`, `canHandle`, and - * the effective-mode guard. - */ - async function handleTransparentPaymentRequired( - message: PaymentRequiredNotification, - requestEventId: string, + async function transparentPayment( + option: PaymentOption, + eventId: string, + state: ClientPaymentRequest, + pending?: PendingRequest, ): Promise { - const isNostrTransport = transport instanceof NostrClientTransport; - - const pending = isNostrTransport - ? transport.getPendingRequestForEventId(requestEventId) - : undefined; - - if (isNostrTransport && !pending) { - logger.warn('dropping uncorrelated payment_required notification', { - requestEventId, - pmi: message.params.pmi, - amount: message.params.amount, - }); - return; - } - - // CEP-8: a client that required explicit_gating SHOULD NOT auto-satisfy a - // transparent payment_required when the server did not accept it. + if (!lifecycle.isActive(state)) return; if ( - isNostrTransport && + nostr && options.paymentInteraction === 'explicit_gating' && - transport.getEffectivePaymentInteraction() !== 'explicit_gating' + nostr.getEffectivePaymentInteraction() !== 'explicit_gating' ) { - logger.warn( - 'declining transparent payment_required: explicit_gating was not accepted by the server', - { requestEventId, pmi: message.params.pmi }, - ); - synthesizePaymentDecline( - pending, - 'Payment declined: explicit_gating was not accepted by the server', - message.params.pmi, - message.params.amount, - ); - return; - } - - // If the transport can provide the original request's progressToken, emit synthetic - // progress notifications locally to keep the upstream MCP request alive while the - // payment settles (CEP-8 TTL can exceed the default MCP timeout). - if (isNostrTransport) { - const token = pending?.progressToken; - // Fall back to defaultPaymentTtlMs when the server omits ttl so the client - // keeps the MCP request alive for the same duration the server will wait. - const ttlSeconds = - message.params.ttl !== undefined - ? message.params.ttl - : defaultPaymentTtlMs / 1000; - if (token && Number.isFinite(ttlSeconds)) { - // Guard against nonsensical TTLs. - const ttlMs = Math.floor(ttlSeconds * 1000); - if (ttlMs > 0 && !syntheticProgress.has(token)) { - const stopAtMs = Date.now() + ttlMs; - - const wireProgressToken = Number.isFinite(Number(token)) - ? Number(token) - : token; - syntheticProgress.set(token, { - stopAtMs, - wireProgressToken, - }); - ensureSchedulerStarted(); - - // Reset the MCP timeout immediately — don't wait for the interval tick - onmessage?.({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken: wireProgressToken, - progress: 0, - }, - } as JSONRPCNotification); - - logger.debug('started synthetic progress', { - requestEventId, - progressToken: token, - ttlSeconds, - intervalMs: syntheticProgressIntervalMs, - }); - } - } - } - - // Resolve an in-band handler for this PMI. If none matches, the payment is - // left to the application: the notification was already forwarded via - // onmessage, synthetic progress above keeps the request alive while it - // settles out-of-band, and the server's TTL is the ultimate timeout. - const handler = handlersByPmi.get(message.params.pmi); - if (!handler) { - logger.debug( - 'no in-band handler for PMI; leaving payment to the application', + fail( + state, + new ClientPaymentError( + 'payment_negotiation_failed', + 'Payment declined: explicit_gating was not accepted by the server', + ), + -32000, { - pmi: message.params.pmi, - requestEventId, + pmi: option.pmi, + amount: option.amount, + method: pending?.originalRequestContext?.method, + capability: pending?.originalRequestContext?.capability, }, ); return; } - - // Best-effort client-side dedupe keyed by pay_req. - // IMPORTANT: claim synchronously before any await to avoid double-pay races. - if (inFlightPayReqs.has(message.params.pay_req)) { - logger.debug('duplicate pay_req detected, skipping', { - payReq: message.params.pay_req.substring(0, 20) + '...', - requestEventId, - }); + lifecycle.startPayment(state); + if (nostr && pending?.progressToken) + progress.start( + state.key, + pending.progressToken, + option.ttl !== undefined ? option.ttl * 1000 : defaultTtlMs, + ); + const handler = handlers.get(option.pmi); + if (!handler || !lifecycle.isActive(state)) return; + if (limits && state.id === undefined) + throw new ClientPaymentError( + 'payment_request_uncorrelated', + 'Payment limits require a correlated original request', + ); + const prior = attempts.find([option]); + if (prior) { + await priorOutcome(state, prior, false); return; } - - inFlightPayReqs.add(message.params.pay_req); - + if (state.paymentInFlight) + throw new ClientPaymentError( + 'payment_outcome_unknown', + 'Another payment decision is still in progress for this invocation', + ); + lifecycle.approve(state, [option]); + const attempt = attempts.reserve([option], state.controller.signal); + state.paymentInFlight = true; const req: PaymentHandlerRequest = { - amount: message.params.amount, - pay_req: message.params.pay_req, - pmi: message.params.pmi, - description: message.params.description, - ttl: message.params.ttl, - _meta: message.params._meta, - requestEventId, + amount: option.amount, + pmi: option.pmi, + pay_req: option.pay_req, + description: option.description, + ttl: option.ttl, + _meta: option._meta, + requestEventId: eventId, + abortSignal: state.controller.signal, }; - - const synthesizeClientDeclineError = (params: { - message: string; - }): void => { - if (pending?.progressToken) { - stopSyntheticProgress(pending.progressToken); - } - synthesizePaymentDecline(pending, params.message, req.pmi, req.amount); + const decline = (message: string): void => { + attempt.complete('declined'); + fail(state, new ClientPaymentError('payment_declined', message), -32000, { + pmi: option.pmi, + amount: option.amount, + method: pending?.originalRequestContext?.method, + capability: pending?.originalRequestContext?.capability, + }); }; - + let walletStarted = false; + const expiresAt = + option.ttl !== undefined ? Date.now() + option.ttl * 1000 : undefined; try { - logger.info('processing payment_required', { - requestEventId, - pmi: message.params.pmi, - amount: message.params.amount, - }); - if (options.paymentPolicy) { - const isApproved = await options.paymentPolicy( - req, + const approved = await options.paymentPolicy( + { ...req }, pending?.originalRequestContext, ); - if (!isApproved) { - logger.debug('paymentPolicy declined the payment', { - requestEventId, - pmi: message.params.pmi, - amount: message.params.amount, - }); - synthesizeClientDeclineError({ - message: 'Payment declined by client policy', - }); + if (!lifecycle.isActive(state)) return; + if (!approved) { + decline('Payment declined by client policy'); return; } } - - const canHandle = handler.canHandle ? await handler.canHandle(req) : true; + const canHandle = handler.canHandle + ? await handler.canHandle({ ...req }) + : await Promise.resolve(true); + if (!lifecycle.isActive(state)) return; if (!canHandle) { - logger.debug('handler declined to handle', { - requestEventId, - pmi: message.params.pmi, - }); - synthesizeClientDeclineError({ - message: 'Payment declined by client handler', - }); + decline('Payment declined by client handler'); return; } - - logger.debug('invoking payment handler', { - requestEventId, - handler: handler.constructor.name, - pmi: message.params.pmi, - }); - + if (expiresAt !== undefined && Date.now() >= expiresAt) { + attempt.complete('declined'); + throw new ClientPaymentError( + 'payment_offer_expired', + 'Payment offer expired before wallet action', + ); + } + walletStarted = true; await handler.handle(req); - - logger.info('payment handler completed successfully', { - requestEventId, - pmi: message.params.pmi, - }); - } catch (error) { - logger.error('payment handler failed', { - requestEventId, - pmi: message.params.pmi, - error: error instanceof Error ? error.message : String(error), - }); - // Same-severity outcomes must behave the same: policy and canHandle - // declines resolve the pending request with a synthesized error. A - // handler crash must too, or the MCP request hangs until the server TTL. - synthesizeClientDeclineError({ - message: `Payment handler failed: ${ - error instanceof Error ? error.message : String(error) - }`, - }); - throw error; + if (lifecycle.isActive(state)) attempt.complete('handled'); + } catch (error: unknown) { + attempt.complete(walletStarted ? 'unknown' : 'declined'); + if (!lifecycle.isActive(state)) return; + const failure = error instanceof Error ? error : new Error(String(error)); + fail( + state, + error instanceof ClientPaymentError + ? error + : new ClientPaymentError( + 'payment_handler_error', + `Payment handler failed: ${failure.message}`, + ), + -32000, + { + pmi: option.pmi, + amount: option.amount, + outcome: walletStarted ? 'unknown' : 'declined', + }, + ); + if (state.id !== undefined && !(error instanceof ClientPaymentError)) + onerror?.(failure); } finally { - inFlightPayReqs.delete(message.params.pay_req); + state.paymentInFlight = false; } } - /** Classifies an inbound payment message and delegates to the relevant handler. */ - async function maybeHandlePaymentRequired( - message: JSONRPCMessage, - requestEventId: string, - ): Promise { - if ( - isExplicitPaymentRequiredError(message) || - isExplicitPaymentPendingError(message) - ) { - await handleExplicitPaymentError(message, requestEventId); + function dispatch(message: JSONRPCMessage, eventId: string): void { + if (lifecycle.isClosed) return; + const cancelled = cancelledRequestId(message); + if (cancelled !== undefined) { + const state = lifecycle.get(cancelled); + if (state) lifecycle.finish(state); + onmessage?.(message); return; } - if (isPaymentRequiredNotification(message)) { - await handleTransparentPaymentRequired(message, requestEventId); + if (isExplicitPaymentError(message)) { + const state = message.id != null ? lifecycle.get(message.id) : undefined; + void explicitPayment(message).catch((error: unknown) => { + const failure = + error instanceof ClientPaymentError + ? error + : new ClientPaymentError( + 'invalid_payment', + 'Invalid payment message', + ); + if (state) fail(state, failure, message.error.code); + else onerror?.(failure); + }); + return; + } + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + const state = message.id != null ? lifecycle.get(message.id) : undefined; + if (state && !state.active) return; + if (state) lifecycle.finish(state); + onmessage?.(message); + return; + } + if (isPaymentOffer(message)) { + const pending = nostr?.getPendingRequestForEventId(eventId); + const state = transparentState(eventId, pending); + if (!state || !lifecycle.isActive(state)) { + onmessage?.(message); + return; + } + let option: PaymentOption; + try { + option = parseClientPaymentOption(message.params); + } catch (error: unknown) { + fail(state, error as ClientPaymentError); + return; + } + void transparentPayment(option, eventId, state, pending).catch( + (error: unknown) => { + fail( + state, + error instanceof ClientPaymentError + ? error + : new ClientPaymentError('payment_handler_error', String(error)), + ); + }, + ); + onmessage?.(message); return; } - } - - /** - * Runs the payment handler, then forwards to the upstream consumer unless the - * message is an explicit-gating error (those are re-emitted/retried internally). - */ - const dispatchAndForward = ( - message: JSONRPCMessage, - requestEventId: string, - ): void => { - void maybeHandlePaymentRequired(message, requestEventId).catch( - (err: unknown) => { - const error = err instanceof Error ? err : new Error(String(err)); - onerror?.(error); - }, - ); if ( - isExplicitPaymentRequiredError(message) || - isExplicitPaymentPendingError(message) + isJSONRPCNotification(message) && + (message.method === PAYMENT_ACCEPTED_METHOD || + message.method === PAYMENT_REJECTED_METHOD) ) { - return; + const pending = nostr?.getPendingRequestForEventId(eventId); + const state = + pending?.originalRequestId != null + ? lifecycle.correlated(pending.originalRequestId) + : lifecycle.onlyRequest(); + if (state) { + progress.stop(state.key); + if (message.method === PAYMENT_REJECTED_METHOD) { + const detail = + typeof message.params?.message === 'string' + ? message.params.message + : undefined; + fail( + state, + new ClientPaymentError( + 'payment_rejected', + detail ? `Payment rejected: ${detail}` : 'Payment rejected', + ), + ); + return; + } + } } onmessage?.(message); - }; + } - const wrapped = { + const wrapped: Transport = { get onmessage() { return onmessage; }, @@ -798,117 +587,60 @@ export function withClientPayments( set onclose(fn) { onclose = fn; }, - async start(): Promise { - // Only suppress notifications in `onmessage` when the transport delivers - // them through a separate context path (NostrClientTransport). - const hasContextPath = supportsOnmessageWithContext(transport); - - transport.onmessage = (message: JSONRPCMessage) => { - // `NostrClientTransport` delivers notifications through BOTH `onmessage` - // and `onmessageWithContext`. Forward only from the context path to avoid - // duplicate delivery to the upstream MCP Protocol. - if (hasContextPath && isJSONRPCNotification(message)) { - return; - } - - // Stop synthetic progress when the server responds (success or error). - // message.id was restored to originalRequestId by resolveResponse(). - // When onprogress was set, originalRequestId = progressToken, making this stop exact. - // Otherwise syntheticProgress has no entry and the call is a no-op. - if ( - isJSONRPCResultResponse(message) || - isJSONRPCErrorResponse(message) - ) { - stopSyntheticProgress(String(message.id)); - if ( - !isExplicitPaymentRequiredError(message) && - !isExplicitPaymentPendingError(message) - ) { - const reqId = message.id as string | number; - rawRequestCache.delete(JSON.stringify(reqId)); - retryCounts.delete(JSON.stringify(reqId)); - } - } - - if (hasContextPath) { - return; - } - - // Best-effort: execute handler asynchronously, but never block delivery. - dispatchAndForward(message, 'unknown'); + lifecycle.open(); + const hasContext = Object.prototype.hasOwnProperty.call( + transport, + 'onmessageWithContext', + ); + transport.onmessage = (message): void => { + if (!hasContext) dispatch(message, 'unknown'); }; - - if (hasContextPath) { - transport.onmessageWithContext = ( - message: JSONRPCMessage, - ctx: { eventId: string; correlatedEventId?: string }, - ) => { - const requestEventId = ctx.correlatedEventId ?? 'unknown'; - - // Stop synthetic progress on terminal outcomes (context path). - if (isJSONRPCNotification(message)) { - const isAccepted = message.method === PAYMENT_ACCEPTED_METHOD; - const isRejected = message.method === PAYMENT_REJECTED_METHOD; - if (isAccepted || isRejected) { - const pending = - transport instanceof NostrClientTransport - ? transport.getPendingRequestForEventId(requestEventId) - : undefined; - if (pending?.progressToken) { - stopSyntheticProgress(pending.progressToken); - } - // On rejection, synthesize a JSON-RPC error response so the caller's - // pending promise is rejected immediately rather than timing out after 60 s. - // The server never calls forward() for a rejected payment, so no real - // response will arrive. - if (isRejected && pending?.originalRequestId != null) { - const rejMsg = (message as PaymentRejectedNotification).params - ?.message; - onmessage?.({ - jsonrpc: '2.0', - id: pending.originalRequestId, - error: { - code: -32000, - message: rejMsg - ? `Payment rejected: ${rejMsg}` - : 'Payment rejected', - }, - } as JSONRPCMessage); - return; - } - } - } - - // Forward exactly once (see duplicate-delivery guard in `transport.onmessage`). - dispatchAndForward(message, requestEventId); + if (hasContext) + (transport as TransportWithContext).onmessageWithContext = ( + message, + ctx, + ): void => { + dispatch(message, ctx.correlatedEventId ?? 'unknown'); }; - } - - transport.onerror = (err: Error) => onerror?.(err); - transport.onclose = () => { - disposeClientState(); + transport.onerror = (error): void => onerror?.(error); + transport.onclose = (): void => { + lifecycle.close(); + progress.clear(); onclose?.(); }; - await transport.start(); + try { + await transport.start(); + } catch (error) { + lifecycle.close(); + progress.clear(); + throw error; + } }, - async send(message: JSONRPCMessage): Promise { - if ('method' in message && 'id' in message && message.id != null) { - rawRequestCache.set( - JSON.stringify(message.id), - message as JSONRPCRequest, - ); + if (lifecycle.isClosed) + throw new Error('Client payments transport is closed'); + const cancelled = cancelledRequestId(message); + if (cancelled !== undefined) { + const state = lifecycle.get(cancelled); + if (state) lifecycle.finish(state); + } + const state = isJSONRPCRequest(message) + ? lifecycle.register(message) + : undefined; + try { + await transport.send(message); + } catch (error) { + if (state) lifecycle.finish(state); + throw error; } - await transport.send(message); }, - async close(): Promise { - // disposeClientState is called via transport.onclose, no need to call it here + lifecycle.close(); + progress.clear(); await transport.close(); }, }; - clientPaymentsWrapped.add(transport); clientPaymentsWrapped.add(wrapped); return wrapped; diff --git a/src/payments/types.ts b/src/payments/types.ts index f9fe83d..20c132c 100644 --- a/src/payments/types.ts +++ b/src/payments/types.ts @@ -158,6 +158,8 @@ export interface PaymentHandlerRequest { /** Transparency metadata from the server's `payment_required._meta`. */ _meta?: Record; requestEventId: string; + /** Aborted when the invocation ends; check before starting an awaited wallet action. */ + abortSignal?: AbortSignal; } /** diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index 39492a0..226d0ae 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -42,8 +42,10 @@ import { ServerMetadataStore } from './nostr-client/server-metadata-store.js'; import { ClientOutboundSender } from './nostr-client/outbound-sender.js'; import { ClientInboundNotificationDispatcher } from './nostr-client/inbound-notification-dispatcher.js'; import { ClientEventPipeline } from './nostr-client/event-pipeline.js'; -import { ClientOpenStreamFactory, - type ClientOpenStreamHandle,} from './nostr-client/open-stream-factory.js'; +import { + ClientOpenStreamFactory, + type ClientOpenStreamHandle, +} from './nostr-client/open-stream-factory.js'; import { DEFAULT_CHUNK_SIZE, DEFAULT_OVERSIZED_THRESHOLD, @@ -458,6 +460,11 @@ export class NostrClientTransport return this.correlationStore.getPendingRequest(eventId); } + /** Releases correlation for every wire retry of a terminal caller request. */ + public forgetPendingRequestsForRequestId(requestId: string | number): void { + this.correlationStore.removeRequestsForRequestId(requestId); + } + /** * Returns the CEP-41 stream session for a progress token, creating it lazily if needed. */ diff --git a/src/transport/nostr-client/correlation-store.test.ts b/src/transport/nostr-client/correlation-store.test.ts index dea7948..d97c9f7 100644 --- a/src/transport/nostr-client/correlation-store.test.ts +++ b/src/transport/nostr-client/correlation-store.test.ts @@ -74,6 +74,27 @@ describe('ClientCorrelationStore', () => { }); }); + it('releases all retries of a terminal request while preserving typed id isolation', () => { + const store = new ClientCorrelationStore(); + store.registerRequest('numeric-first', { + originalRequestId: 0, + isInitialize: false, + }); + store.registerRequest('numeric-retry', { + originalRequestId: 0, + isInitialize: false, + }); + store.registerRequest('string', { + originalRequestId: '0', + isInitialize: false, + }); + store.removeRequestsForRequestId(0); + expect(store.hasPendingRequest('numeric-first')).toBe(false); + expect(store.hasPendingRequest('numeric-retry')).toBe(false); + expect(store.hasPendingRequest('string')).toBe(true); + expect(store.pendingRequestCount).toBe(1); + }); + describe('clear', () => { it('removes all pending requests', () => { const store = new ClientCorrelationStore(); diff --git a/src/transport/nostr-client/correlation-store.ts b/src/transport/nostr-client/correlation-store.ts index f422bac..4a89c58 100644 --- a/src/transport/nostr-client/correlation-store.ts +++ b/src/transport/nostr-client/correlation-store.ts @@ -109,6 +109,14 @@ export class ClientCorrelationStore { return this.pendingRequests.delete(eventId); } + /** Removes all wire retries of a terminal or cancelled caller request. */ + removeRequestsForRequestId(requestId: string | number): void { + for (const [eventId, request] of this.pendingRequests.entries()) { + if (request.originalRequestId === requestId) + this.pendingRequests.delete(eventId); + } + } + /** Gets the current number of pending requests */ get pendingRequestCount(): number { return this.pendingRequests.size; diff --git a/src/transport/payment-client-reliability.e2e.test.ts b/src/transport/payment-client-reliability.e2e.test.ts new file mode 100644 index 0000000..169980e --- /dev/null +++ b/src/transport/payment-client-reliability.e2e.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from 'bun:test'; +import { Client } from '@contextvm/mcp-sdk/client'; +import { McpServer } from '@contextvm/mcp-sdk/server/mcp'; +import { z } from 'zod'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { bytesToHex } from 'nostr-tools/utils'; +import { MockRelayHub } from '../__mocks__/mock-relay-handler.js'; +import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; +import { PrivateKeySigner } from '../signer/private-key-signer.js'; +import { NostrClientTransport } from './nostr-client-transport.js'; +import { NostrServerTransport } from './nostr-server-transport.js'; +import { withClientPayments, withServerPayments } from '../payments/index.js'; +import type { PaymentProcessor } from '../payments/types.js'; + +const cases = [ + { + name: 'cleartext', + encryptionMode: EncryptionMode.DISABLED, + giftWrapMode: GiftWrapMode.OPTIONAL, + kind: 25910, + }, + { + name: 'persistent encryption', + encryptionMode: EncryptionMode.REQUIRED, + giftWrapMode: GiftWrapMode.PERSISTENT, + kind: 1059, + }, + { + name: 'ephemeral encryption', + encryptionMode: EncryptionMode.REQUIRED, + giftWrapMode: GiftWrapMode.EPHEMERAL, + kind: 21059, + }, +]; + +describe('instant explicit payment retry delivery', () => { + for (const mode of cases) { + test(`instant payment executes once over ${mode.name} without callback sleeps`, async () => { + const hub = new MockRelayHub(); + const serverKey = generateSecretKey(); + const clientSigner = new PrivateKeySigner( + bytesToHex(generateSecretKey()), + ); + const requests: Array<{ id: string; createdAt: number }> = []; + const sign = clientSigner.signEvent.bind(clientSigner); + clientSigner.signEvent = async (event) => { + const signed = await sign(event); + if ( + event.kind === 25910 && + JSON.parse(event.content).method === 'tools/call' + ) { + requests.push({ id: signed.id, createdAt: event.created_at }); + } + return signed; + }; + let executions = 0; + let invoices = 0; + let approvals = 0; + const processor: PaymentProcessor = { + pmi: 'fake', + async createPaymentRequired(params) { + invoices++; + return { + amount: params.amount, + pmi: 'fake', + pay_req: `invoice-${invoices}`, + ttl: 60, + }; + }, + async verifyPayment() { + return {}; + }, + }; + const server = new McpServer({ + name: 'payment-retry-server', + version: '1.0.0', + }); + server.registerTool( + 'paid', + { inputSchema: { value: z.number() } }, + async ({ value }) => { + executions++; + return { content: [{ type: 'text', text: String(value) }] }; + }, + ); + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(bytesToHex(serverKey)), + relayHandler: hub.createRelayHandler(), + encryptionMode: mode.encryptionMode, + giftWrapMode: mode.giftWrapMode, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'paid', + amount: 5, + currencyUnit: 'sats', + }, + ], + }, + ); + const base = new NostrClientTransport({ + signer: clientSigner, + relayHandler: hub.createRelayHandler(), + serverPubkey: getPublicKey(serverKey), + encryptionMode: mode.encryptionMode, + giftWrapMode: mode.giftWrapMode, + }); + const paid = withClientPayments(base, { + paymentInteraction: 'explicit_gating', + minRetryDelayMs: 0, + async onPaymentRequired() { + approvals++; + return { paid: true }; + }, + }); + const client = new Client({ + name: 'payment-retry-client', + version: '1.0.0', + }); + try { + await server.connect(serverTransport); + await client.connect(paid); + // Initialization finishes negotiation, so routine invocations have stable tags. + const result = await client.callTool( + { name: 'paid', arguments: { value: 7 } }, + undefined, + { timeout: 5000 }, + ); + expect(result.content).toEqual([{ type: 'text', text: '7' }]); + expect(approvals).toBe(1); + expect(invoices).toBe(1); + expect(executions).toBe(1); + expect(requests).toHaveLength(2); + expect(requests[1]!.id).not.toBe(requests[0]!.id); + expect(requests[1]!.createdAt).toBeGreaterThan(requests[0]!.createdAt); + expect(hub.getEvents().some((event) => event.kind === mode.kind)).toBe( + true, + ); + } finally { + await client.close(); + await server.close(); + } + }); + } +}); diff --git a/src/transport/payments-flow.test.ts b/src/transport/payments-flow.test.ts index 3bbd471..0a52d24 100644 --- a/src/transport/payments-flow.test.ts +++ b/src/transport/payments-flow.test.ts @@ -90,13 +90,8 @@ async function captureNextCtxvmEvent(params: { } /** - * Nostr event ids are derived from (content, author, created_at, tags) at - * second granularity. An explicit-gating retry republishes the same JSON-RPC - * request; if it lands in the same second as the original, the identical event - * id is deduplicated by relay subscriptions and the server never sees the - * retry. Sleep past the current second boundary so the retry's event id - * differs. Real-world payment flows naturally take >1s (wallet interaction, - * settlement); this only affects instant-pay tests. + * Separates intentional test invocations at Nostr second granularity. + * Automatic payment retries are delayed by the client payment lifecycle. */ async function sleepPastSecondBoundary(): Promise { const waitMs = 1000 - (Date.now() % 1000) + 10; @@ -217,11 +212,11 @@ describe.serial('payments fake flow (transport-level)', () => { ); const processorB = new FakePaymentProcessor({ - pmi: 'pmi:B', + pmi: 'pmi-b', verifyDelayMs: 1, }); const processorC = new FakePaymentProcessor({ - pmi: 'pmi:C', + pmi: 'pmi-c', verifyDelayMs: 1, }); const pricedCapabilities = [ @@ -252,8 +247,8 @@ describe.serial('payments fake flow (transport-level)', () => { const clientPrivateKey = bytesToHex(clientSK); const clientPubkey = getPublicKey(clientSK); const handlers = [ - new FakePaymentHandler({ pmi: 'pmi:A', delayMs: 1 }), - new FakePaymentHandler({ pmi: 'pmi:B', delayMs: 1 }), + new FakePaymentHandler({ pmi: 'pmi-a', delayMs: 1 }), + new FakePaymentHandler({ pmi: 'pmi-b', delayMs: 1 }), ]; const clientTransport = new NostrClientTransport({ @@ -271,7 +266,7 @@ describe.serial('payments fake flow (transport-level)', () => { if (!event.content.includes('"method":"initialize"')) { return false; } - return event.tags.some((t) => t[0] === 'pmi' && t[1] === 'pmi:A'); + return event.tags.some((t) => t[0] === 'pmi' && t[1] === 'pmi-a'); }, }); @@ -285,8 +280,8 @@ describe.serial('payments fake flow (transport-level)', () => { const initializeEvent = await initializeEventPromise; const pmiTags = initializeEvent.tags.filter((t) => t[0] === 'pmi'); expect(pmiTags).toEqual([ - ['pmi', 'pmi:A'], - ['pmi', 'pmi:B'], + ['pmi', 'pmi-a'], + ['pmi', 'pmi-b'], ]); const originalOnMessage = paidClientTransport.onmessage; @@ -300,7 +295,7 @@ describe.serial('payments fake flow (transport-level)', () => { arguments: { a: 1, b: 2 }, }); - expect(observedPaymentPmi).toBe('pmi:B'); + expect(observedPaymentPmi).toBe('pmi-b'); await client.close(); await mcpServer.close(); @@ -352,7 +347,7 @@ describe.serial('payments fake flow (transport-level)', () => { const clientSK = generateSecretKey(); const clientPrivateKey = bytesToHex(clientSK); const clientPubkey = getPublicKey(clientSK); - const handlers = [new FakePaymentHandler({ pmi: 'pmi:A', delayMs: 1 })]; + const handlers = [new FakePaymentHandler({ pmi: 'pmi-a', delayMs: 1 })]; const clientTransport = new NostrClientTransport({ signer: new PrivateKeySigner(clientPrivateKey), @@ -374,7 +369,7 @@ describe.serial('payments fake flow (transport-level)', () => { if (!event.content.includes('"method":"tools/call"')) { return false; } - return event.tags.some((t) => t[0] === 'pmi' && t[1] === 'pmi:A'); + return event.tags.some((t) => t[0] === 'pmi' && t[1] === 'pmi-a'); }, timeoutMs: 4000, }); @@ -388,7 +383,7 @@ describe.serial('payments fake flow (transport-level)', () => { const callEvent = await callEventPromise; const pmiTags = callEvent.tags.filter((t) => t[0] === 'pmi'); - expect(pmiTags).toEqual([['pmi', 'pmi:A']]); + expect(pmiTags).toEqual([['pmi', 'pmi-a']]); await client.close(); await mcpServer.close(); @@ -413,11 +408,11 @@ describe.serial('payments fake flow (transport-level)', () => { ); const processorB = new FakePaymentProcessor({ - pmi: 'pmi:B', + pmi: 'pmi-b', verifyDelayMs: 1, }); const processorC = new FakePaymentProcessor({ - pmi: 'pmi:C', + pmi: 'pmi-c', verifyDelayMs: 1, }); const pricedCapabilities = [ @@ -445,7 +440,7 @@ describe.serial('payments fake flow (transport-level)', () => { await mcpServer.connect(serverTransport); const clientPrivateKey = bytesToHex(generateSecretKey()); - const handlers = [new FakePaymentHandler({ pmi: 'pmi:A', delayMs: 1 })]; + const handlers = [new FakePaymentHandler({ pmi: 'pmi-a', delayMs: 1 })]; const clientTransport = new NostrClientTransport({ signer: new PrivateKeySigner(clientPrivateKey), @@ -472,7 +467,7 @@ describe.serial('payments fake flow (transport-level)', () => { arguments: { a: 1, b: 2 }, }); - expect(observedPaymentPmi).toBe('pmi:B'); + expect(observedPaymentPmi).toBe('pmi-b'); await client.close(); await mcpServer.close(); @@ -497,11 +492,11 @@ describe.serial('payments fake flow (transport-level)', () => { ); const processorB = new FakePaymentProcessor({ - pmi: 'pmi:B', + pmi: 'pmi-b', verifyDelayMs: 1, }); const processorC = new FakePaymentProcessor({ - pmi: 'pmi:C', + pmi: 'pmi-c', verifyDelayMs: 1, }); const pricedCapabilities = [ @@ -553,7 +548,7 @@ describe.serial('payments fake flow (transport-level)', () => { arguments: { a: 1, b: 2 }, }); - expect(observedPaymentPmi).toBe('pmi:B'); + expect(observedPaymentPmi).toBe('pmi-b'); await client.close(); await mcpServer.close(); @@ -1091,7 +1086,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitPaymentHandled = true; return { paid: true }; }, @@ -1268,7 +1262,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -1548,7 +1541,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -1646,7 +1638,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -1976,7 +1967,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitHandled = true; return { paid: true }; }, @@ -2082,7 +2072,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitHandled = true; return { paid: true }; }, @@ -2273,7 +2262,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -2413,7 +2401,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitHandled = true; return { paid: true }; }, From 8e4318397c09c19d9f832ffcc62e1affd4ca9c75 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Sun, 18 Oct 2026 21:13:05 +0530 Subject: [PATCH 2/6] feat(payments): persist server payment state and fence retries --- .github/workflows/test.yml | 14 + bun.lock | 36 ++ package.json | 14 + src/__mocks__/payment-state-worker.ts | 49 ++ src/__mocks__/payment-state.ts | 76 +++ src/__mocks__/payment-store-contract.ts | 521 +++++++++++++++++ src/payments/authorization-store.test.ts | 218 ++----- src/payments/authorization-store.ts | 200 +------ src/payments/canonical-identity.test.ts | 18 + src/payments/client-payment-validation.ts | 35 +- .../fixtures/canonical-invocations.json | 150 +++++ src/payments/in-memory-payment-state-store.ts | 97 ++++ src/payments/index.ts | 4 + src/payments/payment-option-schema.ts | 26 + src/payments/payment-state-machine.ts | 351 ++++++++++++ src/payments/payment-state-store.ts | 140 +++++ src/payments/postgres/index.ts | 5 + src/payments/postgres/migrations.ts | 23 + .../postgres-payment-state-store.test.ts | 322 +++++++++++ .../postgres/postgres-payment-state-store.ts | 262 +++++++++ .../ln-bolt11-lnbits-payment-processor.ts | 2 + .../ln-bolt11-nwc-payment-processor.ts | 2 + .../ln-bolt11-zap-payment-processor.ts | 2 + src/payments/resolve-price.test.ts | 4 + src/payments/server-explicit-gating.test.ts | 108 ++-- src/payments/server-explicit-gating.ts | 387 +++++-------- .../server-payment-reliability.test.ts | 355 ++++++++++++ src/payments/server-payment-state.ts | 63 ++ .../server-payment-validation.test.ts | 233 ++++++++ src/payments/server-payment-validation.ts | 126 ++++ src/payments/server-payments-utils.ts | 85 ++- src/payments/server-payments.test.ts | 27 +- src/payments/server-payments.ts | 538 ++++++------------ ...server-transport-payments.shutdown.test.ts | 31 +- src/payments/server-transport-payments.ts | 8 +- src/payments/types.ts | 20 +- src/transport/middleware.ts | 2 + src/transport/nostr-client-transport.test.ts | 6 +- src/transport/nostr-server-transport.test.ts | 8 +- .../nostr-server/inbound-coordinator.test.ts | 33 ++ .../nostr-server/inbound-coordinator.ts | 6 + .../payment-state-durability.e2e.test.ts | 195 +++++++ src/transport/payments-flow.test.ts | 52 +- 43 files changed, 3731 insertions(+), 1123 deletions(-) create mode 100644 src/__mocks__/payment-state-worker.ts create mode 100644 src/__mocks__/payment-state.ts create mode 100644 src/__mocks__/payment-store-contract.ts create mode 100644 src/payments/fixtures/canonical-invocations.json create mode 100644 src/payments/in-memory-payment-state-store.ts create mode 100644 src/payments/payment-option-schema.ts create mode 100644 src/payments/payment-state-machine.ts create mode 100644 src/payments/payment-state-store.ts create mode 100644 src/payments/postgres/index.ts create mode 100644 src/payments/postgres/migrations.ts create mode 100644 src/payments/postgres/postgres-payment-state-store.test.ts create mode 100644 src/payments/postgres/postgres-payment-state-store.ts create mode 100644 src/payments/server-payment-reliability.test.ts create mode 100644 src/payments/server-payment-state.ts create mode 100644 src/payments/server-payment-validation.test.ts create mode 100644 src/payments/server-payment-validation.ts create mode 100644 src/transport/payment-state-durability.e2e.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cab0d58..4d99401 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,6 +10,19 @@ on: jobs: test: runs-on: ubuntu-latest + services: + postgres: + image: postgres:17.7-bookworm + env: + POSTGRES_PASSWORD: contextvm-test + POSTGRES_DB: contextvm_test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres -d contextvm_test" + --health-interval 5s + --health-timeout 5s + --health-retries 10 steps: - uses: actions/checkout@v4 @@ -25,4 +38,5 @@ jobs: - name: Run tests env: NODE_ENV: test + PAYMENT_TEST_DATABASE_URL: postgres://postgres:contextvm-test@127.0.0.1:5432/contextvm_test run: bun test --concurrent --timeout=60000 diff --git a/bun.lock b/bun.lock index 2562102..281f926 100644 --- a/bun.lock +++ b/bun.lock @@ -19,13 +19,21 @@ "@changesets/cli": "^2.31.1", "@eslint/js": "^9.39.5", "@types/bun": "^1.4.2", + "@types/pg": "^8.23.1", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", + "pg": "^8.23.1", "prettier": "^3.9.6", "typescript": "^5.9.3", "typescript-eslint": "^8.70.0", }, + "peerDependencies": { + "pg": "^8.23.1", + }, + "optionalPeers": [ + "pg", + ], }, }, "packages": { @@ -133,6 +141,8 @@ "@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], + "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/type-utils": "8.70.0", "@typescript-eslint/utils": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.70.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q=="], @@ -527,6 +537,22 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], + "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], + + "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], + + "pg-connection-string": ["pg-connection-string@2.14.1", "", {}, "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA=="], + + "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], + + "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], + + "pg-protocol": ["pg-protocol@1.16.1", "", {}, "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg=="], + + "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], + + "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -541,6 +567,14 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], + "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], + + "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], + + "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], + + "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], @@ -685,6 +719,8 @@ "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], + "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], diff --git a/package.json b/package.json index 874a47e..4083ae7 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,10 @@ "./payments/*": { "types": "./dist/esm/payments/*.d.ts", "default": "./dist/esm/payments/*.js" + }, + "./payments/postgres": { + "types": "./dist/esm/payments/postgres/index.d.ts", + "default": "./dist/esm/payments/postgres/index.js" } }, "types": "./dist/esm/index.d.ts", @@ -92,9 +96,11 @@ "@changesets/cli": "^2.31.1", "@eslint/js": "^9.39.5", "@types/bun": "^1.4.2", + "@types/pg": "^8.23.1", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", + "pg": "^8.23.1", "prettier": "^3.9.6", "typescript": "^5.9.3", "typescript-eslint": "^8.70.0" @@ -109,5 +115,13 @@ "rxjs": "^7.8.2", "ws": "^8.21.3", "zod": "^4.5.4" + }, + "peerDependencies": { + "pg": "^8.23.1" + }, + "peerDependenciesMeta": { + "pg": { + "optional": true + } } } diff --git a/src/__mocks__/payment-state-worker.ts b/src/__mocks__/payment-state-worker.ts new file mode 100644 index 0000000..91505de --- /dev/null +++ b/src/__mocks__/payment-state-worker.ts @@ -0,0 +1,49 @@ +import { Pool } from 'pg'; +import { PostgresPaymentStateStore } from '../payments/postgres/index.js'; +import { + paymentAuthorizationKey, + paymentEventKey, +} from '../payments/payment-state-store.js'; + +const [action, namespace] = process.argv.slice(2); +const pool = new Pool({ + connectionString: process.env.PAYMENT_TEST_DATABASE_URL, + max: 1, +}); +const input = { + key: + action === 'claim' + ? paymentAuthorizationKey('client', 'hash') + : paymentEventKey('client', 'event-1'), + mode: + action === 'claim' + ? ('explicit_gating' as const) + : ('transparent' as const), + clientPubkey: 'client', + invocationHash: 'hash', + requestEventId: action === 'claim' ? 'worker-retry' : 'event-1', + leaseMs: 10000, +}; +const store = new PostgresPaymentStateStore({ pool, namespace }); +await store.initialize(); +if (action === 'claim') { + console.log(JSON.stringify(await store.admit(input))); + await pool.end(); +} else if (action === 'invoice') { + const result = await store.admit(input); + if (result.status !== 'reserved') + throw new Error('Worker admission was refused'); + const record = result.record; + const fence = { key: record.key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + await store.transition({ + ...fence, + type: 'bind', + invoice: { pmi: 'fake', amount: 10, pay_req: 'persisted-worker-invoice' }, + leaseMs: 10000, + }); + console.log(JSON.stringify(await store.read(record.key))); + setInterval(() => {}, 1000); +} else { + throw new Error('Unknown payment test worker action'); +} diff --git a/src/__mocks__/payment-state.ts b/src/__mocks__/payment-state.ts new file mode 100644 index 0000000..6b5df83 --- /dev/null +++ b/src/__mocks__/payment-state.ts @@ -0,0 +1,76 @@ +import type { CanonicalInvocationIdentity } from '../payments/types.js'; +import type { + PaymentStateAdmission, + PaymentStateRecord, + PaymentStateStore, +} from '../payments/payment-state-store.js'; +import { paymentAuthorizationKey } from '../payments/payment-state-store.js'; + +export function authorizationInput( + identity: CanonicalInvocationIdentity, + leaseMs = 10000, +): PaymentStateAdmission { + return { + key: paymentAuthorizationKey( + identity.clientPubkey, + identity.invocationHash, + ), + mode: 'explicit_gating', + clientPubkey: identity.clientPubkey, + invocationHash: identity.invocationHash, + requestEventId: `seed-${crypto.randomUUID()}`, + leaseMs, + }; +} + +export async function seedPending( + store: PaymentStateStore, + identity: CanonicalInvocationIdentity, + leaseMs: number, +): Promise { + const reserved = await store.admit(authorizationInput(identity, leaseMs)); + if (reserved.status !== 'reserved') + throw new Error(`Seed admission failed: ${reserved.status}`); + return reserved.record; +} + +export async function seedGrant( + store: PaymentStateStore, + identity: CanonicalInvocationIdentity, + ttlMs: number, +): Promise { + const record = await seedPending(store, identity, 10000); + const fence = { key: record.key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + await store.transition({ + ...fence, + type: 'bind', + invoice: { pmi: 'fake', amount: 10, pay_req: 'seed-invoice' }, + leaseMs: 10000, + }); + await store.transition({ ...fence, type: 'grant', ttlMs }); +} + +export async function hasGrant( + store: PaymentStateStore, + identity: CanonicalInvocationIdentity, +): Promise { + const record = await store.read( + paymentAuthorizationKey(identity.clientPubkey, identity.invocationHash), + ); + return ( + record?.phase === 'granted' && (record.grantExpiresAtMs ?? 0) > Date.now() + ); +} + +export async function pendingRemainingMs( + store: PaymentStateStore, + identity: CanonicalInvocationIdentity, +): Promise { + const record = await store.read( + paymentAuthorizationKey(identity.clientPubkey, identity.invocationHash), + ); + return record && ['reserved', 'issuing', 'pending'].includes(record.phase) + ? Math.max(0, record.leaseExpiresAtMs - Date.now()) + : 0; +} diff --git a/src/__mocks__/payment-store-contract.ts b/src/__mocks__/payment-store-contract.ts new file mode 100644 index 0000000..343c5ce --- /dev/null +++ b/src/__mocks__/payment-store-contract.ts @@ -0,0 +1,521 @@ +import { describe, expect, test } from 'bun:test'; +import type { + PaymentStateAdmission, + PaymentStatePolicy, + PaymentStateRecord, + PaymentStateStore, +} from '../payments/payment-state-store.js'; +import { + paymentAuthorizationKey, + paymentEventKey, +} from '../payments/payment-state-store.js'; + +export interface PaymentStoreHarness { + store: PaymentStateStore; + peer: PaymentStateStore; + advance: (ms: number) => Promise; + close: () => Promise; +} + +export function admission( + mode: 'transparent' | 'explicit_gating' = 'explicit_gating', + eventId = 'event-1', +): PaymentStateAdmission { + const clientPubkey = 'client'; + const invocationHash = 'hash'; + return { + key: + mode === 'transparent' + ? paymentEventKey(clientPubkey, eventId) + : paymentAuthorizationKey(clientPubkey, invocationHash), + mode, + clientPubkey, + invocationHash, + requestEventId: eventId, + leaseMs: 10000, + }; +} + +export async function bind( + store: PaymentStateStore, + input: PaymentStateAdmission, +): Promise { + const result = await store.admit(input); + expect(result.status).toBe('reserved'); + const record = required(result.record); + const fence = { key: record.key, generation: record.generation }; + expect(await store.transition({ ...fence, type: 'issuing' })).toBe(true); + expect( + await store.transition({ + ...fence, + type: 'bind', + invoice: { + pmi: 'fake', + amount: 10, + pay_req: `invoice-${record.generation}`, + }, + leaseMs: input.leaseMs, + }), + ).toBe(true); + return required(await store.read(record.key)); +} + +export function paymentStoreContract( + name: string, + create: ( + policy?: Partial, + ) => Promise, +): void { + describe(name, () => { + async function run( + fn: (h: PaymentStoreHarness) => Promise, + policy?: Partial, + ): Promise { + const h = await create(policy); + try { + await fn(h); + } finally { + await h.close(); + } + } + + test('fractional millisecond durations persist as integer timestamps in every backend', async () => + run(async ({ store }) => { + const input = { ...admission(), leaseMs: 10000.75 }; + const result = await store.admit(input); + const record = required(result.record); + expect(Number.isInteger(record.leaseExpiresAtMs)).toBe(true); + const fence = { key: input.key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + expect( + await store.transition({ + ...fence, + type: 'bind', + invoice: { + pmi: 'fake', + amount: 10, + pay_req: 'invoice', + ttl: 10.00075, + }, + leaseMs: input.leaseMs, + }), + ).toBe(true); + const pending = required(await store.read(input.key)); + expect(Number.isInteger(pending.leaseExpiresAtMs)).toBe(true); + expect(Number.isInteger(pending.invoiceExpiresAtMs)).toBe(true); + expect( + await store.transition({ ...fence, type: 'grant', ttlMs: 10000.75 }), + ).toBe(true); + expect( + Number.isInteger((await store.read(input.key))?.grantExpiresAtMs), + ).toBe(true); + })); + + test('late explicit offers and pending retries remain deduplicated after grant consumption', async () => + run( + async ({ store, peer }) => { + const input = admission(); + const record = await bind(store, input); + expect( + (await peer.admit({ ...input, requestEventId: 'pending-retry-1' })) + .status, + ).toBe('pending'); + expect( + (await peer.admit({ ...input, requestEventId: 'pending-retry-2' })) + .status, + ).toBe('pending'); + expect( + (await peer.admit({ ...input, requestEventId: 'overflow-retry' })) + .status, + ).toBe('capacity'); + expect( + (await peer.admit({ ...input, requestEventId: 'pending-retry-1' })) + .status, + ).toBe('pending'); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 10000, + }); + const claimed = await peer.admit({ + ...input, + requestEventId: 'paid-retry', + }); + expect(claimed.status).toBe('claimed'); + expect(required(claimed.record).eventIds).toHaveLength(4); + for (const id of [ + 'event-1', + 'pending-retry-1', + 'pending-retry-2', + 'paid-retry', + ]) + expect( + (await store.admit({ ...input, requestEventId: id })).status, + ).toBe('duplicate'); + expect( + ( + await peer.admit({ + ...input, + requestEventId: 'independent-invocation', + }) + ).status, + ).toBe('reserved'); + }, + { maxEventsPerPayment: 4 }, + )); + + test('consumed rights retain future-dated offer aliases until their last admission deadline', async () => + run(async ({ store }) => { + const now = Math.floor(Date.now() / 1000); + const input = { + ...admission(), + event: { id: 'event-1', author: 'client', createdAt: now + 30 }, + }; + const record = await bind(store, input); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 10000, + }); + const retry = { + ...input, + requestEventId: 'paid-retry', + event: { id: 'paid-retry', author: 'client', createdAt: now }, + }; + const claimed = await store.admit(retry); + expect(claimed.status).toBe('claimed'); + expect(required(claimed.record).retainUntilMs).toBeGreaterThanOrEqual( + (now + 30) * 1000 + store.policy.replayWindowMs, + ); + expect((await store.admit(input)).status).toBe('duplicate'); + })); + + test('one worker reserves an invoice; matching requests stay pending at capacity', async () => + run( + async ({ store, peer }) => { + const input = admission(); + const results = await Promise.all([ + store.admit(input), + peer.admit(input), + ]); + expect(results.map((r) => r.status).sort()).toEqual([ + 'pending', + 'reserved', + ]); + const other = { + ...input, + key: paymentAuthorizationKey('other', 'hash'), + clientPubkey: 'other', + }; + expect((await peer.admit(other)).status).toBe('capacity'); + expect((await peer.admit(input)).status).toBe('pending'); + }, + { maxPendingPayments: 1 }, + )); + + test('capacity zero refuses both lifecycles before an attempt is reserved', async () => + run( + async ({ store }) => { + for (const mode of ['transparent', 'explicit_gating'] as const) + expect((await store.admit(admission(mode))).status).toBe( + 'capacity', + ); + }, + { maxPendingPayments: 0 }, + )); + + test('a paid grant remains claimable when retained-record capacity is one', async () => + run( + async ({ store }) => { + const input = admission(); + const record = await bind(store, input); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 10000, + }); + const retry = { ...input, requestEventId: 'paid-retry' }; + expect((await store.admit(retry)).status).toBe('claimed'); + expect((await store.admit(retry)).status).toBe('duplicate'); + expect( + ( + await store.read( + paymentEventKey(input.clientPubkey, retry.requestEventId), + ) + )?.phase, + ).toBe('consumed'); + }, + { maxPaymentRecords: 1 }, + )); + + test('commands snapshot admission and invoice values before awaiting the backend', async () => + run(async ({ store }) => { + const input = admission(); + const key = input.key; + const pending = store.admit(input); + input.key = paymentAuthorizationKey('changed', 'hash'); + input.clientPubkey = 'changed'; + const result = await pending; + expect(result.status).toBe('reserved'); + const record = required(result.record); + expect(record.key).toBe(key); + expect(record.clientPubkey).toBe('client'); + const fence = { key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + const invoice = { + pmi: 'fake', + amount: 10, + pay_req: 'original-invoice', + }; + const binding = store.transition({ + ...fence, + type: 'bind', + invoice, + leaseMs: 10000, + }); + invoice.pay_req = 'changed-invoice'; + expect(await binding).toBe(true); + expect((await store.read(key))?.invoice?.pay_req).toBe( + 'original-invoice', + ); + })); + + test('an expired creation lease remains uncertain beyond normal record retention', async () => + run( + async ({ store, advance }) => { + const input = { ...admission(), leaseMs: 150 }; + const record = required((await store.admit(input)).record); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'issuing', + }); + await advance(220); + expect((await store.admit(input)).status).toBe('unknown'); + expect((await store.read(input.key))?.generation).toBe( + record.generation, + ); + }, + { replayWindowMs: 30 }, + )); + + test('one grant consumer wins; replaying its event never consumes a later grant', async () => + run(async ({ store, peer }) => { + const input = admission(); + const record = await bind(store, input); + expect( + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 10000, + }), + ).toBe(true); + const retry = { ...input, requestEventId: 'retry-event' }; + const results = await Promise.all([ + store.admit(retry), + peer.admit(retry), + ]); + expect(results.map((r) => r.status).sort()).toEqual([ + 'claimed', + 'duplicate', + ]); + const next = await bind(peer, { + ...input, + requestEventId: 'next-payment', + }); + await peer.transition({ + key: input.key, + generation: next.generation, + type: 'grant', + ttlMs: 10000, + }); + expect((await store.admit(retry)).status).toBe('duplicate'); + expect((await store.read(input.key))?.phase).toBe('granted'); + })); + + test('expired reservations fence slow pricing and every old verifier mutation', async () => + run(async ({ store, peer, advance }) => { + const input = { ...admission(), leaseMs: 150 }; + const old = await bind(store, input); + await advance(220); + const next = await peer.admit({ + ...input, + requestEventId: 'replacement', + leaseMs: 10000, + }); + expect(next.status).toBe('reserved'); + expect(required(next.record).generation).not.toBe(old.generation); + const fence = { key: input.key, generation: old.generation }; + expect( + await store.transition({ ...fence, type: 'grant', ttlMs: 10000 }), + ).toBe(false); + expect(await store.transition({ ...fence, type: 'expire' })).toBe( + false, + ); + expect(await store.transition({ ...fence, type: 'release' })).toBe( + false, + ); + expect( + await store.transition({ + ...fence, + type: 'bind', + invoice: required(old.invoice), + leaseMs: 10000, + }), + ).toBe(false); + expect((await peer.read(input.key))?.generation).toBe( + required(next.record).generation, + ); + expect( + (await store.read(JSON.stringify(['attempt', old.generation]))) + ?.invoice?.pay_req, + ).toBe(required(old.invoice).pay_req); + })); + + test('an expired invoice-creation lease cannot mint another invoice', async () => + run(async ({ store, peer, advance }) => { + const input = { ...admission(), leaseMs: 150 }; + const record = required((await store.admit(input)).record); + const fence = { key: input.key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + await advance(220); + expect((await peer.admit(input)).status).toBe('unknown'); + expect( + await store.transition({ + ...fence, + type: 'bind', + invoice: { amount: 10, pmi: 'fake', pay_req: 'late-invoice' }, + leaseMs: 10000, + }), + ).toBe(false); + expect((await peer.read(input.key))?.invoice?.pay_req).toBe( + 'late-invoice', + ); + expect((await peer.read(input.key))?.phase).toBe('unknown'); + })); + + test('transparent replay after invoice expiry retains one invoice and one execution right', async () => + run(async ({ store, peer, advance }) => { + const input = { ...admission('transparent'), leaseMs: 150 }; + const record = await bind(store, input); + const fence = { key: input.key, generation: record.generation }; + expect(await store.transition({ ...fence, type: 'consume' })).toBe( + true, + ); + expect(await peer.transition({ ...fence, type: 'consume' })).toBe( + false, + ); + await advance(220); + expect((await peer.admit(input)).status).toBe('duplicate'); + expect((await store.read(input.key))?.invoice?.pay_req).toBe( + required(record.invoice).pay_req, + ); + expect(await store.transition({ ...fence, type: 'dispatch' })).toBe( + true, + ); + expect((await peer.admit(input)).status).toBe('duplicate'); + })); + + test('pending leases expire independently of retained replay records', async () => + run( + async ({ store, advance }) => { + const input = { ...admission('transparent'), leaseMs: 150 }; + await bind(store, input); + await advance(220); + expect((await store.admit(input)).status).toBe('duplicate'); + expect( + (await store.admit(admission('transparent', 'new-event'))).status, + ).toBe('reserved'); + }, + { maxPendingPayments: 1 }, + )); + + test('live and expired paid grants are retained under record capacity pressure', async () => + run( + async ({ store, peer, advance }) => { + const input = admission(); + const record = await bind(store, input); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 40, + }); + expect( + (await peer.admit(admission('transparent', 'new-event'))).status, + ).toBe('capacity'); + await advance(220); + expect((await peer.admit(input)).status).toBe('expired'); + expect((await store.read(input.key))?.phase).toBe('granted'); + }, + { maxPaymentRecords: 1 }, + )); + + test('a failed pre-invoice quote can release only its own generation', async () => + run(async ({ store, peer }) => { + const input = admission(); + const old = required((await store.admit(input)).record); + expect( + await store.transition({ + key: input.key, + generation: old.generation, + type: 'release', + }), + ).toBe(true); + const current = required((await peer.admit(input)).record); + expect( + await store.transition({ + key: input.key, + generation: old.generation, + type: 'issuing', + }), + ).toBe(false); + expect((await peer.read(input.key))?.generation).toBe( + current.generation, + ); + })); + + test('unknown invoice outcomes survive admission and cannot release their reservation', async () => + run(async ({ store, peer }) => { + const input = admission(); + const record = required((await store.admit(input)).record); + const fence = { key: input.key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + await store.transition({ + ...fence, + type: 'uncertain', + reason: 'acknowledgement_lost', + }); + expect(await store.transition({ ...fence, type: 'release' })).toBe( + false, + ); + expect((await peer.admit(input)).status).toBe('unknown'); + })); + + test('rejects old, future, and mismatched authenticated event identities', async () => + run(async ({ store }) => { + const input = admission('transparent'); + const now = Math.floor(Date.now() / 1000); + for (const event of [ + { + id: input.requestEventId, + author: 'client', + createdAt: now - 100000, + }, + { id: input.requestEventId, author: 'client', createdAt: now + 120 }, + { id: 'different', author: 'client', createdAt: now }, + { id: input.requestEventId, author: 'other', createdAt: now }, + ]) + expect((await store.admit({ ...input, event })).status).toBe('stale'); + })); + }); +} + +function required(value: T | undefined): T { + if (value === undefined) throw new Error('Expected payment state fixture'); + return value; +} diff --git a/src/payments/authorization-store.test.ts b/src/payments/authorization-store.test.ts index 8e0610f..743e028 100644 --- a/src/payments/authorization-store.test.ts +++ b/src/payments/authorization-store.test.ts @@ -1,178 +1,54 @@ import { describe, expect, test } from 'bun:test'; import { AuthorizationStore } from './authorization-store.js'; -import type { CanonicalInvocationIdentity } from './types.js'; - -describe('AuthorizationStore', () => { - const identity: CanonicalInvocationIdentity = { - clientPubkey: 'client-1', - invocationHash: 'hash-1', +import { paymentStoreContract } from '../__mocks__/payment-store-contract.js'; + +paymentStoreContract('in-memory atomic payment store', async (policy) => { + let now = Date.now(); + const store = new AuthorizationStore(policy, () => now); + return { + store, + peer: store, + advance: async (ms) => { + now += ms; + }, + close: async () => {}, }; +}); - test('grant and claim a single authorization', () => { - const store = new AuthorizationStore(); - - expect(store.claim(identity)).toBe(false); - - store.grant(identity, 10000); - - expect(store.claim(identity)).toBe(true); - expect(store.claim(identity)).toBe(false); - }); - - test('claim fails after TTL expires', async () => { - const store = new AuthorizationStore(); - - store.grant(identity, 50); - - await new Promise((resolve) => setTimeout(resolve, 75)); - - expect(store.claim(identity)).toBe(false); - }); - - test('trySetPending prevents concurrent duplicates', () => { - const store = new AuthorizationStore(); - - // First call transitions to pending -> true - expect(store.trySetPending(identity, 10000)).toBe(true); - - // Second call is blocked -> false - expect(store.trySetPending(identity, 10000)).toBe(false); - - // Pending state is observable via getPendingRemainingMs - expect(store.getPendingRemainingMs(identity)).toBeGreaterThan(0); - }); - - test('trySetPending allows setting again after clearPending', () => { - const store = new AuthorizationStore(); - - expect(store.trySetPending(identity, 10000)).toBe(true); - expect(store.trySetPending(identity, 10000)).toBe(false); - - store.clearPending(identity); - - expect(store.trySetPending(identity, 10000)).toBe(true); - }); - - test('trySetPending allows setting again after pending state expires', async () => { - const store = new AuthorizationStore(); - - expect(store.trySetPending(identity, 50)).toBe(true); - expect(store.trySetPending(identity, 50)).toBe(false); - - await new Promise((resolve) => setTimeout(resolve, 75)); - - expect(store.trySetPending(identity, 50)).toBe(true); - }); - - test('grant clears pending state', () => { - const store = new AuthorizationStore(); - - expect(store.trySetPending(identity, 10000)).toBe(true); - store.grant(identity, 10000); - // grant cleared pending, so a fresh trySetPending succeeds again - expect(store.trySetPending(identity, 10000)).toBe(true); - }); - - test('LRU eviction works when maxEntries is exceeded', () => { - const store = new AuthorizationStore({ maxEntries: 2 }); - - const id1 = { clientPubkey: 'client', invocationHash: 'h1' }; - const id2 = { clientPubkey: 'client', invocationHash: 'h2' }; - const id3 = { clientPubkey: 'client', invocationHash: 'h3' }; - - store.grant(id1, 10000); - store.grant(id2, 10000); - store.grant(id3, 10000); // This should evict id1 - - expect(store.claim(id1)).toBe(false); - expect(store.claim(id2)).toBe(true); - expect(store.claim(id3)).toBe(true); - }); - - test('pending entries are never evicted while live; capacity refuses instead', () => { - const store = new AuthorizationStore({ maxEntries: 2 }); - - const id1 = { clientPubkey: 'client', invocationHash: 'p1' }; - const id2 = { clientPubkey: 'client', invocationHash: 'p2' }; - const id3 = { clientPubkey: 'client', invocationHash: 'p3' }; - - store.trySetPending(id1, 10000); - store.trySetPending(id2, 10000); - // At capacity with only live entries: refuse rather than evict — an - // evicted live pending entry disarms the payment dedup (double charge). - expect(store.trySetPending(id3, 10000)).toBe(false); - - expect(store.getPendingRemainingMs(id1)).toBeGreaterThan(0); - expect(store.getPendingRemainingMs(id2)).toBeGreaterThan(0); - expect(store.getPendingRemainingMs(id3)).toBe(0); - }); - - test('expired pending entries are purged under capacity pressure', async () => { - const store = new AuthorizationStore({ maxEntries: 2 }); - - const id1 = { clientPubkey: 'client', invocationHash: 'p1' }; - const id2 = { clientPubkey: 'client', invocationHash: 'p2' }; - const id3 = { clientPubkey: 'client', invocationHash: 'p3' }; - - store.trySetPending(id1, 20); - store.trySetPending(id2, 10000); - - await new Promise((resolve) => setTimeout(resolve, 40)); - - // id1 expired: capacity pressure purges it and makes room for id3. - expect(store.trySetPending(id3, 10000)).toBe(true); - expect(store.getPendingRemainingMs(id1)).toBe(0); - expect(store.getPendingRemainingMs(id2)).toBeGreaterThan(0); - expect(store.getPendingRemainingMs(id3)).toBeGreaterThan(0); - }); - - test('expired grants are purged before capacity-driven eviction', async () => { - const store = new AuthorizationStore({ maxEntries: 2 }); - - const id1 = { clientPubkey: 'client', invocationHash: 'g1' }; // stays live - const id2 = { clientPubkey: 'client', invocationHash: 'g2' }; // expires - const id3 = { clientPubkey: 'client', invocationHash: 'g3' }; - - store.grant(id1, 10000); - store.grant(id2, 20); - await new Promise((resolve) => setTimeout(resolve, 40)); - - // id2's grant expired: purging it makes room for id3, so the LIVE id1 - // grant survives instead of being blindly evicted as the LRU victim. - store.grant(id3, 10000); - - expect(store.claim(id1)).toBe(true); - expect(store.claim(id2)).toBe(false); - expect(store.claim(id3)).toBe(true); +describe('payment store configuration', () => { + test('rejects invalid capacity/retention settings', () => { + for (const value of [-1, NaN, Infinity, 1.5]) { + expect( + () => new AuthorizationStore({ maxPendingPayments: value }), + ).toThrow(); + expect( + () => new AuthorizationStore({ maxPaymentRecords: value }), + ).toThrow(); + } + expect(() => new AuthorizationStore({ replayWindowMs: 0 })).toThrow(); }); +}); - test('updatePendingTtl and getPendingRemainingMs behave correctly', async () => { - const store = new AuthorizationStore(); - - // (1) verify getPendingRemainingMs right after trySetPending - expect(store.trySetPending(identity, 100)).toBe(true); - const remainingAfterSet = store.getPendingRemainingMs(identity); - expect(remainingAfterSet).toBeGreaterThan(0); - expect(remainingAfterSet).toBeLessThanOrEqual(100); - - // (2) verify updatePendingTtl extends the pending TTL - store.updatePendingTtl(identity, 500); - const remainingAfterUpdate = store.getPendingRemainingMs(identity); - expect(remainingAfterUpdate).toBeGreaterThan(100); - expect(remainingAfterUpdate).toBeLessThanOrEqual(500); - - // (3) verify getPendingRemainingMs returns 0 after waiting past TTL - await new Promise((resolve) => setTimeout(resolve, 550)); - expect(store.getPendingRemainingMs(identity)).toBe(0); - - // (4) verify updatePendingTtl is a no-op when there is no active pending entry - store.updatePendingTtl(identity, 1000); - expect(store.getPendingRemainingMs(identity)).toBe(0); - - // And after clearPending - store.trySetPending(identity, 1000); - store.clearPending(identity); - store.updatePendingTtl(identity, 1000); - expect(store.getPendingRemainingMs(identity)).toBe(0); - }); +test('clock rollback cannot readmit a collected event', async () => { + let now = 1000000; + const store = new AuthorizationStore({ replayWindowMs: 1000 }, () => now); + const input = { + key: '["event","client","event"]', + mode: 'transparent' as const, + clientPubkey: 'client', + requestEventId: 'event', + invocationHash: 'hash', + leaseMs: 100, + event: { id: 'event', author: 'client', createdAt: 1000 }, + }; + const admitted = await store.admit(input); + expect(admitted.status).toBe('reserved'); + const generation = admitted.record!.generation; + await store.transition({ key: input.key, generation, type: 'consume' }); + await store.transition({ key: input.key, generation, type: 'dispatch' }); + now += 1001; + expect((await store.admit(input)).status).toBe('stale'); + expect(await store.read(input.key)).toBeUndefined(); + now -= 1001; + expect((await store.admit(input)).status).toBe('stale'); }); diff --git a/src/payments/authorization-store.ts b/src/payments/authorization-store.ts index 5ea5703..15d098f 100644 --- a/src/payments/authorization-store.ts +++ b/src/payments/authorization-store.ts @@ -1,199 +1,7 @@ -import type { CanonicalInvocationIdentity } from './types.js'; -import { LruCache } from '../core/utils/lru-cache.js'; -import { createLogger } from '../core/utils/logger.js'; - -interface PaidAuthorization { - /** Composite key: `${clientPubkey}:${invocationHash}` */ - key: string; - expiresAtMs: number; -} +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; /** - * A bounded, TTL-aware store for explicit gating authorizations. - * It manages both the pending state (waiting for payment verification) - * and the granted state (paid and ready to consume). - * - * Capacity safety: at capacity, expired entries are purged first and live - * pending entries are NEVER silently evicted — an evicted live pending entry - * disarms the payment dedup and a retry would mint a second invoice - * (CEP-8: MUST NOT charge twice). `trySetPending` refuses instead. - * Verified grants cannot be refused (the money is already taken), so `grant()` - * purges expired grants first and only then falls back to LRU eviction. - * - * NOTE: The atomicity provided by `trySetPending` relies on in-memory maps, - * meaning it is strictly single-process. For multi-process horizontal scaling, - * implementers should use a distributed lock (e.g. Redis Redlock) keyed by - * the canonical invocation identity to prevent duplicate payments. - * - * NOTE: Atomic reserve-then-dispatch (`claim()` here, `trySetPending()` in the - * gating middlewares) relies on JavaScript run-to-completion: there is no - * interleaving point between the two calls. Porters to await-capable runtimes - * must compose them into a single critical section. + * In-memory authorization and replay storage. Use the atomic admit/transition + * API; the former unfenced grant/clear methods are intentionally unavailable. */ -export class AuthorizationStore { - private readonly authorizations: LruCache; - private readonly pending: LruCache; // Map of key -> expiresAtMs - private readonly maxEntries: number; - private readonly logger = createLogger('authorization-store'); - - constructor(opts?: { maxEntries?: number }) { - this.maxEntries = opts?.maxEntries ?? 5000; - this.authorizations = new LruCache(this.maxEntries); - this.pending = new LruCache(this.maxEntries); - } - - private getKey(identity: CanonicalInvocationIdentity): string { - return `${identity.clientPubkey}:${identity.invocationHash}`; - } - - /** - * Records a paid authorization. Each grant authorizes exactly one future - * execution (CEP-8: "each successful payment SHOULD authorize one future - * execution unless server policy explicitly grants a different number"). - */ - public grant(identity: CanonicalInvocationIdentity, ttlMs: number): void { - const key = this.getKey(identity); - const expiresAtMs = Date.now() + ttlMs; - - // Purge expired grants before any capacity-driven eviction: an evicted - // unconsumed grant is paid-but-unusable and the client would be charged - // again on retry. - if (this.authorizations.size >= this.maxEntries) { - this.purgeExpiredAuthorizations(); - } - - this.authorizations.set(key, { key, expiresAtMs }); - - // Once granted, it's no longer pending - this.pending.delete(key); - - this.logger.debug('authorization granted', { key, ttlMs }); - } - - /** - * Atomically claims the single execution authorization. - * Returns true if claimed, false if none available or expired. - */ - public claim(identity: CanonicalInvocationIdentity): boolean { - const key = this.getKey(identity); - const auth = this.authorizations.get(key); - - if (!auth) { - return false; - } - - if (Date.now() > auth.expiresAtMs) { - this.authorizations.delete(key); - return false; - } - - // Single-use: consume the authorization atomically. - this.authorizations.delete(key); - this.logger.debug('authorization claimed', { key }); - return true; - } - - /** Removes expired pending entries (capacity-pressure cleanup). */ - private purgeExpiredPending(): void { - const now = Date.now(); - for (const [key, expiry] of this.pending.entries()) { - if (expiry <= now) { - this.pending.delete(key); - } - } - } - - /** Removes expired grants (capacity-pressure cleanup). */ - private purgeExpiredAuthorizations(): void { - const now = Date.now(); - for (const [key, auth] of this.authorizations.entries()) { - if (auth.expiresAtMs <= now) { - this.authorizations.delete(key); - } - } - } - - /** - * Atomically checks whether a payment is already pending for this identity - * and, if not, marks it as pending. Returns `true` if this call transitioned - * the identity to pending (caller should emit -32042). Returns `false` if - * already pending (caller should emit -32043) or the store is at capacity - * with only live entries (caller should emit a capacity refusal) — the two - * are distinguished via {@link getPendingRemainingMs}. - * - * This atomic check-and-set prevents concurrent requests from both receiving - * -32042 and triggering duplicate payment flows. Live entries are never - * evicted to make room: an evicted pending entry disarms the dedup and a - * retry would be charged twice. - * NOTE: This is single-process only. Distributed setups must use an external lock. - */ - public trySetPending( - identity: CanonicalInvocationIdentity, - ttlMs: number, - ): boolean { - const key = this.getKey(identity); - const now = Date.now(); - - const existingExpiry = this.pending.get(key); - if (existingExpiry !== undefined) { - if (now > existingExpiry) { - // Expired pending state, we can overwrite it - this.pending.delete(key); - } else { - // Already pending and active - return false; - } - } - - if (this.pending.size >= this.maxEntries) { - this.purgeExpiredPending(); - if (this.pending.size >= this.maxEntries) { - // Refuse rather than evict a live payment's dedup entry. - this.logger.warn('pending authorization capacity reached, refusing', { - key, - maxEntries: this.maxEntries, - }); - return false; - } - } - - this.pending.set(key, now + ttlMs); - this.logger.debug('authorization marked pending', { key, ttlMs }); - return true; - } - - /** - * Updates the TTL of an already pending authorization. No-op if not pending. - * - * @param identity The canonical invocation identity. - * @param ttlMs The new TTL in milliseconds to apply from now. - * @returns void - */ - public updatePendingTtl( - identity: CanonicalInvocationIdentity, - ttlMs: number, - ): void { - const key = this.getKey(identity); - const existingExpiry = this.pending.get(key); - if (existingExpiry !== undefined && Date.now() <= existingExpiry) { - this.pending.set(key, Date.now() + ttlMs); - this.logger.debug('authorization pending TTL updated', { key, ttlMs }); - } - } - - /** Gets the remaining TTL in milliseconds for a pending authorization, or 0 if not pending. */ - public getPendingRemainingMs(identity: CanonicalInvocationIdentity): number { - const key = this.getKey(identity); - const expiry = this.pending.get(key); - if (expiry === undefined) return 0; - const remaining = expiry - Date.now(); - return remaining > 0 ? remaining : 0; - } - - /** Clears pending state (e.g. on verification failure or expiry). */ - public clearPending(identity: CanonicalInvocationIdentity): void { - const key = this.getKey(identity); - this.pending.delete(key); - this.logger.debug('authorization pending state cleared', { key }); - } -} +export class AuthorizationStore extends InMemoryPaymentStateStore {} diff --git a/src/payments/canonical-identity.test.ts b/src/payments/canonical-identity.test.ts index dfd9d1b..38ceedd 100644 --- a/src/payments/canonical-identity.test.ts +++ b/src/payments/canonical-identity.test.ts @@ -1,10 +1,28 @@ import { describe, expect, test } from 'bun:test'; +import vectors from './fixtures/canonical-invocations.json'; import { computeCanonicalInvocationHash, computeCanonicalInvocationIdentity, } from './canonical-identity.js'; describe('Canonical Invocation Identity', () => { + for (const vector of vectors) { + test(`published JCS vector: ${vector.name}`, () => { + expect(computeCanonicalInvocationHash(vector.method, vector.params)).toBe( + vector.sha256, + ); + expect( + computeCanonicalInvocationIdentity( + vector.clientPubkey, + vector.method, + vector.params, + ), + ).toEqual({ + clientPubkey: vector.clientPubkey, + invocationHash: vector.sha256, + }); + }); + } describe('computeCanonicalInvocationHash', () => { test('is deterministic regardless of object key order', () => { const hash1 = computeCanonicalInvocationHash('tools/call', { diff --git a/src/payments/client-payment-validation.ts b/src/payments/client-payment-validation.ts index e86c555..de61c57 100644 --- a/src/payments/client-payment-validation.ts +++ b/src/payments/client-payment-validation.ts @@ -6,13 +6,14 @@ import type { PaymentRequiredErrorData, } from './types.js'; +import { + paymentAmountSchema as amountSchema, + paymentPmiSchema as pmiSchema, + paymentOptionSchema, +} from './payment-option-schema.js'; +export { paymentOptionSchema as clientPaymentOptionSchema } from './payment-option-schema.js'; + const MAX_TIMER_MS = 2_147_483_647; -const amountSchema = z - .number() - .finite() - .nonnegative() - .max(Number.MAX_SAFE_INTEGER); -const pmiSchema = z.string().regex(/^[a-z0-9-]+$/); const timerSchema = z.number().finite().positive().max(MAX_TIMER_MS); /** Internal payment failure with a stable application-visible reason. */ @@ -26,28 +27,10 @@ export class ClientPaymentError extends Error { } } -/** Runtime offer schema; unknown extension metadata is preserved. */ -export const clientPaymentOptionSchema = z.object({ - amount: amountSchema, - pmi: pmiSchema, - pay_req: z - .string() - .min(1) - .refine((value) => value.trim().length > 0), - description: z.string().optional(), - ttl: z - .number() - .finite() - .positive() - .max(Number.MAX_SAFE_INTEGER / 1000) - .optional(), - _meta: z.record(z.string(), z.unknown()).optional(), -}); - const requiredSchema = z.object({ instructions: z.string().optional(), payment_options: z - .array(clientPaymentOptionSchema) + .array(paymentOptionSchema) .min(1) .max(100) .refine((options) => { @@ -81,7 +64,7 @@ function parse(schema: z.ZodType, input: unknown): T { /** Validates a transparent payment offer before invoking policy or wallet code. */ export function parseClientPaymentOption(input: unknown): PaymentOption { - return parse(clientPaymentOptionSchema, input); + return parse(paymentOptionSchema, input); } /** Validates all explicit options, including options that might not be selected. */ diff --git a/src/payments/fixtures/canonical-invocations.json b/src/payments/fixtures/canonical-invocations.json new file mode 100644 index 0000000..af28267 --- /dev/null +++ b/src/payments/fixtures/canonical-invocations.json @@ -0,0 +1,150 @@ +[ + { + "name": "omitted params", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\"}", + "sha256": "8cd110067dc76f633f8f32c07f220feb0170ac161b319a7ba13310841149291c" + }, + { + "name": "empty params", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{}}", + "sha256": "fee95a27604e96d5011447647a735b90de7d370d5bd60a75aa114d8d9dccdedb", + "params": {} + }, + { + "name": "null params", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":null}", + "sha256": "0d42502f6ed2dab6f9ae9c3c6131a56816f0f195824f7899a2566e81fc979003", + "params": null + }, + { + "name": "nested key order", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"arguments\":{\"a\":1,\"b\":2},\"name\":\"add\"}}", + "sha256": "3d51151b6b6d37c9dbbfa39532bb68d51bf3d9a8a6763437ab179648a6f565ff", + "params": { + "name": "add", + "arguments": { + "b": 2, + "a": 1 + } + } + }, + { + "name": "transport metadata excluded", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"arguments\":{\"a\":1,\"b\":2},\"name\":\"add\"}}", + "sha256": "3d51151b6b6d37c9dbbfa39532bb68d51bf3d9a8a6763437ab179648a6f565ff", + "params": { + "_meta": { + "progressToken": 42, + "unrecognized": { + "nested": true + } + }, + "arguments": { + "a": 1, + "b": 2 + }, + "name": "add" + } + }, + { + "name": "another client same invocation hash", + "clientPubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"arguments\":{\"a\":1,\"b\":2},\"name\":\"add\"}}", + "sha256": "3d51151b6b6d37c9dbbfa39532bb68d51bf3d9a8a6763437ab179648a6f565ff", + "params": { + "name": "add", + "arguments": { + "b": 2, + "a": 1 + } + } + }, + { + "name": "changed semantic argument", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"arguments\":{\"a\":2,\"b\":2},\"name\":\"add\"}}", + "sha256": "658de74e9b3235696bc41195377b366c6a051d1fa9a019fe0b8885f1cf337396", + "params": { + "name": "add", + "arguments": { + "b": 2, + "a": 2 + } + } + }, + { + "name": "unicode composed", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"text\":\"é 🌍\"}}", + "sha256": "04129d21e74bd5e67eb3b018f6daf35309e16226a574312e097d542741fcdfdd", + "params": { + "text": "é 🌍" + } + }, + { + "name": "unicode decomposed", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"text\":\"é 🌍\"}}", + "sha256": "5fa930fb9634dfb2022cf4711766e9385269397e54e43f73befb3a78177254ff", + "params": { + "text": "é 🌍" + } + }, + { + "name": "ECMAScript number serialization", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"numbers\":[0,1e-7,1e+21,0.000001,333333333.3333333]}}", + "sha256": "2abb616ec4a2c3756617c08e3f2f2eb519faad2360ad1c4d7120f55edc96941d", + "params": { + "numbers": [-0.0, 1e-7, 1e21, 1e-6, 333333333.3333333] + } + }, + { + "name": "positional params", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":[true,null,\"text\",{\"a\":1,\"z\":0}]}", + "sha256": "fa4d5aca8059f0825e90b7e4945a54b320a9ef855c0e5faf8bd080297c84e28b", + "params": [ + true, + null, + "text", + { + "z": 0, + "a": 1 + } + ] + }, + { + "name": "nested _meta remains semantic", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"arguments\":{\"_meta\":{\"v\":1}}}}", + "sha256": "1e528551273124dd8b83dd7841fec364921c77b9c239bc7b5c3f15e62a47a1ce", + "params": { + "arguments": { + "_meta": { + "v": 1 + } + }, + "_meta": { + "progressToken": 1 + } + } + } +] diff --git a/src/payments/in-memory-payment-state-store.ts b/src/payments/in-memory-payment-state-store.ts new file mode 100644 index 0000000..8269fe2 --- /dev/null +++ b/src/payments/in-memory-payment-state-store.ts @@ -0,0 +1,97 @@ +import type { + PaymentStateAdmission, + PaymentStateAdmissionResult, + PaymentStatePolicy, + PaymentStateRecord, + PaymentStateStore, + PaymentStateTransition, +} from './payment-state-store.js'; +import { createPaymentStatePolicy } from './payment-state-store.js'; +import { + admitPaymentState, + canCollectPaymentRecord, + isLivePaymentLease, + transitionPaymentState, +} from './payment-state-machine.js'; + +let memoryStoreSequence = 0; + +/** Single-process default; live grants and uncertain outcomes are never evicted. */ +export class InMemoryPaymentStateStore implements PaymentStateStore { + public readonly policy: PaymentStatePolicy; + private readonly records = new Map(); + private observedAtMs = 0; + private readonly storeSequence = ++memoryStoreSequence; + private attemptSequence = 0; + + public constructor( + options: Partial = {}, + private readonly now: () => number = Date.now, + ) { + this.policy = createPaymentStatePolicy(options); + } + + /** Atomically claims a grant or reserves an invoice before any asynchronous work. */ + public async admit( + input: PaymentStateAdmission, + ): Promise { + const now = this.timestamp(); + if ( + !Number.isSafeInteger(this.storeSequence) || + this.attemptSequence >= Number.MAX_SAFE_INTEGER + ) + throw new Error('Payment generation space exhausted'); + for (const [key, record] of this.records) { + if (canCollectPaymentRecord(record, now)) this.records.delete(key); + } + const change = admitPaymentState( + input, + { + record: this.records.get(input.key), + execution: [...this.records.values()].find( + (record) => + record.key !== input.key && + record.clientPubkey === input.clientPubkey && + record.eventIds.includes(input.requestEventId), + ), + total: this.records.size, + pending: [...this.records.values()].filter((record) => + isLivePaymentLease(record, now), + ).length, + }, + this.policy, + now, + `${this.storeSequence}.${++this.attemptSequence}`, + ); + for (const key of change.remove) this.records.delete(key); + for (const record of change.put) this.records.set(record.key, record); + return change.result; + } + + /** Commits only transitions belonging to the current attempt generation. */ + public async transition(input: PaymentStateTransition): Promise { + const change = transitionPaymentState( + input, + this.records.get(input.key), + this.timestamp(), + this.policy, + ); + for (const key of change.remove) this.records.delete(key); + for (const record of change.put) this.records.set(record.key, record); + return change.result; + } + + /** Returns a defensive recovery snapshot without exposing mutable store state. */ + public async read(key: string): Promise { + const record = this.records.get(key); + return record && structuredClone(record); + } + + private timestamp(): number { + const now = this.now(); + if (!Number.isSafeInteger(now) || now < 0) + throw new Error('Invalid payment clock'); + this.observedAtMs = Math.max(now, this.observedAtMs); + return this.observedAtMs; + } +} diff --git a/src/payments/index.ts b/src/payments/index.ts index fe5f3eb..244c753 100644 --- a/src/payments/index.ts +++ b/src/payments/index.ts @@ -18,3 +18,7 @@ export * from './processors/ln-bolt11-nwc-payment-processor.js'; export * from './processors/ln-bolt11-lnbits-payment-processor.js'; export * from './processors/ln-bolt11-zap-payment-processor.js'; export * from './constants.js'; +export * from './payment-state-store.js'; +export * from './in-memory-payment-state-store.js'; +export * from './authorization-store.js'; +export * from './canonical-identity.js'; diff --git a/src/payments/payment-option-schema.ts b/src/payments/payment-option-schema.ts new file mode 100644 index 0000000..69ecb10 --- /dev/null +++ b/src/payments/payment-option-schema.ts @@ -0,0 +1,26 @@ +import { z } from 'zod'; + +export const paymentAmountSchema = z + .number() + .finite() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER); +export const paymentPmiSchema = z.string().regex(/^[a-z0-9-]+$/); + +/** Runtime offer schema; unknown extension metadata is preserved. */ +export const paymentOptionSchema = z.object({ + amount: paymentAmountSchema, + pmi: paymentPmiSchema, + pay_req: z + .string() + .min(1) + .refine((value) => value.trim().length > 0), + description: z.string().optional(), + ttl: z + .number() + .finite() + .positive() + .max(Number.MAX_SAFE_INTEGER / 1000) + .optional(), + _meta: z.record(z.string(), z.unknown()).optional(), +}); diff --git a/src/payments/payment-state-machine.ts b/src/payments/payment-state-machine.ts new file mode 100644 index 0000000..995af31 --- /dev/null +++ b/src/payments/payment-state-machine.ts @@ -0,0 +1,351 @@ +import type { + PaymentStateAdmission, + PaymentStateAdmissionResult, + PaymentStatePolicy, + PaymentStateRecord, + PaymentStateTransition, +} from './payment-state-store.js'; +import { + paymentAuthorizationKey, + paymentEventKey, +} from './payment-state-store.js'; +import { paymentOptionSchema } from './payment-option-schema.js'; + +/** Atomic backend view; implementations hold their lock while applying changes. */ +export interface PaymentStateView { + record?: PaymentStateRecord; + execution?: PaymentStateRecord; + total: number; + pending: number; +} + +/** Storage changes produced without external payment or application I/O. */ +export interface PaymentStateChange { + result: T; + put: PaymentStateRecord[]; + remove: string[]; +} + +/** Whether a record currently occupies a pending-payment slot. */ +export function isLivePaymentLease( + record: PaymentStateRecord, + now: number, +): boolean { + return ( + ['reserved', 'issuing', 'pending'].includes(record.phase) && + record.leaseExpiresAtMs > now + ); +} + +/** Tombstones are collected only after their events become inadmissible. */ +export function canCollectPaymentRecord( + record: PaymentStateRecord, + now: number, +): boolean { + return ( + record.retainUntilMs !== null && + record.retainUntilMs <= now && + !isLivePaymentLease(record, now) + ); +} + +/** Applies atomic admission/claim under the backend's namespace lock. */ +export function admitPaymentState( + input: PaymentStateAdmission, + view: PaymentStateView, + policy: PaymentStatePolicy, + now: number, + generation: string, +): PaymentStateChange { + const done = ( + status: Exclude< + PaymentStateAdmissionResult['status'], + 'reserved' | 'claimed' + >, + record = view.record, + ): PaymentStateChange => ({ + result: { status, ...(record && { record: structuredClone(record) }) }, + put: [], + remove: [], + }); + if ( + !Number.isFinite(input.leaseMs) || + input.leaseMs < 1 || + input.leaseMs > 2_147_483_647 || + !input.key || + !input.clientPubkey || + !input.requestEventId || + !input.invocationHash + ) { + throw new TypeError('Invalid payment admission'); + } + const expectedKey = + input.mode === 'transparent' + ? paymentEventKey(input.clientPubkey, input.requestEventId) + : paymentAuthorizationKey(input.clientPubkey, input.invocationHash); + if ( + !['transparent', 'explicit_gating'].includes(input.mode) || + input.key !== expectedKey + ) + throw new TypeError('Payment key does not match its identity'); + const event = input.event; + if ( + event && + (event.id !== input.requestEventId || + event.author !== input.clientPubkey || + !Number.isSafeInteger(event.createdAt) || + event.createdAt < 0 || + event.createdAt * 1000 <= now - policy.replayWindowMs || + event.createdAt * 1000 > now + policy.maxFutureSkewMs) + ) + return done('stale'); + if (view.execution) return done('duplicate'); + const eventDeadline = event + ? event.createdAt * 1000 + policy.replayWindowMs + : null; + const current = view.record; + if (current) { + if ( + current.clientPubkey !== input.clientPubkey || + current.invocationHash !== input.invocationHash + ) + return done('unknown'); + if (input.mode === 'transparent') return done('duplicate'); + if ( + current.phase === 'unknown' || + (current.phase === 'issuing' && current.leaseExpiresAtMs <= now) + ) + return done('unknown'); + if (current.phase === 'granted') { + if ((current.grantExpiresAtMs ?? 0) <= now) return done('expired'); + const eventIds = [ + ...new Set([...current.eventIds, input.requestEventId]), + ]; + const replayExpiresAtMs = + current.replayExpiresAtMs === null || eventDeadline === null + ? null + : Math.max(current.replayExpiresAtMs, eventDeadline); + const consumed: PaymentStateRecord = { + ...current, + phase: 'consumed', + eventIds, + replayExpiresAtMs, + retainUntilMs: + replayExpiresAtMs === null + ? null + : Math.max(replayExpiresAtMs, current.invoiceExpiresAtMs ?? 0), + }; + const execution: PaymentStateRecord = { + ...consumed, + key: paymentEventKey(input.clientPubkey, input.requestEventId), + requestEventId: input.requestEventId, + }; + // Move the paid right into its execution tombstone in the same transaction; + // claiming must not require extra capacity after money has been accepted. + return { + result: { status: 'claimed', record: structuredClone(execution) }, + put: [execution], + remove: [current.key], + }; + } + if (isLivePaymentLease(current, now)) { + if (current.eventIds.includes(input.requestEventId)) + return done('pending'); + if (current.eventIds.length >= policy.maxEventsPerPayment - 1) + return done('capacity'); + const updated = { + ...current, + eventIds: [...current.eventIds, input.requestEventId], + replayExpiresAtMs: + current.replayExpiresAtMs === null || eventDeadline === null + ? null + : Math.max(current.replayExpiresAtMs, eventDeadline), + }; + if (updated.retainUntilMs !== null && updated.replayExpiresAtMs !== null) + updated.retainUntilMs = Math.max( + updated.retainUntilMs, + updated.replayExpiresAtMs, + ); + return { + result: { status: 'pending', record: structuredClone(updated) }, + put: [updated], + remove: [], + }; + } + if (event && current.eventIds.includes(input.requestEventId)) + return done('duplicate'); + if ( + current.requestEventId === input.requestEventId && + ['consumed', 'dispatched', 'rejected'].includes(current.phase) + ) + return done('duplicate'); + } + const archive = current?.invoice + ? { + ...current, + key: JSON.stringify(['attempt', current.generation]), + phase: 'expired' as const, + retainUntilMs: current.retainUntilMs, + } + : undefined; + const additional = current ? (archive ? 1 : 0) : 1; + if ( + view.pending >= policy.maxPendingPayments || + view.total + additional > policy.maxPaymentRecords + ) + return done('capacity'); + const record: PaymentStateRecord = { + key: input.key, + generation, + mode: input.mode, + phase: 'reserved', + clientPubkey: input.clientPubkey, + requestEventId: input.requestEventId, + eventIds: [input.requestEventId], + replayExpiresAtMs: eventDeadline, + invocationHash: input.invocationHash, + leaseExpiresAtMs: now + Math.floor(input.leaseMs), + retainUntilMs: + event && input.mode === 'transparent' + ? event.createdAt * 1000 + policy.replayWindowMs + : input.mode === 'transparent' + ? null + : Math.max(now + policy.replayWindowMs, eventDeadline ?? 0), + }; + return { + result: { status: 'reserved', record: structuredClone(record) }, + put: [...(archive ? [archive] : []), record], + remove: [], + }; +} + +/** Applies a fenced state transition; expired/superseded workers cannot commit. */ +export function transitionPaymentState( + input: PaymentStateTransition, + current: PaymentStateRecord | undefined, + now: number, + policy: PaymentStatePolicy, +): PaymentStateChange { + const none: PaymentStateChange = { + result: false, + put: [], + remove: [], + }; + if (!current || current.generation !== input.generation) return none; + const record = structuredClone(current); + const live = isLivePaymentLease(record, now); + switch (input.type) { + case 'issuing': + if (record.phase !== 'reserved' || !live) return none; + record.phase = 'issuing'; + if (record.mode === 'explicit_gating') record.retainUntilMs = null; + break; + case 'bind': + if (record.phase !== 'issuing') return none; + if ( + !Number.isFinite(input.leaseMs) || + input.leaseMs < 1 || + input.leaseMs > 2_147_483_647 + ) + throw new TypeError('Invalid payment lease'); + record.invoice = paymentOptionSchema.parse(input.invoice); + JSON.stringify(record.invoice, (_key, value: unknown) => { + if ( + typeof value === 'function' || + typeof value === 'symbol' || + typeof value === 'bigint' || + (typeof value === 'number' && !Number.isFinite(value)) + ) + throw new TypeError('Invoice must be JSON serializable'); + return value; + }); + record.invoice = JSON.parse( + JSON.stringify(record.invoice), + ) as NonNullable; + record.invoiceExpiresAtMs = Math.min( + Number.MAX_SAFE_INTEGER, + now + + Math.floor( + record.invoice.ttl === undefined + ? input.leaseMs + : record.invoice.ttl * 1000, + ), + ); + if (!live) { + record.phase = 'unknown'; + if (record.mode === 'explicit_gating') record.retainUntilMs = null; + return { ...none, put: [record] }; + } + record.phase = 'pending'; + record.leaseExpiresAtMs = Math.min( + record.leaseExpiresAtMs, + now + Math.floor(input.leaseMs), + record.invoiceExpiresAtMs, + ); + record.retainUntilMs = + record.replayExpiresAtMs === null + ? null + : Math.max( + record.replayExpiresAtMs, + record.invoiceExpiresAtMs, + record.mode === 'explicit_gating' + ? now + policy.replayWindowMs + : 0, + ); + break; + case 'grant': + if ( + record.phase !== 'pending' || + !live || + record.mode !== 'explicit_gating' + ) + return none; + if ( + !Number.isFinite(input.ttlMs) || + input.ttlMs < 1 || + input.ttlMs > 2_147_483_647 + ) + throw new TypeError('Invalid authorization TTL'); + record.phase = 'granted'; + record.grantExpiresAtMs = now + Math.floor(input.ttlMs); + record.retainUntilMs = null; + break; + case 'consume': + if (!['pending', 'reserved'].includes(record.phase) || !live) return none; + record.phase = 'consumed'; + if (record.mode === 'explicit_gating') { + record.key = paymentEventKey( + record.clientPubkey, + record.requestEventId, + ); + record.retainUntilMs = record.replayExpiresAtMs; + return { result: true, put: [record], remove: [current.key] }; + } + break; + case 'dispatch': + if (record.phase !== 'consumed') return none; + record.phase = 'dispatched'; + break; + case 'release': + if (record.phase !== 'reserved') return none; + return { result: true, put: [], remove: [record.key] }; + case 'reject': + if (record.phase !== 'reserved' || !live) return none; + record.phase = 'rejected'; + break; + case 'expire': + if (record.phase !== 'pending') return none; + record.phase = 'expired'; + record.leaseExpiresAtMs = now; + break; + case 'uncertain': + if (!['issuing', 'pending'].includes(record.phase)) return none; + record.phase = 'unknown'; + record.reason = input.reason; + if (record.mode === 'explicit_gating') record.retainUntilMs = null; + break; + default: + throw new TypeError('Unknown payment state transition'); + } + return { result: true, put: [record], remove: [] }; +} diff --git a/src/payments/payment-state-store.ts b/src/payments/payment-state-store.ts new file mode 100644 index 0000000..236707a --- /dev/null +++ b/src/payments/payment-state-store.ts @@ -0,0 +1,140 @@ +import type { PaymentInteractionMode, PaymentOption } from './types.js'; + +/** Immutable identity captured from a verified, unwrapped Nostr event. */ +export interface VerifiedPaymentEvent { + readonly id: string; + readonly author: string; + /** Nostr creation time in seconds. */ + readonly createdAt: number; +} + +/** Bounded storage and replay-admission policy shared by every worker. */ +export interface PaymentStatePolicy { + /** Maximum live invoice/verification leases across both payment modes. */ + readonly maxPendingPayments: number; + /** Maximum retained records, including replay tombstones and old attempts. */ + readonly maxPaymentRecords: number; + /** Events this old are rejected before admission; default 24 hours. */ + readonly replayWindowMs: number; + /** Maximum accepted future timestamp skew; default 60 seconds. */ + readonly maxFutureSkewMs: number; + /** Bounded event IDs retained per attempt, including its eventual consumer. */ + readonly maxEventsPerPayment: number; +} + +/** Persisted stages. Dispatched means transport handoff, not handler completion or client receipt. */ +export type PaymentStatePhase = + | 'reserved' + | 'issuing' + | 'pending' + | 'granted' + | 'consumed' + | 'dispatched' + | 'expired' + | 'unknown' + | 'rejected'; + +/** Recovery record. Invoice data can be sensitive; never log this object. */ +export interface PaymentStateRecord { + key: string; + generation: string; + mode: PaymentInteractionMode; + phase: PaymentStatePhase; + clientPubkey: string; + requestEventId: string; + eventIds: string[]; + /** Last admission deadline of the attempt's events; null for untimed transports. */ + replayExpiresAtMs: number | null; + invocationHash: string; + leaseExpiresAtMs: number; + /** Null means retain until explicit recovery; never evict a paid grant. */ + retainUntilMs: number | null; + invoice?: PaymentOption; + /** Known invoice expiry, retained independently of the worker's verification lease. */ + invoiceExpiresAtMs?: number; + grantExpiresAtMs?: number; + reason?: string; +} + +/** Atomic claim-or-reserve request. Callers never compose a read and write. */ +export interface PaymentStateAdmission { + key: string; + mode: PaymentInteractionMode; + clientPubkey: string; + requestEventId: string; + invocationHash: string; + leaseMs: number; + event?: VerifiedPaymentEvent; +} + +/** Result of one atomic admission, including capacity and recovery outcomes. */ +export type PaymentStateAdmissionResult = + | { status: 'reserved' | 'claimed'; record: PaymentStateRecord } + | { + status: + 'pending' | 'duplicate' | 'capacity' | 'stale' | 'unknown' | 'expired'; + record?: PaymentStateRecord; + }; + +/** Generation-fenced change; false means the worker must stop side effects. */ +export type PaymentStateTransition = { + key: string; + generation: string; +} & ( + | { + type: + 'issuing' | 'consume' | 'dispatch' | 'release' | 'reject' | 'expire'; + } + | { type: 'bind'; invoice: PaymentOption; leaseMs: number } + | { type: 'grant'; ttlMs: number } + | { type: 'uncertain'; reason: string } +); + +/** + * Atomic payment storage contract. Backend errors propagate, including uncertain + * commit acknowledgement; callers must not retry external effects or fall back + * to a fresh memory store. A consumed execution right is never auto-restored. + */ +export interface PaymentStateStore { + readonly policy: PaymentStatePolicy; + admit(input: PaymentStateAdmission): Promise; + transition(input: PaymentStateTransition): Promise; + read(key: string): Promise; +} + +/** Collision-free, peer-scoped transparent replay key. */ +export function paymentEventKey(clientPubkey: string, eventId: string): string { + return JSON.stringify(['event', clientPubkey, eventId]); +} + +/** Collision-free canonical authorization key. */ +export function paymentAuthorizationKey( + clientPubkey: string, + invocationHash: string, +): string { + return JSON.stringify(['authorization', clientPubkey, invocationHash]); +} + +/** Validates and freezes policy before any store or transport mutation. */ +export function createPaymentStatePolicy( + options: Partial = {}, +): PaymentStatePolicy { + const policy = { + maxPendingPayments: options.maxPendingPayments ?? 1000, + maxPaymentRecords: options.maxPaymentRecords ?? 10000, + replayWindowMs: options.replayWindowMs ?? 86_400_000, + maxFutureSkewMs: options.maxFutureSkewMs ?? 60_000, + maxEventsPerPayment: options.maxEventsPerPayment ?? 128, + }; + for (const [name, value] of Object.entries(policy)) { + if ( + !Number.isSafeInteger(value) || + value < 0 || + (name === 'replayWindowMs' && value === 0) || + (name === 'maxEventsPerPayment' && value < 2) + ) { + throw new TypeError(`Invalid payment state policy: ${name}`); + } + } + return Object.freeze(policy); +} diff --git a/src/payments/postgres/index.ts b/src/payments/postgres/index.ts new file mode 100644 index 0000000..8e07f05 --- /dev/null +++ b/src/payments/postgres/index.ts @@ -0,0 +1,5 @@ +/** Explicit server-only entrypoint; never re-export from shared payment modules. */ +export { + PostgresPaymentStateStore, + type PostgresPaymentStateStoreOptions, +} from './postgres-payment-state-store.js'; diff --git a/src/payments/postgres/migrations.ts b/src/payments/postgres/migrations.ts new file mode 100644 index 0000000..646a33b --- /dev/null +++ b/src/payments/postgres/migrations.ts @@ -0,0 +1,23 @@ +/** Initial durable payment schema; initialization runs under a migration lock. */ +export const PAYMENT_STATE_SCHEMA_VERSION = 1; + +/** PostgreSQL schema shared by all payment namespaces. */ +export const PAYMENT_STATE_MIGRATION = ` +CREATE TABLE IF NOT EXISTS contextvm_payment_namespaces ( + namespace text PRIMARY KEY, + version integer NOT NULL, + policy jsonb NOT NULL, + clock_ms bigint NOT NULL DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS contextvm_payment_records ( + namespace text NOT NULL REFERENCES contextvm_payment_namespaces(namespace), + key text NOT NULL, + phase text NOT NULL, + lease_expires_ms bigint NOT NULL, + retain_until_ms bigint, + record jsonb NOT NULL, + PRIMARY KEY (namespace, key) +); +CREATE INDEX IF NOT EXISTS contextvm_payment_retention + ON contextvm_payment_records(namespace, retain_until_ms); +`; diff --git a/src/payments/postgres/postgres-payment-state-store.test.ts b/src/payments/postgres/postgres-payment-state-store.test.ts new file mode 100644 index 0000000..41f4e1d --- /dev/null +++ b/src/payments/postgres/postgres-payment-state-store.test.ts @@ -0,0 +1,322 @@ +import { describe, expect, test } from 'bun:test'; +import { Pool, type PoolClient } from 'pg'; +import { PostgresPaymentStateStore } from './postgres-payment-state-store.js'; +import { + admission, + bind, + paymentStoreContract, +} from '../../__mocks__/payment-store-contract.js'; +import type { PaymentStateRecord } from '../payment-state-store.js'; + +const connectionString = process.env.PAYMENT_TEST_DATABASE_URL; +const databaseTest = connectionString ? test : test.skip; +const databaseDescribe = connectionString ? describe : describe.skip; + +function createPool(): Pool { + return new Pool({ + connectionString, + max: 2, + connectionTimeoutMillis: 3000, + query_timeout: 5000, + }); +} + +if (connectionString) + paymentStoreContract('PostgreSQL atomic payment store', async (policy) => { + const namespace = `contract-${crypto.randomUUID()}`; + const pool = createPool(); + const otherPool = createPool(); + const store = new PostgresPaymentStateStore({ pool, namespace, ...policy }); + const peer = new PostgresPaymentStateStore({ + pool: otherPool, + namespace, + ...policy, + }); + try { + await store.initialize(); + await peer.initialize(); + } catch (error) { + await Promise.all([pool.end(), otherPool.end()]); + throw error; + } + return { + store, + peer, + advance: async (ms) => { + await Bun.sleep(ms); + }, + close: async () => { + await Promise.all([pool.end(), otherPool.end()]); + }, + }; + }); + +async function withDatabase( + run: ( + store: PostgresPaymentStateStore, + pool: Pool, + namespace: string, + ) => Promise, +): Promise { + const pool = createPool(); + const namespace = `integration-${crypto.randomUUID()}`; + const store = new PostgresPaymentStateStore({ pool, namespace }); + try { + await store.initialize(); + await run(store, pool, namespace); + } finally { + await pool.end(); + } +} + +function worker( + action: string, + namespace: string, +): ReturnType { + return Bun.spawn( + [ + process.execPath, + 'src/__mocks__/payment-state-worker.ts', + action, + namespace, + ], + { + env: { ...process.env, PAYMENT_TEST_DATABASE_URL: connectionString }, + stdout: 'pipe', + stderr: 'pipe', + }, + ); +} + +databaseDescribe('PostgreSQL recovery and failure boundaries', () => { + databaseTest( + 'the admission clock floor survives reconnects and prevents old-event readmission', + async () => + withDatabase(async (store, pool, namespace) => { + const createdAt = Math.floor(Date.now() / 1000); + const floor = Date.now() + store.policy.replayWindowMs + 1000; + await pool.query( + 'UPDATE contextvm_payment_namespaces SET clock_ms = $2 WHERE namespace = $1', + [namespace, floor], + ); + const restarted = new PostgresPaymentStateStore({ pool, namespace }); + await restarted.initialize(); + const input = admission('transparent'); + expect( + ( + await restarted.admit({ + ...input, + event: { + id: input.requestEventId, + author: input.clientPubkey, + createdAt, + }, + }) + ).status, + ).toBe('stale'); + }), + ); + + databaseTest( + 'two separate worker processes have one grant consumer', + async () => + withDatabase(async (store, _pool, namespace) => { + const input = admission(); + const record = await bind(store, input); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 10000, + }); + const children = [ + worker('claim', namespace), + worker('claim', namespace), + ]; + const outputs = await Promise.all( + children.map(async (child) => { + const text = await new Response( + child.stdout as ReadableStream, + ).text(); + const error = await new Response( + child.stderr as ReadableStream, + ).text(); + expect(await child.exited, error).toBe(0); + return JSON.parse(text) as { status: string }; + }), + ); + expect(outputs.map((result) => result.status).sort()).toEqual([ + 'claimed', + 'duplicate', + ]); + }), + ); + + databaseTest( + 'killing the invoice worker and reconnecting preserves its binding and replay tombstone', + async () => + withDatabase(async (store, _pool, namespace) => { + const child = worker('invoice', namespace); + try { + const reader = ( + child.stdout as ReadableStream + ).getReader(); + const chunk = await reader.read(); + const record = JSON.parse( + new TextDecoder().decode(chunk.value), + ) as PaymentStateRecord; + reader.releaseLock(); + child.kill('SIGKILL'); + await child.exited; + const pool = createPool(); + try { + const restarted = new PostgresPaymentStateStore({ + pool, + namespace, + }); + await restarted.initialize(); + expect((await restarted.read(record.key))?.invoice).toEqual( + record.invoice, + ); + expect( + (await restarted.admit(admission('transparent'))).status, + ).toBe('duplicate'); + expect((await store.read(record.key))?.generation).toBe( + record.generation, + ); + } finally { + await pool.end(); + } + } finally { + child.kill(); + await child.exited; + } + }), + ); + + databaseTest( + 'a new store recovers a paid grant and preserves the consumed result', + async () => + withDatabase(async (store, _pool, namespace) => { + const input = admission(); + const record = await bind(store, input); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 10000, + }); + const pool = createPool(); + try { + const restarted = new PostgresPaymentStateStore({ pool, namespace }); + await restarted.initialize(); + const retry = { ...input, requestEventId: 'retry-after-restart' }; + expect((await restarted.admit(retry)).status).toBe('claimed'); + expect((await store.admit(retry)).status).toBe('duplicate'); + } finally { + await pool.end(); + } + }), + ); + + databaseTest('refuses mismatched policies and schema versions', async () => + withDatabase(async (_store, pool, namespace) => { + const mismatch = new PostgresPaymentStateStore({ + pool, + namespace, + maxPendingPayments: 1, + }); + await expect(mismatch.initialize()).rejects.toThrow('mismatch'); + await pool.query( + 'UPDATE contextvm_payment_namespaces SET version = 999 WHERE namespace = $1', + [namespace], + ); + const future = new PostgresPaymentStateStore({ pool, namespace }); + await expect(future.initialize()).rejects.toThrow('mismatch'); + }), + ); + + databaseTest( + 'an unavailable database propagates instead of using empty memory state', + async () => { + const pool = createPool(); + const store = new PostgresPaymentStateStore({ + pool, + namespace: `closed-${crypto.randomUUID()}`, + }); + await store.initialize(); + await pool.end(); + await expect(store.admit(admission())).rejects.toThrow(); + await expect(store.read(admission().key)).rejects.toThrow(); + }, + ); + + databaseTest( + 'lost commit acknowledgement leaves admission reserved and does not replay it', + async () => + withDatabase(async (store, pool, namespace) => { + const fault = acknowledgementFault(pool); + const uncertain = new PostgresPaymentStateStore({ + pool: fault.pool, + namespace, + }); + await uncertain.initialize(); + fault.arm(); + await expect(uncertain.admit(admission())).rejects.toThrow( + 'commit acknowledgement lost', + ); + expect((await store.admit(admission())).status).toBe('pending'); + }), + ); + + databaseTest( + 'lost grant acknowledgement cannot clear a committed paid grant', + async () => + withDatabase(async (store, pool, namespace) => { + const input = admission(); + const record = await bind(store, input); + const fault = acknowledgementFault(pool); + const uncertain = new PostgresPaymentStateStore({ + pool: fault.pool, + namespace, + }); + await uncertain.initialize(); + fault.arm(); + const fence = { key: input.key, generation: record.generation }; + await expect( + uncertain.transition({ ...fence, type: 'grant', ttlMs: 10000 }), + ).rejects.toThrow('commit acknowledgement lost'); + expect(await store.transition({ ...fence, type: 'expire' })).toBe( + false, + ); + expect( + (await store.admit({ ...input, requestEventId: 'retry' })).status, + ).toBe('claimed'); + }), + ); +}); + +function acknowledgementFault(pool: Pool): { pool: Pool; arm: () => void } { + let armed = false; + return { + arm: () => { + armed = true; + }, + pool: { + async connect(): Promise { + const client = await pool.connect(); + return { + query: async (query: string, values?: unknown[]) => { + const result = await client.query(query, values); + if (query === 'COMMIT' && armed) { + armed = false; + throw new Error('commit acknowledgement lost'); + } + return result; + }, + release: (failed?: boolean) => client.release(failed), + } as PoolClient; + }, + } as Pool, + }; +} diff --git a/src/payments/postgres/postgres-payment-state-store.ts b/src/payments/postgres/postgres-payment-state-store.ts new file mode 100644 index 0000000..9008cd2 --- /dev/null +++ b/src/payments/postgres/postgres-payment-state-store.ts @@ -0,0 +1,262 @@ +import type { Pool, PoolClient } from 'pg'; +import type { + PaymentStateAdmission, + PaymentStateAdmissionResult, + PaymentStatePolicy, + PaymentStateRecord, + PaymentStateStore, + PaymentStateTransition, +} from '../payment-state-store.js'; +import { + createPaymentStatePolicy, + paymentEventKey, +} from '../payment-state-store.js'; +import { + admitPaymentState, + transitionPaymentState, + type PaymentStateChange, +} from '../payment-state-machine.js'; +import { + PAYMENT_STATE_MIGRATION, + PAYMENT_STATE_SCHEMA_VERSION, +} from './migrations.js'; + +/** PostgreSQL deployment options. The application retains ownership of its pool. */ +export interface PostgresPaymentStateStoreOptions extends Partial { + pool: Pool; + /** One namespace per server identity/payment policy; shared by all its workers. */ + namespace: string; +} + +/** + * Durable, cross-worker payment state. Short transactions serialize admission + * within one namespace; provider I/O and handlers never run while holding locks. + * Call initialize() before attaching this store. Failed/ambiguous transactions + * propagate without replaying a command or falling back to memory. + * + * @example + * import { Pool } from 'pg'; + * import { PostgresPaymentStateStore } from '@contextvm/sdk/payments/postgres'; + * const pool = new Pool({ connectionString }); + * const store = new PostgresPaymentStateStore({ pool, namespace: serverPubkey }); + * await store.initialize(); + * withServerPayments(transport, { processors, pricedCapabilities, stateStore: store }); + * // The application closes its pool after every worker has stopped using it. + */ +export class PostgresPaymentStateStore implements PaymentStateStore { + public readonly policy: PaymentStatePolicy; + private readonly pool: Pool; + private readonly namespace: string; + private initialized = false; + + public constructor(options: PostgresPaymentStateStoreOptions) { + if (!options.namespace || options.namespace.length > 200) + throw new TypeError('Payment namespace must contain 1–200 characters'); + this.pool = options.pool; + this.namespace = options.namespace; + this.policy = createPaymentStatePolicy(options); + } + + /** Applies the versioned schema and rejects inconsistent worker policies. */ + public async initialize(): Promise { + await this.transaction(async (client) => { + await client.query('SELECT pg_advisory_xact_lock(193608, 8)'); + const schema = await client.query<{ existing: string | null }>( + "SELECT to_regclass('contextvm_payment_namespaces')::text AS existing", + ); + const existing = schema.rows[0]; + if (!existing) throw new Error('Payment schema lookup failed'); + if (!existing.existing) await client.query(PAYMENT_STATE_MIGRATION); + else { + const clock = await client.query<{ present: boolean }>( + `SELECT EXISTS (SELECT 1 FROM pg_attribute WHERE + attrelid = 'contextvm_payment_namespaces'::regclass AND attname = 'clock_ms' AND NOT attisdropped) AS present`, + ); + if (!clock.rows[0]?.present) + await client.query( + 'ALTER TABLE contextvm_payment_namespaces ADD COLUMN clock_ms bigint NOT NULL DEFAULT 0', + ); + } + }, false); + // Release DDL locks before taking a namespace row lock. Repeating schema + // DDL while holding that row can deadlock with a worker writing records. + await this.transaction(async (client) => { + await client.query( + 'INSERT INTO contextvm_payment_namespaces(namespace, version, policy) VALUES ($1, $2, $3::jsonb) ON CONFLICT DO NOTHING', + [ + this.namespace, + PAYMENT_STATE_SCHEMA_VERSION, + JSON.stringify(this.policy), + ], + ); + await this.lockNamespace(client); + }, false); + this.initialized = true; + } + + /** Atomic claim-or-reserve, replay admission, capacity check, and retention. */ + public async admit( + input: PaymentStateAdmission, + ): Promise { + input = structuredClone(input); + return this.transaction(async (client) => { + const { now, generation } = await this.lockNamespace(client); + await client.query( + `DELETE FROM contextvm_payment_records WHERE namespace = $1 AND retain_until_ms <= $2 + AND NOT (phase IN ('reserved', 'issuing', 'pending') AND lease_expires_ms > $2)`, + [this.namespace, now], + ); + const keys = [input.key]; + if (input.mode === 'explicit_gating') + keys.push(paymentEventKey(input.clientPubkey, input.requestEventId)); + const { rows } = await client.query<{ + key: string; + record: PaymentStateRecord; + }>( + `SELECT key, record FROM contextvm_payment_records WHERE namespace = $1 AND + (key = ANY($2::text[]) OR (record->>'clientPubkey' = $3 AND record->'eventIds' ? $4))`, + [this.namespace, keys, input.clientPubkey, input.requestEventId], + ); + const counts = await client.query<{ total: string; pending: string }>( + `SELECT count(*) AS total, + count(*) FILTER (WHERE phase IN ('reserved', 'issuing', 'pending') AND lease_expires_ms > $2) AS pending + FROM contextvm_payment_records WHERE namespace = $1`, + [this.namespace, now], + ); + const usage = counts.rows[0]; + if (!usage) throw new Error('Payment capacity lookup failed'); + const change = admitPaymentState( + input, + { + record: rows.find((row) => row.key === input.key)?.record, + execution: rows.find((row) => row.key !== input.key)?.record, + total: Number(usage.total), + pending: Number(usage.pending), + }, + this.policy, + now, + generation, + ); + await this.write(client, change); + return change.result; + }); + } + + /** Persists only transitions belonging to the current live attempt. */ + public async transition(input: PaymentStateTransition): Promise { + input = structuredClone(input); + return this.transaction(async (client) => { + const { now } = await this.lockNamespace(client); + const { rows } = await client.query<{ record: PaymentStateRecord }>( + 'SELECT record FROM contextvm_payment_records WHERE namespace = $1 AND key = $2', + [this.namespace, input.key], + ); + const change = transitionPaymentState( + input, + rows[0]?.record, + now, + this.policy, + ); + await this.write(client, change); + return change.result; + }); + } + + /** Reads persisted recovery state; an unavailable database is an error. */ + public async read(key: string): Promise { + return this.transaction(async (client) => { + await this.lockNamespace(client); + const { rows } = await client.query<{ record: PaymentStateRecord }>( + 'SELECT record FROM contextvm_payment_records WHERE namespace = $1 AND key = $2', + [this.namespace, key], + ); + return rows[0]?.record; + }); + } + + private async lockNamespace( + client: PoolClient, + ): Promise<{ now: number; generation: string }> { + const { rows } = await client.query<{ + version: number; + policy: PaymentStatePolicy; + }>( + 'SELECT version, policy FROM contextvm_payment_namespaces WHERE namespace = $1 FOR UPDATE', + [this.namespace], + ); + const namespace = rows[0]; + if ( + !namespace || + namespace.version !== PAYMENT_STATE_SCHEMA_VERSION || + Object.entries(this.policy).some( + ([key, value]) => + namespace.policy[key as keyof PaymentStatePolicy] !== value, + ) + ) + throw new Error('Payment namespace schema or policy mismatch'); + const clock = await client.query<{ now: string; generation: string }>( + `UPDATE contextvm_payment_namespaces SET clock_ms = GREATEST(clock_ms, + floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint) + WHERE namespace = $1 RETURNING clock_ms AS now, gen_random_uuid()::text AS generation`, + [this.namespace], + ); + const now = Number(clock.rows[0]?.now); + if (!Number.isSafeInteger(now)) + throw new Error('Payment database clock lookup failed'); + const generation = clock.rows[0]?.generation; + if (!generation) throw new Error('Payment generation allocation failed'); + return { now, generation }; + } + + private async write( + client: PoolClient, + change: PaymentStateChange, + ): Promise { + for (const key of change.remove) + await client.query( + 'DELETE FROM contextvm_payment_records WHERE namespace = $1 AND key = $2', + [this.namespace, key], + ); + for (const record of change.put) + await client.query( + `INSERT INTO contextvm_payment_records(namespace, key, phase, lease_expires_ms, retain_until_ms, record) + VALUES ($1, $2, $3, $4, $5, $6::jsonb) ON CONFLICT(namespace, key) DO UPDATE SET + phase = EXCLUDED.phase, lease_expires_ms = EXCLUDED.lease_expires_ms, + retain_until_ms = EXCLUDED.retain_until_ms, record = EXCLUDED.record`, + [ + this.namespace, + record.key, + record.phase, + record.leaseExpiresAtMs, + record.retainUntilMs, + JSON.stringify(record), + ], + ); + } + + private async transaction( + run: (client: PoolClient) => Promise, + requireInitialized = true, + ): Promise { + if (requireInitialized && !this.initialized) + throw new Error('Initialize the PostgreSQL payment store first'); + const client = await this.pool.connect(); + let failed = false; + try { + await client.query('BEGIN ISOLATION LEVEL READ COMMITTED'); + const result = await run(client); + await client.query('COMMIT'); + return result; + } catch (error) { + failed = true; + try { + await client.query('ROLLBACK'); + } catch { + /* Preserve the original, potentially ambiguous error. */ + } + throw error; + } finally { + client.release(failed); + } + } +} diff --git a/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts b/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts index 70ee0d2..bb3a46f 100644 --- a/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts +++ b/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts @@ -31,6 +31,8 @@ export interface LnBolt11LnbitsPaymentProcessorOptions { * for settlement using the same API. */ export class LnBolt11LnbitsPaymentProcessor implements PaymentProcessor { + /** Native amount unit; conversion to millisatoshis happens inside the adapter. */ + public readonly currencyUnit = 'sats'; public readonly pmi = PMI_BITCOIN_LIGHTNING_BOLT11; private readonly lnbitsUrl: string; diff --git a/src/payments/processors/ln-bolt11-nwc-payment-processor.ts b/src/payments/processors/ln-bolt11-nwc-payment-processor.ts index 75a2ac0..0d90626 100644 --- a/src/payments/processors/ln-bolt11-nwc-payment-processor.ts +++ b/src/payments/processors/ln-bolt11-nwc-payment-processor.ts @@ -81,6 +81,8 @@ export interface LnBolt11NwcPaymentProcessorOptions { * CEP-8 server payment processor for PMI `bitcoin-lightning-bolt11` backed by NIP-47 (NWC). */ export class LnBolt11NwcPaymentProcessor implements PaymentProcessor { + /** Native amount unit; conversion to millisatoshis happens inside the adapter. */ + public readonly currencyUnit = 'sats'; public readonly pmi = PMI_BITCOIN_LIGHTNING_BOLT11; private readonly nwc: NwcClientLike; diff --git a/src/payments/processors/ln-bolt11-zap-payment-processor.ts b/src/payments/processors/ln-bolt11-zap-payment-processor.ts index 0b7f4db..871ceb1 100644 --- a/src/payments/processors/ln-bolt11-zap-payment-processor.ts +++ b/src/payments/processors/ln-bolt11-zap-payment-processor.ts @@ -47,6 +47,8 @@ type PendingInvoiceMeta = { * subscribing for zap receipts (kind 9735). */ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { + /** Native amount unit; conversion to millisatoshis happens inside the adapter. */ + public readonly currencyUnit = 'sats'; public readonly pmi = PMI_BITCOIN_LIGHTNING_BOLT11; // Defaults for the minimal constructor (lnAddress-only). These are public relays; diff --git a/src/payments/resolve-price.test.ts b/src/payments/resolve-price.test.ts index 86c5aaf..1449249 100644 --- a/src/payments/resolve-price.test.ts +++ b/src/payments/resolve-price.test.ts @@ -31,6 +31,7 @@ describe('resolvePrice (server payments)', () => { method: 'tools/call', name: 'add', amount: 1, + maxAmount: 123, currencyUnit: 'test', description: 'listed', }, @@ -138,6 +139,7 @@ describe('resolvePrice (server payments)', () => { method: 'tools/call', name: 'add', amount: 1, + maxAmount: 123, currencyUnit: 'test', }, ] as const; @@ -204,6 +206,7 @@ describe('resolvePrice (server payments)', () => { method: 'tools/call', name: 'add', amount: 1, + maxAmount: 123, currencyUnit: 'test', }, ] as const; @@ -361,6 +364,7 @@ describe('resolvePrice (server payments)', () => { method: 'tools/call', name: 'add', amount: 1, + maxAmount: 123, currencyUnit: 'test', }, ] as const; diff --git a/src/payments/server-explicit-gating.test.ts b/src/payments/server-explicit-gating.test.ts index 52d8117..1a82566 100644 --- a/src/payments/server-explicit-gating.test.ts +++ b/src/payments/server-explicit-gating.test.ts @@ -1,3 +1,9 @@ +import { + seedGrant, + seedPending, + hasGrant, + pendingRemainingMs, +} from '../__mocks__/payment-state.js'; import { describe, expect, test } from 'bun:test'; import type { JSONRPCErrorResponse, @@ -165,7 +171,7 @@ describe('Explicit Gating Middleware', () => { message.method, message.params, ); - store.grant(identity, 10000); + await seedGrant(store, identity, 10000); let forwarded = false; await mw(message, ctx, async () => { @@ -175,9 +181,9 @@ describe('Explicit Gating Middleware', () => { expect(sentResponses.length).toBe(0); expect(forwarded).toBe(true); - // Auth should be consumed, second call should trigger payment required + // A distinct invocation needs a fresh payment; replaying the claimed event stays consumed. let forwarded2 = false; - await mw(message, ctx, async () => { + await mw({ ...message, id: 'independent-invocation' }, ctx, async () => { forwarded2 = true; }); @@ -315,7 +321,7 @@ describe('Explicit Gating Middleware', () => { // (5) Authorization is single-use: next call needs a fresh payment. let forwarded4 = false; - await mw(message, ctx, async () => { + await mw({ ...message, id: 'next-invocation' }, ctx, async () => { forwarded4 = true; }); expect(forwarded4).toBe(false); @@ -446,7 +452,8 @@ describe('Explicit Gating Middleware', () => { }); // Grant authorization for add({ a: 1, b: 2 }). - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -455,10 +462,10 @@ describe('Explicit Gating Middleware', () => { 10000, ); - // Different params: add({ a: 9, b: 9 }). + // Different params arrive as a distinct signed event. const otherMessage: JSONRPCRequest = { jsonrpc: '2.0', - id: 'event-id', + id: 'different-params-event', method: 'tools/call', params: { name: 'add', arguments: { a: 9, b: 9 } }, }; @@ -498,7 +505,8 @@ describe('Explicit Gating Middleware', () => { }); // Grant authorization scoped to 'test-client'. - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -549,7 +557,8 @@ describe('Explicit Gating Middleware', () => { // Identity is computed from method + params only; the original request's // id ('event-id') is intentionally excluded from the canonical form. - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -603,7 +612,8 @@ describe('Explicit Gating Middleware', () => { }); // Grant authorization for the invocation carrying progressToken 1. - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -639,7 +649,8 @@ describe('Explicit Gating Middleware', () => { }); // A single grant is available for this invocation. - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -654,17 +665,19 @@ describe('Explicit Gating Middleware', () => { }; // Fire two concurrent middleware calls for the same invocation. - await Promise.all([mw(message, ctx, forward), mw(message, ctx, forward)]); + await Promise.all([ + mw(message, ctx, forward), + mw({ ...message, id: 'independent-concurrent-event' }, ctx, forward), + ]); // Exactly one consumes the single-use grant and forwards; the other is - // gated with a fresh -32042. claim() is synchronous, so the first call - // to reach it always wins deterministically. + // gated with a fresh -32042. Claim and reservation share one atomic store operation. expect(forwards).toBe(1); expect(sentResponses).toHaveLength(1); expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); }); - test('expired grant yields a fresh -32042 instead of forwarding', async () => { + test('expired paid grant requires recovery instead of another payment', async () => { const store = new AuthorizationStore(); const sentResponses: JSONRPCErrorResponse[] = []; @@ -680,7 +693,8 @@ describe('Explicit Gating Middleware', () => { }); // Grant authorization with a very short TTL. - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -692,8 +706,7 @@ describe('Explicit Gating Middleware', () => { // Wait past the grant TTL so it expires before the retry arrives. await new Promise((r) => setTimeout(r, 75)); - // The stale grant must NOT authorize the request: the middleware should - // treat it as unpaid and emit a fresh -32042 rather than forwarding. + // An expired paid right remains observable; do not silently ask the client to pay again. let forwarded = false; await mw(message, ctx, async () => { forwarded = true; @@ -701,7 +714,8 @@ describe('Explicit Gating Middleware', () => { expect(forwarded).toBe(false); expect(sentResponses).toHaveLength(1); - expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + expect(sentResponses[0].error.code).toBe(-32000); + expect(sentResponses[0].error.data).toEqual({ reason: 'payment_expired' }); }); test('non-priced capability passes through ungated in explicit gating mode', async () => { @@ -736,13 +750,11 @@ describe('Explicit Gating Middleware', () => { expect(sentResponses).toHaveLength(0); }); - test('falls back to the default grant TTL when the invoice carries ttl 0', async () => { + test('rejects processor output with ttl 0 before advertising it', async () => { const store = new AuthorizationStore(); const sentResponses: JSONRPCErrorResponse[] = []; - // ttl: 0 must not birth-expire the pending grant while the invoice stays - // payable. Verification hangs so the pending entry keeps the TTL the - // middleware applied via updatePendingTtl. + // Invalid processor TTLs never reach the client or verifier. const ttlZeroProcessor = { ...processor, async createPaymentRequired(params: { amount: number }) { @@ -769,22 +781,15 @@ describe('Explicit Gating Middleware', () => { }, }); - await mw(message, ctx, async () => {}); - - expect(sentResponses[0]?.error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); - const identity = computeCanonicalInvocationIdentity( - ctx.clientPubkey, - message.method, - message.params, - ); - // Falls back to the 5-minute default window, not 0. - expect(store.getPendingRemainingMs(identity)).toBeGreaterThan(60_000); + await expect(mw(message, ctx, async () => {})).rejects.toThrow(); + expect(sentResponses).toHaveLength(0); }); test('refuses with -32000 without minting an invoice at pending capacity', async () => { // Capacity 1, already occupied by a live pending entry for another identity. - const store = new AuthorizationStore({ maxEntries: 1 }); - store.trySetPending( + const store = new AuthorizationStore({ maxPendingPayments: 1 }); + await seedPending( + store, { clientPubkey: 'other-client', invocationHash: 'other' }, 10000, ); @@ -793,14 +798,12 @@ describe('Explicit Gating Middleware', () => { let invoicesMinted = 0; const countingProcessor = { ...processor, - async createPaymentRequired( - params: { - amount: number; - description?: string; - requestEventId: string; - clientPubkey: string; - }, - ) { + async createPaymentRequired(params: { + amount: number; + description?: string; + requestEventId: string; + clientPubkey: string; + }) { invoicesMinted += 1; return processor.createPaymentRequired(params); }, @@ -831,7 +834,7 @@ describe('Explicit Gating Middleware', () => { ); // The other client's live pending entry must not have been evicted. expect( - store.getPendingRemainingMs({ + await pendingRemainingMs(store, { clientPubkey: 'other-client', invocationHash: 'other', }), @@ -847,7 +850,7 @@ describe('Explicit Gating Middleware', () => { return new Promise(() => {}); }, }; - const store = new AuthorizationStore({ maxEntries: 1 }); + const store = new AuthorizationStore({ maxPendingPayments: 1 }); const sentResponses: JSONRPCErrorResponse[] = []; const mw = createExplicitGatingMiddleware({ @@ -975,7 +978,7 @@ describe('Explicit Gating Middleware transport shutdown', () => { }; } - test('a verify that settles after abort does not grant; the retry mints a fresh -32042', async () => { + test('a verify that settles after abort cannot grant or restart payment work', async () => { const unhandled = captureUnhandledRejections(); const store = new AuthorizationStore(); const sentResponses: JSONRPCErrorResponse[] = []; @@ -1010,15 +1013,14 @@ describe('Explicit Gating Middleware transport shutdown', () => { deferred.resolve(); await new Promise((r) => setTimeout(r, 5)); - expect(store.claim(identity)).toBe(false); + expect(await hasGrant(store, identity)).toBe(false); - // Same cleanup as a failed verify: pending is cleared, retry re-invoices. + // The closed middleware cannot create another invoice even when called again. await mw(message, ctx, async () => { forwards += 1; }); - expect(sentResponses).toHaveLength(2); - expect(sentResponses[1].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); - expect(deferred.createCount()).toBe(2); + expect(sentResponses).toHaveLength(1); + expect(deferred.createCount()).toBe(1); expect(forwards).toBe(0); expect(unhandled()).toEqual([]); }); @@ -1048,8 +1050,8 @@ describe('Explicit Gating Middleware transport shutdown', () => { shutdown.abort(); await new Promise((r) => setTimeout(r, 5)); - expect(store.claim(identity)).toBe(false); - expect(store.getPendingRemainingMs(identity)).toBe(0); + expect(await hasGrant(store, identity)).toBe(false); + expect(await pendingRemainingMs(store, identity)).toBe(0); expect(unhandled()).toEqual([]); }); diff --git a/src/payments/server-explicit-gating.ts b/src/payments/server-explicit-gating.ts index 43befe2..2cd37e8 100644 --- a/src/payments/server-explicit-gating.ts +++ b/src/payments/server-explicit-gating.ts @@ -2,303 +2,222 @@ import type { JSONRPCErrorResponse } from '@contextvm/mcp-sdk/types.js'; import type { ServerMiddlewareFn, PaymentProcessor } from './types.js'; import { isJsonRpcRequest } from './types.js'; import type { ServerPaymentsOptions } from './server-payments.js'; -import type { AuthorizationStore } from './authorization-store.js'; -import { computeCanonicalInvocationIdentity } from './canonical-identity.js'; +import type { PaymentStateStore } from './payment-state-store.js'; +import { paymentEventKey } from './payment-state-store.js'; import { buildProcessorsByPmi, matchPricedCapability, + PaymentMethodUnavailableError, resolveAndInitiatePayment, verifyPaymentUnlessShutdown, } from './server-payments-utils.js'; import { createLogger } from '../core/utils/logger.js'; import { + DEFAULT_PAYMENT_TTL_MS, PAYMENT_CAPACITY_ERROR_CODE, PAYMENT_PENDING_ERROR_CODE, PAYMENT_REQUIRED_ERROR_CODE, } from './constants.js'; +import { + abandonServerPayment, + serverPaymentAdmission, +} from './server-payment-state.js'; +import { validateServerPaymentOptions } from './server-payment-validation.js'; +/** Explicit-gating dependencies; share this store with the transparent middleware. */ export interface ExplicitGatingMiddlewareParams { options: ServerPaymentsOptions; - authorizationStore: AuthorizationStore; + authorizationStore: PaymentStateStore; sendResponse: ( clientPubkey: string, response: JSONRPCErrorResponse, requestEventId: string, ) => Promise; - /** Pre-built PMI → processor map. Built locally when omitted (standalone use). */ processorsByPmi?: Map; - /** - * Transport shutdown signal (see `withServerPayments`). Once aborted, - * in-flight verification is cancelled and a verify that still settles is - * not granted; the pending entry is cleared instead. - */ abortSignal?: AbortSignal; } +/** Atomically claims one paid execution or reserves a generation-fenced invoice. */ export function createExplicitGatingMiddleware( params: ExplicitGatingMiddlewareParams, ): ServerMiddlewareFn { - const { options, authorizationStore, sendResponse, abortSignal } = params; + const { + options, + authorizationStore: store, + sendResponse, + abortSignal, + } = params; + validateServerPaymentOptions({ ...options, stateStore: store }); const logger = createLogger('server-explicit-gating'); const processorsByPmi = params.processorsByPmi ?? buildProcessorsByPmi(options.processors, logger); + const leaseMs = options.paymentTtlMs ?? DEFAULT_PAYMENT_TTL_MS; return async (message, ctx, forward) => { - // Only gate requests. - if (!isJsonRpcRequest(message)) { - await forward(message); - return; - } - - if (ctx.paymentInteraction !== 'explicit_gating') { + if ( + !isJsonRpcRequest(message) || + ctx.paymentInteraction !== 'explicit_gating' + ) { await forward(message); return; } - const priced = matchPricedCapability(message, options.pricedCapabilities); - if (!priced) { - await forward(message); + if (!priced || abortSignal?.aborted) { + if (!priced) await forward(message); return; } - - const requestEventId = String(message.id); - const identity = computeCanonicalInvocationIdentity( - ctx.clientPubkey, - message.method, - message.params, + const admission = serverPaymentAdmission( + message, + ctx, + 'explicit_gating', + leaseMs, ); - - // 1. Try to claim an existing authorization - if (authorizationStore.claim(identity)) { - logger.debug('authorization claimed, forwarding request', { - requestEventId, - method: message.method, - }); - await forward(message); - return; - } - - // Non-positive TTLs would birth-expire the pending grant while the - // invoice stays payable; fall back to the configured/default TTL. - const paymentTtlMs = - options.paymentTtlMs && options.paymentTtlMs > 0 - ? options.paymentTtlMs - : 300_000; - - // 2. Try to set pending state atomically. - // Refusal means either "already pending" (-32043, keep retrying — the - // payment is still verifying) or "store at capacity" (-32000, terminal): - // live entries are never evicted, so a same-identity retry must never - // observe the capacity answer even when the store is full. - if (!authorizationStore.trySetPending(identity, paymentTtlMs)) { - const pendingRemainingMs = - authorizationStore.getPendingRemainingMs(identity); - - if (pendingRemainingMs <= 0) { - logger.warn( - 'pending payment capacity reached, refusing priced request', - { - requestEventId, - method: message.method, - }, - ); - const capacityResponse: JSONRPCErrorResponse = { + const respond = async ( + code: number, + text: string, + data?: Record, + ): Promise => { + await sendResponse( + ctx.clientPubkey, + { jsonrpc: '2.0', id: message.id, - error: { - code: PAYMENT_CAPACITY_ERROR_CODE, - message: 'Payment capacity reached, retry later', - }, - }; - await sendResponse(ctx.clientPubkey, capacityResponse, requestEventId); - return; + error: { code, message: text, ...(data && { data }) }, + }, + admission.requestEventId, + ); + }; + const admitted = await store.admit(admission); + if (admitted.status === 'claimed') { + if (!abortSignal?.aborted) { + await forward(message); + await store.transition({ + key: admitted.record.key, + generation: admitted.record.generation, + type: 'dispatch', + }); } - - logger.debug('payment already pending, returning -32043', { - requestEventId, + return; + } + if (admitted.status === 'duplicate') return; + if (admitted.status === 'pending') { + await respond(PAYMENT_PENDING_ERROR_CODE, 'Payment Pending', { + retry_after: 2, }); - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: message.id, - error: { - code: PAYMENT_PENDING_ERROR_CODE, - message: 'Payment Pending', - data: { - instructions: - 'A payment is already pending for this invocation. Retry the same request later with exactly the same method and params.', - // Suggest a short polling interval (e.g. 2 seconds) rather than the full TTL - retry_after: Math.min( - 2, - Math.max(1, Math.ceil(pendingRemainingMs / 1000)), - ), - }, - }, - }; - await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); return; } - - // 3. Resolve price and initiate new payment + if (admitted.status !== 'reserved') { + await respond( + PAYMENT_CAPACITY_ERROR_CODE, + admitted.status === 'capacity' + ? 'Payment capacity reached, retry later' + : `Payment admission refused: ${admitted.status}`, + { reason: `payment_${admitted.status}` }, + ); + return; + } + const record = admitted.record; + const fence = { key: record.key, generation: record.generation }; try { - const initResult = await resolveAndInitiatePayment({ + if (abortSignal?.aborted) { + await store.transition({ ...fence, type: 'release' }); + return; + } + const initiated = await resolveAndInitiatePayment({ message, priced, - requestEventId, + requestEventId: admission.requestEventId, clientPubkey: ctx.clientPubkey, clientPmis: ctx.clientPmis, options, processorsByPmi, + beforeInvoice: async () => { + if (abortSignal?.aborted) return false; + const owned = await store.transition({ ...fence, type: 'issuing' }); + return owned && !abortSignal?.aborted; + }, }); - - if (initResult.kind === 'rejected') { - logger.info('payment rejected', { - requestEventId, - pmi: initResult.pmi, - amount: priced.amount, - reason: initResult.message, - }); - - authorizationStore.clearPending(identity); - - // Spec: When a capability is rejected by policy, return a standard error. - // We'll use -32000 (Internal error or application-defined error) since CEP-8 doesn't specify a special rejection code. - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: message.id, - error: { - code: -32000, - message: initResult.message || 'Payment rejected by policy', - }, - }; - await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); + if (initiated.kind === 'superseded') { + await store.transition({ ...fence, type: 'release' }); return; } - - if (initResult.kind === 'waived') { - logger.debug('payment waived, forwarding priced request', { - requestEventId, - method: message.method, - }); - - authorizationStore.clearPending(identity); - await forward(message); + if (initiated.kind === 'rejected') { + if (await store.transition({ ...fence, type: 'release' })) + await respond( + -32000, + initiated.message ?? 'Payment rejected by policy', + ); + return; + } + if (initiated.kind === 'waived') { + if ( + !abortSignal?.aborted && + (await store.transition({ ...fence, type: 'consume' })) + ) { + if (abortSignal?.aborted) return; + await forward(message); + await store.transition({ + ...fence, + key: paymentEventKey(ctx.clientPubkey, admission.requestEventId), + type: 'dispatch', + }); + } return; } - const { paymentRequired, mergedMeta, processor, verifyTimeoutMs } = - initResult; - - // Use the strict verification timeout bound for polling - const pollingTimeoutMs = Math.min(verifyTimeoutMs, paymentTtlMs); - - // Note: use the original payment request's TTL as the limit for the grant, not the verify timeout. - // verifyTimeoutMs includes standard bounds, but for grants we want to honor the - // payment option's TTL explicitly if it is smaller, or the fallback paymentTtlMs. - const grantTtlMs = - paymentRequired.ttl !== undefined && - Number.isFinite(paymentRequired.ttl) && - paymentRequired.ttl > 0 - ? paymentRequired.ttl * 1000 - : paymentTtlMs; - - // Update pending with the precise TTL - authorizationStore.updatePendingTtl(identity, grantTtlMs); - - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: message.id, - error: { - code: PAYMENT_REQUIRED_ERROR_CODE, - message: 'Payment Required', - data: { - instructions: - 'Payment is required to process this request. Pay one of the offered options, then retry the same request with exactly the same method and params.', - payment_options: [ - { - amount: paymentRequired.amount, - pmi: paymentRequired.pmi, - pay_req: paymentRequired.pay_req, - description: paymentRequired.description, - ttl: paymentRequired.ttl, - _meta: mergedMeta, - }, - ], - }, - }, - }; - - logger.info('payment required error sent', { - requestEventId, - pmi: paymentRequired.pmi, - amount: paymentRequired.amount, - ttl: paymentRequired.ttl, + initiated; + const timeoutMs = Math.min(verifyTimeoutMs, leaseMs); + if ( + !(await store.transition({ + ...fence, + type: 'bind', + invoice: { ...paymentRequired, _meta: mergedMeta }, + leaseMs: timeoutMs, + })) + ) + return; + if (abortSignal?.aborted) return; + await respond(PAYMENT_REQUIRED_ERROR_CODE, 'Payment Required', { + instructions: + 'Pay one option, then retry with the same method and semantic params.', + payment_options: [{ ...paymentRequired, _meta: mergedMeta }], }); - - await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); - - // Start async verification - // Do not await this, we must let the middleware chain return the error response. - (async () => { + void (async (): Promise => { + const verified = await verifyPaymentUnlessShutdown({ + processor, + verifyParams: { + pay_req: paymentRequired.pay_req, + requestEventId: admission.requestEventId, + clientPubkey: ctx.clientPubkey, + }, + timeoutMs, + shutdownSignal: abortSignal, + }); + if (!verified || abortSignal?.aborted) { + await store.transition({ ...fence, type: 'expire' }); + return; + } + await store.transition({ + ...fence, + type: 'grant', + ttlMs: options.authorizationTtlMs ?? leaseMs, + }); + })().catch(async () => { try { - logger.debug('verifying explicit payment', { - requestEventId, - pmi: paymentRequired.pmi, - timeoutMs: pollingTimeoutMs, - }); - - const verified = await verifyPaymentUnlessShutdown({ - processor, - verifyParams: { - pay_req: paymentRequired.pay_req, - requestEventId, - clientPubkey: ctx.clientPubkey, - }, - timeoutMs: pollingTimeoutMs, - shutdownSignal: abortSignal, + await store.transition({ ...fence, type: 'expire' }); + } catch { + logger.error('Payment verification state could not be persisted', { + requestEventId: admission.requestEventId, }); - - // Transport closed mid-verification: nobody will read a grant, so - // clear the pending entry like any other failed verification. - if (verified === undefined) { - logger.info( - 'transport closed during explicit payment verification', - { - requestEventId, - pmi: paymentRequired.pmi, - }, - ); - authorizationStore.clearPending(identity); - return; - } - - logger.info('explicit payment accepted, granting authorization', { - requestEventId, - pmi: paymentRequired.pmi, - amount: paymentRequired.amount, - }); - - authorizationStore.grant(identity, grantTtlMs); - } catch (err) { - logger.info('explicit payment verification failed or timed out', { - requestEventId, - error: err instanceof Error ? err.message : String(err), - }); - authorizationStore.clearPending(identity); } - })().catch((err) => { - // Belt-and-suspenders: if clearPending/grant itself threw inside the - // inner catch, pending would otherwise stick until TTL answering every - // retry with -32043. Idempotent on the normal path. - authorizationStore.clearPending(identity); - logger.error('unhandled exception in async payment verification', { - requestEventId, - pmi: paymentRequired.pmi, - error: err instanceof Error ? err.message : String(err), - }); }); - } catch (err) { - authorizationStore.clearPending(identity); - throw err; + } catch (error) { + await abandonServerPayment(store, record); + if (error instanceof PaymentMethodUnavailableError) { + await respond(-32000, error.message); + return; + } + throw error; } }; } diff --git a/src/payments/server-payment-reliability.test.ts b/src/payments/server-payment-reliability.test.ts new file mode 100644 index 0000000..ee2d037 --- /dev/null +++ b/src/payments/server-payment-reliability.test.ts @@ -0,0 +1,355 @@ +import { describe, expect, test } from 'bun:test'; +import type { + JSONRPCErrorResponse, + JSONRPCRequest, +} from '@contextvm/mcp-sdk/types.js'; +import { createExplicitGatingMiddleware } from './server-explicit-gating.js'; +import { createServerPaymentsMiddleware } from './server-payments.js'; +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; +import { + paymentAuthorizationKey, + paymentEventKey, +} from './payment-state-store.js'; +import { computeCanonicalInvocationHash } from './canonical-identity.js'; +import type { PaymentProcessor, ServerPaymentsContext } from './types.js'; +import type { ServerPaymentsOptions } from './server-payments.js'; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const request = (id = 'event-1'): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'paid', arguments: { x: 1 }, _meta: { progressToken: 7 } }, +}); +const context: ServerPaymentsContext = { + clientPubkey: 'client', + paymentInteraction: 'explicit_gating', +}; +const priced = [ + { method: 'tools/call', name: 'paid', amount: 10, currencyUnit: 'sats' }, +]; +const authorizationKey = paymentAuthorizationKey( + 'client', + computeCanonicalInvocationHash(request().method, request().params), +); + +function processor(): PaymentProcessor { + return { + pmi: 'fake', + async createPaymentRequired(input) { + return { + pmi: 'fake', + amount: input.amount, + pay_req: `invoice-${input.requestEventId}`, + }; + }, + async verifyPayment() { + return {}; + }, + }; +} + +describe('server attempt generation fences', () => { + for (const outcome of ['resolve', 'reject'] as const) { + test(`old verifier ${outcome} cannot mutate a replacement generation`, async () => { + let now = Date.now(); + const store = new InMemoryPaymentStateStore({}, () => now); + const old = deferred>(); + const current = deferred>(); + let invoices = 0; + let verifications = 0; + const base = processor(); + const replies: JSONRPCErrorResponse[] = []; + const middleware = createExplicitGatingMiddleware({ + authorizationStore: store, + options: { + processors: [ + { + ...base, + async createPaymentRequired(input) { + invoices++; + return base.createPaymentRequired(input); + }, + verifyPayment: () => + ++verifications === 1 ? old.promise : current.promise, + }, + ], + pricedCapabilities: priced, + paymentTtlMs: 10000, + }, + sendResponse: async (_client, response) => { + replies.push(response); + }, + }); + await middleware(request(), context, async () => {}); + const oldGeneration = (await store.read(authorizationKey))!.generation; + now += 10001; + await middleware(request('replacement'), context, async () => {}); + const replacement = (await store.read(authorizationKey))!; + expect(replacement.generation).not.toBe(oldGeneration); + if (outcome === 'resolve') old.resolve({}); + else old.reject(new Error('old failure')); + await Bun.sleep(0); + expect((await store.read(authorizationKey))?.phase).toBe('pending'); + expect((await store.read(authorizationKey))?.generation).toBe( + replacement.generation, + ); + expect(invoices).toBe(2); + expect(replies.map((response) => response.error.code)).toEqual([ + -32042, -32042, + ]); + current.resolve({}); + await Bun.sleep(0); + let forwards = 0; + await middleware(request('paid-retry'), context, async () => { + forwards++; + }); + expect(forwards).toBe(1); + expect(invoices).toBe(2); + }); + } + + test('slow pricing cannot create an invoice after its reservation is replaced', async () => { + let now = Date.now(); + const store = new InMemoryPaymentStateStore({}, () => now); + const quote = deferred<{ amount: number }>(); + const started = deferred(); + let quotes = 0; + let invoices = 0; + const base = processor(); + const verify = deferred>(); + const middleware = createExplicitGatingMiddleware({ + authorizationStore: store, + options: { + processors: [ + { + ...base, + async createPaymentRequired(input) { + invoices++; + return base.createPaymentRequired(input); + }, + verifyPayment: () => verify.promise, + }, + ], + pricedCapabilities: priced, + paymentTtlMs: 10000, + resolvePrice: async () => { + if (++quotes === 1) { + started.resolve(undefined); + return quote.promise; + } + return { amount: 10 }; + }, + }, + sendResponse: async () => {}, + }); + const first = middleware(request(), context, async () => {}); + await started.promise; + now += 10001; + await middleware(request('replacement'), context, async () => {}); + const generation = (await store.read(authorizationKey))!.generation; + quote.resolve({ amount: 10 }); + await first; + expect(invoices).toBe(1); + expect((await store.read(authorizationKey))?.generation).toBe(generation); + expect((await store.read(authorizationKey))?.phase).toBe('pending'); + verify.resolve({}); + await Bun.sleep(0); + }); + + test('slow invoice creation becomes uncertain without creating a replacement invoice', async () => { + let now = Date.now(); + const store = new InMemoryPaymentStateStore({}, () => now); + const created = deferred<{ + pmi: string; + amount: number; + pay_req: string; + }>(); + const started = deferred(); + let invoices = 0; + const replies: JSONRPCErrorResponse[] = []; + const middleware = createExplicitGatingMiddleware({ + authorizationStore: store, + options: { + processors: [ + { + pmi: 'fake', + async createPaymentRequired() { + invoices++; + started.resolve(undefined); + return created.promise; + }, + async verifyPayment() { + throw new Error('Must not verify an obsolete invoice'); + }, + }, + ], + pricedCapabilities: priced, + paymentTtlMs: 10000, + }, + sendResponse: async (_client, response) => { + replies.push(response); + }, + }); + const first = middleware(request(), context, async () => {}); + await started.promise; + now += 10001; + await middleware(request('replacement'), context, async () => {}); + expect(replies[0]?.error.data).toEqual({ reason: 'payment_unknown' }); + created.resolve({ amount: 10, pmi: 'fake', pay_req: 'late-invoice' }); + await first; + expect(invoices).toBe(1); + expect(replies).toHaveLength(1); + expect((await store.read(authorizationKey))?.invoice?.pay_req).toBe( + 'late-invoice', + ); + expect((await store.read(authorizationKey))?.phase).toBe('unknown'); + }); +}); + +describe('server boundaries and transparent replay', () => { + test('transparent replay beyond the invoice TTL and middleware recreation never invoices or executes twice', async () => { + let now = Date.now(); + const store = new InMemoryPaymentStateStore({}, () => now); + let invoices = 0; + let executions = 0; + const base = processor(); + const options: ServerPaymentsOptions = { + stateStore: store, + paymentTtlMs: 100, + processors: [ + { + ...base, + async createPaymentRequired(input) { + invoices++; + return base.createPaymentRequired(input); + }, + }, + ], + pricedCapabilities: priced, + }; + const ctx: ServerPaymentsContext = { + clientPubkey: 'client', + paymentInteraction: 'transparent', + requestEvent: Object.freeze({ + id: 'event-1', + author: 'client', + createdAt: Math.floor(now / 1000), + }), + }; + const invoke = async (): Promise => { + await createServerPaymentsMiddleware({ + options, + sender: { sendNotification: async () => {} }, + })(request(), ctx, async () => { + executions++; + }); + }; + await invoke(); + now += 1000; + await invoke(); + expect(invoices).toBe(1); + expect(executions).toBe(1); + expect( + (await store.read(paymentEventKey('client', 'event-1')))?.phase, + ).toBe('dispatched'); + }); + + for (const mode of ['transparent', 'explicit_gating'] as const) { + test(`${mode}: waiver and rejection work without configured processors`, async () => { + for (const quote of [ + { waive: true } as const, + { reject: true, message: 'denied' } as const, + ]) { + const options: ServerPaymentsOptions = { + processors: [], + pricedCapabilities: priced, + resolvePrice: async () => quote, + }; + let forwarded = 0; + let notified = 0; + const middleware = + mode === 'transparent' + ? createServerPaymentsMiddleware({ + options, + sender: { + sendNotification: async () => { + notified++; + }, + }, + }) + : createExplicitGatingMiddleware({ + options, + authorizationStore: new InMemoryPaymentStateStore(), + sendResponse: async () => { + notified++; + }, + }); + await middleware( + request(), + { ...context, paymentInteraction: mode }, + async () => { + forwarded++; + }, + ); + expect(forwarded).toBe('waive' in quote ? 1 : 0); + expect(notified).toBe('reject' in quote ? 1 : 0); + } + }); + + test(`${mode}: backend admission failure cannot mint an invoice or dispatch`, async () => { + const store = new InMemoryPaymentStateStore(); + store.admit = async () => { + throw new Error('database unavailable'); + }; + let sideEffects = 0; + const options: ServerPaymentsOptions = { + processors: [ + { + ...processor(), + async createPaymentRequired() { + sideEffects++; + throw new Error('unexpected invoice'); + }, + }, + ], + pricedCapabilities: priced, + stateStore: store, + }; + const middleware = + mode === 'transparent' + ? createServerPaymentsMiddleware({ + options, + sender: { sendNotification: async () => {} }, + }) + : createExplicitGatingMiddleware({ + options, + authorizationStore: store, + sendResponse: async () => {}, + }); + await expect( + middleware( + request(), + { ...context, paymentInteraction: mode }, + async () => { + sideEffects++; + }, + ), + ).rejects.toThrow('database unavailable'); + expect(sideEffects).toBe(0); + }); + } +}); diff --git a/src/payments/server-payment-state.ts b/src/payments/server-payment-state.ts new file mode 100644 index 0000000..b5f0564 --- /dev/null +++ b/src/payments/server-payment-state.ts @@ -0,0 +1,63 @@ +import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; +import type { ServerPaymentsOptions } from './server-payments.js'; +import type { PaymentInteractionMode, ServerPaymentsContext } from './types.js'; +import type { + PaymentStateAdmission, + PaymentStateRecord, + PaymentStateStore, +} from './payment-state-store.js'; +import { + paymentAuthorizationKey, + paymentEventKey, +} from './payment-state-store.js'; +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; +import { computeCanonicalInvocationHash } from './canonical-identity.js'; + +/** Uses the injected backend consistently for both lifecycles. */ +export function serverPaymentStore( + options: ServerPaymentsOptions, +): PaymentStateStore { + return options.stateStore ?? new InMemoryPaymentStateStore(options); +} + +/** Derives peer-scoped admission from authenticated event context, never params metadata. */ +export function serverPaymentAdmission( + message: JSONRPCRequest, + ctx: ServerPaymentsContext, + mode: PaymentInteractionMode, + leaseMs: number, +): PaymentStateAdmission { + const requestEventId = ctx.requestEvent?.id ?? String(message.id); + const invocationHash = computeCanonicalInvocationHash( + message.method, + message.params, + ); + return { + key: + mode === 'transparent' + ? paymentEventKey(ctx.clientPubkey, requestEventId) + : paymentAuthorizationKey(ctx.clientPubkey, invocationHash), + mode, + clientPubkey: ctx.clientPubkey, + requestEventId, + invocationHash, + leaseMs, + event: ctx.requestEvent, + }; +} + +/** Failed quote work can be retried; ambiguous invoice creation cannot be repeated. */ +export async function abandonServerPayment( + store: PaymentStateStore, + record: PaymentStateRecord, +): Promise { + const fence = { key: record.key, generation: record.generation }; + if ( + !(await store.transition({ + ...fence, + type: 'uncertain', + reason: 'payment_work_failed', + })) + ) + await store.transition({ ...fence, type: 'release' }); +} diff --git a/src/payments/server-payment-validation.test.ts b/src/payments/server-payment-validation.test.ts new file mode 100644 index 0000000..ea3e6f0 --- /dev/null +++ b/src/payments/server-payment-validation.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from 'bun:test'; +import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; +import { createServerPaymentsMiddleware } from './server-payments.js'; +import { + resolveAndInitiatePayment, + resolvePaymentProcessor, +} from './server-payments-utils.js'; +import { + validateServerPaymentOptions, + validateServerQuote, +} from './server-payment-validation.js'; +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; +import type { PaymentProcessor, ResolvePriceResult } from './types.js'; + +const priced = { + method: 'tools/call', + amount: 10, + maxAmount: 20, + currencyUnit: 'sats', +}; +const message: JSONRPCRequest = { + jsonrpc: '2.0', + id: 'event', + method: 'tools/call', + params: { name: 'paid', arguments: { x: 1 }, _meta: { progressToken: 3 } }, +}; +const processor: PaymentProcessor = { + pmi: 'fake', + currencyUnit: 'sats', + async createPaymentRequired(input) { + return { pmi: 'fake', pay_req: 'invoice', amount: input.amount }; + }, + async verifyPayment() { + return {}; + }, +}; + +function initiate( + override: { + quote?: unknown; + processor?: PaymentProcessor; + clientPmis?: string[]; + } = {}, +): ReturnType { + const rail = override.processor ?? processor; + return resolveAndInitiatePayment({ + message, + priced, + requestEventId: 'event', + clientPubkey: 'client', + clientPmis: override.clientPmis, + options: { + processors: [rail], + resolvePrice: async () => + (override.quote ?? { amount: 10 }) as ResolvePriceResult, + }, + processorsByPmi: new Map([[rail.pmi, rail]]), + }); +} + +describe('server payment boundary validation', () => { + test('inclusive quote endpoints are valid; fixed quotes cannot change their advertised amount', () => { + for (const amount of [10, 15, 20]) + expect( + validateServerQuote({ amount, currencyUnit: 'sats' }, priced), + ).toMatchObject({ amount }); + expect(() => + validateServerQuote({ amount: 11 }, { ...priced, maxAmount: undefined }), + ).toThrow('advertised'); + }); + + test('rejects malformed/out-of-range quotes and changed units before processor work', async () => { + let calls = 0; + const rail = { + ...processor, + async createPaymentRequired() { + calls++; + throw new Error('unexpected invoice'); + }, + }; + for (const quote of [ + { amount: -1 }, + { amount: NaN }, + { amount: Infinity }, + { amount: 9 }, + { amount: 21 }, + { amount: 10, currencyUnit: 'msats' }, + { amount: 10, meta: [] }, + { rejected: true }, + { waive: true, amount: 10 }, + { amount: 10, meta: { fn: () => {} } }, + ]) + await expect(initiate({ processor: rail, quote })).rejects.toThrow(); + expect(calls).toBe(0); + }); + + test('rejects incompatible native processor units before invoice creation', async () => { + await expect( + initiate({ processor: { ...processor, currencyUnit: 'msats' } }), + ).rejects.toThrow('unit'); + }); + + test('rejects mismatched or malformed processor output', async () => { + for (const invoice of [ + { amount: 11, pmi: 'fake', pay_req: 'invoice' }, + { amount: 10, pmi: 'other', pay_req: 'invoice' }, + { amount: 10, pmi: 'fake', pay_req: '' }, + { amount: 10, pmi: 'fake', pay_req: ' ' }, + { amount: 10, pmi: 'INVALID:PMI', pay_req: 'invoice' }, + { amount: 10, pmi: 'fake', pay_req: 'invoice', ttl: 0 }, + { amount: 10, pmi: 'fake', pay_req: 'invoice', ttl: NaN }, + { amount: 10, pmi: 'fake', pay_req: 'invoice', _meta: { fn: () => {} } }, + ]) + await expect( + initiate({ + processor: { + ...processor, + async createPaymentRequired() { + return invoice; + }, + }, + }), + ).rejects.toThrow(); + }); + + test('honors advertised PMI preference, refuses disjoint sets, and permits absent PMIs', () => { + const second = { ...processor, pmi: 'second' }; + const map = new Map([ + [processor.pmi, processor], + [second.pmi, second], + ]); + expect( + resolvePaymentProcessor(['unknown', 'second', 'fake'], map, [ + processor, + second, + ]), + ).toBe(second); + expect(resolvePaymentProcessor(undefined, map, [processor, second])).toBe( + processor, + ); + expect(() => + resolvePaymentProcessor(['unsupported'], map, [processor]), + ).toThrow('mutually'); + expect(() => resolvePaymentProcessor([], map, [processor])).toThrow( + 'mutually', + ); + }); + + test('disjoint PMI sets never reach invoice creation', async () => { + let invoices = 0; + await expect( + initiate({ + clientPmis: ['unsupported'], + processor: { + ...processor, + async createPaymentRequired() { + invoices++; + throw new Error('unexpected'); + }, + }, + }), + ).rejects.toThrow('mutually'); + expect(invoices).toBe(0); + }); + + test('invalid configuration and injected-store policy mismatch are rejected synchronously', () => { + const base = { processors: [processor], pricedCapabilities: [priced] }; + for (const config of [ + { paymentTtlMs: 0 }, + { paymentTtlMs: Infinity }, + { authorizationTtlMs: -1 }, + { maxPendingPayments: -1 }, + { maxPaymentRecords: 1.5 }, + { replayWindowMs: 0 }, + ]) + expect(() => + validateServerPaymentOptions({ ...base, ...config }), + ).toThrow(); + for (const capability of [ + { ...priced, amount: NaN }, + { ...priced, maxAmount: 9 }, + { ...priced, currencyUnit: '' }, + ]) + expect(() => + validateServerPaymentOptions({ + ...base, + pricedCapabilities: [capability], + }), + ).toThrow(); + expect(() => + validateServerPaymentOptions({ + ...base, + stateStore: new InMemoryPaymentStateStore({ maxPendingPayments: 1 }), + maxPendingPayments: 2, + }), + ).toThrow('mismatch'); + }); + + test('pricing cannot change the dispatched semantic arguments or metadata', async () => { + const original = { + ...message, + params: { + name: 'paid', + arguments: { x: 1 }, + _meta: { progressToken: 3 }, + }, + }; + const middleware = createServerPaymentsMiddleware({ + sender: { sendNotification: async () => {} }, + options: { + processors: [processor], + pricedCapabilities: [priced], + resolvePrice: async ({ request, capability }) => { + (request.params!.arguments as { x: number }).x = 999; + request.params!._meta!.progressToken = 100; + capability.amount = 500; + return { waive: true }; + }, + }, + }); + let forwarded: unknown; + await middleware(original, { clientPubkey: 'client' }, async (value) => { + forwarded = value; + }); + expect(forwarded).toBe(original); + expect(original.params).toEqual({ + name: 'paid', + arguments: { x: 1 }, + _meta: { progressToken: 3 }, + }); + expect(priced.amount).toBe(10); + }); +}); diff --git a/src/payments/server-payment-validation.ts b/src/payments/server-payment-validation.ts new file mode 100644 index 0000000..499a06a --- /dev/null +++ b/src/payments/server-payment-validation.ts @@ -0,0 +1,126 @@ +import { z } from 'zod'; +import type { ServerPaymentsOptions } from './server-payments.js'; +import type { + PaymentProcessor, + PaymentRequired, + PricedCapability, + ResolvePriceResult, +} from './types.js'; +import { paymentOptionSchema } from './payment-option-schema.js'; +import { + createPaymentStatePolicy, + type PaymentStatePolicy, +} from './payment-state-store.js'; + +const amount = z.number().finite().nonnegative().max(Number.MAX_SAFE_INTEGER); +const timer = z.number().finite().min(1).max(2_147_483_647); +const unit = z.string().trim().min(1); +const metadata = z.record(z.string(), z.unknown()); +const quoteSchema = z.union([ + z + .object({ reject: z.literal(true), message: z.string().optional() }) + .strict(), + z.object({ waive: z.literal(true), meta: metadata.optional() }).strict(), + z + .object({ + amount, + currencyUnit: unit.optional(), + description: z.string().optional(), + meta: metadata.optional(), + }) + .strict(), +]); + +/** Rejects values whose JSON representation would silently lose payment data. */ +export function assertPaymentJson(value: unknown): void { + JSON.stringify(value, (_key, item: unknown) => { + if ( + typeof item === 'function' || + typeof item === 'symbol' || + typeof item === 'bigint' || + (typeof item === 'number' && !Number.isFinite(item)) + ) + throw new TypeError('Payment data must be JSON serializable'); + return item; + }); +} + +/** Validates configuration before middleware registration or announcement mutation. */ +export function validateServerPaymentOptions( + options: ServerPaymentsOptions, +): void { + const capability = z + .object({ + method: z.string().min(1), + name: z.string().optional(), + amount, + maxAmount: amount.optional(), + currencyUnit: unit, + description: z.string().optional(), + }) + .refine( + (value) => + value.maxAmount === undefined || value.maxAmount >= value.amount, + ); + const schema = z.object({ + processors: z.array( + z.object({ + pmi: z.string().regex(/^[a-z0-9-]+$/), + currencyUnit: unit.optional(), + }), + ), + pricedCapabilities: z.array(capability), + paymentTtlMs: timer.optional(), + authorizationTtlMs: timer.optional(), + paymentInteraction: z.enum(['optional', 'transparent']).optional(), + }); + if (!schema.safeParse(options).success) + throw new TypeError('Invalid server payment options'); + const policy = createPaymentStatePolicy(options); + if (options.stateStore) { + for (const key of Object.keys(policy) as Array) { + if ( + options[key] !== undefined && + options[key] !== options.stateStore.policy[key] + ) + throw new TypeError(`Payment store policy mismatch: ${key}`); + } + } +} + +/** Checks inclusive advertised bounds and the unchanged configured amount unit. */ +export function validateServerQuote( + input: unknown, + priced: PricedCapability, +): ResolvePriceResult { + const quote = quoteSchema.parse(input); + assertPaymentJson(quote); + if ( + 'amount' in quote && + (quote.amount < priced.amount || + quote.amount > (priced.maxAmount ?? priced.amount) || + (quote.currencyUnit !== undefined && + quote.currencyUnit !== priced.currencyUnit)) + ) + throw new TypeError( + 'Payment quote is outside its advertised range or unit', + ); + return quote; +} + +/** Validates the processor's result before binding or advertising an invoice. */ +export function validateProcessorPayment( + input: unknown, + processor: PaymentProcessor, + quotedAmount: number, +): PaymentRequired { + const invoice = paymentOptionSchema.parse(input); + if ( + (invoice.ttl !== undefined && invoice.ttl * 1000 < 1) || + invoice.pmi !== processor.pmi || + invoice.amount !== quotedAmount + ) + throw new TypeError('Payment processor changed the quoted amount or PMI'); + assertPaymentJson(invoice); + return JSON.parse(JSON.stringify(invoice)) as PaymentRequired; +} diff --git a/src/payments/server-payments-utils.ts b/src/payments/server-payments-utils.ts index ebf1a68..d1a2808 100644 --- a/src/payments/server-payments-utils.ts +++ b/src/payments/server-payments-utils.ts @@ -1,6 +1,10 @@ import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; import { type Logger } from '../core/utils/logger.js'; import { withTimeout } from '../core/utils/utils.js'; +import { + validateProcessorPayment, + validateServerQuote, +} from './server-payment-validation.js'; import type { PricedCapability, ResolvePriceRejection, @@ -11,6 +15,9 @@ import type { PaymentRequired, } from './types.js'; +/** A client-visible payment-method refusal, raised before provider side effects. */ +export class PaymentMethodUnavailableError extends Error {} + /** Builds the PMI → processor map, warning once on duplicate PMIs. */ export function buildProcessorsByPmi( processors: readonly PaymentProcessor[], @@ -93,6 +100,16 @@ export function resolvePaymentProcessor( processorsByPmi: Map, processors: readonly PaymentProcessor[], ): PaymentProcessor { + if ( + clientPmis !== undefined && + (!Array.isArray(clientPmis) || + clientPmis.some( + (pmi) => typeof pmi !== 'string' || !/^[a-z0-9-]+$/.test(pmi), + )) + ) + throw new PaymentMethodUnavailableError( + 'Invalid client payment method identifier', + ); const chosenPmi = clientPmis ? clientPmis.find((pmi) => processorsByPmi.has(pmi)) : undefined; @@ -101,14 +118,21 @@ export function resolvePaymentProcessor( ? processorsByPmi.get(chosenPmi) : processors[0]; + if (clientPmis !== undefined && !chosenPmi) { + throw new PaymentMethodUnavailableError( + 'No mutually supported payment method', + ); + } + if (!chosenProcessor) { - throw new Error('No payment processors configured'); + throw new PaymentMethodUnavailableError('No payment processors configured'); } return chosenProcessor; } export type InitiationResult = + | { kind: 'superseded' } | { kind: 'rejected'; pmi: string; @@ -141,26 +165,23 @@ export async function resolveAndInitiatePayment(params: { }) => Promise; }; processorsByPmi: Map; + /** Persists invoice-creation ownership immediately before provider I/O. */ + beforeInvoice?: () => Promise; }): Promise { - const processor = resolvePaymentProcessor( - params.clientPmis, - params.processorsByPmi, - params.options.processors, - ); - - const quote = params.options.resolvePrice + const rawQuote = params.options.resolvePrice ? await params.options.resolvePrice({ - capability: params.priced, - request: params.message, + capability: { ...params.priced }, + request: copyPricingRequest(params.message), clientPubkey: params.clientPubkey, requestEventId: params.requestEventId, }) : { amount: params.priced.amount, description: params.priced.description }; + const quote = validateServerQuote(rawQuote, params.priced); if (isResolvePriceRejection(quote)) { return { kind: 'rejected', - pmi: processor.pmi, + pmi: params.options.processors[0]?.pmi ?? 'unspecified', amount: params.priced.amount, message: quote.message, quote, @@ -172,12 +193,30 @@ export async function resolveAndInitiatePayment(params: { } const resolvedQuote = quote; - const paymentRequired = await processor.createPaymentRequired({ - amount: resolvedQuote.amount, - description: resolvedQuote.description, - requestEventId: params.requestEventId, - clientPubkey: params.clientPubkey, - }); + const processor = resolvePaymentProcessor( + params.clientPmis, + params.processorsByPmi, + params.options.processors, + ); + if ( + processor.currencyUnit !== undefined && + processor.currencyUnit !== params.priced.currencyUnit + ) + throw new TypeError( + 'Processor amount unit differs from the advertised capability unit', + ); + if (params.beforeInvoice && !(await params.beforeInvoice())) + return { kind: 'superseded' }; + const paymentRequired = validateProcessorPayment( + await processor.createPaymentRequired({ + amount: resolvedQuote.amount, + description: resolvedQuote.description, + requestEventId: params.requestEventId, + clientPubkey: params.clientPubkey, + }), + processor, + resolvedQuote.amount, + ); const mergedMeta = resolvedQuote.meta === undefined && paymentRequired._meta === undefined @@ -200,6 +239,18 @@ export async function resolveAndInitiatePayment(params: { }; } +function copyPricingRequest(request: JSONRPCRequest): JSONRPCRequest { + if (!request.params) return { ...request }; + const { _meta, ...semantic } = request.params; + return { + ...request, + params: { + ...JSON.parse(JSON.stringify(semantic)), + ...(_meta && { _meta: { ..._meta } }), + }, + }; +} + /** * Runs `processor.verifyPayment` under `timeoutMs` with a per-request abort * controller, chained to the transport's shutdown signal so `close()` stops diff --git a/src/payments/server-payments.test.ts b/src/payments/server-payments.test.ts index a7ba5d6..79001d6 100644 --- a/src/payments/server-payments.test.ts +++ b/src/payments/server-payments.test.ts @@ -138,13 +138,8 @@ describe('createServerPaymentsMiddleware pending-payment retention', () => { ); expect(harness.invoiceCount('evt1')).toBe(1); - // Redelivery of the same request event while the entry is within its TTL: - // the duplicate awaits the (rejected) in-flight lifecycle instead of - // minting a second invoice. (After TTL expiry a retry re-invoices by - // design — idempotency is TTL-bounded.) - await expect(harness.run('evt1')).rejects.toThrow( - 'payment rail unreachable', - ); + // The durable event record refuses another invoice even after the local run ends. + await harness.run('evt1'); expect(harness.invoiceCount('evt1')).toBe(1); }); @@ -258,24 +253,12 @@ describe('createServerPaymentsMiddleware pending capacity', () => { expect(harness.forwards()).toBe(0); }); - test('paymentTtlMs 0 falls back to the default so redelivery dedup survives', async () => { - // A zero TTL would birth-expire the pending entry while the invoice stays - // payable — disarming the duplicate-request dedup (CEP-8). It must fall - // back to the default window instead. + test('rejects paymentTtlMs 0 before creating payment work', () => { const spy = makeProcessor({ verify: 'throw' }); const { sender } = makeSender(); - const harness = buildHarness(spy, sender, { paymentTtlMs: 0 }); - - await expect(harness.run('evt-ttl0')).rejects.toThrow( - 'payment rail unreachable', - ); - - // Redelivery while the (paid) invoice is still outstanding: deduped by - // the surviving pending entry, not re-invoiced. - await expect(harness.run('evt-ttl0')).rejects.toThrow( - 'payment rail unreachable', + expect(() => buildHarness(spy, sender, { paymentTtlMs: 0 })).toThrow( + 'Invalid server payment options', ); - expect(harness.invoiceCount('evt-ttl0')).toBe(1); }); }); diff --git a/src/payments/server-payments.ts b/src/payments/server-payments.ts index 20912d2..f505a1f 100644 --- a/src/payments/server-payments.ts +++ b/src/payments/server-payments.ts @@ -1,16 +1,16 @@ import { isJsonRpcRequest } from './types.js'; import type { CorrelatedNotificationSender, - PaymentAcceptedNotification, PaymentInteractionPolicy, PaymentProcessor, - PaymentRejectedNotification, - PaymentRequiredNotification, PricedCapability, ResolvePriceFn, ServerMiddlewareFn, } from './types.js'; -import { LruCache } from '../core/utils/lru-cache.js'; +import type { + PaymentStatePolicy, + PaymentStateStore, +} from './payment-state-store.js'; import { createLogger } from '../core/utils/logger.js'; import { DEFAULT_PAYMENT_TTL_MS, @@ -21,414 +21,230 @@ import { import { buildProcessorsByPmi, matchPricedCapability, + PaymentMethodUnavailableError, resolveAndInitiatePayment, - resolvePaymentProcessor, verifyPaymentUnlessShutdown, } from './server-payments-utils.js'; - -export interface ServerPaymentsOptions { +import { + abandonServerPayment, + serverPaymentAdmission, + serverPaymentStore, +} from './server-payment-state.js'; +import { validateServerPaymentOptions } from './server-payment-validation.js'; + +/** Server-side payment policy; both lifecycles share one atomic state store. */ +export interface ServerPaymentsOptions extends Partial { processors: readonly PaymentProcessor[]; pricedCapabilities: readonly PricedCapability[]; - - /** Optional dynamic pricing callback used to compute a per-request quote. */ + /** Optional per-request quote, constrained by the advertised inclusive range. */ resolvePrice?: ResolvePriceFn; - /** - * Maximum time to keep a request in pending-payment state. - * - * Note: if the payment request includes a CEP-8 `ttl` (seconds), the effective - * verification timeout will be derived from that TTL. This option is primarily - * a memory/DoS guardrail. - * - * @default 300_000 - */ + /** Invoice creation/verification lease in milliseconds. @default 300_000 */ paymentTtlMs?: number; - - /** - * Maximum number of concurrent pending-payment request ids to track. - * - * This is a DoS/memory-safety guardrail. Once reached, new priced requests - * are refused (best-effort `payment_rejected`, no invoice minted) rather than - * evicting a live entry — an evicted live payment loses its dedup and a - * redelivery would mint a second invoice (CEP-8: MUST NOT charge twice). - * `0` refuses all priced requests. - * - * @default 1000 - */ - maxPendingPayments?: number; - - /** - * Server-side policy for which payment interaction lifecycles this server - * accepts. `optional` mirrors the client's requested mode (the default); - * `transparent` makes the server transparent-only. - * @default 'optional' - */ + /** Lifetime of a verified execution grant, measured from settlement. @default paymentTtlMs */ + authorizationTtlMs?: number; + /** Injectable backend. Initialize durable stores before attaching the transport. */ + stateStore?: PaymentStateStore; + /** Accepted interaction modes. @default 'optional' */ paymentInteraction?: PaymentInteractionPolicy; } -function purgeExpiredPending(params: { - pending: LruCache; - nowMs: number; - maxToCheck: number; -}): void { - let checked = 0; - for (const [key, value] of params.pending.entries()) { - if (checked >= params.maxToCheck) { - break; - } - checked += 1; - if (value.expiresAtMs <= params.nowMs) { - params.pending.delete(key); - } - } -} - -type PendingPaymentState = { - expiresAtMs: number; - inFlight: Promise; -}; - -/** - * Extra lifetime granted to a transport route snapshot beyond the payment TTL, - * so a settled response can still be routed after a slow handler finishes. - */ -const ROUTE_SNAPSHOT_GRACE_MS = 60_000; - -function createPaymentRequiredNotification(params: { - amount: number; - pay_req: string; - pmi: string; - description?: string; - ttl?: number; - _meta?: Record; -}): PaymentRequiredNotification { - return { - jsonrpc: '2.0', - method: PAYMENT_REQUIRED_METHOD, - params, - }; -} - -function createPaymentAcceptedNotification(params: { - amount: number; - pmi: string; - _meta?: Record; -}): PaymentAcceptedNotification { - return { - jsonrpc: '2.0', - method: PAYMENT_ACCEPTED_METHOD, - params, - }; -} - -function createPaymentRejectedNotification(params: { - pmi: string; - amount?: number; - message?: string; -}): PaymentRejectedNotification { - return { - jsonrpc: '2.0', - method: PAYMENT_REJECTED_METHOD, - params, - }; -} - /** - * Creates a server-side middleware that gates priced requests until payment is verified. + * Gates transparent requests using a retained event ledger. Verified event IDs + * are admitted only within the store's replay window; invoice expiry never + * releases their tombstones. Custom transports without event timestamps retain + * their records indefinitely and fail closed when record capacity is reached. + * Verified grants (including expired ones) and uncertain invoice creation are + * retained for reconciliation. Expired known invoices can be replaced only by + * a new admitted event; consumed rights never trigger automatic re-execution. */ export function createServerPaymentsMiddleware(params: { sender: CorrelatedNotificationSender; options: ServerPaymentsOptions; - /** Pre-built PMI → processor map. Built locally when omitted (standalone use). */ processorsByPmi?: Map; - /** - * Fired once an invoice has been issued for a request, before verification - * begins. Lets the transport snapshot the correlation route/session a paid - * response may need after its route is popped (duplicate-delivery cleanup) - * or its idle session is LRU-evicted mid-payment (CEP-8). - */ onInvoiceIssued?: (params: { requestEventId: string; snapshotTtlMs: number; }) => void; - /** - * Transport shutdown signal (see `withServerPayments`). Once aborted, - * in-flight verification is cancelled and a verify that still settles is - * not acted on: no `payment_accepted`, no forward. - */ abortSignal?: AbortSignal; }): ServerMiddlewareFn { const { sender, options, abortSignal } = params; + validateServerPaymentOptions(options); const logger = createLogger('server-payments'); const processorsByPmi = params.processorsByPmi ?? buildProcessorsByPmi(options.processors, logger); - - // Non-positive TTLs would birth-expire every pending entry (disarming the - // redelivery dedup while invoices stay payable); fall back to the default, - // mirroring getVerificationTimeoutMs's guard. - const paymentTtlMs = - options.paymentTtlMs && options.paymentTtlMs > 0 - ? options.paymentTtlMs - : DEFAULT_PAYMENT_TTL_MS; - const maxPendingPayments = options.maxPendingPayments ?? 1000; - const pending = new LruCache( - Math.max(1, maxPendingPayments), - ); + const store = serverPaymentStore(options); + const leaseMs = options.paymentTtlMs ?? DEFAULT_PAYMENT_TTL_MS; + const running = new Map>(); return async (message, ctx, forward) => { - // Only gate requests. Never interfere with notifications. - if (!isJsonRpcRequest(message)) { - await forward(message); - return; - } - if ( - ctx.paymentInteraction !== undefined && - ctx.paymentInteraction !== 'transparent' + !isJsonRpcRequest(message) || + (ctx.paymentInteraction !== undefined && + ctx.paymentInteraction !== 'transparent') ) { await forward(message); return; } - const priced = matchPricedCapability(message, options.pricedCapabilities); - if (!priced) { - await forward(message); - return; - } - - logger.debug('priced capability matched', { - method: message.method, - requestEventId: String(message.id), - pricedMethod: priced.method, - pricedName: priced.name, - }); - - const requestEventId = String(message.id); - const now = Date.now(); - - // Opportunistic cleanup so one-shot spam doesn't accumulate until reuse. - purgeExpiredPending({ pending, nowMs: now, maxToCheck: 25 }); - - const existing = pending.get(requestEventId); - if (existing && existing.expiresAtMs > now) { - // Duplicate request event id: await the in-flight work deterministically. - // This avoids double-charge races and avoids black-holing duplicates. - logger.debug('duplicate request event detected, awaiting in-flight', { - requestEventId, - }); - await existing.inFlight; + if (!priced || abortSignal?.aborted) { + if (!priced) await forward(message); return; } - - // Capacity guard, before any invoice is minted: silently evicting a live - // entry here would disarm its dedup, and a redelivery of the evicted - // request would be charged twice (CEP-8: MUST NOT charge twice). - if (pending.size >= maxPendingPayments) { - purgeExpiredPending({ - pending, - nowMs: now, - maxToCheck: Number.POSITIVE_INFINITY, - }); - } - if (pending.size >= maxPendingPayments) { - logger.warn('pending payment capacity reached, refusing priced request', { - requestEventId, - method: message.method, - maxPendingPayments, - }); - // Refuse without charging: best-effort payment_rejected so the client - // stops waiting instead of hanging until its own TTL. - try { - const processor = resolvePaymentProcessor( - ctx.clientPmis, - processorsByPmi, - options.processors, - ); - await sender.sendNotification( - ctx.clientPubkey, - createPaymentRejectedNotification({ - pmi: processor.pmi, - amount: priced.amount, - message: 'payment capacity reached, retry later', - }), - requestEventId, - ); - } catch (err) { - logger.warn('failed to send payment capacity rejection', { - requestEventId, - error: err instanceof Error ? err.message : String(err), - }); - } + const admission = serverPaymentAdmission( + message, + ctx, + 'transparent', + leaseMs, + ); + const existing = running.get(admission.key); + if (existing) { + await existing; return; } - - // IMPORTANT: set pending state synchronously before any await to make idempotency atomic. - let invoiceIssued = false; - const inFlight = (async (): Promise => { - const initResult = await resolveAndInitiatePayment({ - message, - priced, - requestEventId, - clientPubkey: ctx.clientPubkey, - clientPmis: ctx.clientPmis, - options, - processorsByPmi, - }); - - // Handle rejection: emit payment_rejected and do not forward. - if (initResult.kind === 'rejected') { - logger.info('payment rejected', { - requestEventId, - pmi: initResult.pmi, - amount: priced.amount, - reason: initResult.message, - }); - - const rejectedNotification = createPaymentRejectedNotification({ - pmi: initResult.pmi, - amount: priced.amount, - message: initResult.message, - }); - - await sender.sendNotification( - ctx.clientPubkey, - rejectedNotification, - requestEventId, - ); - return; - } - - if (initResult.kind === 'waived') { - logger.debug('payment waived, forwarding priced request', { - requestEventId, - method: message.method, - }); - - await forward(message); - return; - } - - const { paymentRequired, mergedMeta, processor, verifyTimeoutMs } = - initResult; - - // An invoice now exists on the payment rail — from here on, every - // outcome keeps the pending entry until TTL: the client's money may - // already be gone, and a redelivery MUST NOT mint a second invoice. - invoiceIssued = true; - params.onInvoiceIssued?.({ - requestEventId, - snapshotTtlMs: paymentTtlMs + ROUTE_SNAPSHOT_GRACE_MS, - }); - - const requiredNotification = createPaymentRequiredNotification({ - amount: paymentRequired.amount, - pay_req: paymentRequired.pay_req, - pmi: paymentRequired.pmi, - description: paymentRequired.description, - ttl: paymentRequired.ttl, - _meta: mergedMeta, - }); - - logger.info('payment required notification sent', { - requestEventId, - pmi: paymentRequired.pmi, - amount: paymentRequired.amount, - ttl: paymentRequired.ttl, - }); - + const reject = async (reason?: string): Promise => { await sender.sendNotification( ctx.clientPubkey, - requiredNotification, - requestEventId, - ); - - // Use the strict verification timeout bound for polling - const pollingTimeoutMs = Math.min(verifyTimeoutMs, paymentTtlMs); - - logger.debug('verifying payment', { - requestEventId, - pmi: paymentRequired.pmi, - timeoutMs: pollingTimeoutMs, - }); - - const verified = await verifyPaymentUnlessShutdown({ - processor, - verifyParams: { - pay_req: paymentRequired.pay_req, - requestEventId, - clientPubkey: ctx.clientPubkey, + { + jsonrpc: '2.0', + method: PAYMENT_REJECTED_METHOD, + params: { + pmi: options.processors[0]?.pmi ?? 'unspecified', + amount: priced.amount, + message: reason, + }, }, - timeoutMs: pollingTimeoutMs, - shutdownSignal: abortSignal, - }); - - // Transport closed mid-verification: the invoice stays pending until - // TTL (same as any post-invoice outcome), but nothing is forwarded. - if (verified === undefined) { - logger.info('transport closed during payment verification', { - requestEventId, - pmi: paymentRequired.pmi, - }); + admission.requestEventId, + ); + }; + const work = (async (): Promise => { + const admitted = await store.admit(admission); + if (admitted.status !== 'reserved') { + if (admitted.status !== 'duplicate') + await reject(`Payment admission refused: ${admitted.status}`); return; } - - logger.info('payment accepted', { - requestEventId, - pmi: paymentRequired.pmi, - amount: paymentRequired.amount, - }); - - const acceptedNotification = createPaymentAcceptedNotification({ - amount: paymentRequired.amount, - pmi: paymentRequired.pmi, - _meta: verified._meta, - }); - - // payment_accepted is a SHOULD (CEP-8); the capability result is the - // point. A publish failure here (relay error, or the idle paying - // client's session LRU-evicted mid-payment) MUST NOT abort the forward — - // that would be paid-but-undelivered. Do not turn this back into an - // early return. + const record = admitted.record; + const fence = { key: record.key, generation: record.generation }; try { + if (abortSignal?.aborted) { + await store.transition({ ...fence, type: 'release' }); + return; + } + const initiated = await resolveAndInitiatePayment({ + message, + priced, + requestEventId: admission.requestEventId, + clientPubkey: ctx.clientPubkey, + clientPmis: ctx.clientPmis, + options, + processorsByPmi, + beforeInvoice: async () => { + if (abortSignal?.aborted) return false; + const owned = await store.transition({ ...fence, type: 'issuing' }); + return owned && !abortSignal?.aborted; + }, + }); + if (initiated.kind === 'superseded') { + await store.transition({ ...fence, type: 'release' }); + return; + } + if (initiated.kind === 'rejected') { + if (await store.transition({ ...fence, type: 'reject' })) + await reject(initiated.message); + return; + } + if (initiated.kind === 'waived') { + if ( + !abortSignal?.aborted && + (await store.transition({ ...fence, type: 'consume' })) + ) { + if (abortSignal?.aborted) return; + await forward(message); + await store.transition({ ...fence, type: 'dispatch' }); + } + return; + } + const { paymentRequired, mergedMeta, processor, verifyTimeoutMs } = + initiated; + const pollingTimeoutMs = Math.min(verifyTimeoutMs, leaseMs); + if ( + !(await store.transition({ + ...fence, + type: 'bind', + invoice: { ...paymentRequired, _meta: mergedMeta }, + leaseMs: pollingTimeoutMs, + })) + ) + return; + if (abortSignal?.aborted) return; + params.onInvoiceIssued?.({ + requestEventId: admission.requestEventId, + snapshotTtlMs: leaseMs + 60_000, + }); + if (abortSignal?.aborted) return; await sender.sendNotification( ctx.clientPubkey, - acceptedNotification, - requestEventId, + { + jsonrpc: '2.0', + method: PAYMENT_REQUIRED_METHOD, + params: { ...paymentRequired, _meta: mergedMeta }, + }, + admission.requestEventId, ); - } catch (err) { - logger.warn('failed to publish payment_accepted, forwarding anyway', { - requestEventId, - error: err instanceof Error ? err.message : String(err), + const verified = await verifyPaymentUnlessShutdown({ + processor, + verifyParams: { + pay_req: paymentRequired.pay_req, + requestEventId: admission.requestEventId, + clientPubkey: ctx.clientPubkey, + }, + timeoutMs: pollingTimeoutMs, + shutdownSignal: abortSignal, }); + if ( + !verified || + abortSignal?.aborted || + !(await store.transition({ ...fence, type: 'consume' })) + ) + return; + if (abortSignal?.aborted) return; + try { + await sender.sendNotification( + ctx.clientPubkey, + { + jsonrpc: '2.0', + method: PAYMENT_ACCEPTED_METHOD, + params: { + amount: paymentRequired.amount, + pmi: paymentRequired.pmi, + _meta: verified._meta, + }, + }, + admission.requestEventId, + ); + } catch { + logger.warn( + 'payment_accepted publication failed; execution right remains consumed', + { requestEventId: admission.requestEventId }, + ); + } + if (abortSignal?.aborted) return; + await forward(message); + await store.transition({ ...fence, type: 'dispatch' }); + } catch (error) { + await abandonServerPayment(store, record); + if (error instanceof PaymentMethodUnavailableError) { + await reject(error.message); + return; + } + throw error; } - - logger.debug('forwarding priced request after payment', { - requestEventId, - method: message.method, - }); - - await forward(message); })(); - - const state: PendingPaymentState = { - expiresAtMs: now + paymentTtlMs, - inFlight, - }; - pending.set(requestEventId, state); - + running.set(admission.key, work); try { - await inFlight; - // On success, keep the entry in `pending` until TTL expiry. - // This guards against relay redelivery triggering a second charge. - // purgeExpiredPending handles eventual cleanup. - } catch (err) { - // Pre-invoice failures never billed anyone — delete so the retry is free. - // Post-invoice failures keep the entry until TTL for the same reason as - // success: the client may already have paid, and a redelivery of the - // same request event MUST NOT mint a second invoice (CEP-8). - if (!invoiceIssued) { - pending.delete(requestEventId); - } - throw err; + await work; + } finally { + if (running.get(admission.key) === work) running.delete(admission.key); } }; } diff --git a/src/payments/server-transport-payments.shutdown.test.ts b/src/payments/server-transport-payments.shutdown.test.ts index 2e4e46c..b087306 100644 --- a/src/payments/server-transport-payments.shutdown.test.ts +++ b/src/payments/server-transport-payments.shutdown.test.ts @@ -10,7 +10,7 @@ import { waitFor } from '../core/utils/test.utils.js'; import { PrivateKeySigner } from '../signer/private-key-signer.js'; import { NostrClientTransport } from '../transport/nostr-client-transport.js'; import { NostrServerTransport } from '../transport/nostr-server-transport.js'; -import { AuthorizationStore } from './authorization-store.js'; +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; import { PAYMENT_ACCEPTED_METHOD, PAYMENT_REQUIRED_ERROR_CODE, @@ -21,7 +21,7 @@ import type { PaymentProcessorVerifyParams, } from './types.js'; -const PMI = 'pmi:controlled'; +const PMI = 'controlled'; /** * Processor whose verification never settles on its own. The test decides @@ -81,7 +81,10 @@ function createControlledProcessor(options: { rejectOnAbort: boolean }): { return { processor, verifyStarted, settle: () => settle(), outcomes }; } -async function startPaidServer(processor: PaymentProcessor): Promise<{ +async function startPaidServer( + processor: PaymentProcessor, + stateStore?: InMemoryPaymentStateStore, +): Promise<{ hub: MockRelayHub; serverPubkey: string; serverTransport: NostrServerTransport; @@ -110,6 +113,7 @@ async function startPaidServer(processor: PaymentProcessor): Promise<{ encryptionMode: EncryptionMode.DISABLED, }); withServerPayments(serverTransport, { + stateStore, processors: [processor], pricedCapabilities: [ { method: 'tools/call', name: 'add', amount: 21, currencyUnit: 'sats' }, @@ -237,13 +241,13 @@ describe('withServerPayments shutdown cancels in-flight verification', () => { }, 20_000); test('explicit gating: close() aborts the verify signal and a late settle clears pending instead of granting', async () => { - const grantSpy = spyOn(AuthorizationStore.prototype, 'grant'); - const clearPendingSpy = spyOn(AuthorizationStore.prototype, 'clearPending'); + const stateStore = new InMemoryPaymentStateStore(); + const transitionSpy = spyOn(stateStore, 'transition'); const { processor, verifyStarted, settle } = createControlledProcessor({ rejectOnAbort: false, }); const { hub, serverPubkey, serverTransport, toolCalls } = - await startPaidServer(processor); + await startPaidServer(processor, stateStore); const client = await connectClient({ hub, serverPubkey, @@ -257,7 +261,9 @@ describe('withServerPayments shutdown cancels in-flight verification', () => { const verifyParams = await verifyStarted; expect(verifyParams.abortSignal?.aborted).toBe(false); - expect(grantSpy).not.toHaveBeenCalled(); + expect( + transitionSpy.mock.calls.filter(([change]) => change.type === 'grant'), + ).toHaveLength(0); await serverTransport.close(); @@ -266,12 +272,15 @@ describe('withServerPayments shutdown cancels in-flight verification', () => { settle(); await Bun.sleep(100); - expect(grantSpy).not.toHaveBeenCalled(); - expect(clearPendingSpy).toHaveBeenCalled(); + expect( + transitionSpy.mock.calls.filter(([change]) => change.type === 'grant'), + ).toHaveLength(0); + expect( + transitionSpy.mock.calls.some(([change]) => change.type === 'expire'), + ).toBe(true); expect(toolCalls).toHaveLength(0); } finally { - grantSpy.mockRestore(); - clearPendingSpy.mockRestore(); + transitionSpy.mockRestore(); await client.close(); } }, 20_000); diff --git a/src/payments/server-transport-payments.ts b/src/payments/server-transport-payments.ts index ea497b2..94631a1 100644 --- a/src/payments/server-transport-payments.ts +++ b/src/payments/server-transport-payments.ts @@ -5,7 +5,8 @@ import { createCapTagsFromPricedCapabilities } from './cap-tags.js'; import { createPmiTagsFromProcessors } from './pmi-tags.js'; import { createServerPaymentsMiddleware } from './server-payments.js'; import { createExplicitGatingMiddleware } from './server-explicit-gating.js'; -import { AuthorizationStore } from './authorization-store.js'; +import { serverPaymentStore } from './server-payment-state.js'; +import { validateServerPaymentOptions } from './server-payment-validation.js'; import { buildProcessorsByPmi } from './server-payments-utils.js'; import { createLogger } from '../core/utils/logger.js'; import { NOSTR_TAGS } from '../core/constants.js'; @@ -35,6 +36,9 @@ export function withServerPayments( 'withServerPayments already called on this transport; registering the payments lifecycle twice double-charges priced requests', ); } + validateServerPaymentOptions(options); + const stateStore = serverPaymentStore(options); + options = { ...options, stateStore }; paymentsRegistered.add(transport); // Build the PMI → processor map once and share it across both middlewares. @@ -87,7 +91,7 @@ export function withServerPayments( // it is safe to register the explicit-gating middleware alongside it. Each // request is routed to exactly one lifecycle based on the negotiated mode. if (supportsExplicitGating) { - const authorizationStore = new AuthorizationStore({}); + const authorizationStore = stateStore; transport.addInboundMiddleware( createExplicitGatingMiddleware({ options, diff --git a/src/payments/types.ts b/src/payments/types.ts index 20c132c..f51835c 100644 --- a/src/payments/types.ts +++ b/src/payments/types.ts @@ -3,6 +3,7 @@ import type { JSONRPCNotification, JSONRPCRequest, } from '@contextvm/mcp-sdk/types.js'; +import type { VerifiedPaymentEvent } from './payment-state-store.js'; /** * A priced capability pattern, consistent with server-side authorization exclusions. @@ -197,6 +198,8 @@ export interface PaymentProcessorVerifyParams { export type ResolvePriceQuote = { /** Final amount to charge for this specific invocation. */ amount: number; + /** When supplied, must match the advertised capability unit; no implicit conversion. */ + currencyUnit?: string; /** Optional override for the payment request description. */ description?: string; /** Optional transparency metadata attached to `payment_required.params._meta`. */ @@ -246,10 +249,17 @@ export function waivePrice(meta?: Record): ResolvePriceWaiver { */ export function quotePrice( amount: number, - options?: { description?: string; meta?: Record }, + options?: { + description?: string; + meta?: Record; + currencyUnit?: string; + }, ): ResolvePriceQuote { return { amount, + ...(options?.currencyUnit !== undefined && { + currencyUnit: options.currencyUnit, + }), ...(options?.description !== undefined && { description: options.description, }), @@ -264,9 +274,7 @@ export function quotePrice( * - Return a rejection object to emit `payment_rejected` without asking for payment. */ export type ResolvePriceResult = - | ResolvePriceQuote - | ResolvePriceRejection - | ResolvePriceWaiver; + ResolvePriceQuote | ResolvePriceRejection | ResolvePriceWaiver; /** * Server-side callback for dynamic pricing. @@ -300,6 +308,8 @@ export interface PaymentRequired { export interface PaymentProcessor { /** The PMI this processor can issue/verify */ readonly pmi: string; + /** Native amount unit. Legacy processors without this field inherit the capability unit. */ + readonly currencyUnit?: string; /** Create a payment request for a specific capability invocation */ createPaymentRequired( @@ -315,6 +325,8 @@ export interface PaymentProcessor { export interface ServerPaymentsContext { /** The client pubkey associated with the inbound request. */ clientPubkey: string; + /** Immutable identity of the verified event, independent of mutable JSON-RPC IDs. */ + requestEvent?: VerifiedPaymentEvent; /** * Optional list of client-advertised PMIs (ordered by client preference). diff --git a/src/transport/middleware.ts b/src/transport/middleware.ts index ba2ddca..c5f0f20 100644 --- a/src/transport/middleware.ts +++ b/src/transport/middleware.ts @@ -1,6 +1,7 @@ import type { JSONRPCMessage } from '@contextvm/mcp-sdk/types.js'; import type { PaymentInteractionMode } from '../payments/types.js'; +import type { VerifiedPaymentEvent } from '../payments/payment-state-store.js'; /** * Inbound middleware hook for server transports. @@ -15,6 +16,7 @@ export type InboundMiddlewareFn = ( message: JSONRPCMessage, ctx: { clientPubkey: string; + requestEvent?: VerifiedPaymentEvent; clientPmis?: readonly string[]; paymentInteraction?: PaymentInteractionMode; }, diff --git a/src/transport/nostr-client-transport.test.ts b/src/transport/nostr-client-transport.test.ts index 8153738..be6e44c 100644 --- a/src/transport/nostr-client-transport.test.ts +++ b/src/transport/nostr-client-transport.test.ts @@ -739,7 +739,7 @@ describe.serial('NostrClientTransport', () => { withServerPayments(paidServerTransport, { processors: [ - new FakePaymentProcessor({ pmi: 'pmi:test', verifyDelayMs: 1 }), + new FakePaymentProcessor({ pmi: 'pmi-test', verifyDelayMs: 1 }), ], pricedCapabilities: [ { @@ -818,7 +818,7 @@ describe.serial('NostrClientTransport', () => { withServerPayments(paidServerTransport, { processors: [ - new FakePaymentProcessor({ pmi: 'pmi:test', verifyDelayMs: 1 }), + new FakePaymentProcessor({ pmi: 'pmi-test', verifyDelayMs: 1 }), ], pricedCapabilities: [ { @@ -907,7 +907,7 @@ describe.serial('NostrClientTransport', () => { withServerPayments(paidServerTransport, { processors: [ - new FakePaymentProcessor({ pmi: 'pmi:test', verifyDelayMs: 1 }), + new FakePaymentProcessor({ pmi: 'pmi-test', verifyDelayMs: 1 }), ], pricedCapabilities: [ { diff --git a/src/transport/nostr-server-transport.test.ts b/src/transport/nostr-server-transport.test.ts index 7b7e99d..3c40dfc 100644 --- a/src/transport/nostr-server-transport.test.ts +++ b/src/transport/nostr-server-transport.test.ts @@ -197,8 +197,8 @@ describe.serial('NostrServerTransport', () => { withServerPayments(transport, { processors: [ - new FakePaymentProcessor({ pmi: 'pmi:B', verifyDelayMs: 1 }), - new FakePaymentProcessor({ pmi: 'pmi:C', verifyDelayMs: 1 }), + new FakePaymentProcessor({ pmi: 'pmi-b', verifyDelayMs: 1 }), + new FakePaymentProcessor({ pmi: 'pmi-c', verifyDelayMs: 1 }), ], pricedCapabilities: [ { @@ -250,8 +250,8 @@ describe.serial('NostrServerTransport', () => { expect(announcement).toBeDefined(); expect(announcement!.tags).toEqual( expect.arrayContaining([ - ['pmi', 'pmi:B'], - ['pmi', 'pmi:C'], + ['pmi', 'pmi-b'], + ['pmi', 'pmi-c'], ['cap', 'tool:add', '123', 'sats'], ]), ); diff --git a/src/transport/nostr-server/inbound-coordinator.test.ts b/src/transport/nostr-server/inbound-coordinator.test.ts index fd814a8..5e50cd9 100644 --- a/src/transport/nostr-server/inbound-coordinator.test.ts +++ b/src/transport/nostr-server/inbound-coordinator.test.ts @@ -185,3 +185,36 @@ describe('ServerInboundCoordinator resource subscriptions', () => { expect(subscriptions.getSubscribers(resourceUri)).toEqual(new Set()); }); }); + +test('payment event context is frozen before asynchronous authorization or middleware mutation', async () => { + const event = requestEvent('verified-event'); + const original = { + id: event.id, + author: event.pubkey, + createdAt: event.created_at, + }; + let observed: unknown; + const { coordinator } = createCoordinator({ + authorizationPolicy: new AuthorizationPolicy({ + isPubkeyAllowed: async () => { + event.created_at = 0; + return true; + }, + }), + inboundMiddlewares: [ + async (message, ctx, forward) => { + observed = ctx.requestEvent; + expect(Object.isFrozen(ctx.requestEvent)).toBe(true); + if ('id' in message) message.id = 'application-mutated-id'; + await forward(message); + }, + ], + }); + await coordinator.authorizeAndProcessEvent( + event, + false, + subscriptionRequest('rpc-id', 'resources/read', resourceUri), + ); + await sleep(0); + expect(observed).toEqual(original); +}); diff --git a/src/transport/nostr-server/inbound-coordinator.ts b/src/transport/nostr-server/inbound-coordinator.ts index 2b56141..eb413da 100644 --- a/src/transport/nostr-server/inbound-coordinator.ts +++ b/src/transport/nostr-server/inbound-coordinator.ts @@ -104,6 +104,11 @@ export class ServerInboundCoordinator { ): Promise { try { const inboundMessage: JSONRPCMessage = mcpMessage; + const requestEvent = Object.freeze({ + id: event.id, + author: event.pubkey, + createdAt: event.created_at, + }); const authDecision = await this.deps.authorizationPolicy.authorize( event.pubkey, @@ -281,6 +286,7 @@ export class ServerInboundCoordinator { const ctx = { clientPubkey: event.pubkey, + requestEvent, clientPmis: clientPmis.length > 0 ? clientPmis : undefined, paymentInteraction: session.effectivePaymentInteraction ?? 'transparent', diff --git a/src/transport/payment-state-durability.e2e.test.ts b/src/transport/payment-state-durability.e2e.test.ts new file mode 100644 index 0000000..8189241 --- /dev/null +++ b/src/transport/payment-state-durability.e2e.test.ts @@ -0,0 +1,195 @@ +import { expect, test } from 'bun:test'; +import { Pool } from 'pg'; +import { Client } from '@contextvm/mcp-sdk/client'; +import { McpServer } from '@contextvm/mcp-sdk/server/mcp'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { bytesToHex } from 'nostr-tools/utils'; +import { z } from 'zod'; +import type { NostrEvent } from 'nostr-tools'; +import { withTimeout } from '../core/utils/utils.js'; +import { MockRelayHub } from '../__mocks__/mock-relay-handler.js'; +import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; +import { PrivateKeySigner } from '../signer/private-key-signer.js'; +import { NostrClientTransport } from './nostr-client-transport.js'; +import { NostrServerTransport } from './nostr-server-transport.js'; +import { withClientPayments, withServerPayments } from '../payments/index.js'; +import { PostgresPaymentStateStore } from '../payments/postgres/index.js'; +import type { + PaymentProcessor, + PaymentStateRecord, +} from '../payments/index.js'; + +const connectionString = process.env.PAYMENT_TEST_DATABASE_URL; +const databaseTest = connectionString ? test : test.skip; +const modes = [ + { + name: 'cleartext', + encryptionMode: EncryptionMode.DISABLED, + giftWrapMode: GiftWrapMode.OPTIONAL, + }, + { + name: 'persistent encryption', + encryptionMode: EncryptionMode.REQUIRED, + giftWrapMode: GiftWrapMode.PERSISTENT, + }, + { + name: 'ephemeral encryption', + encryptionMode: EncryptionMode.REQUIRED, + giftWrapMode: GiftWrapMode.EPHEMERAL, + }, +]; + +for (const mode of modes) + databaseTest( + `durable transparent payments: two workers and replay after TTL/restart over ${mode.name}`, + async () => { + const pool = new Pool({ + connectionString, + max: 4, + connectionTimeoutMillis: 3000, + query_timeout: 5000, + }); + const namespace = `e2e-${crypto.randomUUID()}`; + const hub = new MockRelayHub(); + const serverKey = generateSecretKey(); + const clientKey = generateSecretKey(); + const clientPubkey = getPublicKey(clientKey); + let invoices = 0; + let approvals = 0; + let verifications = 0; + let executions = 0; + let admittedRecord: PaymentStateRecord | undefined; + const processor: PaymentProcessor = { + pmi: 'fake', + currencyUnit: 'sats', + async createPaymentRequired(input) { + invoices++; + return { + amount: input.amount, + pmi: 'fake', + pay_req: `invoice-${invoices}`, + ttl: 2, + }; + }, + async verifyPayment() { + verifications++; + return {}; + }, + }; + const servers: McpServer[] = []; + const createWorker = async ( + onReplay?: () => void, + ): Promise => { + const store = new PostgresPaymentStateStore({ pool, namespace }); + await store.initialize(); + const admit = store.admit.bind(store); + store.admit = async (input) => { + const result = await admit(input); + if (result.status === 'reserved') admittedRecord = result.record; + if (result.status === 'duplicate') onReplay?.(); + return result; + }; + const server = new McpServer({ + name: 'durable-worker', + version: '1.0.0', + }); + server.registerTool( + 'paid', + { inputSchema: { value: z.number() } }, + async ({ value }) => { + executions++; + return { content: [{ type: 'text', text: String(value) }] }; + }, + ); + servers.push(server); + const transport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(bytesToHex(serverKey)), + relayHandler: hub.createRelayHandler(), + encryptionMode: mode.encryptionMode, + giftWrapMode: mode.giftWrapMode, + }), + { + stateStore: store, + paymentTtlMs: 2000, + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'paid', + amount: 5, + currencyUnit: 'sats', + }, + ], + }, + ); + await server.connect(transport); + return transport; + }; + const clientEvents: NostrEvent[] = []; + const clientRelay = hub.createRelayHandler(); + const publish = clientRelay.publish.bind(clientRelay); + clientRelay.publish = async (event, options) => { + clientEvents.push(event); + await publish(event, options); + }; + const client = new Client({ + name: 'durable-payment-client', + version: '1.0.0', + }); + try { + await createWorker(); + await createWorker(); + const base = new NostrClientTransport({ + signer: new PrivateKeySigner(bytesToHex(clientKey)), + relayHandler: clientRelay, + serverPubkey: getPublicKey(serverKey), + encryptionMode: mode.encryptionMode, + giftWrapMode: mode.giftWrapMode, + }); + await client.connect( + withClientPayments(base, { + handlers: [ + { + pmi: 'fake', + async handle() { + approvals++; + }, + }, + ], + }), + ); + const result = await client.callTool( + { name: 'paid', arguments: { value: 7 } }, + undefined, + { timeout: 5000 }, + ); + expect(result.content).toEqual([{ type: 'text', text: '7' }]); + expect([invoices, approvals, verifications, executions]).toEqual([ + 1, 1, 1, 1, + ]); + expect(admittedRecord?.clientPubkey).toBe(clientPubkey); + await Promise.all(servers.splice(0).map((server) => server.close())); + await Bun.sleep( + Math.max(0, admittedRecord!.leaseExpiresAtMs - Date.now() + 25), + ); + let replayed!: () => void; + const replay = new Promise((resolve) => { + replayed = resolve; + }); + const restartedTransport = await createWorker(replayed); + // Deliver a relay replay directly into the real verification/decryption pipeline, + // independently of the new subscription's narrower `since` filter. + for (const event of clientEvents) + await restartedTransport['processIncomingEvent'](event); + await withTimeout(replay, 3000, 'Retained request was not replayed'); + expect([invoices, approvals, verifications, executions]).toEqual([ + 1, 1, 1, 1, + ]); + } finally { + await client.close(); + await Promise.all(servers.map((server) => server.close())); + await pool.end(); + } + }, + ); diff --git a/src/transport/payments-flow.test.ts b/src/transport/payments-flow.test.ts index 0a52d24..9ac00bb 100644 --- a/src/transport/payments-flow.test.ts +++ b/src/transport/payments-flow.test.ts @@ -319,7 +319,10 @@ describe.serial('payments fake flow (transport-level)', () => { }), ); - const processor = new FakePaymentProcessor({ verifyDelayMs: 1 }); + const processor = new FakePaymentProcessor({ + verifyDelayMs: 1, + pmi: 'pmi-a', + }); const pricedCapabilities = [ { method: 'tools/call', @@ -389,7 +392,7 @@ describe.serial('payments fake flow (transport-level)', () => { await mcpServer.close(); }, 20000); - test('PMI selection: falls back to server order when no intersection', async () => { + test('PMI selection: disjoint sets refuse before invoice creation or wallet work', async () => { const serverSK = generateSecretKey(); const serverPrivateKey = bytesToHex(serverSK); const serverPublicKey = getPublicKey(serverSK); @@ -415,6 +418,8 @@ describe.serial('payments fake flow (transport-level)', () => { pmi: 'pmi-c', verifyDelayMs: 1, }); + const createB = spyOn(processorB, 'createPaymentRequired'); + const createC = spyOn(processorC, 'createPaymentRequired'); const pricedCapabilities = [ { method: 'tools/call', @@ -441,6 +446,7 @@ describe.serial('payments fake flow (transport-level)', () => { const clientPrivateKey = bytesToHex(generateSecretKey()); const handlers = [new FakePaymentHandler({ pmi: 'pmi-a', delayMs: 1 })]; + const wallet = spyOn(handlers[0], 'handle'); const clientTransport = new NostrClientTransport({ signer: new PrivateKeySigner(clientPrivateKey), @@ -462,15 +468,21 @@ describe.serial('payments fake flow (transport-level)', () => { originalOnMessage?.(msg); }; - await client.callTool({ - name: 'add', - arguments: { a: 1, b: 2 }, - }); - - expect(observedPaymentPmi).toBe('pmi-b'); - - await client.close(); - await mcpServer.close(); + try { + await expect( + client.callTool({ name: 'add', arguments: { a: 1, b: 2 } }), + ).rejects.toThrow('mutually supported'); + expect(observedPaymentPmi).toBeUndefined(); + expect(createB).not.toHaveBeenCalled(); + expect(createC).not.toHaveBeenCalled(); + expect(wallet).not.toHaveBeenCalled(); + } finally { + createB.mockRestore(); + createC.mockRestore(); + wallet.mockRestore(); + await client.close(); + await mcpServer.close(); + } }, 20000); test('PMI selection: when client advertises none, uses server order', async () => { @@ -832,10 +844,12 @@ describe.serial('payments fake flow (transport-level)', () => { expect(acceptedSent).toBe(0); }); - test('verification timeout clears pending state (ttl-based)', async () => { + test('verification timeout preserves invoice identity for event replay', async () => { + let invoices = 0; const processor: PaymentProcessor = { pmi: 'fake', async createPaymentRequired(params) { + invoices++; return { amount: params.amount, pay_req: `fake:${params.requestEventId}:${params.clientPubkey}:${params.amount}`, @@ -899,10 +913,10 @@ describe.serial('payments fake flow (transport-level)', () => { expect(elapsed).toBeLessThan(3000); expect(forwarded).toBe(0); - // Retry should not be black-holed: it should attempt again and time out again. - await expect(mw(message, ctx, forward)).rejects.toThrow( - /verifyPayment timed out/, - ); + // The same admitted event cannot create a second payment after its lease expires. + await mw(message, ctx, forward); + expect(invoices).toBe(1); + expect(forwarded).toBe(0); }, 20000); test('client respects payment TTL without timing out even when TTL > MCP timeout (immediate-heartbeat regression)', async () => { @@ -1190,8 +1204,7 @@ describe.serial('payments fake flow (transport-level)', () => { const event = await capturePromise; const piTag = event.tags.find((t) => t[0] === 'payment_interaction') as - | readonly string[] - | undefined; + readonly string[] | undefined; expect(piTag?.[1]).toBe('explicit_gating'); await client.close(); @@ -1612,7 +1625,8 @@ describe.serial('payments fake flow (transport-level)', () => { { method: 'tools/call', name: 'add', - amount: 1, // static fallback; resolvePrice overrides this + amount: 1, + maxAmount: 42, // Inclusive advertised range for dynamic quotes. currencyUnit: 'test', }, ], From 9d2990c40514bff924bb5a70ab4a3c1674cc7f0c Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Tue, 27 Oct 2026 02:26:05 +0530 Subject: [PATCH 3/6] fix(payments): pin unresolved attempts and restore client compatibility --- src/payments/client-payment-attempts.test.ts | 95 +++++++++++++++++++ src/payments/client-payment-attempts.ts | 28 ++++-- src/payments/client-payment-options.ts | 7 +- src/payments/client-payment-validation.ts | 10 +- src/payments/client-payments-limits.test.ts | 61 +++++++++++- .../client-payments-reliability.test.ts | 33 +++---- src/payments/client-payments.ts | 31 +++++- .../payment-client-reliability.e2e.test.ts | 8 +- 8 files changed, 237 insertions(+), 36 deletions(-) create mode 100644 src/payments/client-payment-attempts.test.ts diff --git a/src/payments/client-payment-attempts.test.ts b/src/payments/client-payment-attempts.test.ts new file mode 100644 index 0000000..8d72853 --- /dev/null +++ b/src/payments/client-payment-attempts.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { ClientPaymentAttempts } from './client-payment-attempts.js'; +import { option } from '../__mocks__/client-payments-harness.js'; + +describe('client payment attempt retention under capacity pressure', () => { + for (const outcome of ['handled', 'declined'] as const) { + test(`evicts ${outcome} history least recently used first`, () => { + const attempts = new ClientPaymentAttempts(2); + const signal = new AbortController().signal; + const first = attempts.reserve([option('first')], signal); + first.complete(outcome); + const second = attempts.reserve([option('second')], signal); + second.complete(outcome); + + expect(attempts.find([option('first')])).toBe(first); + const third = attempts.reserve([option('third')], signal); + expect(attempts.find([option('first')])).toBe(first); + expect(attempts.find([option('second')])).toBeUndefined(); + expect(attempts.find([option('third')])).toBe(third); + third.complete(outcome); + }); + } + + test('skips in-flight and unknown pins when evicting older history', () => { + const attempts = new ClientPaymentAttempts(3); + const signal = new AbortController().signal; + const inFlight = attempts.reserve([option('pending')], signal); + const unknown = attempts.reserve([option('unknown')], signal); + unknown.complete('unknown'); + const handled = attempts.reserve([option('handled')], signal); + handled.complete('handled'); + + const replacement = attempts.reserve([option('replacement')], signal); + expect(attempts.find([option('handled')])).toBeUndefined(); + expect(attempts.find([option('pending')])).toBe(inFlight); + expect(attempts.find([option('unknown')])).toBe(unknown); + expect(attempts.find([option('replacement')])).toBe(replacement); + inFlight.complete('handled'); + replacement.complete('handled'); + }); + + test('refuses atomically when a multi-option reservation cannot fit beside pins', () => { + const attempts = new ClientPaymentAttempts(2); + const signal = new AbortController().signal; + const unknown = attempts.reserve([option('unknown')], signal); + unknown.complete('unknown'); + const handled = attempts.reserve([option('handled')], signal); + handled.complete('handled'); + + expect(() => + attempts.reserve([option('new-one'), option('new-two')], signal), + ).toThrow(expect.objectContaining({ reason: 'payment_attempt_capacity' })); + expect(attempts.find([option('unknown')])).toBe(unknown); + expect(attempts.find([option('handled')])).toBe(handled); + expect( + attempts.find([option('new-one'), option('new-two')]), + ).toBeUndefined(); + }); + + test('pins every distinct option of an in-flight or unknown explicit attempt', async () => { + const attempts = new ClientPaymentAttempts(2); + const controller = new AbortController(); + const attempt = attempts.reserve( + [option('one'), option('one'), option('two')], + controller.signal, + ); + expect(attempts.find([option('one')])).toBe(attempt); + expect(attempts.find([option('two')])).toBe(attempt); + expect(() => + attempts.reserve([option('three')], controller.signal), + ).toThrow(expect.objectContaining({ reason: 'payment_attempt_capacity' })); + + controller.abort(); + await attempt.completion; + attempt.complete('handled'); + expect(attempt.outcome).toBe('unknown'); + expect(() => + attempts.reserve([option('three')], new AbortController().signal), + ).toThrow(expect.objectContaining({ reason: 'payment_attempt_capacity' })); + expect(attempts.find([option('one')])).toBe(attempt); + expect(attempts.find([option('two')])).toBe(attempt); + }); + + test('reclaims enough terminal identities for a multi-option reservation', () => { + const attempts = new ClientPaymentAttempts(2); + const signal = new AbortController().signal; + const prior = attempts.reserve([option('one'), option('two')], signal); + prior.complete('declined'); + const next = attempts.reserve([option('three'), option('four')], signal); + expect(attempts.find([option('one'), option('two')])).toBeUndefined(); + expect(attempts.find([option('three')])).toBe(next); + expect(attempts.find([option('four')])).toBe(next); + next.complete('handled'); + }); +}); diff --git a/src/payments/client-payment-attempts.ts b/src/payments/client-payment-attempts.ts index a8ac981..7bef17b 100644 --- a/src/payments/client-payment-attempts.ts +++ b/src/payments/client-payment-attempts.ts @@ -1,4 +1,5 @@ import type { PaymentOption } from './types.js'; +import { LruCache } from '../core/utils/lru-cache.js'; import { ClientPaymentError } from './client-payment-validation.js'; type AttemptOutcome = 'in_flight' | 'handled' | 'declined' | 'unknown'; @@ -13,15 +14,18 @@ export interface ClientPaymentAttempt { /** * Wrapper-scoped attempt history. A Nostr wrapper is bound to one authenticated * server; generic wrappers use their transport instance as the peer boundary. - * No live/completed/unknown entry is evicted and silently made payable again. + * In-flight and unknown attempts stay pinned; handled and declined identities + * may be evicted least recently used first under capacity pressure. */ export class ClientPaymentAttempts { - private readonly entries = new Map< - string, - { amount: number; attempt: ClientPaymentAttempt } - >(); + private readonly entries: LruCache<{ + amount: number; + attempt: ClientPaymentAttempt; + }>; - public constructor(private readonly maxEntries: number) {} + public constructor(private readonly maxEntries: number) { + this.entries = new LruCache(maxEntries); + } private key(option: PaymentOption): string { return JSON.stringify([option.pmi, option.pay_req]); @@ -59,12 +63,20 @@ export class ClientPaymentAttempts { signal: AbortSignal, ): ClientPaymentAttempt { const unique = new Map(options.map((option) => [this.key(option), option])); - if (this.entries.size + unique.size > this.maxEntries) { + const needed = this.entries.size + unique.size - this.maxEntries; + const evictable: string[] = []; + for (const [key, { attempt }] of this.entries.entries()) { + if (evictable.length >= needed) break; + if (attempt.outcome === 'handled' || attempt.outcome === 'declined') + evictable.push(key); + } + if (evictable.length < needed) { throw new ClientPaymentError( 'payment_attempt_capacity', - 'Client payment attempt capacity reached', + 'Client payment attempt capacity exceeded by unresolved payments or offered options', ); } + for (const key of evictable) this.entries.delete(key); let resolve!: () => void; const attempt: ClientPaymentAttempt = { outcome: 'in_flight', diff --git a/src/payments/client-payment-options.ts b/src/payments/client-payment-options.ts index 95f33c9..f16d6b0 100644 --- a/src/payments/client-payment-options.ts +++ b/src/payments/client-payment-options.ts @@ -40,9 +40,10 @@ export interface ClientPaymentsOptions { /** Optional limits across all automatic payment offers for one invocation. */ limits?: ClientPaymentLimits; /** - * Maximum distinct payment identities retained by this wrapper. Completed and - * uncertain attempts are never evicted to make room for a new payment. - * At capacity, new attempts fail without invoking a wallet. @default 1000 + * Maximum distinct payment identities retained by this wrapper. In-flight and + * unknown attempts stay pinned; handled and declined identities are evicted + * least recently used first. New attempts fail before wallet work if pinned + * identities and offered options cannot fit. Zero refuses payment. @default 1000 * Explicit callbacks reserve every offered identity because option selection * is opaque; they must pay only one of the supplied options. */ diff --git a/src/payments/client-payment-validation.ts b/src/payments/client-payment-validation.ts index e86c555..ea23e03 100644 --- a/src/payments/client-payment-validation.ts +++ b/src/payments/client-payment-validation.ts @@ -12,7 +12,7 @@ const amountSchema = z .finite() .nonnegative() .max(Number.MAX_SAFE_INTEGER); -const pmiSchema = z.string().regex(/^[a-z0-9-]+$/); +const pmiSchema = z.string().min(1); const timerSchema = z.number().finite().positive().max(MAX_TIMER_MS); /** Internal payment failure with a stable application-visible reason. */ @@ -125,6 +125,14 @@ export function validateClientPaymentOptions( .optional(), paymentInteraction: z.enum(['transparent', 'explicit_gating']).optional(), handlers: z.array(z.object({ pmi: pmiSchema })).optional(), + onPaymentRequired: z + .unknown() + .refine((value) => typeof value === 'function') + .optional(), + paymentPolicy: z + .unknown() + .refine((value) => typeof value === 'function') + .optional(), limits: z .object({ maxAmountByPmi: z.record(pmiSchema, amountSchema).optional(), diff --git a/src/payments/client-payments-limits.test.ts b/src/payments/client-payments-limits.test.ts index a53c91b..62488a7 100644 --- a/src/payments/client-payments-limits.test.ts +++ b/src/payments/client-payments-limits.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from 'bun:test'; import type { Transport } from '@contextvm/mcp-sdk/shared/transport'; import { withClientPayments } from './client-payments.js'; +import type { ClientPaymentsOptions } from './client-payment-options.js'; import type { PaymentOption } from './types.js'; import { run, @@ -175,7 +176,8 @@ describe('client payment limits and validation', () => { { ...option(), amount: Infinity }, { ...option(), amount: Number.MAX_SAFE_INTEGER + 1 }, { ...option(), amount: '5' }, - { ...option(), pmi: 'INVALID:PMI' }, + { ...option(), pmi: '' }, + { ...option(), pmi: 1 }, { ...option(), pay_req: '' }, { ...option(), pay_req: ' ' }, { ...option(), ttl: 0 }, @@ -210,9 +212,55 @@ describe('client payment limits and validation', () => { }); } + for (const mode of ['transparent', 'explicit'] as const) { + test(`accepts a non-kebab PMI in ${mode} offers, handlers, and amount limits`, async () => { + const pmi = 'pmi:A'; + const paid: PaymentOption[] = []; + await run( + { + limits: { maxAmountByPmi: { [pmi]: 5 } }, + handlers: [ + { + pmi, + async handle(req): Promise { + paid.push(req); + }, + }, + ], + onPaymentRequired: async ({ options }) => { + paid.push(...options); + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver( + mode === 'transparent' + ? offer(option('invoice', 5, pmi)) + : required(1, [option('invoice', 5, pmi)]), + ); + await waitFor(() => paid.length === 1); + expect(paid[0]).toMatchObject(option('invoice', 5, pmi)); + expect(failures(h)).toHaveLength(0); + h.deliver( + mode === 'transparent' + ? offer(option('replacement', 1, pmi)) + : required(1, [option('replacement', 1, pmi)]), + ); + await waitFor(() => failures(h).length === 1); + expect(paid).toHaveLength(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_limit_exceeded', + }); + }, + ); + }); + } + for (const invalid of [ [], [option(), { ...option('invalid'), amount: -1 }], + [{ ...option(), pmi: '' }], [option(), option('invoice', 6)], ]) { test(`validates every explicit option ${JSON.stringify(invalid)}`, async () => { @@ -318,6 +366,11 @@ describe('client payment limits and validation', () => { { limits: { timeoutMs: 2_147_483_648 } }, { limits: { maxAttempts: -1 } }, { limits: { maxAmountByPmi: { fake: Infinity } } }, + { handlers: [{ pmi: '', handle: async () => {} }] }, + { limits: { maxAmountByPmi: { '': 1 } } }, + ...['onPaymentRequired', 'paymentPolicy'].flatMap((key) => + [true, false, null, 1, 'typo', {}].map((value) => ({ [key]: value })), + ), ]) { test(`configuration validation rejects ${JSON.stringify(options)} before wrapping`, () => { const base: Transport = { @@ -325,9 +378,9 @@ describe('client payment limits and validation', () => { async send(): Promise {}, async close(): Promise {}, }; - expect(() => withClientPayments(base, options)).toThrow( - 'Invalid client payment options', - ); + expect(() => + withClientPayments(base, options as ClientPaymentsOptions), + ).toThrow('Invalid client payment options'); expect(() => withClientPayments(base, {})).not.toThrow(); }); } diff --git a/src/payments/client-payments-reliability.test.ts b/src/payments/client-payments-reliability.test.ts index 27aa664..84b0d2b 100644 --- a/src/payments/client-payments-reliability.test.ts +++ b/src/payments/client-payments-reliability.test.ts @@ -69,6 +69,7 @@ describe('client payment attempt reliability', () => { let calls = 0; await run( { + maxTrackedPaymentAttempts: 1, onPaymentRequired: async () => { calls++; throw new Error('wallet response lost'); @@ -85,6 +86,13 @@ describe('client payment attempt reliability', () => { expect(failures(h)[1]?.error.data).toMatchObject({ reason: 'payment_outcome_unknown', }); + await h.paid.send(request(3)); + h.deliver(required(3, [option('replacement')])); + await waitFor(() => failures(h).length === 3); + expect(calls).toBe(1); + expect(failures(h)[2]?.error.data).toMatchObject({ + reason: 'payment_attempt_capacity', + }); }, ); }); @@ -138,7 +146,7 @@ describe('client payment attempt reliability', () => { }); }); - test('attempt capacity refuses a new invoice without forgetting a completed payment', async () => { + test('completed history does not exhaust a long-lived client payment capacity', async () => { let calls = 0; await run( { @@ -149,21 +157,14 @@ describe('client payment attempt reliability', () => { }, }, async (h) => { - await h.paid.send(request()); - h.deliver(required()); - await waitFor(() => h.sent.length === 2); - h.deliver({ jsonrpc: '2.0', id: 1, result: {} }); - await h.paid.send(request(2)); - h.deliver(required(2, [option('new-invoice')])); - await waitFor(() => failures(h).length === 1); - expect(calls).toBe(1); - expect(failures(h)[0]?.error.data).toMatchObject({ - reason: 'payment_attempt_capacity', - }); - await h.paid.send(request(3)); - h.deliver(required(3)); - await waitFor(() => h.sent.length === 5); - expect(calls).toBe(1); + for (let id = 1; id <= 3; id++) { + await h.paid.send(request(id)); + h.deliver(required(id, [option(`invoice-${id}`)])); + await waitFor(() => h.sent.length === id * 2); + h.deliver({ jsonrpc: '2.0', id, result: {} }); + } + expect(calls).toBe(3); + expect(failures(h)).toHaveLength(0); }, ); }); diff --git a/src/payments/client-payments.ts b/src/payments/client-payments.ts index 058b7d0..a13ca64 100644 --- a/src/payments/client-payments.ts +++ b/src/payments/client-payments.ts @@ -90,7 +90,8 @@ function cancelledRequestId( * Wraps a transport with validated CEP-8 offers, bounded payment attempts, and * request-owned retries. Nostr peers are authenticated by their transport; * generic transports isolate attempt history per wrapper and must keep their - * peer identity stable. History is retained for the lifetime of the wrapper. + * peer identity stable. In-flight and unknown attempts stay pinned; terminal + * history is bounded and may be evicted under capacity pressure. * * Unmatched handlers leave transparent payment to the application. Generic * notifications without request correlation support legacy handler execution, @@ -133,6 +134,11 @@ export function withClientPayments( code = -32000, data: Record = {}, ): void => { + logger.info('Client payment failed', { + requestId: state.id, + reason: data.type === 'payment_handler_error' ? data.type : error.reason, + outcome: data.outcome, + }); if (state.id === undefined) { onerror?.(error); return; @@ -207,6 +213,11 @@ export function withClientPayments( }, Math.max(delayMs, minRetryDelayMs), ); + logger.debug('Payment retry scheduled', { + requestId: state.id, + delayMs: Math.max(delayMs, minRetryDelayMs), + code, + }); }; const priorOutcome = async ( @@ -214,6 +225,10 @@ export function withClientPayments( attempt: ClientPaymentAttempt, explicit: boolean, ): Promise => { + logger.debug('Payment offer deduplicated', { + requestId: state.id, + outcome: attempt.outcome, + }); await attempt.completion; if (!lifecycle.isActive(state)) return; if (attempt.outcome === 'handled') { @@ -284,6 +299,10 @@ export function withClientPayments( const allowed = lifecycle.approve(state, data.payment_options); const attempt = attempts.reserve(allowed, state.controller.signal); state.paymentInFlight = true; + logger.debug('Explicit payment offer accepted; invoking approval handler', { + requestId: state.id, + optionCount: allowed.length, + }); try { const result = await options.onPaymentRequired({ options: allowed, @@ -401,6 +420,11 @@ export function withClientPayments( lifecycle.approve(state, [option]); const attempt = attempts.reserve([option], state.controller.signal); state.paymentInFlight = true; + logger.debug('Transparent payment offer accepted', { + requestId: state.id, + pmi: option.pmi, + amount: option.amount, + }); const req: PaymentHandlerRequest = { amount: option.amount, pmi: option.pmi, @@ -451,6 +475,11 @@ export function withClientPayments( ); } walletStarted = true; + logger.debug('Invoking payment handler', { + requestId: state.id, + pmi: option.pmi, + amount: option.amount, + }); await handler.handle(req); if (lifecycle.isActive(state)) attempt.complete('handled'); } catch (error: unknown) { diff --git a/src/transport/payment-client-reliability.e2e.test.ts b/src/transport/payment-client-reliability.e2e.test.ts index 169980e..fd253be 100644 --- a/src/transport/payment-client-reliability.e2e.test.ts +++ b/src/transport/payment-client-reliability.e2e.test.ts @@ -57,12 +57,12 @@ describe('instant explicit payment retry delivery', () => { let invoices = 0; let approvals = 0; const processor: PaymentProcessor = { - pmi: 'fake', + pmi: 'pmi:A', async createPaymentRequired(params) { invoices++; return { amount: params.amount, - pmi: 'fake', + pmi: 'pmi:A', pay_req: `invoice-${invoices}`, ttl: 60, }; @@ -112,7 +112,9 @@ describe('instant explicit payment retry delivery', () => { const paid = withClientPayments(base, { paymentInteraction: 'explicit_gating', minRetryDelayMs: 0, - async onPaymentRequired() { + limits: { maxAmountByPmi: { 'pmi:A': 5 } }, + async onPaymentRequired({ options }) { + expect(options[0]?.pmi).toBe('pmi:A'); approvals++; return { paid: true }; }, From 96ad5b6d153c2aba3ec4ee378acef1e94edf48c9 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Tue, 27 Oct 2026 02:39:13 +0530 Subject: [PATCH 4/6] refactor(payments): defer PostgreSQL backend from core state fixes --- .github/workflows/test.yml | 14 - bun.lock | 36 -- package.json | 14 - src/__mocks__/payment-state-worker.ts | 49 --- src/payments/in-memory-payment-state-store.ts | 1 + src/payments/postgres/index.ts | 5 - src/payments/postgres/migrations.ts | 23 -- .../postgres-payment-state-store.test.ts | 322 ------------------ .../postgres/postgres-payment-state-store.ts | 262 -------------- src/payments/server-payments.ts | 2 +- src/payments/server-transport-payments.ts | 2 + .../payment-state-durability.e2e.test.ts | 195 ----------- 12 files changed, 4 insertions(+), 921 deletions(-) delete mode 100644 src/__mocks__/payment-state-worker.ts delete mode 100644 src/payments/postgres/index.ts delete mode 100644 src/payments/postgres/migrations.ts delete mode 100644 src/payments/postgres/postgres-payment-state-store.test.ts delete mode 100644 src/payments/postgres/postgres-payment-state-store.ts delete mode 100644 src/transport/payment-state-durability.e2e.test.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 4d99401..cab0d58 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,19 +10,6 @@ on: jobs: test: runs-on: ubuntu-latest - services: - postgres: - image: postgres:17.7-bookworm - env: - POSTGRES_PASSWORD: contextvm-test - POSTGRES_DB: contextvm_test - ports: - - 5432:5432 - options: >- - --health-cmd "pg_isready -U postgres -d contextvm_test" - --health-interval 5s - --health-timeout 5s - --health-retries 10 steps: - uses: actions/checkout@v4 @@ -38,5 +25,4 @@ jobs: - name: Run tests env: NODE_ENV: test - PAYMENT_TEST_DATABASE_URL: postgres://postgres:contextvm-test@127.0.0.1:5432/contextvm_test run: bun test --concurrent --timeout=60000 diff --git a/bun.lock b/bun.lock index 281f926..2562102 100644 --- a/bun.lock +++ b/bun.lock @@ -19,21 +19,13 @@ "@changesets/cli": "^2.31.1", "@eslint/js": "^9.39.5", "@types/bun": "^1.4.2", - "@types/pg": "^8.23.1", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", - "pg": "^8.23.1", "prettier": "^3.9.6", "typescript": "^5.9.3", "typescript-eslint": "^8.70.0", }, - "peerDependencies": { - "pg": "^8.23.1", - }, - "optionalPeers": [ - "pg", - ], }, }, "packages": { @@ -141,8 +133,6 @@ "@types/node": ["@types/node@26.5.0", "", { "dependencies": { "undici-types": "~8.9.0" } }, "sha512-dVSGpriSoCgz8WnDNTuSSuSv1PC/ALXihO4ulRZt7Md8k9mlbdin3lGOcDE8SnWOgf513ByWlXd7BK4azmyg/A=="], - "@types/pg": ["@types/pg@8.23.1", "", { "dependencies": { "@types/node": "*", "pg-protocol": "*", "pg-types": "^2.2.0" } }, "sha512-fKVHpikPdg4GKks3JuLEhvwSyvwzF23hnabPy6DD8ljVbC7+6J5dQzdv4arV6jqq57djnMgs1HKBxX4P8aBI3A=="], - "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.70.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/type-utils": "8.70.0", "@typescript-eslint/utils": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.70.0", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-/v8HZt6RlyIZxB3ntehELOcUcfxKPVGWXnQdJuHRmzrqgF8nQypcC/oxGW+Ot4VGKDq81XugPKxx0n5PBtf9PA=="], "@typescript-eslint/parser": ["@typescript-eslint/parser@8.70.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.70.0", "@typescript-eslint/types": "8.70.0", "@typescript-eslint/typescript-estree": "8.70.0", "@typescript-eslint/visitor-keys": "8.70.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-zYvrmj9Yxd63UGaXw+kdt6A0F0s0qveJyuatIM77bYC2DE4pgmg7a50u8LR7PRtXd0x+h+Tl3eXabGm06SWd3Q=="], @@ -537,22 +527,6 @@ "path-type": ["path-type@4.0.0", "", {}, "sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw=="], - "pg": ["pg@8.23.1", "", { "dependencies": { "pg-connection-string": "^2.14.1", "pg-pool": "^3.14.0", "pg-protocol": "^1.16.1", "pg-types": "2.2.0", "pgpass": "1.0.5" }, "optionalDependencies": { "pg-cloudflare": "^1.4.1" }, "peerDependencies": { "pg-native": ">=3.0.1" }, "optionalPeers": ["pg-native"] }, "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ=="], - - "pg-cloudflare": ["pg-cloudflare@1.4.1", "", {}, "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ=="], - - "pg-connection-string": ["pg-connection-string@2.14.1", "", {}, "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA=="], - - "pg-int8": ["pg-int8@1.0.1", "", {}, "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw=="], - - "pg-pool": ["pg-pool@3.14.0", "", { "peerDependencies": { "pg": ">=8.0" } }, "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw=="], - - "pg-protocol": ["pg-protocol@1.16.1", "", {}, "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg=="], - - "pg-types": ["pg-types@2.2.0", "", { "dependencies": { "pg-int8": "1.0.1", "postgres-array": "~2.0.0", "postgres-bytea": "~1.0.0", "postgres-date": "~1.0.4", "postgres-interval": "^1.1.0" } }, "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA=="], - - "pgpass": ["pgpass@1.0.5", "", { "dependencies": { "split2": "^4.1.0" } }, "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug=="], - "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="], @@ -567,14 +541,6 @@ "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], - "postgres-array": ["postgres-array@2.0.0", "", {}, "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA=="], - - "postgres-bytea": ["postgres-bytea@1.0.1", "", {}, "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ=="], - - "postgres-date": ["postgres-date@1.0.7", "", {}, "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q=="], - - "postgres-interval": ["postgres-interval@1.2.0", "", { "dependencies": { "xtend": "^4.0.0" } }, "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ=="], - "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prettier": ["prettier@3.9.6", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g=="], @@ -719,8 +685,6 @@ "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - "xtend": ["xtend@4.0.2", "", {}, "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ=="], - "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], "zod": ["zod@4.5.4", "", {}, "sha512-sC95tT5iHHH9gtpj6A81kh+NEaRAUFN+qlUPDUbRfOMvNf5QCBqsb3WgvnpVtK5Y+4UfA6KqufotuTvMGiTlsA=="], diff --git a/package.json b/package.json index 4083ae7..874a47e 100644 --- a/package.json +++ b/package.json @@ -72,10 +72,6 @@ "./payments/*": { "types": "./dist/esm/payments/*.d.ts", "default": "./dist/esm/payments/*.js" - }, - "./payments/postgres": { - "types": "./dist/esm/payments/postgres/index.d.ts", - "default": "./dist/esm/payments/postgres/index.js" } }, "types": "./dist/esm/index.d.ts", @@ -96,11 +92,9 @@ "@changesets/cli": "^2.31.1", "@eslint/js": "^9.39.5", "@types/bun": "^1.4.2", - "@types/pg": "^8.23.1", "eslint": "^9.39.5", "eslint-config-prettier": "^10.1.8", "eslint-plugin-import": "^2.32.0", - "pg": "^8.23.1", "prettier": "^3.9.6", "typescript": "^5.9.3", "typescript-eslint": "^8.70.0" @@ -115,13 +109,5 @@ "rxjs": "^7.8.2", "ws": "^8.21.3", "zod": "^4.5.4" - }, - "peerDependencies": { - "pg": "^8.23.1" - }, - "peerDependenciesMeta": { - "pg": { - "optional": true - } } } diff --git a/src/__mocks__/payment-state-worker.ts b/src/__mocks__/payment-state-worker.ts deleted file mode 100644 index 91505de..0000000 --- a/src/__mocks__/payment-state-worker.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { Pool } from 'pg'; -import { PostgresPaymentStateStore } from '../payments/postgres/index.js'; -import { - paymentAuthorizationKey, - paymentEventKey, -} from '../payments/payment-state-store.js'; - -const [action, namespace] = process.argv.slice(2); -const pool = new Pool({ - connectionString: process.env.PAYMENT_TEST_DATABASE_URL, - max: 1, -}); -const input = { - key: - action === 'claim' - ? paymentAuthorizationKey('client', 'hash') - : paymentEventKey('client', 'event-1'), - mode: - action === 'claim' - ? ('explicit_gating' as const) - : ('transparent' as const), - clientPubkey: 'client', - invocationHash: 'hash', - requestEventId: action === 'claim' ? 'worker-retry' : 'event-1', - leaseMs: 10000, -}; -const store = new PostgresPaymentStateStore({ pool, namespace }); -await store.initialize(); -if (action === 'claim') { - console.log(JSON.stringify(await store.admit(input))); - await pool.end(); -} else if (action === 'invoice') { - const result = await store.admit(input); - if (result.status !== 'reserved') - throw new Error('Worker admission was refused'); - const record = result.record; - const fence = { key: record.key, generation: record.generation }; - await store.transition({ ...fence, type: 'issuing' }); - await store.transition({ - ...fence, - type: 'bind', - invoice: { pmi: 'fake', amount: 10, pay_req: 'persisted-worker-invoice' }, - leaseMs: 10000, - }); - console.log(JSON.stringify(await store.read(record.key))); - setInterval(() => {}, 1000); -} else { - throw new Error('Unknown payment test worker action'); -} diff --git a/src/payments/in-memory-payment-state-store.ts b/src/payments/in-memory-payment-state-store.ts index 8269fe2..886a3a5 100644 --- a/src/payments/in-memory-payment-state-store.ts +++ b/src/payments/in-memory-payment-state-store.ts @@ -19,6 +19,7 @@ let memoryStoreSequence = 0; /** Single-process default; live grants and uncertain outcomes are never evicted. */ export class InMemoryPaymentStateStore implements PaymentStateStore { public readonly policy: PaymentStatePolicy; + // Collect by replay deadlines, not LRU recency: evicting a tombstone can repeat payment or execution. private readonly records = new Map(); private observedAtMs = 0; private readonly storeSequence = ++memoryStoreSequence; diff --git a/src/payments/postgres/index.ts b/src/payments/postgres/index.ts deleted file mode 100644 index 8e07f05..0000000 --- a/src/payments/postgres/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -/** Explicit server-only entrypoint; never re-export from shared payment modules. */ -export { - PostgresPaymentStateStore, - type PostgresPaymentStateStoreOptions, -} from './postgres-payment-state-store.js'; diff --git a/src/payments/postgres/migrations.ts b/src/payments/postgres/migrations.ts deleted file mode 100644 index 646a33b..0000000 --- a/src/payments/postgres/migrations.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** Initial durable payment schema; initialization runs under a migration lock. */ -export const PAYMENT_STATE_SCHEMA_VERSION = 1; - -/** PostgreSQL schema shared by all payment namespaces. */ -export const PAYMENT_STATE_MIGRATION = ` -CREATE TABLE IF NOT EXISTS contextvm_payment_namespaces ( - namespace text PRIMARY KEY, - version integer NOT NULL, - policy jsonb NOT NULL, - clock_ms bigint NOT NULL DEFAULT 0 -); -CREATE TABLE IF NOT EXISTS contextvm_payment_records ( - namespace text NOT NULL REFERENCES contextvm_payment_namespaces(namespace), - key text NOT NULL, - phase text NOT NULL, - lease_expires_ms bigint NOT NULL, - retain_until_ms bigint, - record jsonb NOT NULL, - PRIMARY KEY (namespace, key) -); -CREATE INDEX IF NOT EXISTS contextvm_payment_retention - ON contextvm_payment_records(namespace, retain_until_ms); -`; diff --git a/src/payments/postgres/postgres-payment-state-store.test.ts b/src/payments/postgres/postgres-payment-state-store.test.ts deleted file mode 100644 index 41f4e1d..0000000 --- a/src/payments/postgres/postgres-payment-state-store.test.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { Pool, type PoolClient } from 'pg'; -import { PostgresPaymentStateStore } from './postgres-payment-state-store.js'; -import { - admission, - bind, - paymentStoreContract, -} from '../../__mocks__/payment-store-contract.js'; -import type { PaymentStateRecord } from '../payment-state-store.js'; - -const connectionString = process.env.PAYMENT_TEST_DATABASE_URL; -const databaseTest = connectionString ? test : test.skip; -const databaseDescribe = connectionString ? describe : describe.skip; - -function createPool(): Pool { - return new Pool({ - connectionString, - max: 2, - connectionTimeoutMillis: 3000, - query_timeout: 5000, - }); -} - -if (connectionString) - paymentStoreContract('PostgreSQL atomic payment store', async (policy) => { - const namespace = `contract-${crypto.randomUUID()}`; - const pool = createPool(); - const otherPool = createPool(); - const store = new PostgresPaymentStateStore({ pool, namespace, ...policy }); - const peer = new PostgresPaymentStateStore({ - pool: otherPool, - namespace, - ...policy, - }); - try { - await store.initialize(); - await peer.initialize(); - } catch (error) { - await Promise.all([pool.end(), otherPool.end()]); - throw error; - } - return { - store, - peer, - advance: async (ms) => { - await Bun.sleep(ms); - }, - close: async () => { - await Promise.all([pool.end(), otherPool.end()]); - }, - }; - }); - -async function withDatabase( - run: ( - store: PostgresPaymentStateStore, - pool: Pool, - namespace: string, - ) => Promise, -): Promise { - const pool = createPool(); - const namespace = `integration-${crypto.randomUUID()}`; - const store = new PostgresPaymentStateStore({ pool, namespace }); - try { - await store.initialize(); - await run(store, pool, namespace); - } finally { - await pool.end(); - } -} - -function worker( - action: string, - namespace: string, -): ReturnType { - return Bun.spawn( - [ - process.execPath, - 'src/__mocks__/payment-state-worker.ts', - action, - namespace, - ], - { - env: { ...process.env, PAYMENT_TEST_DATABASE_URL: connectionString }, - stdout: 'pipe', - stderr: 'pipe', - }, - ); -} - -databaseDescribe('PostgreSQL recovery and failure boundaries', () => { - databaseTest( - 'the admission clock floor survives reconnects and prevents old-event readmission', - async () => - withDatabase(async (store, pool, namespace) => { - const createdAt = Math.floor(Date.now() / 1000); - const floor = Date.now() + store.policy.replayWindowMs + 1000; - await pool.query( - 'UPDATE contextvm_payment_namespaces SET clock_ms = $2 WHERE namespace = $1', - [namespace, floor], - ); - const restarted = new PostgresPaymentStateStore({ pool, namespace }); - await restarted.initialize(); - const input = admission('transparent'); - expect( - ( - await restarted.admit({ - ...input, - event: { - id: input.requestEventId, - author: input.clientPubkey, - createdAt, - }, - }) - ).status, - ).toBe('stale'); - }), - ); - - databaseTest( - 'two separate worker processes have one grant consumer', - async () => - withDatabase(async (store, _pool, namespace) => { - const input = admission(); - const record = await bind(store, input); - await store.transition({ - key: input.key, - generation: record.generation, - type: 'grant', - ttlMs: 10000, - }); - const children = [ - worker('claim', namespace), - worker('claim', namespace), - ]; - const outputs = await Promise.all( - children.map(async (child) => { - const text = await new Response( - child.stdout as ReadableStream, - ).text(); - const error = await new Response( - child.stderr as ReadableStream, - ).text(); - expect(await child.exited, error).toBe(0); - return JSON.parse(text) as { status: string }; - }), - ); - expect(outputs.map((result) => result.status).sort()).toEqual([ - 'claimed', - 'duplicate', - ]); - }), - ); - - databaseTest( - 'killing the invoice worker and reconnecting preserves its binding and replay tombstone', - async () => - withDatabase(async (store, _pool, namespace) => { - const child = worker('invoice', namespace); - try { - const reader = ( - child.stdout as ReadableStream - ).getReader(); - const chunk = await reader.read(); - const record = JSON.parse( - new TextDecoder().decode(chunk.value), - ) as PaymentStateRecord; - reader.releaseLock(); - child.kill('SIGKILL'); - await child.exited; - const pool = createPool(); - try { - const restarted = new PostgresPaymentStateStore({ - pool, - namespace, - }); - await restarted.initialize(); - expect((await restarted.read(record.key))?.invoice).toEqual( - record.invoice, - ); - expect( - (await restarted.admit(admission('transparent'))).status, - ).toBe('duplicate'); - expect((await store.read(record.key))?.generation).toBe( - record.generation, - ); - } finally { - await pool.end(); - } - } finally { - child.kill(); - await child.exited; - } - }), - ); - - databaseTest( - 'a new store recovers a paid grant and preserves the consumed result', - async () => - withDatabase(async (store, _pool, namespace) => { - const input = admission(); - const record = await bind(store, input); - await store.transition({ - key: input.key, - generation: record.generation, - type: 'grant', - ttlMs: 10000, - }); - const pool = createPool(); - try { - const restarted = new PostgresPaymentStateStore({ pool, namespace }); - await restarted.initialize(); - const retry = { ...input, requestEventId: 'retry-after-restart' }; - expect((await restarted.admit(retry)).status).toBe('claimed'); - expect((await store.admit(retry)).status).toBe('duplicate'); - } finally { - await pool.end(); - } - }), - ); - - databaseTest('refuses mismatched policies and schema versions', async () => - withDatabase(async (_store, pool, namespace) => { - const mismatch = new PostgresPaymentStateStore({ - pool, - namespace, - maxPendingPayments: 1, - }); - await expect(mismatch.initialize()).rejects.toThrow('mismatch'); - await pool.query( - 'UPDATE contextvm_payment_namespaces SET version = 999 WHERE namespace = $1', - [namespace], - ); - const future = new PostgresPaymentStateStore({ pool, namespace }); - await expect(future.initialize()).rejects.toThrow('mismatch'); - }), - ); - - databaseTest( - 'an unavailable database propagates instead of using empty memory state', - async () => { - const pool = createPool(); - const store = new PostgresPaymentStateStore({ - pool, - namespace: `closed-${crypto.randomUUID()}`, - }); - await store.initialize(); - await pool.end(); - await expect(store.admit(admission())).rejects.toThrow(); - await expect(store.read(admission().key)).rejects.toThrow(); - }, - ); - - databaseTest( - 'lost commit acknowledgement leaves admission reserved and does not replay it', - async () => - withDatabase(async (store, pool, namespace) => { - const fault = acknowledgementFault(pool); - const uncertain = new PostgresPaymentStateStore({ - pool: fault.pool, - namespace, - }); - await uncertain.initialize(); - fault.arm(); - await expect(uncertain.admit(admission())).rejects.toThrow( - 'commit acknowledgement lost', - ); - expect((await store.admit(admission())).status).toBe('pending'); - }), - ); - - databaseTest( - 'lost grant acknowledgement cannot clear a committed paid grant', - async () => - withDatabase(async (store, pool, namespace) => { - const input = admission(); - const record = await bind(store, input); - const fault = acknowledgementFault(pool); - const uncertain = new PostgresPaymentStateStore({ - pool: fault.pool, - namespace, - }); - await uncertain.initialize(); - fault.arm(); - const fence = { key: input.key, generation: record.generation }; - await expect( - uncertain.transition({ ...fence, type: 'grant', ttlMs: 10000 }), - ).rejects.toThrow('commit acknowledgement lost'); - expect(await store.transition({ ...fence, type: 'expire' })).toBe( - false, - ); - expect( - (await store.admit({ ...input, requestEventId: 'retry' })).status, - ).toBe('claimed'); - }), - ); -}); - -function acknowledgementFault(pool: Pool): { pool: Pool; arm: () => void } { - let armed = false; - return { - arm: () => { - armed = true; - }, - pool: { - async connect(): Promise { - const client = await pool.connect(); - return { - query: async (query: string, values?: unknown[]) => { - const result = await client.query(query, values); - if (query === 'COMMIT' && armed) { - armed = false; - throw new Error('commit acknowledgement lost'); - } - return result; - }, - release: (failed?: boolean) => client.release(failed), - } as PoolClient; - }, - } as Pool, - }; -} diff --git a/src/payments/postgres/postgres-payment-state-store.ts b/src/payments/postgres/postgres-payment-state-store.ts deleted file mode 100644 index 9008cd2..0000000 --- a/src/payments/postgres/postgres-payment-state-store.ts +++ /dev/null @@ -1,262 +0,0 @@ -import type { Pool, PoolClient } from 'pg'; -import type { - PaymentStateAdmission, - PaymentStateAdmissionResult, - PaymentStatePolicy, - PaymentStateRecord, - PaymentStateStore, - PaymentStateTransition, -} from '../payment-state-store.js'; -import { - createPaymentStatePolicy, - paymentEventKey, -} from '../payment-state-store.js'; -import { - admitPaymentState, - transitionPaymentState, - type PaymentStateChange, -} from '../payment-state-machine.js'; -import { - PAYMENT_STATE_MIGRATION, - PAYMENT_STATE_SCHEMA_VERSION, -} from './migrations.js'; - -/** PostgreSQL deployment options. The application retains ownership of its pool. */ -export interface PostgresPaymentStateStoreOptions extends Partial { - pool: Pool; - /** One namespace per server identity/payment policy; shared by all its workers. */ - namespace: string; -} - -/** - * Durable, cross-worker payment state. Short transactions serialize admission - * within one namespace; provider I/O and handlers never run while holding locks. - * Call initialize() before attaching this store. Failed/ambiguous transactions - * propagate without replaying a command or falling back to memory. - * - * @example - * import { Pool } from 'pg'; - * import { PostgresPaymentStateStore } from '@contextvm/sdk/payments/postgres'; - * const pool = new Pool({ connectionString }); - * const store = new PostgresPaymentStateStore({ pool, namespace: serverPubkey }); - * await store.initialize(); - * withServerPayments(transport, { processors, pricedCapabilities, stateStore: store }); - * // The application closes its pool after every worker has stopped using it. - */ -export class PostgresPaymentStateStore implements PaymentStateStore { - public readonly policy: PaymentStatePolicy; - private readonly pool: Pool; - private readonly namespace: string; - private initialized = false; - - public constructor(options: PostgresPaymentStateStoreOptions) { - if (!options.namespace || options.namespace.length > 200) - throw new TypeError('Payment namespace must contain 1–200 characters'); - this.pool = options.pool; - this.namespace = options.namespace; - this.policy = createPaymentStatePolicy(options); - } - - /** Applies the versioned schema and rejects inconsistent worker policies. */ - public async initialize(): Promise { - await this.transaction(async (client) => { - await client.query('SELECT pg_advisory_xact_lock(193608, 8)'); - const schema = await client.query<{ existing: string | null }>( - "SELECT to_regclass('contextvm_payment_namespaces')::text AS existing", - ); - const existing = schema.rows[0]; - if (!existing) throw new Error('Payment schema lookup failed'); - if (!existing.existing) await client.query(PAYMENT_STATE_MIGRATION); - else { - const clock = await client.query<{ present: boolean }>( - `SELECT EXISTS (SELECT 1 FROM pg_attribute WHERE - attrelid = 'contextvm_payment_namespaces'::regclass AND attname = 'clock_ms' AND NOT attisdropped) AS present`, - ); - if (!clock.rows[0]?.present) - await client.query( - 'ALTER TABLE contextvm_payment_namespaces ADD COLUMN clock_ms bigint NOT NULL DEFAULT 0', - ); - } - }, false); - // Release DDL locks before taking a namespace row lock. Repeating schema - // DDL while holding that row can deadlock with a worker writing records. - await this.transaction(async (client) => { - await client.query( - 'INSERT INTO contextvm_payment_namespaces(namespace, version, policy) VALUES ($1, $2, $3::jsonb) ON CONFLICT DO NOTHING', - [ - this.namespace, - PAYMENT_STATE_SCHEMA_VERSION, - JSON.stringify(this.policy), - ], - ); - await this.lockNamespace(client); - }, false); - this.initialized = true; - } - - /** Atomic claim-or-reserve, replay admission, capacity check, and retention. */ - public async admit( - input: PaymentStateAdmission, - ): Promise { - input = structuredClone(input); - return this.transaction(async (client) => { - const { now, generation } = await this.lockNamespace(client); - await client.query( - `DELETE FROM contextvm_payment_records WHERE namespace = $1 AND retain_until_ms <= $2 - AND NOT (phase IN ('reserved', 'issuing', 'pending') AND lease_expires_ms > $2)`, - [this.namespace, now], - ); - const keys = [input.key]; - if (input.mode === 'explicit_gating') - keys.push(paymentEventKey(input.clientPubkey, input.requestEventId)); - const { rows } = await client.query<{ - key: string; - record: PaymentStateRecord; - }>( - `SELECT key, record FROM contextvm_payment_records WHERE namespace = $1 AND - (key = ANY($2::text[]) OR (record->>'clientPubkey' = $3 AND record->'eventIds' ? $4))`, - [this.namespace, keys, input.clientPubkey, input.requestEventId], - ); - const counts = await client.query<{ total: string; pending: string }>( - `SELECT count(*) AS total, - count(*) FILTER (WHERE phase IN ('reserved', 'issuing', 'pending') AND lease_expires_ms > $2) AS pending - FROM contextvm_payment_records WHERE namespace = $1`, - [this.namespace, now], - ); - const usage = counts.rows[0]; - if (!usage) throw new Error('Payment capacity lookup failed'); - const change = admitPaymentState( - input, - { - record: rows.find((row) => row.key === input.key)?.record, - execution: rows.find((row) => row.key !== input.key)?.record, - total: Number(usage.total), - pending: Number(usage.pending), - }, - this.policy, - now, - generation, - ); - await this.write(client, change); - return change.result; - }); - } - - /** Persists only transitions belonging to the current live attempt. */ - public async transition(input: PaymentStateTransition): Promise { - input = structuredClone(input); - return this.transaction(async (client) => { - const { now } = await this.lockNamespace(client); - const { rows } = await client.query<{ record: PaymentStateRecord }>( - 'SELECT record FROM contextvm_payment_records WHERE namespace = $1 AND key = $2', - [this.namespace, input.key], - ); - const change = transitionPaymentState( - input, - rows[0]?.record, - now, - this.policy, - ); - await this.write(client, change); - return change.result; - }); - } - - /** Reads persisted recovery state; an unavailable database is an error. */ - public async read(key: string): Promise { - return this.transaction(async (client) => { - await this.lockNamespace(client); - const { rows } = await client.query<{ record: PaymentStateRecord }>( - 'SELECT record FROM contextvm_payment_records WHERE namespace = $1 AND key = $2', - [this.namespace, key], - ); - return rows[0]?.record; - }); - } - - private async lockNamespace( - client: PoolClient, - ): Promise<{ now: number; generation: string }> { - const { rows } = await client.query<{ - version: number; - policy: PaymentStatePolicy; - }>( - 'SELECT version, policy FROM contextvm_payment_namespaces WHERE namespace = $1 FOR UPDATE', - [this.namespace], - ); - const namespace = rows[0]; - if ( - !namespace || - namespace.version !== PAYMENT_STATE_SCHEMA_VERSION || - Object.entries(this.policy).some( - ([key, value]) => - namespace.policy[key as keyof PaymentStatePolicy] !== value, - ) - ) - throw new Error('Payment namespace schema or policy mismatch'); - const clock = await client.query<{ now: string; generation: string }>( - `UPDATE contextvm_payment_namespaces SET clock_ms = GREATEST(clock_ms, - floor(extract(epoch FROM clock_timestamp()) * 1000)::bigint) - WHERE namespace = $1 RETURNING clock_ms AS now, gen_random_uuid()::text AS generation`, - [this.namespace], - ); - const now = Number(clock.rows[0]?.now); - if (!Number.isSafeInteger(now)) - throw new Error('Payment database clock lookup failed'); - const generation = clock.rows[0]?.generation; - if (!generation) throw new Error('Payment generation allocation failed'); - return { now, generation }; - } - - private async write( - client: PoolClient, - change: PaymentStateChange, - ): Promise { - for (const key of change.remove) - await client.query( - 'DELETE FROM contextvm_payment_records WHERE namespace = $1 AND key = $2', - [this.namespace, key], - ); - for (const record of change.put) - await client.query( - `INSERT INTO contextvm_payment_records(namespace, key, phase, lease_expires_ms, retain_until_ms, record) - VALUES ($1, $2, $3, $4, $5, $6::jsonb) ON CONFLICT(namespace, key) DO UPDATE SET - phase = EXCLUDED.phase, lease_expires_ms = EXCLUDED.lease_expires_ms, - retain_until_ms = EXCLUDED.retain_until_ms, record = EXCLUDED.record`, - [ - this.namespace, - record.key, - record.phase, - record.leaseExpiresAtMs, - record.retainUntilMs, - JSON.stringify(record), - ], - ); - } - - private async transaction( - run: (client: PoolClient) => Promise, - requireInitialized = true, - ): Promise { - if (requireInitialized && !this.initialized) - throw new Error('Initialize the PostgreSQL payment store first'); - const client = await this.pool.connect(); - let failed = false; - try { - await client.query('BEGIN ISOLATION LEVEL READ COMMITTED'); - const result = await run(client); - await client.query('COMMIT'); - return result; - } catch (error) { - failed = true; - try { - await client.query('ROLLBACK'); - } catch { - /* Preserve the original, potentially ambiguous error. */ - } - throw error; - } finally { - client.release(failed); - } - } -} diff --git a/src/payments/server-payments.ts b/src/payments/server-payments.ts index f505a1f..0fd9a89 100644 --- a/src/payments/server-payments.ts +++ b/src/payments/server-payments.ts @@ -42,7 +42,7 @@ export interface ServerPaymentsOptions extends Partial { paymentTtlMs?: number; /** Lifetime of a verified execution grant, measured from settlement. @default paymentTtlMs */ authorizationTtlMs?: number; - /** Injectable backend. Initialize durable stores before attaching the transport. */ + /** Injectable atomic store. The default is process-local and loses state on restart. */ stateStore?: PaymentStateStore; /** Accepted interaction modes. @default 'optional' */ paymentInteraction?: PaymentInteractionPolicy; diff --git a/src/payments/server-transport-payments.ts b/src/payments/server-transport-payments.ts index 94631a1..be4a979 100644 --- a/src/payments/server-transport-payments.ts +++ b/src/payments/server-transport-payments.ts @@ -13,6 +13,8 @@ import { NOSTR_TAGS } from '../core/constants.js'; /** * Attaches CEP-8 payments gating to a NostrServerTransport. + * One paying server identity must run in one process with the default in-memory + * store. It cannot coordinate payments across processes or retain state after restart. * * By default the server uses the `optional` policy: it advertises * `explicit_gating` support and mirrors each client's requested lifecycle, so a diff --git a/src/transport/payment-state-durability.e2e.test.ts b/src/transport/payment-state-durability.e2e.test.ts deleted file mode 100644 index 8189241..0000000 --- a/src/transport/payment-state-durability.e2e.test.ts +++ /dev/null @@ -1,195 +0,0 @@ -import { expect, test } from 'bun:test'; -import { Pool } from 'pg'; -import { Client } from '@contextvm/mcp-sdk/client'; -import { McpServer } from '@contextvm/mcp-sdk/server/mcp'; -import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; -import { bytesToHex } from 'nostr-tools/utils'; -import { z } from 'zod'; -import type { NostrEvent } from 'nostr-tools'; -import { withTimeout } from '../core/utils/utils.js'; -import { MockRelayHub } from '../__mocks__/mock-relay-handler.js'; -import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; -import { PrivateKeySigner } from '../signer/private-key-signer.js'; -import { NostrClientTransport } from './nostr-client-transport.js'; -import { NostrServerTransport } from './nostr-server-transport.js'; -import { withClientPayments, withServerPayments } from '../payments/index.js'; -import { PostgresPaymentStateStore } from '../payments/postgres/index.js'; -import type { - PaymentProcessor, - PaymentStateRecord, -} from '../payments/index.js'; - -const connectionString = process.env.PAYMENT_TEST_DATABASE_URL; -const databaseTest = connectionString ? test : test.skip; -const modes = [ - { - name: 'cleartext', - encryptionMode: EncryptionMode.DISABLED, - giftWrapMode: GiftWrapMode.OPTIONAL, - }, - { - name: 'persistent encryption', - encryptionMode: EncryptionMode.REQUIRED, - giftWrapMode: GiftWrapMode.PERSISTENT, - }, - { - name: 'ephemeral encryption', - encryptionMode: EncryptionMode.REQUIRED, - giftWrapMode: GiftWrapMode.EPHEMERAL, - }, -]; - -for (const mode of modes) - databaseTest( - `durable transparent payments: two workers and replay after TTL/restart over ${mode.name}`, - async () => { - const pool = new Pool({ - connectionString, - max: 4, - connectionTimeoutMillis: 3000, - query_timeout: 5000, - }); - const namespace = `e2e-${crypto.randomUUID()}`; - const hub = new MockRelayHub(); - const serverKey = generateSecretKey(); - const clientKey = generateSecretKey(); - const clientPubkey = getPublicKey(clientKey); - let invoices = 0; - let approvals = 0; - let verifications = 0; - let executions = 0; - let admittedRecord: PaymentStateRecord | undefined; - const processor: PaymentProcessor = { - pmi: 'fake', - currencyUnit: 'sats', - async createPaymentRequired(input) { - invoices++; - return { - amount: input.amount, - pmi: 'fake', - pay_req: `invoice-${invoices}`, - ttl: 2, - }; - }, - async verifyPayment() { - verifications++; - return {}; - }, - }; - const servers: McpServer[] = []; - const createWorker = async ( - onReplay?: () => void, - ): Promise => { - const store = new PostgresPaymentStateStore({ pool, namespace }); - await store.initialize(); - const admit = store.admit.bind(store); - store.admit = async (input) => { - const result = await admit(input); - if (result.status === 'reserved') admittedRecord = result.record; - if (result.status === 'duplicate') onReplay?.(); - return result; - }; - const server = new McpServer({ - name: 'durable-worker', - version: '1.0.0', - }); - server.registerTool( - 'paid', - { inputSchema: { value: z.number() } }, - async ({ value }) => { - executions++; - return { content: [{ type: 'text', text: String(value) }] }; - }, - ); - servers.push(server); - const transport = withServerPayments( - new NostrServerTransport({ - signer: new PrivateKeySigner(bytesToHex(serverKey)), - relayHandler: hub.createRelayHandler(), - encryptionMode: mode.encryptionMode, - giftWrapMode: mode.giftWrapMode, - }), - { - stateStore: store, - paymentTtlMs: 2000, - processors: [processor], - pricedCapabilities: [ - { - method: 'tools/call', - name: 'paid', - amount: 5, - currencyUnit: 'sats', - }, - ], - }, - ); - await server.connect(transport); - return transport; - }; - const clientEvents: NostrEvent[] = []; - const clientRelay = hub.createRelayHandler(); - const publish = clientRelay.publish.bind(clientRelay); - clientRelay.publish = async (event, options) => { - clientEvents.push(event); - await publish(event, options); - }; - const client = new Client({ - name: 'durable-payment-client', - version: '1.0.0', - }); - try { - await createWorker(); - await createWorker(); - const base = new NostrClientTransport({ - signer: new PrivateKeySigner(bytesToHex(clientKey)), - relayHandler: clientRelay, - serverPubkey: getPublicKey(serverKey), - encryptionMode: mode.encryptionMode, - giftWrapMode: mode.giftWrapMode, - }); - await client.connect( - withClientPayments(base, { - handlers: [ - { - pmi: 'fake', - async handle() { - approvals++; - }, - }, - ], - }), - ); - const result = await client.callTool( - { name: 'paid', arguments: { value: 7 } }, - undefined, - { timeout: 5000 }, - ); - expect(result.content).toEqual([{ type: 'text', text: '7' }]); - expect([invoices, approvals, verifications, executions]).toEqual([ - 1, 1, 1, 1, - ]); - expect(admittedRecord?.clientPubkey).toBe(clientPubkey); - await Promise.all(servers.splice(0).map((server) => server.close())); - await Bun.sleep( - Math.max(0, admittedRecord!.leaseExpiresAtMs - Date.now() + 25), - ); - let replayed!: () => void; - const replay = new Promise((resolve) => { - replayed = resolve; - }); - const restartedTransport = await createWorker(replayed); - // Deliver a relay replay directly into the real verification/decryption pipeline, - // independently of the new subscription's narrower `since` filter. - for (const event of clientEvents) - await restartedTransport['processIncomingEvent'](event); - await withTimeout(replay, 3000, 'Retained request was not replayed'); - expect([invoices, approvals, verifications, executions]).toEqual([ - 1, 1, 1, 1, - ]); - } finally { - await client.close(); - await Promise.all(servers.map((server) => server.close())); - await pool.end(); - } - }, - ); From 62d54d2b776ae797dd0df2f10f6fd28ed1ab1cb3 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Tue, 27 Oct 2026 02:45:43 +0530 Subject: [PATCH 5/6] fix(payments): align bounded PMIs and clarify expired grants --- .changeset/atomic-payment-state.md | 13 ++++ src/payments/client-payment-validation.ts | 35 +++------ src/payments/payment-option-schema.test.ts | 83 +++++++++++++++++++++ src/payments/payment-option-schema.ts | 3 +- src/payments/server-explicit-gating.test.ts | 3 + src/payments/server-explicit-gating.ts | 4 +- src/payments/server-payment-validation.ts | 7 +- src/payments/server-payments-utils.ts | 5 +- 8 files changed, 120 insertions(+), 33 deletions(-) create mode 100644 .changeset/atomic-payment-state.md create mode 100644 src/payments/payment-option-schema.test.ts diff --git a/.changeset/atomic-payment-state.md b/.changeset/atomic-payment-state.md new file mode 100644 index 0000000..da18229 --- /dev/null +++ b/.changeset/atomic-payment-state.md @@ -0,0 +1,13 @@ +--- +'@contextvm/sdk': minor +--- + +Prevent duplicate payment offers and stale authorization changes using atomic, generation-fenced payment state and a retained transparent-request replay ledger. + +**Breaking change:** `AuthorizationStore` now implements the asynchronous `PaymentStateStore` contract (`admit`, `transition`, and `read`). Replace the former synchronous grant/claim/pending/clear methods and `maxEntries` option with the atomic store operations and `PaymentStatePolicy` limits. Inject the store through `ServerPaymentsOptions.stateStore`; both payment modes must use the same store. + +The supplied store is in memory: one paying server identity must run in one process with a shared store. Restarts lose its invoice bindings, grants, and replay records. PostgreSQL support is deferred to a separate backend branch. + +Server quotes and processor output are validated before payment or execution. Expired paid grants return `payment_expired` with instructions to reconcile the payment rather than request another payment automatically. + +Client and server boundaries share a non-empty, 2,048-character PMI schema. Identifiers are opaque and matched exactly, preserving custom rails and supporting HTTPS method identifiers without adding a protocol grammar restriction. diff --git a/src/payments/client-payment-validation.ts b/src/payments/client-payment-validation.ts index ea23e03..ec4aed7 100644 --- a/src/payments/client-payment-validation.ts +++ b/src/payments/client-payment-validation.ts @@ -6,13 +6,14 @@ import type { PaymentRequiredErrorData, } from './types.js'; +import { + paymentAmountSchema as amountSchema, + paymentPmiSchema as pmiSchema, + paymentOptionSchema, +} from './payment-option-schema.js'; +export { paymentOptionSchema as clientPaymentOptionSchema } from './payment-option-schema.js'; + const MAX_TIMER_MS = 2_147_483_647; -const amountSchema = z - .number() - .finite() - .nonnegative() - .max(Number.MAX_SAFE_INTEGER); -const pmiSchema = z.string().min(1); const timerSchema = z.number().finite().positive().max(MAX_TIMER_MS); /** Internal payment failure with a stable application-visible reason. */ @@ -26,28 +27,10 @@ export class ClientPaymentError extends Error { } } -/** Runtime offer schema; unknown extension metadata is preserved. */ -export const clientPaymentOptionSchema = z.object({ - amount: amountSchema, - pmi: pmiSchema, - pay_req: z - .string() - .min(1) - .refine((value) => value.trim().length > 0), - description: z.string().optional(), - ttl: z - .number() - .finite() - .positive() - .max(Number.MAX_SAFE_INTEGER / 1000) - .optional(), - _meta: z.record(z.string(), z.unknown()).optional(), -}); - const requiredSchema = z.object({ instructions: z.string().optional(), payment_options: z - .array(clientPaymentOptionSchema) + .array(paymentOptionSchema) .min(1) .max(100) .refine((options) => { @@ -81,7 +64,7 @@ function parse(schema: z.ZodType, input: unknown): T { /** Validates a transparent payment offer before invoking policy or wallet code. */ export function parseClientPaymentOption(input: unknown): PaymentOption { - return parse(clientPaymentOptionSchema, input); + return parse(paymentOptionSchema, input); } /** Validates all explicit options, including options that might not be selected. */ diff --git a/src/payments/payment-option-schema.test.ts b/src/payments/payment-option-schema.test.ts new file mode 100644 index 0000000..6c843d7 --- /dev/null +++ b/src/payments/payment-option-schema.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from 'bun:test'; +import { + parseClientPaymentOption, + validateClientPaymentOptions, +} from './client-payment-validation.js'; +import { + validateProcessorPayment, + validateServerPaymentOptions, +} from './server-payment-validation.js'; +import { resolvePaymentProcessor } from './server-payments-utils.js'; +import type { PaymentProcessor } from './types.js'; + +function processor(pmi: string): PaymentProcessor { + return { + pmi, + async createPaymentRequired() { + return { amount: 1, pmi, pay_req: 'invoice' }; + }, + async verifyPayment() { + return {}; + }, + }; +} + +for (const pmi of [ + 'bitcoin-lightning-bolt11', + 'https://wallet.example/method', + 'pmi:A', + 'x'.repeat(2048), +]) { + test(`PMIs match exactly across client and server boundaries (${pmi.slice(0, 45)}, length=${pmi.length})`, () => { + const rail = processor(pmi); + const option = { amount: 1, pmi, pay_req: 'invoice' }; + expect(parseClientPaymentOption(option)).toEqual(option); + expect(() => + validateClientPaymentOptions({ + handlers: [{ pmi, async handle() {} }], + limits: { maxAmountByPmi: { [pmi]: 1 } }, + }), + ).not.toThrow(); + expect(() => + validateServerPaymentOptions({ + processors: [rail], + pricedCapabilities: [], + }), + ).not.toThrow(); + expect(validateProcessorPayment(option, rail, 1)).toEqual(option); + expect(resolvePaymentProcessor([pmi], new Map([[pmi, rail]]), [rail])).toBe( + rail, + ); + expect(() => + resolvePaymentProcessor([`${pmi}-different`], new Map([[pmi, rail]]), [ + rail, + ]), + ).toThrow(); + }); +} + +for (const pmi of ['', 'x'.repeat(2049)]) { + test(`PMI bounds apply to every client and server boundary (length=${pmi.length})`, () => { + const rail = processor(pmi); + const option = { amount: 1, pmi, pay_req: 'invoice' }; + expect(() => parseClientPaymentOption(option)).toThrow(); + expect(() => + validateClientPaymentOptions({ handlers: [{ pmi, async handle() {} }] }), + ).toThrow(); + expect(() => + validateClientPaymentOptions({ + limits: { maxAmountByPmi: { [pmi]: 1 } }, + }), + ).toThrow(); + expect(() => + validateServerPaymentOptions({ + processors: [rail], + pricedCapabilities: [], + }), + ).toThrow(); + expect(() => validateProcessorPayment(option, rail, 1)).toThrow(); + expect(() => + resolvePaymentProcessor([pmi], new Map([[pmi, rail]]), [rail]), + ).toThrow(); + }); +} diff --git a/src/payments/payment-option-schema.ts b/src/payments/payment-option-schema.ts index 69ecb10..793cd6c 100644 --- a/src/payments/payment-option-schema.ts +++ b/src/payments/payment-option-schema.ts @@ -5,7 +5,8 @@ export const paymentAmountSchema = z .finite() .nonnegative() .max(Number.MAX_SAFE_INTEGER); -export const paymentPmiSchema = z.string().regex(/^[a-z0-9-]+$/); +/** Opaque, exact-match identifiers, including HTTPS methods and existing custom rails. */ +export const paymentPmiSchema = z.string().min(1).max(2048); /** Runtime offer schema; unknown extension metadata is preserved. */ export const paymentOptionSchema = z.object({ diff --git a/src/payments/server-explicit-gating.test.ts b/src/payments/server-explicit-gating.test.ts index 1a82566..897e947 100644 --- a/src/payments/server-explicit-gating.test.ts +++ b/src/payments/server-explicit-gating.test.ts @@ -716,6 +716,9 @@ describe('Explicit Gating Middleware', () => { expect(sentResponses).toHaveLength(1); expect(sentResponses[0].error.code).toBe(-32000); expect(sentResponses[0].error.data).toEqual({ reason: 'payment_expired' }); + expect(sentResponses[0].error.message).toContain( + 'contact the server operator', + ); }); test('non-priced capability passes through ungated in explicit gating mode', async () => { diff --git a/src/payments/server-explicit-gating.ts b/src/payments/server-explicit-gating.ts index 2cd37e8..1ae2c7a 100644 --- a/src/payments/server-explicit-gating.ts +++ b/src/payments/server-explicit-gating.ts @@ -111,7 +111,9 @@ export function createExplicitGatingMiddleware( PAYMENT_CAPACITY_ERROR_CODE, admitted.status === 'capacity' ? 'Payment capacity reached, retry later' - : `Payment admission refused: ${admitted.status}`, + : admitted.status === 'expired' + ? 'Paid authorization expired; contact the server operator to reconcile the payment before paying again' + : `Payment admission refused: ${admitted.status}`, { reason: `payment_${admitted.status}` }, ); return; diff --git a/src/payments/server-payment-validation.ts b/src/payments/server-payment-validation.ts index 499a06a..b2a3ed4 100644 --- a/src/payments/server-payment-validation.ts +++ b/src/payments/server-payment-validation.ts @@ -6,7 +6,10 @@ import type { PricedCapability, ResolvePriceResult, } from './types.js'; -import { paymentOptionSchema } from './payment-option-schema.js'; +import { + paymentOptionSchema, + paymentPmiSchema, +} from './payment-option-schema.js'; import { createPaymentStatePolicy, type PaymentStatePolicy, @@ -65,7 +68,7 @@ export function validateServerPaymentOptions( const schema = z.object({ processors: z.array( z.object({ - pmi: z.string().regex(/^[a-z0-9-]+$/), + pmi: paymentPmiSchema, currencyUnit: unit.optional(), }), ), diff --git a/src/payments/server-payments-utils.ts b/src/payments/server-payments-utils.ts index d1a2808..230a2b8 100644 --- a/src/payments/server-payments-utils.ts +++ b/src/payments/server-payments-utils.ts @@ -1,6 +1,7 @@ import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; import { type Logger } from '../core/utils/logger.js'; import { withTimeout } from '../core/utils/utils.js'; +import { paymentPmiSchema } from './payment-option-schema.js'; import { validateProcessorPayment, validateServerQuote, @@ -103,9 +104,7 @@ export function resolvePaymentProcessor( if ( clientPmis !== undefined && (!Array.isArray(clientPmis) || - clientPmis.some( - (pmi) => typeof pmi !== 'string' || !/^[a-z0-9-]+$/.test(pmi), - )) + clientPmis.some((pmi) => !paymentPmiSchema.safeParse(pmi).success)) ) throw new PaymentMethodUnavailableError( 'Invalid client payment method identifier', From b85f302b821ce788de13affc307bb5fbe4d4ce18 Mon Sep 17 00:00:00 2001 From: Khushvendra Date: Tue, 27 Oct 2026 21:38:06 +0530 Subject: [PATCH 6/6] refactor(payments): define application-owned storage with memory default --- .changeset/atomic-payment-state.md | 2 +- src/payments/payment-state-store.ts | 2 ++ .../server-payment-reliability.test.ts | 18 ++++++++++++++---- src/payments/server-payments.ts | 2 +- 4 files changed, 18 insertions(+), 6 deletions(-) diff --git a/.changeset/atomic-payment-state.md b/.changeset/atomic-payment-state.md index da18229..a43019c 100644 --- a/.changeset/atomic-payment-state.md +++ b/.changeset/atomic-payment-state.md @@ -6,7 +6,7 @@ Prevent duplicate payment offers and stale authorization changes using atomic, g **Breaking change:** `AuthorizationStore` now implements the asynchronous `PaymentStateStore` contract (`admit`, `transition`, and `read`). Replace the former synchronous grant/claim/pending/clear methods and `maxEntries` option with the atomic store operations and `PaymentStatePolicy` limits. Inject the store through `ServerPaymentsOptions.stateStore`; both payment modes must use the same store. -The supplied store is in memory: one paying server identity must run in one process with a shared store. Restarts lose its invoice bindings, grants, and replay records. PostgreSQL support is deferred to a separate backend branch. +The SDK bundles only an in-memory store, with no added storage dependencies. One paying server identity must run in one process with a shared store. Restarts lose its invoice bindings, grants, and replay records. Applications needing persistence or cross-process coordination can inject their own `PaymentStateStore` and manage its dependencies and lifecycle. Server quotes and processor output are validated before payment or execution. Expired paid grants return `payment_expired` with instructions to reconcile the payment rather than request another payment automatically. diff --git a/src/payments/payment-state-store.ts b/src/payments/payment-state-store.ts index 236707a..ee30976 100644 --- a/src/payments/payment-state-store.ts +++ b/src/payments/payment-state-store.ts @@ -94,6 +94,8 @@ export type PaymentStateTransition = { * Atomic payment storage contract. Backend errors propagate, including uncertain * commit acknowledgement; callers must not retry external effects or fall back * to a fresh memory store. A consumed execution right is never auto-restored. + * Applications may inject their own implementation through `stateStore` and + * own its dependencies and lifecycle. Only the in-memory implementation is bundled. */ export interface PaymentStateStore { readonly policy: PaymentStatePolicy; diff --git a/src/payments/server-payment-reliability.test.ts b/src/payments/server-payment-reliability.test.ts index ee2d037..10c5e0b 100644 --- a/src/payments/server-payment-reliability.test.ts +++ b/src/payments/server-payment-reliability.test.ts @@ -9,6 +9,8 @@ import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; import { paymentAuthorizationKey, paymentEventKey, + createPaymentStatePolicy, + type PaymentStateStore, } from './payment-state-store.js'; import { computeCanonicalInvocationHash } from './canonical-identity.js'; import type { PaymentProcessor, ServerPaymentsContext } from './types.js'; @@ -311,9 +313,17 @@ describe('server boundaries and transparent replay', () => { }); test(`${mode}: backend admission failure cannot mint an invoice or dispatch`, async () => { - const store = new InMemoryPaymentStateStore(); - store.admit = async () => { - throw new Error('database unavailable'); + const store: PaymentStateStore = { + policy: createPaymentStatePolicy(), + async admit() { + throw new Error('storage unavailable'); + }, + async transition() { + return false; + }, + async read() { + return undefined; + }, }; let sideEffects = 0; const options: ServerPaymentsOptions = { @@ -348,7 +358,7 @@ describe('server boundaries and transparent replay', () => { sideEffects++; }, ), - ).rejects.toThrow('database unavailable'); + ).rejects.toThrow('storage unavailable'); expect(sideEffects).toBe(0); }); } diff --git a/src/payments/server-payments.ts b/src/payments/server-payments.ts index 0fd9a89..3d43020 100644 --- a/src/payments/server-payments.ts +++ b/src/payments/server-payments.ts @@ -42,7 +42,7 @@ export interface ServerPaymentsOptions extends Partial { paymentTtlMs?: number; /** Lifetime of a verified execution grant, measured from settlement. @default paymentTtlMs */ authorizationTtlMs?: number; - /** Injectable atomic store. The default is process-local and loses state on restart. */ + /** Application-owned atomic backend; defaults to process-local memory. The SDK does not initialize or close custom backends. */ stateStore?: PaymentStateStore; /** Accepted interaction modes. @default 'optional' */ paymentInteraction?: PaymentInteractionPolicy;