diff --git a/.changeset/calm-wallets-gate.md b/.changeset/calm-wallets-gate.md new file mode 100644 index 0000000..1f8b9e0 --- /dev/null +++ b/.changeset/calm-wallets-gate.md @@ -0,0 +1,56 @@ +--- +"@contextvm/sdk": minor +--- + +feat: CEP-8 Explicit Payment Gating lifecycle + +Add full support for the CEP-8 Explicit Gating payment interaction mode (`explicit_gating`), +enabling servers to strictly gate priced MCP capabilities behind verifiable payments before +execution. + +**Protocol** + +- Servers and clients negotiate `payment_interaction` mode via Nostr event tags on the first + direct message. Servers disclose their effective mode on the first response event. +- `-32042 Payment Required`: returned with structured `payment_options` (PMI, amount, pay_req, + description, TTL) when a priced capability is invoked without authorization. +- `-32043 Payment Pending`: returned with `retry_after` backoff when a retry races against + active payment verification, preventing invoice-generation spam. +- `-32602 Invalid Params`: returned with `{ requested, supported }` when a client requests + `explicit_gating` on a transparent-only server. + +**Server** + +- New `createExplicitGatingMiddleware` with TTL-bounded `AuthorizationStore` for single-use, + atomic check-and-set execution grants scoped by canonical invocation identity + (SHA-256 over JCS-canonicalized method + params + client pubkey). +- Shared `resolveAndInitiatePayment` pipeline eliminates duplication between transparent and + explicit-gating server middlewares. +- New `PaymentInteractionPolicy` type (`'optional' | 'transparent'`) separates the server-side + policy from the wire-level `PaymentInteractionMode`. `withServerPayments` now defaults to + `'optional'`: a server that accepts payments advertises `explicit_gating` support and mirrors + each client's requested lifecycle, so explicit-gating clients are gated while transparent + clients keep the notification flow. Pass `paymentInteraction: 'transparent'` for a + transparent-only server. + +**Client** + +- `withClientPayments` intercepts `-32042`/`-32043` upstream, delegates to the user's + `onPaymentRequired` handler, and auto-retries the original request with configurable + `maxPendingRetries` and exponential backoff. +- Effective-mode guard prevents auto-satisfying transparent payments when the server rejected + explicit gatingβ€”synthesizes a local `-32000` decline instead. +- An inbound `payment_interaction` tag on a server response is now recorded as the session's + effective mode only when the client itself requested `explicit_gating`. Otherwise the tag is + treated as a server availability advertisement, preventing a transparent client from + incorrectly believing it is on the explicit-gating lifecycle. + +**Backward Compatibility** + +- Wire and client compatible: legacy clients not advertising the new mode continue using the + default `transparent` flow. Per-session middleware guards ensure explicit-gating behavior + only activates for sessions that opted in. +- The new `'optional'` server default is a behavioral change for server operators who relied on + the previous implicit transparent-only default: their server now also accepts + `explicit_gating` requests from clients that ask for it. Set `paymentInteraction: 'transparent'` + to restore transparent-only behavior. diff --git a/src/core/constants.ts b/src/core/constants.ts index 4a4ea17..f0633cc 100644 --- a/src/core/constants.ts +++ b/src/core/constants.ts @@ -121,6 +121,9 @@ export const NOSTR_TAGS = { * Support CEP-41 open-ended stream transfer via notifications/progress framing. */ SUPPORT_OPEN_STREAM: 'support_open_stream', + + /** CEP-8 payment interaction negotiation tag. */ + PAYMENT_INTERACTION: 'payment_interaction', } as const; export const DEFAULT_LRU_SIZE = 5000; diff --git a/src/payments/authorization-store.test.ts b/src/payments/authorization-store.test.ts new file mode 100644 index 0000000..800b84d --- /dev/null +++ b/src/payments/authorization-store.test.ts @@ -0,0 +1,137 @@ +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', + }; + + 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 LRU eviction works when maxEntries is exceeded', () => { + 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); + store.trySetPending(id3, 10000); // This should evict id1 + + expect(store.getPendingRemainingMs(id1)).toBe(0); + expect(store.getPendingRemainingMs(id2)).toBeGreaterThan(0); + expect(store.getPendingRemainingMs(id3)).toBeGreaterThan(0); + }); + + 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); + }); +}); diff --git a/src/payments/authorization-store.ts b/src/payments/authorization-store.ts new file mode 100644 index 0000000..28c1939 --- /dev/null +++ b/src/payments/authorization-store.ts @@ -0,0 +1,143 @@ +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; +} + +/** + * 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). + * + * 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. + */ +export class AuthorizationStore { + private readonly authorizations: LruCache; + private readonly pending: LruCache; // Map of key -> expiresAtMs + private readonly logger = createLogger('authorization-store'); + + constructor(opts?: { maxEntries?: number }) { + const maxEntries = opts?.maxEntries ?? 5000; + this.authorizations = new LruCache(maxEntries); + this.pending = new LruCache(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; + + 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; + } + + /** + * 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). + * + * This atomic check-and-set prevents concurrent requests from both receiving + * -32042 and triggering duplicate payment flows. + * 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; + } + } + + 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 }); + } +} diff --git a/src/payments/canonical-identity.test.ts b/src/payments/canonical-identity.test.ts new file mode 100644 index 0000000..dca0de5 --- /dev/null +++ b/src/payments/canonical-identity.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, test } from 'bun:test'; +import { + computeCanonicalInvocationHash, + computeCanonicalInvocationIdentity, +} from './canonical-identity.js'; + +describe('Canonical Invocation Identity', () => { + describe('computeCanonicalInvocationHash', () => { + test('is deterministic regardless of object key order', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', { + a: 1, + b: 2, + name: 'test', + }); + + const hash2 = computeCanonicalInvocationHash('tools/call', { + name: 'test', + b: 2, + a: 1, + }); + + expect(hash1).toBe(hash2); + // Ensure we're getting a hex string + expect(hash1).toMatch(/^[0-9a-f]{64}$/); + }); + + test('handles empty params', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', undefined); + const hash2 = computeCanonicalInvocationHash('tools/call', null); + + expect(hash1).not.toBe(hash2); + expect(hash1).toMatch(/^[0-9a-f]{64}$/); + }); + + test('handles nested objects deterministically', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', { + nested: { z: 1, y: 2, x: 3 }, + arr: [1, 2, 3], + }); + + const hash2 = computeCanonicalInvocationHash('tools/call', { + arr: [1, 2, 3], + nested: { x: 3, z: 1, y: 2 }, + }); + + expect(hash1).toBe(hash2); + }); + + test('handles unicode correctly', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', { + text: 'Hello 🌍', + }); + + const hash2 = computeCanonicalInvocationHash('tools/call', { + text: 'Hello 🌍', + }); + + expect(hash1).toBe(hash2); + }); + + test('differs for different methods', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', { a: 1 }); + const hash2 = computeCanonicalInvocationHash('prompts/get', { a: 1 }); + + expect(hash1).not.toBe(hash2); + }); + + test('differs for different param values', () => { + const hash1 = computeCanonicalInvocationHash('tools/call', { a: 1 }); + const hash2 = computeCanonicalInvocationHash('tools/call', { a: 2 }); + + expect(hash1).not.toBe(hash2); + }); + + test('throws error for circular references', () => { + const obj: Record = {}; + obj.self = obj; + expect(() => computeCanonicalInvocationHash('tools/call', obj)).toThrow( + "Failed to canonicalize invocation payload for method 'tools/call'. Ensure params contain only JSON-serializable values (no circular references, functions, symbols, or BigInt).", + ); + }); + + test('throws error for non-serializable values', () => { + expect(() => + computeCanonicalInvocationHash('tools/call', { fn: () => {} }), + ).toThrow( + "Failed to canonicalize invocation payload for method 'tools/call'. Ensure params contain only JSON-serializable values (no circular references, functions, symbols, or BigInt).", + ); + expect(() => + computeCanonicalInvocationHash('tools/call', { sym: Symbol('test') }), + ).toThrow( + "Failed to canonicalize invocation payload for method 'tools/call'. Ensure params contain only JSON-serializable values (no circular references, functions, symbols, or BigInt).", + ); + expect(() => + computeCanonicalInvocationHash('tools/call', { + big: BigInt('9007199254740991'), + }), + ).toThrow( + "Failed to canonicalize invocation payload for method 'tools/call'. Ensure params contain only JSON-serializable values (no circular references, functions, symbols, or BigInt).", + ); + }); + + test('handles empty string method', () => { + const hash1 = computeCanonicalInvocationHash('', { a: 1 }); + expect(hash1).toMatch(/^[0-9a-f]{64}$/); + }); + }); + + describe('computeCanonicalInvocationIdentity', () => { + test('combines pubkey and hash correctly', () => { + const pubkey = 'test-client-pubkey'; + const method = 'tools/call'; + const params = { name: 'test' }; + + const identity = computeCanonicalInvocationIdentity( + pubkey, + method, + params, + ); + + expect(identity.clientPubkey).toBe(pubkey); + expect(identity.invocationHash).toBe( + computeCanonicalInvocationHash(method, params), + ); + }); + }); +}); diff --git a/src/payments/canonical-identity.ts b/src/payments/canonical-identity.ts new file mode 100644 index 0000000..aeaa36e --- /dev/null +++ b/src/payments/canonical-identity.ts @@ -0,0 +1,69 @@ +import canonicalizePackage from 'canonicalize'; +type CanonicalizeFn = (input: unknown) => string | undefined; +const canonicalize = canonicalizePackage as unknown as CanonicalizeFn; +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import type { CanonicalInvocationIdentity } from './types.js'; + +/** + * Computes a deterministic SHA-256 hash of an invocation's method and parameters. + * Uses RFC 8785 JSON Canonicalization Scheme (JCS) to ensure structurally + * identical JSON objects produce the same hash regardless of key ordering. + * + * @param method - The JSON-RPC method (e.g. 'tools/call') + * @param params - The JSON-RPC parameters + * @returns A hex-encoded SHA-256 hash string + */ +export function computeCanonicalInvocationHash( + method: string, + params: unknown, +): string { + const payload = { method, params }; + let canonicalString: string | undefined; + try { + // Pre-validate that all values are strictly JSON-serializable. + // canonicalize() might ignore functions/symbols or throw stack overflows, + // so we use JSON.stringify as a strict validator first. + JSON.stringify(payload, (_key, value) => { + if ( + typeof value === 'function' || + typeof value === 'symbol' || + typeof value === 'bigint' + ) { + throw new Error('Invalid type'); + } + return value; + }); + canonicalString = canonicalize(payload); + } catch { + canonicalString = undefined; + } + + if (canonicalString === undefined) { + throw new Error( + `Failed to canonicalize invocation payload for method '${method}'. ` + + 'Ensure params contain only JSON-serializable values (no circular references, functions, symbols, or BigInt).', + ); + } + + return bytesToHex(sha256(new TextEncoder().encode(canonicalString))); +} + +/** + * Computes the canonical invocation identity for explicit-gating authorization matching. + * + * @param clientPubkey - The client's public key + * @param method - The JSON-RPC method + * @param params - The JSON-RPC parameters + * @returns The computed identity + */ +export function computeCanonicalInvocationIdentity( + clientPubkey: string, + method: string, + params: unknown, +): CanonicalInvocationIdentity { + return { + clientPubkey, + invocationHash: computeCanonicalInvocationHash(method, params), + }; +} diff --git a/src/payments/client-payments.test.ts b/src/payments/client-payments.test.ts index ac98d19..15a3d48 100644 --- a/src/payments/client-payments.test.ts +++ b/src/payments/client-payments.test.ts @@ -344,6 +344,129 @@ describe('withClientPayments()', () => { }); }); + test('declines transparent payment_required when client requested explicit_gating but server did not accept it', async () => { + const transport = createMockNostrTransport(); + + const observed: JSONRPCMessage[] = []; + let handleCalls = 0; + const paid = withClientPayments(transport, { + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + handleCalls += 1; + }, + }, + ], + paymentInteraction: 'explicit_gating', + }); + + paid.onmessage = (msg) => observed.push(msg); + await paid.start(); + + // Server never disclosed explicit_gating, so getEffectivePaymentInteraction() is undefined. + ( + transport as unknown as { + correlationStore: { + registerRequest: (eventId: string, req: unknown) => void; + }; + } + ).correlationStore.registerRequest('req-event-id', { + originalRequestId: 7, + isInitialize: false, + progressToken: undefined, + originalRequestContext: { method: 'tools/call', capability: 'tool:paid' }, + }); + + (transport as unknown as TransportWithContext).onmessageWithContext?.( + { + jsonrpc: '2.0', + method: 'notifications/payment_required', + params: { amount: 1, pay_req: 'z', pmi: 'fake' }, + } as JSONRPCMessage, + { eventId: 'evt', correlatedEventId: 'req-event-id' }, + ); + + await new Promise((r) => setTimeout(r, 0)); + + // CEP-8 effective-mode guard: handler MUST NOT be invoked. + expect(handleCalls).toBe(0); + const errResp = observed.find( + ( + m, + ): m is { + jsonrpc: '2.0'; + id: number; + error: { code: number; message: string; data?: unknown }; + } => 'id' in m && m.id === 7 && 'error' in m, + ); + expect(errResp?.error?.code).toBe(-32000); + expect(errResp?.error?.message).toBe( + 'Payment declined: explicit_gating was not accepted by the server', + ); + expect(errResp?.error?.data).toEqual({ + pmi: 'fake', + amount: 1, + method: 'tools/call', + capability: 'tool:paid', + }); + }); + + test('proceeds with transparent payment when server accepted explicit_gating for the session', async () => { + const transport = createMockNostrTransport(); + + let observed: PaymentHandlerRequest | undefined; + const paid = withClientPayments(transport, { + handlers: [ + { + pmi: 'fake', + async handle(req): Promise { + observed = req; + }, + }, + ], + paymentInteraction: 'explicit_gating', + }); + + await paid.start(); + + // Server disclosed explicit_gating as the effective mode for the session. + ( + transport as unknown as { + metadataStore: { + setEffectivePaymentInteraction: (mode: string) => void; + }; + } + ).metadataStore.setEffectivePaymentInteraction('explicit_gating'); + ( + transport as unknown as { + correlationStore: { + registerRequest: (eventId: string, req: unknown) => void; + }; + } + ).correlationStore.registerRequest('req-event-id', { + originalRequestId: 8, + isInitialize: false, + progressToken: undefined, + originalRequestContext: undefined, + }); + + (transport as unknown as TransportWithContext).onmessageWithContext?.( + { + jsonrpc: '2.0', + method: 'notifications/payment_required', + params: { amount: 1, pay_req: 'w', pmi: 'fake' }, + } as JSONRPCMessage, + { eventId: 'evt', correlatedEventId: 'req-event-id' }, + ); + + await new Promise((r) => setTimeout(r, 0)); + + // Guard does not fire: handler IS invoked. + expect(observed).toBeDefined(); + expect(observed?.pmi).toBe('fake'); + }); + test('drops uncorrelated payment_required notifications on Nostr transports', async () => { const transport = createMockNostrTransport(); @@ -593,4 +716,339 @@ describe('withClientPayments()', () => { await paid.close(); }); + + test('handles explicit gating -32042 error and retries request', async () => { + const transport = createMockNostrTransport(); + let sentMessage: JSONRPCMessage | undefined; + transport.send = async (msg) => { + sentMessage = msg; + }; + + transport + .getInternalStateForTesting() + .correlationStore.registerRequest('req-event-id-3', { + originalRequestId: 77, + isInitialize: false, + + originalRequestContext: { method: 'tools/call' }, + }); + + const observed: JSONRPCMessage[] = []; + const paid = withClientPayments(transport, { + handlers: [{ pmi: 'fake', async handle(): Promise {} }], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => ({ paid: true }), + }); + paid.onmessage = (msg) => observed.push(msg); + await paid.start(); + + // Populate the wrapper's cache with the original request + await paid.send({ + jsonrpc: '2.0', + id: 77, + method: 'tools/call', + params: { name: 'test' }, + }); + sentMessage = undefined; // Reset mock state so we can observe the retry + + // Deliver -32042 Payment Required error + transport.onmessageWithContext!( + { + jsonrpc: '2.0', + id: 77, + error: { + code: -32042, + message: 'Payment Required', + data: { + payment_options: [{ amount: 10, pmi: 'fake', pay_req: 'pr1' }], + }, + }, + }, + { eventId: 'evt4', correlatedEventId: 'req-event-id-3' }, + ); + + // Wait for async processing + await new Promise((r) => setTimeout(r, 0)); + + // Error should not be delivered to caller + expect(observed).toHaveLength(0); + + // Original request should be retried + expect(sentMessage as unknown).toEqual({ + jsonrpc: '2.0', + id: 77, + method: 'tools/call', + params: { name: 'test' }, + }); + + await paid.close(); + }); + + test('propagates -32042 error if onPaymentRequired returns paid: false', async () => { + const transport = createMockNostrTransport(); + + transport + .getInternalStateForTesting() + .correlationStore.registerRequest('req-event-id-4', { + originalRequestId: 88, + isInitialize: false, + + originalRequestContext: { method: 'tools/call' }, + }); + + const observed: JSONRPCMessage[] = []; + const paid = withClientPayments(transport, { + handlers: [{ pmi: 'fake', async handle(): Promise {} }], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => ({ + paid: false, + reason: 'user_cancelled', + }), + }); + paid.onmessage = (msg) => observed.push(msg); + await paid.start(); + + // Populate the wrapper's cache with the original request + await paid.send({ + jsonrpc: '2.0', + id: 88, + method: 'tools/call', + params: { name: 'test' }, + }); + + // Deliver -32042 Payment Required error + transport.onmessageWithContext!( + { + jsonrpc: '2.0', + id: 88, + error: { + code: -32042, + message: 'Payment Required', + data: { + payment_options: [{ amount: 10, pmi: 'fake', pay_req: 'pr2' }], + }, + }, + }, + { eventId: 'evt5', correlatedEventId: 'req-event-id-4' }, + ); + + await new Promise((r) => setTimeout(r, 0)); + + // Error should be delivered to caller with reason + expect(observed).toHaveLength(1); + const errResp = observed[0] as { + id?: unknown; + error?: { code?: number; data?: { reason?: string } }; + }; + expect(errResp.id).toBe(88); + expect(errResp.error?.code).toBe(-32042); + expect(errResp.error?.data?.reason).toBe('user_cancelled'); + + await paid.close(); + }); + + test('handles explicit gating -32043 Payment Pending error and retries after backoff', async () => { + const transport = createMockNostrTransport(); + let sentMessage: JSONRPCMessage | undefined; + transport.send = async (msg) => { + sentMessage = msg; + }; + + transport + .getInternalStateForTesting() + .correlationStore.registerRequest('req-event-id-5', { + originalRequestId: 99, + isInitialize: false, + + originalRequestContext: { method: 'tools/call' }, + }); + + const observed: JSONRPCMessage[] = []; + const paid = withClientPayments(transport, { + handlers: [{ pmi: 'fake', async handle(): Promise {} }], + paymentInteraction: 'explicit_gating', + }); + paid.onmessage = (msg) => observed.push(msg); + await paid.start(); + + // Populate the wrapper's cache with the original request + await paid.send({ + jsonrpc: '2.0', + id: 99, + method: 'tools/call', + params: { name: 'test_pending' }, + }); + sentMessage = undefined; // Reset mock state so we can observe the retry + + // Deliver -32043 Payment Pending error + transport.onmessageWithContext!( + { + jsonrpc: '2.0', + id: 99, + error: { + code: -32043, + message: 'Payment Pending', + data: { + instructions: 'Wait and retry.', + retry_after: 0.05, // 50ms for test + }, + }, + }, + { eventId: 'evt6', correlatedEventId: 'req-event-id-5' }, + ); + + // Initial check: Should intercept error and wait + await new Promise((r) => setTimeout(r, 10)); + expect(observed).toHaveLength(0); + expect(sentMessage).toBeUndefined(); + + // Wait for retry_after timer to fire + await new Promise((r) => setTimeout(r, 60)); + + // Error should not be delivered to caller + expect(observed).toHaveLength(0); + + // Original request should be retried + expect(sentMessage as unknown).toEqual({ + jsonrpc: '2.0', + id: 99, + method: 'tools/call', + params: { name: 'test_pending' }, + }); + + await paid.close(); + }); + + test('synthesizes -32042 with type payment_handler_error when onPaymentRequired rejects', async () => { + const transport = createMockNostrTransport(); + + transport + .getInternalStateForTesting() + .correlationStore.registerRequest('req-event-id-reject', { + originalRequestId: 55, + isInitialize: false, + originalRequestContext: { method: 'tools/call' }, + }); + + const observed: JSONRPCMessage[] = []; + const paid = withClientPayments(transport, { + handlers: [{ pmi: 'fake', async handle(): Promise {} }], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => { + throw new Error('wallet offline'); + }, + }); + paid.onmessage = (msg) => observed.push(msg); + await paid.start(); + + await paid.send({ + jsonrpc: '2.0', + id: 55, + method: 'tools/call', + params: { name: 'test' }, + }); + + (transport as unknown as TransportWithContext).onmessageWithContext?.( + { + jsonrpc: '2.0', + id: 55, + error: { + code: -32042, + message: 'Payment Required', + data: { + payment_options: [ + { amount: 10, pmi: 'fake', pay_req: 'pr-reject' }, + ], + }, + }, + }, + { eventId: 'evt', correlatedEventId: 'req-event-id-reject' }, + ); + + await new Promise((r) => setTimeout(r, 0)); + + expect(observed).toHaveLength(1); + const errResp = observed[0] as { + id?: unknown; + error?: { + code?: number; + data?: { reason?: string; type?: string }; + }; + }; + expect(errResp.id).toBe(55); + expect(errResp.error?.code).toBe(-32042); + expect(errResp.error?.data?.reason).toBe('wallet offline'); + expect(errResp.error?.data?.type).toBe('payment_handler_error'); + + await paid.close(); + }); + + test('forwards -32043 to caller after maxPendingRetries is exceeded', async () => { + const transport = createMockNostrTransport(); + transport.send = async (): Promise => { + // no-op: retries do not produce a server response in this unit test + }; + + transport + .getInternalStateForTesting() + .correlationStore.registerRequest('req-event-id-exhaust', { + originalRequestId: 66, + isInitialize: false, + originalRequestContext: { method: 'tools/call' }, + }); + + const observed: JSONRPCMessage[] = []; + const paid = withClientPayments(transport, { + handlers: [{ pmi: 'fake', async handle(): Promise {} }], + paymentInteraction: 'explicit_gating', + maxPendingRetries: 2, + }); + paid.onmessage = (msg) => observed.push(msg); + await paid.start(); + + await paid.send({ + jsonrpc: '2.0', + id: 66, + method: 'tools/call', + params: { name: 'test' }, + }); + + const deliverPending = (): void => { + (transport as unknown as TransportWithContext).onmessageWithContext?.( + { + jsonrpc: '2.0', + id: 66, + error: { + code: -32043, + message: 'Payment Pending', + data: { retry_after: 0.01 }, + }, + }, + { eventId: 'evt', correlatedEventId: 'req-event-id-exhaust' }, + ); + }; + + // First two: intercepted and retried (not observed by caller). + deliverPending(); + await new Promise((r) => setTimeout(r, 20)); + expect(observed).toHaveLength(0); + + deliverPending(); + await new Promise((r) => setTimeout(r, 25)); + expect(observed).toHaveLength(0); + + // Third: retry budget exhausted β†’ -32043 reaches the caller. + deliverPending(); + await new Promise((r) => setTimeout(r, 0)); + + expect(observed).toHaveLength(1); + const errResp = observed[0] as { + id?: unknown; + error?: { code?: number }; + }; + expect(errResp.id).toBe(66); + expect(errResp.error?.code).toBe(-32043); + + await paid.close(); + }); }); diff --git a/src/payments/client-payments.ts b/src/payments/client-payments.ts index 3debf0e..6374db3 100644 --- a/src/payments/client-payments.ts +++ b/src/payments/client-payments.ts @@ -3,8 +3,10 @@ import { isJSONRPCNotification, isJSONRPCResultResponse, isJSONRPCErrorResponse, - JSONRPCNotification, + type JSONRPCNotification, type JSONRPCMessage, + type JSONRPCRequest, + type JSONRPCErrorResponse, } from '@contextvm/mcp-sdk/types.js'; import { NostrClientTransport } from '../transport/nostr-client-transport.js'; import { @@ -13,14 +15,24 @@ import { PaymentRequiredNotification, PaymentHandlerRequest, } from './types.js'; +import { LruCache } from '../core/utils/lru-cache.js'; import { createLogger } from '../core/utils/logger.js'; import type { OriginalRequestContext } from '../transport/nostr-client/correlation-store.js'; +import type { + PaymentInteractionMode, + PaymentOption, + PaymentRequiredErrorData, + PaymentPendingErrorData, +} from './types.js'; + import { DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS, DEFAULT_PAYMENT_TTL_MS, PAYMENT_ACCEPTED_METHOD, PAYMENT_REJECTED_METHOD, PAYMENT_REQUIRED_METHOD, + PAYMENT_REQUIRED_ERROR_CODE, + PAYMENT_PENDING_ERROR_CODE, } from './constants.js'; export interface ClientPaymentsOptions { @@ -56,6 +68,47 @@ export interface ClientPaymentsOptions { 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; @@ -82,6 +135,35 @@ function supportsOnmessageWithContext( ); } +function isExplicitPaymentRequiredError( + msg: JSONRPCMessage, +): msg is JSONRPCErrorResponse { + 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 + ); +} + +function isExplicitPaymentPendingError( + msg: JSONRPCMessage, +): msg 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' + ); +} + function isPaymentRequiredNotification( msg: JSONRPCMessage, ): msg is PaymentRequiredNotification { @@ -162,12 +244,27 @@ export function withClientPayments( maybeStopScheduler(); }; - const stopAllSyntheticProgress = (): void => { + const pendingTimers = new Set>(); + const retryCounts = new Map(); + 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 @@ -177,6 +274,12 @@ export function withClientPayments( logger.debug('advertised client PMIs', { pmis: options.handlers.map((h) => h.pmi), }); + if (options.paymentInteraction === 'explicit_gating') { + transport.setPaymentInteraction('explicit_gating'); + logger.debug('advertised requested payment interaction mode', { + mode: 'explicit_gating', + }); + } } const handlersByPmi = new Map( @@ -201,14 +304,174 @@ export function withClientPayments( let onerror: ((error: Error) => void) | undefined; let onclose: (() => void) | undefined; - async function maybeHandlePaymentRequired( + /** 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, + error: { + code: params.code, + message: params.message, + data: params.data, + }, + } as JSONRPCMessage); + }; + + /** + * 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 (!isPaymentRequiredNotification(message)) { + if (isExplicitPaymentRequiredError(message)) { + const errorMsg = message; + const data = errorMsg.error.data as PaymentRequiredErrorData; + + if (!options.onPaymentRequired) { + onmessage?.(message); + return; + } + + const requestId = errorMsg.id; + const rawRequest = + requestId != null ? rawRequestCache.get(String(requestId)) : undefined; + if (!rawRequest) { + logger.warn( + 'missing raw original request, cannot retry explicit payment', + { requestEventId }, + ); + 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', + }, + }); + } 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(String(requestId)) : undefined; + if (!rawRequest) { + logger.warn( + 'missing raw original request, cannot retry explicit payment pending', + { requestEventId }, + ); + onmessage?.(message); + return; + } + + const requestIdKey = errorMsg.id as string | number; + 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); + const delayMs = Math.min(baseDelayMs * exponentialMultiplier, 10000); + + 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); + } + } + + /** + * 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, + ): Promise { const handler = handlersByPmi.get(message.params.pmi); if (!handler) { logger.debug('no handler for PMI, ignoring payment_required', { @@ -233,6 +496,34 @@ export function withClientPayments( 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 ( + isNostrTransport && + options.paymentInteraction === 'explicit_gating' && + transport.getEffectivePaymentInteraction() !== 'explicit_gating' + ) { + logger.warn( + 'declining transparent payment_required: explicit_gating was not accepted by the server', + { requestEventId, pmi: message.params.pmi }, + ); + if (pending?.originalRequestId != null) { + synthesizePaymentError({ + id: pending.originalRequestId, + code: -32000, + message: + 'Payment declined: explicit_gating was not accepted by the server', + data: { + pmi: message.params.pmi, + amount: message.params.amount, + method: pending.originalRequestContext?.method, + capability: pending.originalRequestContext?.capability, + }, + }); + } + 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). @@ -312,20 +603,17 @@ export function withClientPayments( stopSyntheticProgress(pending.progressToken); } - onmessage?.({ - jsonrpc: '2.0', + synthesizePaymentError({ id: pending.originalRequestId, - error: { - code: -32000, - message: params.message, - data: { - pmi: req.pmi, - amount: req.amount, - method: pending.originalRequestContext?.method, - capability: pending.originalRequestContext?.capability, - }, + code: -32000, + message: params.message, + data: { + pmi: req.pmi, + amount: req.amount, + method: pending.originalRequestContext?.method, + capability: pending.originalRequestContext?.capability, }, - } as JSONRPCMessage); + }); }; logger.info('processing payment_required', { @@ -388,6 +676,47 @@ export function withClientPayments( } } + /** 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); + return; + } + if (isPaymentRequiredNotification(message)) { + await handleTransparentPaymentRequired(message, requestEventId); + 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) + ) { + return; + } + onmessage?.(message); + }; + const wrapped = { get onmessage() { return onmessage; @@ -430,17 +759,22 @@ export function withClientPayments( isJSONRPCErrorResponse(message) ) { stopSyntheticProgress(String(message.id)); + if ( + !isExplicitPaymentRequiredError(message) && + !isExplicitPaymentPendingError(message) + ) { + const reqId = message.id as string | number; + rawRequestCache.delete(String(reqId)); + retryCounts.delete(reqId); + } } - // Best-effort: execute handler asynchronously, but never block delivery. - void maybeHandlePaymentRequired(message, 'unknown').catch( - (err: unknown) => { - const error = err instanceof Error ? err : new Error(String(err)); - onerror?.(error); - }, - ); + if (hasContextPath) { + return; + } - onmessage?.(message); + // Best-effort: execute handler asynchronously, but never block delivery. + dispatchAndForward(message, 'unknown'); }; if (hasContextPath) { @@ -484,32 +818,28 @@ export function withClientPayments( } } - void maybeHandlePaymentRequired(message, requestEventId).catch( - (err: unknown) => { - const error = err instanceof Error ? err : new Error(String(err)); - onerror?.(error); - }, - ); - // Forward exactly once (see duplicate-delivery guard in `transport.onmessage`). - onmessage?.(message); + dispatchAndForward(message, requestEventId); }; } transport.onerror = (err: Error) => onerror?.(err); transport.onclose = () => { - stopAllSyntheticProgress(); + disposeClientState(); onclose?.(); }; await transport.start(); }, async send(message: JSONRPCMessage): Promise { + if ('method' in message && 'id' in message && message.id != null) { + rawRequestCache.set(String(message.id), message as JSONRPCRequest); + } await transport.send(message); }, async close(): Promise { - // stopAllSyntheticProgress is called via transport.onclose, no need to call it here + // disposeClientState is called via transport.onclose, no need to call it here await transport.close(); }, }; diff --git a/src/payments/constants.ts b/src/payments/constants.ts index bd54f87..63b7add 100644 --- a/src/payments/constants.ts +++ b/src/payments/constants.ts @@ -22,3 +22,18 @@ export const PAYMENT_ACCEPTED_METHOD = 'notifications/payment_accepted'; /** CEP-8 notification method: server rejected payment (or refused to proceed). */ export const PAYMENT_REJECTED_METHOD = 'notifications/payment_rejected'; + +/** CEP-8 explicit-gating JSON-RPC error: payment required. */ +export const PAYMENT_REQUIRED_ERROR_CODE = -32042; + +/** CEP-8 explicit-gating JSON-RPC error: payment pending. */ +export const PAYMENT_PENDING_ERROR_CODE = -32043; + +/** + * CEP-8 unsupported payment_interaction negotiation error. + * + * Uses -32602 (Invalid params) as mandated by CEP-8 spec: the `payment_interaction` + * tag value is treated as an invalid parameter when the server does not support it. + * This is an intentional reuse of the standard JSON-RPC code, not a CEP-specific code. + */ +export const UNSUPPORTED_PAYMENT_INTERACTION_ERROR_CODE = -32602; diff --git a/src/payments/server-explicit-gating.test.ts b/src/payments/server-explicit-gating.test.ts new file mode 100644 index 0000000..2c5b0c9 --- /dev/null +++ b/src/payments/server-explicit-gating.test.ts @@ -0,0 +1,689 @@ +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 type { ServerPaymentsContext } from './types.js'; +import { AuthorizationStore } from './authorization-store.js'; +import { computeCanonicalInvocationIdentity } from './canonical-identity.js'; +import { + PAYMENT_PENDING_ERROR_CODE, + PAYMENT_REQUIRED_ERROR_CODE, +} from './constants.js'; + +describe('Explicit Gating Middleware', () => { + const processor = { + pmi: 'fake', + async createPaymentRequired(params: { + amount: number; + description?: string; + requestEventId: string; + clientPubkey: string; + }) { + return { + amount: params.amount, + pay_req: 'pay_req', + description: params.description, + pmi: 'fake', + ttl: 300, + _meta: { test: true }, + }; + }, + async verifyPayment() { + return { _meta: { ok: true } }; + }, + }; + + const pricedCapabilities = [ + { + method: 'tools/call', + name: 'add', + amount: 10, + currencyUnit: 'test', + description: 'listed', + }, + ] as const; + + const ctx: ServerPaymentsContext = { + clientPubkey: 'test-client', + paymentInteraction: 'explicit_gating', + }; + + const message: JSONRPCRequest = { + jsonrpc: '2.0', + id: 'event-id', + method: 'tools/call', + params: { name: 'add', arguments: { a: 1, b: 2 } }, + }; + + test('emits -32042 Payment Required on first request', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + let forwarded = false; + await mw(message, ctx, async () => { + forwarded = true; + }); + + expect(forwarded).toBe(false); + expect(sentResponses.length).toBe(1); + + const response = sentResponses[0]; + expect(response.error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + + const data = response.error.data as { + payment_options: { amount: number; pay_req: string }[]; + }; + expect(data.payment_options.length).toBe(1); + expect(data.payment_options[0].amount).toBe(10); + expect(data.payment_options[0].pay_req).toBe('pay_req'); + }); + + test('forwards request directly if client is using legacy transparent mode', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + let forwarded = false; + const legacyCtx = { ...ctx, paymentInteraction: 'transparent' as const }; + await mw(message, legacyCtx, async () => { + forwarded = true; + }); + + expect(forwarded).toBe(true); + expect(sentResponses.length).toBe(0); + }); + + test('emits -32043 Payment Pending if already pending', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + await mw(message, ctx, async () => {}); + await mw(message, ctx, async () => {}); // Second call should be pending + + expect(sentResponses.length).toBe(2); + expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + expect(sentResponses[1].error.code).toBe(PAYMENT_PENDING_ERROR_CODE); + }); + + test('forwards request if authorization is granted', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + // We fake the authorization grant + // The canonical identity depends on the method and params + // JCS of { method: "tools/call", params: { name: "add", arguments: { a: 1, b: 2 } } } + // We can just use the utility to compute it + const { computeCanonicalInvocationIdentity } = + await import('./canonical-identity.js'); + const identity = computeCanonicalInvocationIdentity( + ctx.clientPubkey, + message.method, + message.params, + ); + store.grant(identity, 10000); + + let forwarded = false; + await mw(message, ctx, async () => { + forwarded = true; + }); + + expect(sentResponses.length).toBe(0); + expect(forwarded).toBe(true); + + // Auth should be consumed, second call should trigger payment required + let forwarded2 = false; + await mw(message, ctx, async () => { + forwarded2 = true; + }); + + expect(forwarded2).toBe(false); + expect(sentResponses.length).toBe(1); + expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + }); + + test('forwards request directly if resolvePrice waives payment', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + resolvePrice: async () => ({ waive: true }), + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + let forwarded = false; + await mw(message, ctx, async () => { + forwarded = true; + }); + + expect(sentResponses.length).toBe(0); + expect(forwarded).toBe(true); + }); + + test('rejects request immediately if resolvePrice rejects', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + resolvePrice: async () => ({ reject: true, message: 'Rate limited' }), + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response as JSONRPCErrorResponse); + }, + }); + + let forwarded = false; + await mw(message, ctx, async () => { + forwarded = true; + }); + + expect(forwarded).toBe(false); + expect(sentResponses.length).toBe(1); + expect(sentResponses[0].error.code).toBe(-32000); + expect(sentResponses[0].error.message).toBe('Rate limited'); + }); + + // Also covers the -32043 window during verify and single-use grant consumption. + test('exercises async verifyPayment β†’ grant β†’ claim β†’ forward on retry', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + let verifyResolve!: () => void; + const verifyGate = new Promise((resolve) => { + verifyResolve = resolve; + }); + let verifyCount = 0; + const asyncProcessor = { + pmi: 'fake', + async createPaymentRequired(params: { + amount: number; + description?: string; + requestEventId: string; + clientPubkey: string; + }) { + return { + amount: params.amount, + pay_req: 'pay_req', + description: params.description, + pmi: 'fake', + ttl: 300, + }; + }, + async verifyPayment() { + verifyCount += 1; + await verifyGate; + return { _meta: { ok: true } }; + }, + }; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [asyncProcessor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + // (1) First request: -32042 emitted, verifyPayment started but unresolved. + let forwarded1 = false; + await mw(message, ctx, async () => { + forwarded1 = true; + }); + expect(forwarded1).toBe(false); + expect(sentResponses).toHaveLength(1); + expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + expect(verifyCount).toBe(1); + + // (2) Retry while verify is still in flight: -32043 (pending), no forward. + let forwarded2 = false; + await mw(message, ctx, async () => { + forwarded2 = true; + }); + expect(forwarded2).toBe(false); + expect(sentResponses).toHaveLength(2); + expect(sentResponses[1].error.code).toBe(PAYMENT_PENDING_ERROR_CODE); + + // (3) Release verifyPayment β†’ middleware grants authorization. + verifyResolve(); + await new Promise((r) => setTimeout(r, 5)); + + // (4) Retry now: claim consumes the grant β†’ forward, no new response. + let forwarded3 = false; + await mw(message, ctx, async () => { + forwarded3 = true; + }); + expect(forwarded3).toBe(true); + expect(sentResponses).toHaveLength(2); + + // (5) Authorization is single-use: next call needs a fresh payment. + let forwarded4 = false; + await mw(message, ctx, async () => { + forwarded4 = true; + }); + expect(forwarded4).toBe(false); + expect(sentResponses).toHaveLength(3); + expect(sentResponses[2].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + }); + + test('clears pending and returns fresh -32042 when verifyPayment rejects', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + let createCount = 0; + const rejectingProcessor = { + pmi: 'fake', + async createPaymentRequired(params: { + amount: number; + requestEventId: string; + clientPubkey: string; + }) { + createCount += 1; + return { + amount: params.amount, + pay_req: `pr-${createCount}`, + pmi: 'fake', + ttl: 300, + }; + }, + async verifyPayment() { + throw new Error('settlement failed'); + }, + }; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [rejectingProcessor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + await mw(message, ctx, async () => {}); + expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + + // Let the async verifyPayment reject and clear pending state. + await new Promise((r) => setTimeout(r, 5)); + + // Retry: fresh -32042 (not -32043) with a brand-new payment request. + await mw(message, ctx, async () => {}); + expect(sentResponses).toHaveLength(2); + expect(sentResponses[1].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + expect(createCount).toBe(2); + }); + + // Timeout-path counterpart to the test above. + test('clears pending and returns fresh -32042 when verifyPayment times out', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + let createCount = 0; + const timeoutProcessor = { + pmi: 'fake', + async createPaymentRequired(params: { + amount: number; + requestEventId: string; + clientPubkey: string; + }) { + createCount += 1; + return { + amount: params.amount, + pay_req: `pr-${createCount}`, + pmi: 'fake', + ttl: 1, + }; + }, + verifyPayment() { + return new Promise<{ _meta?: Record }>(() => { + // never resolves + }); + }, + }; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [timeoutProcessor], + pricedCapabilities: [...pricedCapabilities], + // Cap the polling timeout so the test stays fast. + paymentTtlMs: 200, + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + await mw(message, ctx, async () => {}); + expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + + // Wait for the verify timeout (~200ms) + clearPending. + await new Promise((r) => setTimeout(r, 300)); + + // Retry: fresh -32042 (not -32043). + await mw(message, ctx, async () => {}); + expect(sentResponses).toHaveLength(2); + expect(sentResponses[1].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + expect(createCount).toBe(2); + }); + + // --- CEP-8 explicit-gating security invariants --- + // A paid grant authorizes exactly one execution of one specific invocation + // by one specific client. The canonical identity is SHA-256(JCS({method, + // params})) scoped to the client pubkey; the JSON-RPC id MUST NOT affect it. + // These tests lock each isolation axis at the middleware level. + + test('grant for one param set does not authorize a different param set', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + // Grant authorization for add({ a: 1, b: 2 }). + store.grant( + computeCanonicalInvocationIdentity( + ctx.clientPubkey, + message.method, + message.params, + ), + 10000, + ); + + // Different params: add({ a: 9, b: 9 }). + const otherMessage: JSONRPCRequest = { + jsonrpc: '2.0', + id: 'event-id', + method: 'tools/call', + params: { name: 'add', arguments: { a: 9, b: 9 } }, + }; + + let forwarded = false; + await mw(otherMessage, ctx, async () => { + forwarded = true; + }); + + // The grant for {a:1,b:2} must NOT authorize {a:9,b:9}. + expect(forwarded).toBe(false); + expect(sentResponses).toHaveLength(1); + expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + + // The original grant is still consumable by its own params. + let forwardedOriginal = false; + await mw(message, ctx, async () => { + forwardedOriginal = true; + }); + expect(forwardedOriginal).toBe(true); + expect(sentResponses).toHaveLength(1); + }); + + test('grant for one client does not authorize a different client', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + // Grant authorization scoped to 'test-client'. + store.grant( + computeCanonicalInvocationIdentity( + ctx.clientPubkey, + message.method, + message.params, + ), + 10000, + ); + + // Same method + params, but a different client pubkey. + const otherCtx: ServerPaymentsContext = { + ...ctx, + clientPubkey: 'other-client', + }; + + let forwarded = false; + await mw(message, otherCtx, async () => { + forwarded = true; + }); + + // The grant for 'test-client' must NOT authorize 'other-client'. + expect(forwarded).toBe(false); + expect(sentResponses).toHaveLength(1); + expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + + // The original client can still consume its grant. + let forwardedOriginal = false; + await mw(message, ctx, async () => { + forwardedOriginal = true; + }); + expect(forwardedOriginal).toBe(true); + expect(sentResponses).toHaveLength(1); + }); + + test('grant matches across different JSON-RPC ids (id is not part of identity)', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + // Identity is computed from method + params only; the original request's + // id ('event-id') is intentionally excluded from the canonical form. + store.grant( + computeCanonicalInvocationIdentity( + ctx.clientPubkey, + message.method, + message.params, + ), + 10000, + ); + + // Retry with a DIFFERENT JSON-RPC id but identical method + params. + const retryWithDifferentId: JSONRPCRequest = { + ...message, + id: 'a-completely-different-event-id', + }; + + let forwarded = false; + await mw(retryWithDifferentId, ctx, async () => { + forwarded = true; + }); + + // The grant must still match despite the different id. + expect(forwarded).toBe(true); + expect(sentResponses).toHaveLength(0); + }); + + test('concurrent requests after a grant: exactly one consumes it, the other is gated', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + // A single grant is available for this invocation. + store.grant( + computeCanonicalInvocationIdentity( + ctx.clientPubkey, + message.method, + message.params, + ), + 10000, + ); + + let forwards = 0; + const forward = async () => { + forwards += 1; + }; + + // Fire two concurrent middleware calls for the same invocation. + await Promise.all([mw(message, ctx, forward), mw(message, 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. + 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 () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + // Grant authorization with a very short TTL. + store.grant( + computeCanonicalInvocationIdentity( + ctx.clientPubkey, + message.method, + message.params, + ), + 50, + ); + + // 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. + let forwarded = false; + await mw(message, ctx, async () => { + forwarded = true; + }); + + expect(forwarded).toBe(false); + expect(sentResponses).toHaveLength(1); + expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + }); + + test('non-priced capability passes through ungated in explicit gating mode', async () => { + const store = new AuthorizationStore(); + const sentResponses: JSONRPCErrorResponse[] = []; + + const mw = createExplicitGatingMiddleware({ + options: { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + }, + authorizationStore: store, + sendResponse: async (_pubkey, response) => { + sentResponses.push(response); + }, + }); + + // A tool NOT listed in pricedCapabilities (only 'add' is priced). + const unpricedMessage: JSONRPCRequest = { + jsonrpc: '2.0', + id: 'event-id', + method: 'tools/call', + params: { name: 'free', arguments: {} }, + }; + + let forwarded = false; + await mw(unpricedMessage, ctx, async () => { + forwarded = true; + }); + + expect(forwarded).toBe(true); + expect(sentResponses).toHaveLength(0); + }); +}); diff --git a/src/payments/server-explicit-gating.ts b/src/payments/server-explicit-gating.ts new file mode 100644 index 0000000..5603343 --- /dev/null +++ b/src/payments/server-explicit-gating.ts @@ -0,0 +1,254 @@ +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 { + buildProcessorsByPmi, + matchPricedCapability, + resolveAndInitiatePayment, +} from './server-payments-utils.js'; +import { createLogger } from '../core/utils/logger.js'; +import { withTimeout } from '../core/utils/utils.js'; +import { + PAYMENT_PENDING_ERROR_CODE, + PAYMENT_REQUIRED_ERROR_CODE, +} from './constants.js'; + +export interface ExplicitGatingMiddlewareParams { + options: ServerPaymentsOptions; + authorizationStore: AuthorizationStore; + sendResponse: ( + clientPubkey: string, + response: JSONRPCErrorResponse, + requestEventId: string, + ) => Promise; + /** Pre-built PMI β†’ processor map. Built locally when omitted (standalone use). */ + processorsByPmi?: Map; +} + +export function createExplicitGatingMiddleware( + params: ExplicitGatingMiddlewareParams, +): ServerMiddlewareFn { + const { options, authorizationStore, sendResponse } = params; + const logger = createLogger('server-explicit-gating'); + const processorsByPmi = + params.processorsByPmi ?? buildProcessorsByPmi(options.processors, logger); + + return async (message, ctx, forward) => { + // Only gate requests. + if (!isJsonRpcRequest(message)) { + await forward(message); + return; + } + + if (ctx.paymentInteraction !== 'explicit_gating') { + await forward(message); + return; + } + + const priced = matchPricedCapability(message, options.pricedCapabilities); + if (!priced) { + await forward(message); + return; + } + + const requestEventId = String(message.id); + const identity = computeCanonicalInvocationIdentity( + ctx.clientPubkey, + message.method, + message.params, + ); + + // 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; + } + + const paymentTtlMs = options.paymentTtlMs ?? 300_000; + + // 2. Try to set pending state atomically + // We use a safe default TTL here, but will override it below if the payment option has a specific TTL + if (!authorizationStore.trySetPending(identity, paymentTtlMs)) { + logger.debug('payment already pending, returning -32043', { + requestEventId, + }); + 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( + authorizationStore.getPendingRemainingMs(identity) / 1000, + ), + ), + ), + }, + }, + }; + await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); + return; + } + + // 3. Resolve price and initiate new payment + try { + const initResult = await resolveAndInitiatePayment({ + message, + priced, + requestEventId, + clientPubkey: ctx.clientPubkey, + clientPmis: ctx.clientPmis, + options, + processorsByPmi, + }); + + 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); + return; + } + + if (initResult.kind === 'waived') { + logger.debug('payment waived, forwarding priced request', { + requestEventId, + method: message.method, + }); + + authorizationStore.clearPending(identity); + await forward(message); + 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 + ? 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, + }); + + await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); + + // Start async verification + // Do not await this, we must let the middleware chain return the error response. + (async () => { + const controller = new AbortController(); + try { + logger.debug('verifying explicit payment', { + requestEventId, + pmi: paymentRequired.pmi, + timeoutMs: pollingTimeoutMs, + }); + + await withTimeout( + processor.verifyPayment({ + pay_req: paymentRequired.pay_req, + requestEventId, + clientPubkey: ctx.clientPubkey, + abortSignal: controller.signal, + }), + pollingTimeoutMs, + 'verifyPayment timed out', + ); + + 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); + } finally { + controller.abort(); + } + })().catch((err) => { + 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; + } + }; +} diff --git a/src/payments/server-payments-utils.ts b/src/payments/server-payments-utils.ts new file mode 100644 index 0000000..e36be75 --- /dev/null +++ b/src/payments/server-payments-utils.ts @@ -0,0 +1,199 @@ +import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; +import { type Logger } from '../core/utils/logger.js'; +import type { + PricedCapability, + ResolvePriceRejection, + ResolvePriceWaiver, + ResolvePriceResult, + PaymentProcessor, + PaymentRequired, +} from './types.js'; + +/** Builds the PMI β†’ processor map, warning once on duplicate PMIs. */ +export function buildProcessorsByPmi( + processors: readonly PaymentProcessor[], + logger: Logger, +): Map { + const seenProcessorPmis = new Set(); + for (const p of processors) { + if (seenProcessorPmis.has(p.pmi)) { + logger.warn('duplicate PMI processor registered, last one wins', { + pmi: p.pmi, + }); + } + seenProcessorPmis.add(p.pmi); + } + return new Map(processors.map((p) => [p.pmi, p] as const)); +} + +function getVerificationTimeoutMs(params: { + ttlSeconds: number | undefined; +}): number { + // CEP-8 TTL is in seconds. If TTL is absent, default is 5 minutes. + const ttlSeconds = params.ttlSeconds; + if (ttlSeconds === undefined) { + return 5 * 60 * 1000; + } + if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { + return 5 * 60 * 1000; + } + const ms = ttlSeconds * 1000; + return Number.isFinite(ms) ? Math.floor(ms) : 5 * 60 * 1000; +} + +export function matchPricedCapability( + message: JSONRPCRequest, + priced: readonly PricedCapability[], +): PricedCapability | undefined { + const capabilityName = getCapabilityNameForPricing(message); + + return priced.find((p) => { + if (p.method !== message.method) return false; + if (p.name === undefined) return true; + return p.name === capabilityName; + }); +} + +function getCapabilityNameForPricing( + message: JSONRPCRequest, +): string | undefined { + const params = message.params as Record | undefined; + + switch (message.method) { + case 'tools/call': + case 'prompts/get': { + const name = params?.name; + return typeof name === 'string' ? name : undefined; + } + case 'resources/read': { + const uri = params?.uri; + return typeof uri === 'string' ? uri : undefined; + } + default: + return undefined; + } +} + +function isResolvePriceRejection( + quote: ResolvePriceResult, +): quote is ResolvePriceRejection { + return 'reject' in quote && quote.reject; +} + +function isResolvePriceWaiver( + quote: ResolvePriceResult, +): quote is ResolvePriceWaiver { + return 'waive' in quote && quote.waive; +} + +function resolvePaymentProcessor( + clientPmis: readonly string[] | undefined, + processorsByPmi: Map, + processors: readonly PaymentProcessor[], +): PaymentProcessor { + const chosenPmi = clientPmis + ? clientPmis.find((pmi) => processorsByPmi.has(pmi)) + : undefined; + + const chosenProcessor = chosenPmi + ? processorsByPmi.get(chosenPmi) + : processors[0]; + + if (!chosenProcessor) { + throw new Error('No payment processors configured'); + } + + return chosenProcessor; +} + +export type InitiationResult = + | { + kind: 'rejected'; + pmi: string; + amount: number; + message?: string; + quote: ResolvePriceRejection; + } + | { kind: 'waived' } + | { + kind: 'payment_required'; + processor: PaymentProcessor; + paymentRequired: PaymentRequired; + mergedMeta: Record | undefined; + verifyTimeoutMs: number; + }; + +export async function resolveAndInitiatePayment(params: { + message: JSONRPCRequest; + priced: PricedCapability; + requestEventId: string; + clientPubkey: string; + clientPmis: readonly string[] | undefined; + options: { + processors: readonly PaymentProcessor[]; + resolvePrice?: (params: { + capability: PricedCapability; + request: JSONRPCRequest; + clientPubkey: string; + requestEventId: string; + }) => Promise; + }; + processorsByPmi: Map; +}): Promise { + const processor = resolvePaymentProcessor( + params.clientPmis, + params.processorsByPmi, + params.options.processors, + ); + + const quote = params.options.resolvePrice + ? await params.options.resolvePrice({ + capability: params.priced, + request: params.message, + clientPubkey: params.clientPubkey, + requestEventId: params.requestEventId, + }) + : { amount: params.priced.amount, description: params.priced.description }; + + if (isResolvePriceRejection(quote)) { + return { + kind: 'rejected', + pmi: processor.pmi, + amount: params.priced.amount, + message: quote.message, + quote, + }; + } + + if (isResolvePriceWaiver(quote)) { + return { kind: 'waived' }; + } + + const resolvedQuote = quote; + const paymentRequired = await processor.createPaymentRequired({ + amount: resolvedQuote.amount, + description: resolvedQuote.description, + requestEventId: params.requestEventId, + clientPubkey: params.clientPubkey, + }); + + const mergedMeta = + resolvedQuote.meta === undefined && paymentRequired._meta === undefined + ? undefined + : { + ...(paymentRequired._meta ?? {}), + ...(resolvedQuote.meta ?? {}), + }; + + const verifyTimeoutMs = getVerificationTimeoutMs({ + ttlSeconds: paymentRequired.ttl, + }); + + return { + kind: 'payment_required', + processor, + paymentRequired, + mergedMeta, + verifyTimeoutMs, + }; +} diff --git a/src/payments/server-payments.ts b/src/payments/server-payments.ts index 4149f78..cfdfe31 100644 --- a/src/payments/server-payments.ts +++ b/src/payments/server-payments.ts @@ -1,17 +1,14 @@ -import { type JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; -import { +import { isJsonRpcRequest } from './types.js'; +import type { CorrelatedNotificationSender, PaymentAcceptedNotification, + PaymentInteractionPolicy, PaymentProcessor, PaymentRejectedNotification, PaymentRequiredNotification, PricedCapability, - ResolvePriceRejection, - ResolvePriceWaiver, - ResolvePriceResult, ResolvePriceFn, ServerMiddlewareFn, - isJsonRpcRequest, } from './types.js'; import { LruCache } from '../core/utils/lru-cache.js'; import { withTimeout } from '../core/utils/utils.js'; @@ -22,6 +19,11 @@ import { PAYMENT_REJECTED_METHOD, PAYMENT_REQUIRED_METHOD, } from './constants.js'; +import { + buildProcessorsByPmi, + matchPricedCapability, + resolveAndInitiatePayment, +} from './server-payments-utils.js'; export interface ServerPaymentsOptions { processors: readonly PaymentProcessor[]; @@ -47,6 +49,14 @@ export interface ServerPaymentsOptions { * @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' + */ + paymentInteraction?: PaymentInteractionPolicy; } function purgeExpiredPending(params: { @@ -71,56 +81,6 @@ type PendingPaymentState = { inFlight: Promise; }; -function getVerificationTimeoutMs(params: { - ttlSeconds: number | undefined; -}): number { - // CEP-8 TTL is in seconds. If TTL is absent, default is 5 minutes. - const ttlSeconds = params.ttlSeconds; - if (ttlSeconds === undefined) { - return 5 * 60 * 1000; - } - if (!Number.isFinite(ttlSeconds) || ttlSeconds <= 0) { - return 5 * 60 * 1000; - } - return Math.floor(ttlSeconds * 1000); -} - -function matchPricedCapability( - message: JSONRPCRequest, - priced: readonly PricedCapability[], -): PricedCapability | undefined { - const capabilityName = getCapabilityNameForPricing(message); - - return priced.find((p) => { - if (p.method !== message.method) return false; - if (p.name === undefined) return true; - return p.name === capabilityName; - }); -} - -function getCapabilityNameForPricing( - message: JSONRPCRequest, -): string | undefined { - const params = message.params as Record | undefined; - - switch (message.method) { - case 'tools/call': { - const name = params?.name; - return typeof name === 'string' ? name : undefined; - } - case 'prompts/get': { - const name = params?.name; - return typeof name === 'string' ? name : undefined; - } - case 'resources/read': { - const uri = params?.uri; - return typeof uri === 'string' ? uri : undefined; - } - default: - return undefined; - } -} - function createPaymentRequiredNotification(params: { amount: number; pay_req: string; @@ -160,41 +120,19 @@ function createPaymentRejectedNotification(params: { }; } -function isResolvePriceRejection( - quote: ResolvePriceResult, -): quote is ResolvePriceRejection { - return 'reject' in quote && quote.reject; -} - -function isResolvePriceWaiver( - quote: ResolvePriceResult, -): quote is ResolvePriceWaiver { - return 'waive' in quote && quote.waive; -} - /** * Creates a server-side middleware that gates priced requests until payment is verified. */ export function createServerPaymentsMiddleware(params: { sender: CorrelatedNotificationSender; options: ServerPaymentsOptions; + /** Pre-built PMI β†’ processor map. Built locally when omitted (standalone use). */ + processorsByPmi?: Map; }): ServerMiddlewareFn { const { sender, options } = params; const logger = createLogger('server-payments'); - const processorsByPmi = new Map( - options.processors.map((p) => [p.pmi, p] as const), - ); - - // Warn on duplicate PMI processors β€” Map construction silently keeps only the last. - const seenProcessorPmis = new Set(); - for (const p of options.processors) { - if (seenProcessorPmis.has(p.pmi)) { - logger.warn('duplicate PMI processor registered, last one wins', { - pmi: p.pmi, - }); - } - seenProcessorPmis.add(p.pmi); - } + const processorsByPmi = + params.processorsByPmi ?? buildProcessorsByPmi(options.processors, logger); const paymentTtlMs = options.paymentTtlMs ?? DEFAULT_PAYMENT_TTL_MS; const pending = new LruCache( @@ -208,6 +146,14 @@ export function createServerPaymentsMiddleware(params: { return; } + if ( + ctx.paymentInteraction !== undefined && + ctx.paymentInteraction !== 'transparent' + ) { + await forward(message); + return; + } + const priced = matchPricedCapability(message, options.pricedCapabilities); if (!priced) { await forward(message); @@ -240,44 +186,29 @@ export function createServerPaymentsMiddleware(params: { // IMPORTANT: set pending state synchronously before any await to make idempotency atomic. const inFlight = (async (): Promise => { - const clientPmis = ctx.clientPmis; - - const chosenPmi = clientPmis - ? clientPmis.find((pmi) => processorsByPmi.has(pmi)) - : undefined; - - const chosenProcessor = chosenPmi - ? processorsByPmi.get(chosenPmi) - : options.processors[0]; - - if (!chosenProcessor) { - throw new Error('No payment processors configured'); - } - - const processor = chosenProcessor; - - const quote = options.resolvePrice - ? await options.resolvePrice({ - capability: priced, - request: message, - clientPubkey: ctx.clientPubkey, - requestEventId, - }) - : { amount: priced.amount, description: priced.description }; + 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 (isResolvePriceRejection(quote)) { + if (initResult.kind === 'rejected') { logger.info('payment rejected', { requestEventId, - pmi: processor.pmi, + pmi: initResult.pmi, amount: priced.amount, - reason: quote.message, + reason: initResult.message, }); const rejectedNotification = createPaymentRejectedNotification({ - pmi: processor.pmi, + pmi: initResult.pmi, amount: priced.amount, - message: quote.message, + message: initResult.message, }); await sender.sendNotification( @@ -285,101 +216,89 @@ export function createServerPaymentsMiddleware(params: { rejectedNotification, requestEventId, ); - } else if (isResolvePriceWaiver(quote)) { + return; + } + + if (initResult.kind === 'waived') { logger.debug('payment waived, forwarding priced request', { requestEventId, method: message.method, }); await forward(message); - } else { - const resolvedQuote = quote; - const paymentRequired = await processor.createPaymentRequired({ - amount: resolvedQuote.amount, - description: resolvedQuote.description, - requestEventId, - clientPubkey: ctx.clientPubkey, - }); + return; + } - const mergedMeta = - resolvedQuote.meta === undefined && - paymentRequired._meta === undefined - ? undefined - : { - ...(paymentRequired._meta ?? {}), - ...(resolvedQuote.meta ?? {}), - }; - - const requiredNotification = createPaymentRequiredNotification({ - amount: paymentRequired.amount, - pay_req: paymentRequired.pay_req, - pmi: paymentRequired.pmi, - description: paymentRequired.description, - ttl: paymentRequired.ttl, - _meta: mergedMeta, - }); + const { paymentRequired, mergedMeta, processor, verifyTimeoutMs } = + initResult; - logger.info('payment required notification sent', { - requestEventId, - pmi: paymentRequired.pmi, - amount: paymentRequired.amount, - ttl: paymentRequired.ttl, - }); + const requiredNotification = createPaymentRequiredNotification({ + amount: paymentRequired.amount, + pay_req: paymentRequired.pay_req, + pmi: paymentRequired.pmi, + description: paymentRequired.description, + ttl: paymentRequired.ttl, + _meta: mergedMeta, + }); - await sender.sendNotification( - ctx.clientPubkey, - requiredNotification, - requestEventId, - ); + logger.info('payment required notification sent', { + requestEventId, + pmi: paymentRequired.pmi, + amount: paymentRequired.amount, + ttl: paymentRequired.ttl, + }); - const verifyTimeoutMs = getVerificationTimeoutMs({ - ttlSeconds: paymentRequired.ttl, - }); - const effectiveTimeoutMs = Math.min(verifyTimeoutMs, paymentTtlMs); + await sender.sendNotification( + ctx.clientPubkey, + requiredNotification, + requestEventId, + ); - logger.debug('verifying payment', { - requestEventId, - pmi: paymentRequired.pmi, - timeoutMs: effectiveTimeoutMs, - }); + // 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 controller = new AbortController(); - const verified = await withTimeout( - processor.verifyPayment({ - pay_req: paymentRequired.pay_req, - requestEventId, - clientPubkey: ctx.clientPubkey, - abortSignal: controller.signal, - }), - effectiveTimeoutMs, - 'verifyPayment timed out', - ).finally(() => controller.abort()); - - logger.info('payment accepted', { + const controller = new AbortController(); + const verified = await withTimeout( + processor.verifyPayment({ + pay_req: paymentRequired.pay_req, requestEventId, - pmi: paymentRequired.pmi, - amount: paymentRequired.amount, - }); + clientPubkey: ctx.clientPubkey, + abortSignal: controller.signal, + }), + pollingTimeoutMs, + 'verifyPayment timed out', + ).finally(() => controller.abort()); - const acceptedNotification = createPaymentAcceptedNotification({ - amount: paymentRequired.amount, - pmi: paymentRequired.pmi, - _meta: verified._meta, - }); + logger.info('payment accepted', { + requestEventId, + pmi: paymentRequired.pmi, + amount: paymentRequired.amount, + }); - await sender.sendNotification( - ctx.clientPubkey, - acceptedNotification, - requestEventId, - ); + const acceptedNotification = createPaymentAcceptedNotification({ + amount: paymentRequired.amount, + pmi: paymentRequired.pmi, + _meta: verified._meta, + }); - logger.debug('forwarding priced request after payment', { - requestEventId, - method: message.method, - }); + await sender.sendNotification( + ctx.clientPubkey, + acceptedNotification, + requestEventId, + ); - await forward(message); - } + logger.debug('forwarding priced request after payment', { + requestEventId, + method: message.method, + }); + + await forward(message); })(); const state: PendingPaymentState = { diff --git a/src/payments/server-transport-payments.ts b/src/payments/server-transport-payments.ts index 66ae160..d9e42dd 100644 --- a/src/payments/server-transport-payments.ts +++ b/src/payments/server-transport-payments.ts @@ -1,26 +1,83 @@ import type { NostrServerTransport } from '../transport/nostr-server-transport.js'; +import type { PaymentInteractionPolicy } from './types.js'; import type { ServerPaymentsOptions } from './server-payments.js'; 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 { buildProcessorsByPmi } from './server-payments-utils.js'; +import { createLogger } from '../core/utils/logger.js'; +import { NOSTR_TAGS } from '../core/constants.js'; /** * Attaches CEP-8 payments gating to a NostrServerTransport. + * + * By default the server uses the `optional` policy: it advertises + * `explicit_gating` support and mirrors each client's requested lifecycle, so a + * client that requests `explicit_gating` is gated while transparent clients keep + * the notification-based flow. Pass `paymentInteraction: 'transparent'` for a + * transparent-only server. */ export function withServerPayments( transport: NostrServerTransport, options: ServerPaymentsOptions, ): NostrServerTransport { - // CEP-8 discovery tags: advertise supported PMIs + reference pricing on announcement/list events. - transport.setAnnouncementExtraTags( - createPmiTagsFromProcessors(options.processors), + // Build the PMI β†’ processor map once and share it across both middlewares. + const processorsByPmi = buildProcessorsByPmi( + options.processors, + createLogger('server-payments'), ); + + const policy: PaymentInteractionPolicy = + options.paymentInteraction ?? 'optional'; + const supportsExplicitGating = policy === 'optional'; + + // CEP-8 discovery tags: advertise supported PMIs + reference pricing on + // announcement/list events. When explicit gating is supported, also advertise + // it as an available opt-in mode (availability, not effective session mode). + const extraTags: string[][] = createPmiTagsFromProcessors(options.processors); + + if (supportsExplicitGating) { + extraTags.push([NOSTR_TAGS.PAYMENT_INTERACTION, 'explicit_gating']); + } + + transport.setAnnouncementExtraTags(extraTags); transport.setAnnouncementPricingTags( createCapTagsFromPricedCapabilities(options.pricedCapabilities), ); + // Expose the configured policy to the transport coordinator so it can accept + // or reject per-session `payment_interaction` requests. + transport.setSupportedPaymentInteraction(policy); + transport.addInboundMiddleware( - createServerPaymentsMiddleware({ sender: transport, options }), + createServerPaymentsMiddleware({ + sender: transport, + options, + processorsByPmi, + }), ); + + // The transparent middleware self-gates on the per-session effective mode, so + // 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({}); + transport.addInboundMiddleware( + createExplicitGatingMiddleware({ + options, + authorizationStore, + sendResponse: async (clientPubkey, response, requestEventId) => { + await transport.sendTargetedResponse( + clientPubkey, + response, + requestEventId, + ); + }, + processorsByPmi, + }), + ); + } return transport; } diff --git a/src/payments/types.ts b/src/payments/types.ts index 682f632..531821e 100644 --- a/src/payments/types.ts +++ b/src/payments/types.ts @@ -62,6 +62,73 @@ export type PaymentRequiredNotification = JSONRPCNotification & { }; }; +/** + * CEP-8 payment interaction modes. + * + * These are the wire/session-level modes negotiated via the `payment_interaction` + * tag: `transparent` (default) and `explicit_gating` (opt-in). + */ +export type PaymentInteractionMode = 'transparent' | 'explicit_gating'; + +/** + * Server-side policy for which payment interaction lifecycles it accepts. + * + * This is a server configuration concern, distinct from the wire-level + * {@link PaymentInteractionMode}. It mirrors the OPTIONAL policy used for + * encryption and gift wrapping, where the peer's chosen mode is mirrored rather + * than forced. + * + * - `optional`: Accept both lifecycles and mirror the client's requested mode + * for the session (the default). A client that requests `explicit_gating` + * gets it; a client that omits the tag or requests `transparent` stays on the + * transparent lifecycle. + * - `transparent`: Transparent-only. Reject `explicit_gating` requests with a + * `-32602` negotiation error per CEP-8 effective-mode disclosure. + */ +export type PaymentInteractionPolicy = 'optional' | 'transparent'; + +/** A single payment option inside a -32042 error.data.payment_options entry. */ +export interface PaymentOption { + amount: number; + pmi: string; + pay_req: string; + description?: string; + ttl?: number; + _meta?: Record; +} + +/** Shape of error.data for -32042 Payment Required. */ +export interface PaymentRequiredErrorData { + instructions?: string; + payment_options: PaymentOption[]; +} + +/** Shape of error.data for -32043 Payment Pending. */ +export interface PaymentPendingErrorData { + instructions?: string; + retry_after?: number; +} + +/** Nostr `payment_interaction` tag as defined by CEP-8. */ +export type PaymentInteractionTag = [ + 'payment_interaction', + PaymentInteractionMode, +]; + +/** + * Canonical invocation identity for explicit-gating authorization matching. + * + * `invocationHash` is SHA-256 over JCS({method, params}). This means `params` MUST be + * deterministic β€” no timestamps, UUIDs, or ephemeral IDs that change across retries. + * Clients MUST preserve the exact original `params` object when retrying after payment + * so the retry computes the same `invocationHash` and matches the paid authorization. + */ +export interface CanonicalInvocationIdentity { + clientPubkey: string; + /** Hex-encoded SHA-256 of JCS({method, params}). */ + invocationHash: string; +} + /** A CEP-8 payment-accepted notification (JSON-RPC notification). */ export type PaymentAcceptedNotification = JSONRPCNotification & { method: 'notifications/payment_accepted'; @@ -212,6 +279,19 @@ export type ResolvePriceFn = (params: { requestEventId: string; }) => Promise; +/** + * The structure returned by a PaymentProcessor when a new payment is issued. + */ +export interface PaymentRequired { + amount: number; + pay_req: string; + description?: string; + pmi: string; + /** Time-to-live in seconds (CEP-8). */ + ttl?: number; + _meta?: Record; +} + /** * Server-side module that can issue and verify payments for a single PMI. */ @@ -220,15 +300,9 @@ export interface PaymentProcessor { readonly pmi: string; /** Create a payment request for a specific capability invocation */ - createPaymentRequired(params: PaymentProcessorCreateParams): Promise<{ - amount: number; - pay_req: string; - description?: string; - pmi: string; - /** Time-to-live in seconds (CEP-8). */ - ttl?: number; - _meta?: Record; - }>; + createPaymentRequired( + params: PaymentProcessorCreateParams, + ): Promise; /** Wait for and/or verify settlement for a previously issued pay_req */ verifyPayment( @@ -246,6 +320,11 @@ export interface ServerPaymentsContext { * Source: Nostr event tags (e.g. multiple `['pmi', '']`). */ clientPmis?: readonly string[]; + + /** + * The negotiated payment interaction mode for the session. + */ + paymentInteraction?: PaymentInteractionMode; } /** diff --git a/src/transport/capability-negotiator.test.ts b/src/transport/capability-negotiator.test.ts new file mode 100644 index 0000000..29b5fbc --- /dev/null +++ b/src/transport/capability-negotiator.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, test } from 'bun:test'; +import { ClientCapabilityNegotiator } from './capability-negotiator.js'; + +import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; + +describe('ClientCapabilityNegotiator', () => { + test('should not consume payment_interaction tag during measurement calls', () => { + const negotiator = new ClientCapabilityNegotiator({ + encryptionMode: EncryptionMode.OPTIONAL, + giftWrapMode: GiftWrapMode.EPHEMERAL, + oversizedEnabled: false, + openStreamEnabled: false, + composeOutboundTags: ({ baseTags, discoveryTags, negotiationTags }) => [ + ...baseTags, + ...discoveryTags, + ...negotiationTags, + ], + }); + + negotiator.setPaymentInteraction('explicit_gating'); + + // Simulate measurement call (tags discarded) + const measurementTags = negotiator.buildOutboundTags({ + baseTags: [['p', 'server-pubkey']], + includeDiscovery: true, + }); + expect( + measurementTags.some( + (t) => t[0] === 'payment_interaction' && t[1] === 'explicit_gating', + ), + ).toBe(true); + + // Simulate real send (tags actually used) + const realTags = negotiator.buildOutboundTags({ + baseTags: [['p', 'server-pubkey']], + includeDiscovery: true, + }); + expect( + realTags.some( + (t) => t[0] === 'payment_interaction' && t[1] === 'explicit_gating', + ), + ).toBe(true); + + // Mark as sent (post-send) + negotiator.markNegotiationTagsSent(); + + // Should no longer appear + const afterTags = negotiator.buildOutboundTags({ + baseTags: [['p', 'server-pubkey']], + includeDiscovery: true, + }); + expect(afterTags.some((t) => t[0] === 'payment_interaction')).toBe(false); + }); + + test('getRequestedPaymentInteraction reflects the negotiated mode', () => { + const negotiator = new ClientCapabilityNegotiator({ + encryptionMode: EncryptionMode.OPTIONAL, + giftWrapMode: GiftWrapMode.EPHEMERAL, + oversizedEnabled: false, + openStreamEnabled: false, + composeOutboundTags: () => [], + }); + + // Defaults to undefined (transparent client). + expect(negotiator.getRequestedPaymentInteraction()).toBeUndefined(); + + negotiator.setPaymentInteraction('explicit_gating'); + expect(negotiator.getRequestedPaymentInteraction()).toBe('explicit_gating'); + }); +}); diff --git a/src/transport/capability-negotiator.ts b/src/transport/capability-negotiator.ts index cae603b..cf67344 100644 --- a/src/transport/capability-negotiator.ts +++ b/src/transport/capability-negotiator.ts @@ -7,6 +7,7 @@ import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; import { type NostrEvent } from 'nostr-tools'; import { type ClientSession } from './nostr-server/session-store.js'; import { queryTags } from '../core/utils/utils.js'; +import type { PaymentInteractionMode } from '../payments/types.js'; const NON_DISCOVERY_TAG_NAMES = new Set(['e', 'p']); @@ -180,8 +181,10 @@ export class ServerCapabilityNegotiator { export class ClientCapabilityNegotiator { private hasSentDiscoveryTags = false; private clientPmis?: readonly string[]; + private paymentInteraction?: PaymentInteractionMode; private serverSupportsEphemeralGiftWraps = false; private _serverInitializeEvent?: NostrEvent; + private hasSentPaymentInteraction = false; constructor( private deps: { @@ -204,6 +207,24 @@ export class ClientCapabilityNegotiator { this.clientPmis = pmis; } + /** + * Sets the requested payment interaction mode for negotiation. + */ + public setPaymentInteraction(mode: PaymentInteractionMode): void { + this.paymentInteraction = mode; + } + + /** + * Returns the payment interaction mode this client requested, if any. + * + * Used to distinguish an inbound `payment_interaction` tag observed as the + * session's effective mode (authoritative only when the client requested a + * non-default mode) from a server availability advertisement. + */ + public getRequestedPaymentInteraction(): PaymentInteractionMode | undefined { + return this.paymentInteraction; + } + /** * Updates server capability flags from discovered peer tags. * Called by the transport when it learns new capabilities from inbound events. @@ -253,6 +274,13 @@ export class ClientCapabilityNegotiator { if (this.clientPmis) { tags.push(...this.clientPmis.map((pmi) => ['pmi', pmi])); } + if ( + this.paymentInteraction && + this.paymentInteraction !== 'transparent' && + !this.hasSentPaymentInteraction + ) { + tags.push([NOSTR_TAGS.PAYMENT_INTERACTION, this.paymentInteraction]); + } return tags; } @@ -279,12 +307,15 @@ export class ClientCapabilityNegotiator { } /** - * Marks discovery tags as sent to prevent re-sending. + * Marks discovery and negotiation tags as sent to prevent re-sending. */ - public markDiscoveryTagsSent(): void { + public markNegotiationTagsSent(): void { if (this.getPendingDiscoveryTags().length > 0) { this.hasSentDiscoveryTags = true; } + if (this.paymentInteraction && this.paymentInteraction !== 'transparent') { + this.hasSentPaymentInteraction = true; + } } /** diff --git a/src/transport/middleware.ts b/src/transport/middleware.ts index b62f8b4..ba2ddca 100644 --- a/src/transport/middleware.ts +++ b/src/transport/middleware.ts @@ -1,10 +1,22 @@ import type { JSONRPCMessage } from '@contextvm/mcp-sdk/types.js'; +import type { PaymentInteractionMode } from '../payments/types.js'; + /** * Inbound middleware hook for server transports. + * + * @note Context relationship: `InboundMiddlewareFn`'s `ctx` is the authoritative source + * of per-request context, populated by the inbound coordinator from the session and + * inbound event tags. `ServerPaymentsContext` (used by `ServerMiddlewareFn`) is a subset + * of this context β€” it reads the same `paymentInteraction` field. The inbound coordinator + * constructs both from the same session state, so they stay synchronized automatically. */ export type InboundMiddlewareFn = ( message: JSONRPCMessage, - ctx: { clientPubkey: string; clientPmis?: readonly string[] }, + ctx: { + clientPubkey: string; + clientPmis?: readonly string[]; + paymentInteraction?: PaymentInteractionMode; + }, forward: (message: JSONRPCMessage) => Promise, ) => Promise; diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index ca9cc18..042aaf8 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -48,6 +48,7 @@ import { DEFAULT_OVERSIZED_THRESHOLD, } from './oversized-transfer/constants.js'; import type { OpenStreamTransportPolicy } from './open-stream-policy.js'; +import type { PaymentInteractionMode } from '../payments/types.js'; /** * Options for configuring the NostrClientTransport. @@ -584,6 +585,20 @@ export class NostrClientTransport return this.metadataStore.getServerInitializePicture(); } + /** + * Sets the requested payment interaction mode for negotiation. + */ + public setPaymentInteraction(mode: PaymentInteractionMode): void { + this.capabilityNegotiator.setPaymentInteraction(mode); + } + + /** + * Gets the effective payment interaction mode disclosed by the server. + */ + public getEffectivePaymentInteraction(): PaymentInteractionMode | undefined { + return this.metadataStore.getEffectivePaymentInteraction(); + } + /** Gets the server's most recently observed tools/list event envelope, if any. */ public getServerToolsListEvent(): NostrEvent | undefined { return this.metadataStore.getServerToolsListEvent(); @@ -628,6 +643,7 @@ export class NostrClientTransport private handleResponse( correlatedEventId: string, mcpMessage: JSONRPCMessage, + eventId?: string, ): void { try { const resolved = this.correlationStore.resolveResponse( @@ -637,6 +653,10 @@ export class NostrClientTransport if (resolved) { this.onmessage?.(mcpMessage); + this.onmessageWithContext?.(mcpMessage, { + eventId: eventId ?? correlatedEventId, + correlatedEventId, + }); } else { this.logger.warn('Response for unknown request', { eventId: correlatedEventId, diff --git a/src/transport/nostr-client/inbound-coordinator.ts b/src/transport/nostr-client/inbound-coordinator.ts index 353cb97..8eaa01d 100644 --- a/src/transport/nostr-client/inbound-coordinator.ts +++ b/src/transport/nostr-client/inbound-coordinator.ts @@ -11,6 +11,7 @@ import { } from '@contextvm/mcp-sdk/types.js'; import { type NostrEvent } from 'nostr-tools'; import { type Logger } from '../../core/utils/logger.js'; +import { NOSTR_TAGS } from '../../core/constants.js'; import { getNostrEventTag } from '../../core/utils/serializers.js'; import { type ClientCapabilityNegotiator, @@ -20,6 +21,7 @@ import { type ClientCorrelationStore } from './correlation-store.js'; import { type UnwrappedClientEvent } from './event-pipeline.js'; import { type ClientInboundNotificationDispatcher } from './inbound-notification-dispatcher.js'; import { type ServerMetadataStore } from './server-metadata-store.js'; +import type { PaymentInteractionMode } from '../../payments/types.js'; export interface ClientInboundCoordinatorDeps { capabilityNegotiator: ClientCapabilityNegotiator; @@ -28,7 +30,11 @@ export interface ClientInboundCoordinatorDeps { metadataStore: ServerMetadataStore; unwrapEvent: (event: NostrEvent) => Promise; convertNostrEventToMcpMessage: (event: NostrEvent) => JSONRPCMessage | null; - handleResponse: (correlatedEventId: string, msg: JSONRPCMessage) => void; + handleResponse: ( + correlatedEventId: string, + msg: JSONRPCMessage, + eventId?: string, + ) => void; handleNotification: ( eventId: string, correlatedEventId: string | undefined, @@ -144,7 +150,7 @@ export class ClientInboundCoordinator { } } - this.deps.handleResponse(eTag, mcpMessage); + this.deps.handleResponse(eTag, mcpMessage, nostrEvent.id); return; } @@ -195,6 +201,28 @@ export class ClientInboundCoordinator { discovered.supportsOpenStream, ); + const paymentInteractionTag = event.tags.find( + (tag) => + tag[0] === NOSTR_TAGS.PAYMENT_INTERACTION && typeof tag[1] === 'string', + ); + if ( + paymentInteractionTag && + // CEP-8: the effective mode observed on a response is authoritative only + // when the client requested a non-default mode. Otherwise the tag is a + // server availability advertisement and MUST NOT be recorded as this + // session's effective mode (which would leave a transparent client + // incorrectly believing it is on the explicit-gating lifecycle). + this.deps.capabilityNegotiator.getRequestedPaymentInteraction() === + 'explicit_gating' + ) { + const mode = paymentInteractionTag[1]; + if (mode === 'transparent' || mode === 'explicit_gating') { + this.deps.metadataStore.setEffectivePaymentInteraction( + mode as PaymentInteractionMode, + ); + } + } + if (!this.deps.metadataStore.getServerInitializeEvent()) { this.setInitializeEvent(event); this.deps.logger.info( diff --git a/src/transport/nostr-client/outbound-sender.test.ts b/src/transport/nostr-client/outbound-sender.test.ts index 3f6efd7..0212933 100644 --- a/src/transport/nostr-client/outbound-sender.test.ts +++ b/src/transport/nostr-client/outbound-sender.test.ts @@ -18,12 +18,12 @@ const testLogger: Logger = { function createCapabilityNegotiator(): Pick< ClientCapabilityNegotiator, - 'buildOutboundTags' | 'chooseOutboundGiftWrapKind' | 'markDiscoveryTagsSent' + 'buildOutboundTags' | 'chooseOutboundGiftWrapKind' | 'markNegotiationTagsSent' > { return { buildOutboundTags: ({ baseTags }) => baseTags as string[][], chooseOutboundGiftWrapKind: () => 0, - markDiscoveryTagsSent: () => undefined, + markNegotiationTagsSent: () => undefined, }; } diff --git a/src/transport/nostr-client/outbound-sender.ts b/src/transport/nostr-client/outbound-sender.ts index 733b45e..50a0814 100644 --- a/src/transport/nostr-client/outbound-sender.ts +++ b/src/transport/nostr-client/outbound-sender.ts @@ -114,6 +114,7 @@ export class ClientOutboundSender { String(progressToken), giftWrapKind, ); + return 'oversized-transfer'; } } @@ -152,7 +153,7 @@ export class ClientOutboundSender { ); if (isRequest) { - this.deps.capabilityNegotiator.markDiscoveryTagsSent(); + this.deps.capabilityNegotiator.markNegotiationTagsSent(); } return eventId; @@ -216,6 +217,6 @@ export class ClientOutboundSender { }); } - this.deps.capabilityNegotiator.markDiscoveryTagsSent(); + this.deps.capabilityNegotiator.markNegotiationTagsSent(); } } diff --git a/src/transport/nostr-client/server-metadata-store.ts b/src/transport/nostr-client/server-metadata-store.ts index 0d8c5a1..5471b99 100644 --- a/src/transport/nostr-client/server-metadata-store.ts +++ b/src/transport/nostr-client/server-metadata-store.ts @@ -6,6 +6,7 @@ import { type NostrEvent } from 'nostr-tools'; import { NOSTR_TAGS } from '../../core/constants.js'; import { getNostrEventTag } from '../../core/utils/serializers.js'; import { queryTags } from '../../core/utils/utils.js'; +import type { PaymentInteractionMode } from '../../payments/types.js'; export type ListEnvelopeType = 'tools' | 'resources' | 'templates' | 'prompts'; @@ -20,6 +21,15 @@ export class ServerMetadataStore { private serverResourceTemplatesListEvent: NostrEvent | undefined; private supportsOversizedTransfer = false; private supportsOpenStream = false; + private effectivePaymentInteraction?: PaymentInteractionMode; + + public setEffectivePaymentInteraction(mode: PaymentInteractionMode): void { + this.effectivePaymentInteraction = mode; + } + + public getEffectivePaymentInteraction(): PaymentInteractionMode | undefined { + return this.effectivePaymentInteraction; + } public clear(): void { this.serverInitializeEvent = undefined; @@ -29,6 +39,7 @@ export class ServerMetadataStore { this.serverResourceTemplatesListEvent = undefined; this.supportsOversizedTransfer = false; this.supportsOpenStream = false; + this.effectivePaymentInteraction = undefined; } public setServerInitializeEvent(event: NostrEvent): void { diff --git a/src/transport/nostr-server-transport.test.ts b/src/transport/nostr-server-transport.test.ts index c267cdf..7b7e99d 100644 --- a/src/transport/nostr-server-transport.test.ts +++ b/src/transport/nostr-server-transport.test.ts @@ -602,6 +602,57 @@ describe.serial('NostrServerTransport', () => { await server.close(); }, 10000); + test('should return -32602 if client requests explicit_gating but server does not support it', async () => { + const serverPrivateKey = bytesToHex(generateSecretKey()); + const serverPublicKey = getPublicKey(hexToBytes(serverPrivateKey)); + + const server = new McpServer({ + name: 'Test Server', + version: '1.0.0', + }); + + const serverTransport = new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + }); + // Server does NOT configure explicit_gating + await server.connect(serverTransport); + + const clientPrivateKey = bytesToHex(generateSecretKey()); + const { client, clientNostrTransport } = createClientAndTransport( + clientPrivateKey, + 'Test Client', + serverPublicKey, + ); + + // Client requests explicit gating + clientNostrTransport.setPaymentInteraction('explicit_gating'); + + // connect() sends `initialize` request, server should return -32602 error + let connectError: unknown; + try { + await client.connect(clientNostrTransport); + } catch (e: unknown) { + connectError = e; + } + + expect(connectError).toBeDefined(); + // MCP client wraps JSON-RPC errors into McpError, preserving code/message/data. + // The underlying code should be -32602 + expect((connectError as { code: number }).code).toBe(-32602); + expect((connectError as { message: string }).message).toContain( + 'Unsupported payment_interaction mode', + ); + // CEP-8: the -32602 MUST disclose the requested mode and the modes the server supports. + expect((connectError as { data: unknown }).data).toEqual({ + requested: 'explicit_gating', + supported: ['transparent'], + }); + + await server.close(); + await clientNostrTransport.close(); + }, 10000); + test('should allow call excluded capabilities for disallowed public keys', async () => { // Use a unique server key per test to avoid cross-pollution with concurrent files. const serverPrivateKey = bytesToHex(generateSecretKey()); diff --git a/src/transport/nostr-server-transport.ts b/src/transport/nostr-server-transport.ts index 51b9a65..42dfbcf 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -52,6 +52,7 @@ import { ServerOpenStreamFactory } from './nostr-server/open-stream-factory.js'; import { ServerEventPipeline } from './nostr-server/event-pipeline.js'; import { ServerInboundCoordinator } from './nostr-server/inbound-coordinator.js'; import type { InboundMiddlewareFn } from './middleware.js'; +import type { PaymentInteractionPolicy } from '../payments/types.js'; export type { InboundMiddlewareFn } from './middleware.js'; /** @@ -487,6 +488,15 @@ export class NostrServerTransport this.listToolsResultTransformers.push(transformer); } + /** + * Sets the supported payment interaction policy for this server. + */ + public setSupportedPaymentInteraction( + mode: PaymentInteractionPolicy | undefined, + ): void { + this.inboundCoordinator.setSupportedPaymentInteraction(mode); + } + /** * Adds a provider for extra tags on public tools/list announcement events. */ @@ -686,6 +696,27 @@ export class NostrServerTransport await this.outboundResponseRouter.route(response); } + /** + * Sends a targeted response explicitly bypassing the correlation store lookup. + * Useful for middleware that needs to proactively reject requests without + * letting them reach the MCP application. + * + * @param clientPubkey The target client's public key. + * @param response The JSON-RPC response or error to send. + * @param requestEventId The original Nostr event ID of the request being responded to. + */ + public async sendTargetedResponse( + clientPubkey: string, + response: JSONRPCResponse | JSONRPCErrorResponse, + requestEventId: string, + ): Promise { + await this.outboundResponseRouter.routeTargeted( + clientPubkey, + response, + requestEventId, + ); + } + /** * Handles notification messages with routing. * @param notification The JSON-RPC notification to send. diff --git a/src/transport/nostr-server/inbound-coordinator.ts b/src/transport/nostr-server/inbound-coordinator.ts index bd9baea..0189c18 100644 --- a/src/transport/nostr-server/inbound-coordinator.ts +++ b/src/transport/nostr-server/inbound-coordinator.ts @@ -8,6 +8,7 @@ import { import { type NostrEvent } from 'nostr-tools'; import { type Logger } from '../../core/utils/logger.js'; import { type SessionStore, type ClientSession } from './session-store.js'; +import { NOSTR_TAGS } from '../../core/constants.js'; import { type CorrelationStore } from './correlation-store.js'; import { type AuthorizationPolicy } from './authorization-policy.js'; import { type ServerOpenStreamFactory } from './open-stream-factory.js'; @@ -26,6 +27,11 @@ import { } from '../../core/index.js'; import { GiftWrapMode } from '../../core/interfaces.js'; import { type OpenStreamWriter } from '../open-stream/index.js'; +import { UNSUPPORTED_PAYMENT_INTERACTION_ERROR_CODE } from '../../payments/constants.js'; +import type { + PaymentInteractionMode, + PaymentInteractionPolicy, +} from '../../payments/types.js'; export interface ServerInboundCoordinatorDeps { sessionStore: SessionStore; @@ -38,6 +44,7 @@ export interface ServerInboundCoordinatorDeps { oversizedEnabled: boolean; openStreamEnabled: boolean; giftWrapMode: GiftWrapMode; + supportedPaymentInteraction?: PaymentInteractionPolicy; sendMcpMessage: ( msg: JSONRPCMessage, pubkey: string, @@ -75,6 +82,12 @@ export class ServerInboundCoordinator { this.inboundNotificationDispatcher = dispatcher; } + public setSupportedPaymentInteraction( + mode: PaymentInteractionPolicy | undefined, + ): void { + this.deps.supportedPaymentInteraction = mode; + } + /** * Authorizes and processes an incoming Nostr event, handling message validation, * client authorization, session management, and optional client public key injection. @@ -164,9 +177,82 @@ export class ServerInboundCoordinator { const clientPmis = event.tags .filter((tag) => tag[0] === 'pmi' && typeof tag[1] === 'string') .map((tag) => tag[1] as string); + + const serverSupportsExplicitGating = + this.deps.supportedPaymentInteraction === 'optional'; + + const paymentInteractionTag = event.tags.find( + (tag) => + tag[0] === NOSTR_TAGS.PAYMENT_INTERACTION && + typeof tag[1] === 'string', + ); + + if (paymentInteractionTag && !session.requestedPaymentInteraction) { + const mode = paymentInteractionTag[1]; + if (mode === 'transparent' || mode === 'explicit_gating') { + session.requestedPaymentInteraction = mode as PaymentInteractionMode; + + if (mode === 'explicit_gating' && !serverSupportsExplicitGating) { + session.effectivePaymentInteraction = 'transparent'; + + if (isJSONRPCRequest(inboundMessage)) { + const errorResponse: JSONRPCErrorResponse = { + jsonrpc: '2.0', + id: inboundMessage.id, + error: { + code: UNSUPPORTED_PAYMENT_INTERACTION_ERROR_CODE, + message: + 'Unsupported payment_interaction mode: explicit_gating', + // CEP-8 effective-mode disclosure: requested + supported modes. + data: { + requested: mode, + supported: ['transparent'], + }, + }, + }; + const tags = this.deps.createResponseTags(event.pubkey, event.id); + this.deps + .sendMcpMessage( + errorResponse, + event.pubkey, + CTXVM_MESSAGES_KIND, + tags, + isEncrypted, + undefined, + isEncrypted + ? this.deps.giftWrapMode === GiftWrapMode.EPHEMERAL + ? EPHEMERAL_GIFT_WRAP_KIND + : this.deps.giftWrapMode === GiftWrapMode.PERSISTENT + ? GIFT_WRAP_KIND + : wrapKind + : undefined, + ) + .catch((err) => { + this.deps.logger.error( + 'Failed to send negotiation error response', + { + error: err instanceof Error ? err.message : String(err), + }, + ); + }); + return; + } + } + + session.effectivePaymentInteraction = serverSupportsExplicitGating + ? session.requestedPaymentInteraction + : 'transparent'; + } else { + session.requestedPaymentInteraction = 'transparent'; + session.effectivePaymentInteraction = 'transparent'; + } + } + const ctx = { clientPubkey: event.pubkey, clientPmis: clientPmis.length > 0 ? clientPmis : undefined, + paymentInteraction: + session.effectivePaymentInteraction ?? 'transparent', }; const middlewares = this.deps.inboundMiddlewares; diff --git a/src/transport/nostr-server/outbound-response-router.ts b/src/transport/nostr-server/outbound-response-router.ts index 5f35678..d70cdbd 100644 --- a/src/transport/nostr-server/outbound-response-router.ts +++ b/src/transport/nostr-server/outbound-response-router.ts @@ -14,8 +14,7 @@ import { type Logger } from '../../core/utils/logger.js'; import { type CorrelationStore } from './correlation-store.js'; import { type ClientSession, type SessionStore } from './session-store.js'; import { type AnnouncementManager } from './announcement-manager.js'; - -import { CTXVM_MESSAGES_KIND } from '../../core/constants.js'; +import { NOSTR_TAGS, CTXVM_MESSAGES_KIND } from '../../core/constants.js'; import { sendOversizedServerResponse } from './oversized-server-handler.js'; /** @@ -220,6 +219,9 @@ export class OutboundResponseRouter { logger: this.deps.logger, }, ); + // Note: Oversized transfers skip maybeAppendPaymentInteractionDisclosure() and marking discovery + // tags as sent on this early return path. This is low risk in practice because oversized transfers + // only trigger for large payloads, and negotiation usually happens early with small messages. return; } } @@ -230,6 +232,8 @@ export class OutboundResponseRouter { session, }); + this.maybeAppendPaymentInteractionDisclosure(tags, session); + const giftWrapKind = this.deps.chooseGiftWrapKind({ session, fallbackWrapKind: route.wrapKind, @@ -269,4 +273,70 @@ export class OutboundResponseRouter { throw error; } } + + /** + * Routes a response back to a specifically targeted client and request event. + * This bypasses the normal correlation lookup, which is useful when + * middleware needs to reject a request early (e.g. for explicit gating). + */ + public async routeTargeted( + clientPubkey: string, + response: JSONRPCResponse | JSONRPCErrorResponse, + requestEventId: string, + ): Promise { + const session = this.deps.sessionStore.getSession(clientPubkey); + if (!session) { + this.deps.logger.warn( + 'Cannot route targeted response: no active session found', + { clientPubkey, requestEventId }, + ); + return; + } + + const tags = this.deps.buildOutboundTags({ + baseTags: this.deps.createResponseTags(clientPubkey, requestEventId), + session, + }); + + this.maybeAppendPaymentInteractionDisclosure(tags, session); + + const giftWrapKind = this.deps.chooseGiftWrapKind({ + session, + }); + + await this.deps.sendMcpMessage( + response, + clientPubkey, + CTXVM_MESSAGES_KIND, + tags, + session.isEncrypted, + undefined, + giftWrapKind, + ); + } + + private maybeAppendPaymentInteractionDisclosure( + tags: string[][], + session: ClientSession, + ): void { + // CEP-8: Disclose effective mode on first response if client requested a non-default mode. + if ( + session.requestedPaymentInteraction && + session.requestedPaymentInteraction !== 'transparent' && + !session.hasDisclosedPaymentInteraction && + session.effectivePaymentInteraction + ) { + const effective = session.effectivePaymentInteraction; + // The availability advertisement (extraCommonTags) may already be flushed + // onto this first response with the same value. Avoid emitting a duplicate + // tag; the existing one already satisfies the disclosure obligation. + const alreadyPresent = tags.some( + (t) => t[0] === NOSTR_TAGS.PAYMENT_INTERACTION && t[1] === effective, + ); + if (!alreadyPresent) { + tags.push([NOSTR_TAGS.PAYMENT_INTERACTION, effective]); + } + session.hasDisclosedPaymentInteraction = true; + } + } } diff --git a/src/transport/nostr-server/session-store.ts b/src/transport/nostr-server/session-store.ts index e911c1b..169d81d 100644 --- a/src/transport/nostr-server/session-store.ts +++ b/src/transport/nostr-server/session-store.ts @@ -6,6 +6,7 @@ */ import { DEFAULT_LRU_SIZE } from '../../core/constants.js'; import { LruCache } from '../../core/utils/lru-cache.js'; +import type { PaymentInteractionMode } from '../../payments/types.js'; /** * Represents a connected client session. * Simplified from the original design - correlation data is now @@ -26,6 +27,12 @@ export interface ClientSession { supportsOversizedTransfer: boolean; /** Whether the client has advertised CEP-41 open stream support. */ supportsOpenStream: boolean; + /** Client-requested payment interaction mode (from first message). */ + requestedPaymentInteraction?: PaymentInteractionMode; + /** Effective payment interaction mode for this session. */ + effectivePaymentInteraction?: PaymentInteractionMode; + /** Whether the effective mode has been disclosed on the first response. */ + hasDisclosedPaymentInteraction?: boolean; } /** diff --git a/src/transport/payments-flow.test.ts b/src/transport/payments-flow.test.ts index 1c9b12e..17cecee 100644 --- a/src/transport/payments-flow.test.ts +++ b/src/transport/payments-flow.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test, + spyOn, } from 'bun:test'; import { sleep } from 'bun'; import { Client } from '@contextvm/mcp-sdk/client'; @@ -27,6 +28,7 @@ import { FakePaymentHandler, FakePaymentProcessor, createServerPaymentsMiddleware, + rejectPrice, withClientPayments, withServerPayments, } from '../payments/index.js'; @@ -87,6 +89,20 @@ 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. + */ +async function sleepPastSecondBoundary(): Promise { + const waitMs = 1000 - (Date.now() % 1000) + 10; + await new Promise((resolve) => setTimeout(resolve, waitMs)); +} + describe.serial('payments fake flow (transport-level)', () => { let stopRelay: (() => void) | undefined; @@ -1009,4 +1025,1016 @@ describe.serial('payments fake flow (transport-level)', () => { await client.close(); await mcpServer.close(); }, 20000); + + test('explicit gating: gates tools/call via -32042 error and auto-retries', async () => { + const serverSK = generateSecretKey(); + const serverPrivateKey = bytesToHex(serverSK); + const serverPublicKey = getPublicKey(serverSK); + + const mcpServer = new McpServer({ + name: 'explicit-server', + version: '1.0.0', + }); + let toolCallCount = 0; + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + toolCallCount++; + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + const processor = new FakePaymentProcessor({ verifyDelayMs: 20 }); + const createSpy = spyOn(processor, 'createPaymentRequired'); + const verifySpy = spyOn(processor, 'verifyPayment'); + const pricedCapabilities = [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + description: 'explicit test payment', + }, + ] as const; + + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [...pricedCapabilities], + paymentInteraction: 'optional', + }, + ); + + await mcpServer.connect(serverTransport); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + + // Track if onPaymentRequired was called + let explicitPaymentHandled = false; + + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => { + await sleepPastSecondBoundary(); + explicitPaymentHandled = true; + return { paid: true }; + }, + }); + + const client = new Client({ name: 'explicit-client', version: '1.0.0' }); + await client.connect(paidClientTransport); + + const result = await client.callTool({ + name: 'add', + arguments: { a: 10, b: 20 }, + }); + + const typedResult = result as { + content: Array<{ type: string; text?: string }>; + }; + expect(typedResult.content[0]).toMatchObject({ type: 'text', text: '30' }); + + expect(explicitPaymentHandled).toBe(true); + expect(toolCallCount).toBe(1); + expect(createSpy).toHaveBeenCalled(); + expect(verifySpy).toHaveBeenCalled(); + + await client.close(); + await mcpServer.close(); + }, 20000); + + // CEP-8 MUST: server indicates the effective mode on its first direct response. + test('explicit gating: server discloses payment_interaction=explicit_gating on first direct response', async () => { + const serverSK = generateSecretKey(); + const serverPublicKey = getPublicKey(serverSK); + const serverPrivateKey = bytesToHex(serverSK); + + const mcpServer = new McpServer({ + name: 'disclosure-server', + version: '1.0.0', + }); + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + const processor = new FakePaymentProcessor(); + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + }, + ], + paymentInteraction: 'optional', + }, + ); + await mcpServer.connect(serverTransport); + + // Capture the server's first published CTXVM event carrying a payment_interaction tag. + const capturePromise = captureNextCtxvmEvent({ + relayUrl, + authors: [serverPublicKey], + where: (event) => + event.tags.some( + (t) => t[0] === 'payment_interaction' && typeof t[1] === 'string', + ), + timeoutMs: 5000, + }); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => ({ paid: true }), + }); + + const client = new Client({ + name: 'disclosure-client', + version: '1.0.0', + }); + await client.connect(paidClientTransport); // triggers initialize β†’ first response + + const event = await capturePromise; + const piTag = event.tags.find((t) => t[0] === 'payment_interaction') as + | readonly string[] + | undefined; + expect(piTag?.[1]).toBe('explicit_gating'); + + await client.close(); + await mcpServer.close(); + }, 20000); + + // Locks the pending race: pay β†’ slow verify β†’ -32043 β†’ backoff β†’ grant β†’ success. + test('explicit gating: -32043 pending race resolves after verify completes', async () => { + const serverSK = generateSecretKey(); + const serverPublicKey = getPublicKey(serverSK); + const serverPrivateKey = bytesToHex(serverSK); + + const mcpServer = new McpServer({ + name: 'pending-race-server', + version: '1.0.0', + }); + let toolCallCount = 0; + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + toolCallCount++; + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + // verifyDelayMs >> relay round-trip, so the client's first retry arrives + // while verification is still pending (β†’ -32043). Default grant TTL (5 min) + // keeps retry_after at 2 s; verification completes well before the backoff. + const processor = new FakePaymentProcessor({ verifyDelayMs: 500 }); + const createSpy = spyOn(processor, 'createPaymentRequired'); + const verifySpy = spyOn(processor, 'verifyPayment'); + + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + }, + ], + paymentInteraction: 'optional', + }, + ); + await mcpServer.connect(serverTransport); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => { + await sleepPastSecondBoundary(); + return { paid: true }; + }, + }); + + const client = new Client({ + name: 'pending-race-client', + version: '1.0.0', + }); + await client.connect(paidClientTransport); + + const result = await client.callTool({ + name: 'add', + arguments: { a: 5, b: 7 }, + }); + + const typedResult = result as { + content: Array<{ type: string; text?: string }>; + }; + expect(typedResult.content[0]).toMatchObject({ type: 'text', text: '12' }); + + // Despite the request being sent multiple times (initial + retry-after-pay + + // retry-after-32043), exactly one payment was created and one verification ran. + // This is the core anti-double-charge invariant of the explicit-gating flow. + expect(toolCallCount).toBe(1); + expect(createSpy.mock.calls.length).toBe(1); + expect(verifySpy.mock.calls.length).toBe(1); + + await client.close(); + await mcpServer.close(); + }, 25000); + + // User declines to pay: the wrapper synthesizes -32042 with the given reason + // and does not retry. Locks the { paid: false } contract end-to-end. + test('explicit gating: user-declined payment surfaces -32042 and does not retry', async () => { + const serverSK = generateSecretKey(); + const serverPrivateKey = bytesToHex(serverSK); + const serverPublicKey = getPublicKey(serverSK); + + const mcpServer = new McpServer({ + name: 'decline-server', + version: '1.0.0', + }); + let toolCallCount = 0; + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + toolCallCount++; + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + const processor = new FakePaymentProcessor(); + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + }, + ], + paymentInteraction: 'optional', + }, + ); + await mcpServer.connect(serverTransport); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => ({ + paid: false, + reason: 'user_cancelled', + }), + }); + + const client = new Client({ name: 'decline-client', version: '1.0.0' }); + await client.connect(paidClientTransport); + + await expect( + client.callTool({ name: 'add', arguments: { a: 1, b: 2 } }), + ).rejects.toMatchObject({ + code: -32042, + data: { reason: 'user_cancelled' }, + }); + + expect(toolCallCount).toBe(0); + + await client.close(); + await mcpServer.close(); + }, 20000); + + // onPaymentRequired rejects: the wrapper synthesizes -32042 with + // data.type = 'payment_handler_error' and surfaces it to the caller. + test('explicit gating: onPaymentRequired throwing surfaces -32042 with type payment_handler_error', async () => { + const serverSK = generateSecretKey(); + const serverPrivateKey = bytesToHex(serverSK); + const serverPublicKey = getPublicKey(serverSK); + + const mcpServer = new McpServer({ + name: 'handler-error-server', + version: '1.0.0', + }); + let toolCallCount = 0; + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + toolCallCount++; + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + const processor = new FakePaymentProcessor(); + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + }, + ], + paymentInteraction: 'optional', + }, + ); + await mcpServer.connect(serverTransport); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => { + throw new Error('wallet offline'); + }, + }); + + const client = new Client({ + name: 'handler-error-client', + version: '1.0.0', + }); + await client.connect(paidClientTransport); + + await expect( + client.callTool({ name: 'add', arguments: { a: 1, b: 2 } }), + ).rejects.toMatchObject({ + code: -32042, + data: { reason: 'wallet offline', type: 'payment_handler_error' }, + }); + + expect(toolCallCount).toBe(0); + + await client.close(); + await mcpServer.close(); + }, 20000); + + // Verify-failure window: when verification fails after the client paid, the + // server clears pending state and the next retry yields a FRESH invoice + // (distinct pay_req). The client pays twice; the tool runs exactly once. + // Locks wire-level correlation across the verify-failure branch (the double- + // charge window documented on onPaymentRequired). + test('explicit gating: verifyPayment failure yields a fresh invoice on retry', async () => { + const serverSK = generateSecretKey(); + const serverPrivateKey = bytesToHex(serverSK); + const serverPublicKey = getPublicKey(serverSK); + + const mcpServer = new McpServer({ + name: 'fresh-invoice-server', + version: '1.0.0', + }); + let toolCallCount = 0; + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + toolCallCount++; + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + const issuedPayReqs: string[] = []; + let verifyCount = 0; + const processor: PaymentProcessor = { + pmi: 'fake', + async createPaymentRequired(params) { + const pay_req = `pr-${issuedPayReqs.length + 1}`; + issuedPayReqs.push(pay_req); + return { + amount: params.amount, + pay_req, + description: params.description, + pmi: 'fake', + ttl: 300, + }; + }, + async verifyPayment() { + verifyCount += 1; + if (verifyCount === 1) { + throw new Error('settlement failed'); + } + return { _meta: { settled: true } }; + }, + }; + + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + }, + ], + paymentInteraction: 'optional', + }, + ); + await mcpServer.connect(serverTransport); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + // Client pays unconditionally, so both invoices get paid. + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => { + await sleepPastSecondBoundary(); + return { paid: true }; + }, + }); + + const client = new Client({ + name: 'fresh-invoice-client', + version: '1.0.0', + }); + await client.connect(paidClientTransport); + + const result = await client.callTool({ + name: 'add', + arguments: { a: 5, b: 7 }, + }); + const typedResult = result as { + content: Array<{ type: string; text?: string }>; + }; + expect(typedResult.content[0]).toMatchObject({ type: 'text', text: '12' }); + + // Two distinct invoices issued; verify ran twice; the tool ran exactly once. + expect(issuedPayReqs).toHaveLength(2); + expect(issuedPayReqs[0]).not.toBe(issuedPayReqs[1]); + expect(verifyCount).toBe(2); + expect(toolCallCount).toBe(1); + + await client.close(); + await mcpServer.close(); + }, 25000); + + // resolvePrice + explicit_gating integration: a dynamic quote flows through + // the explicit-gating path end-to-end. The quoted amount (not the static + // pricedCapabilities amount) reaches createPaymentRequired, payment succeeds, + // and the tool runs exactly once. + test('explicit gating: resolvePrice quotes a dynamic amount that reaches createPaymentRequired', async () => { + const serverSK = generateSecretKey(); + const serverPublicKey = getPublicKey(serverSK); + const serverPrivateKey = bytesToHex(serverSK); + + const mcpServer = new McpServer({ + name: 'dynamic-price-server', + version: '1.0.0', + }); + let toolCallCount = 0; + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + toolCallCount++; + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + const processor = new FakePaymentProcessor(); + const createSpy = spyOn(processor, 'createPaymentRequired'); + + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'add', + amount: 1, // static fallback; resolvePrice overrides this + currencyUnit: 'test', + }, + ], + paymentInteraction: 'optional', + resolvePrice: async () => ({ + amount: 42, + description: 'dynamic quote', + meta: { quoted: true }, + }), + }, + ); + await mcpServer.connect(serverTransport); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => { + await sleepPastSecondBoundary(); + return { paid: true }; + }, + }); + + const client = new Client({ + name: 'dynamic-price-client', + version: '1.0.0', + }); + await client.connect(paidClientTransport); + + const result = await client.callTool({ + name: 'add', + arguments: { a: 5, b: 7 }, + }); + const typedResult = result as { + content: Array<{ type: string; text?: string }>; + }; + expect(typedResult.content[0]).toMatchObject({ type: 'text', text: '12' }); + + // resolvePrice was consulted: createPaymentRequired received the quoted + // amount (42), not the static pricedCapabilities amount (1). + expect(createSpy.mock.calls.length).toBe(1); + expect(createSpy.mock.calls[0][0].amount).toBe(42); + + expect(toolCallCount).toBe(1); + + await client.close(); + await mcpServer.close(); + }, 20000); + + // CEP-8 negotiation: a client requesting explicit_gating against a transparent- + // only server receives -32602 with the requested + supported modes. Locks the + // effective-mode-disclosure MUST at the integration level. + test('explicit gating: transparent-only server rejects initialize with -32602', async () => { + const serverSK = generateSecretKey(); + const serverPrivateKey = bytesToHex(serverSK); + const serverPublicKey = getPublicKey(serverSK); + + const mcpServer = new McpServer({ + name: 'transparent-only-server', + version: '1.0.0', + }); + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + // Explicitly transparent-only: rejects explicit_gating negotiation. + const processor = new FakePaymentProcessor(); + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + }, + ], + paymentInteraction: 'transparent', + }, + ); + await mcpServer.connect(serverTransport); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => ({ paid: true }), + }); + + const client = new Client({ name: 'negotiation-client', version: '1.0.0' }); + + await expect(client.connect(paidClientTransport)).rejects.toMatchObject({ + code: -32602, + data: { requested: 'explicit_gating', supported: ['transparent'] }, + }); + + await mcpServer.close(); + }, 20000); + + // resolvePrice rejection: server emits payment_rejected instead of requesting + // payment; the client synthesizes -32000 so the caller rejects immediately + // instead of timing out. Locks the full transparent rejection path end-to-end. + test('transparent: resolvePrice rejection surfaces -32000 to the caller', async () => { + const serverSK = generateSecretKey(); + const serverPrivateKey = bytesToHex(serverSK); + const serverPublicKey = getPublicKey(serverSK); + + const mcpServer = new McpServer({ + name: 'reject-server', + version: '1.0.0', + }); + let toolCallCount = 0; + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + toolCallCount++; + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + const processor = new FakePaymentProcessor(); + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + }, + ], + resolvePrice: async () => rejectPrice('Free quota exhausted'), + }, + ); + await mcpServer.connect(serverTransport); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [new FakePaymentHandler({ pmi: 'fake', delayMs: 1 })], + }); + + const client = new Client({ name: 'reject-client', version: '1.0.0' }); + await client.connect(paidClientTransport); + + await expect( + client.callTool({ name: 'add', arguments: { a: 1, b: 2 } }), + ).rejects.toMatchObject({ code: -32000 }); + // McpError wraps the message as 'MCP error -32000: ' + await expect( + client.callTool({ name: 'add', arguments: { a: 3, b: 4 } }), + ).rejects.toThrow('Free quota exhausted'); + + expect(toolCallCount).toBe(0); + + await client.close(); + await mcpServer.close(); + }, 20000); + + // CEP-8 coexistence: a server that offers explicit_gating is opt-in. When the + // client omits the payment_interaction tag (default transparent), the session + // falls back to transparent mode and the request flows through the transparent + // payment_required path. The reverse direction (client requests, server + // doesn't support) is covered by the -32602 negotiation test above. + test('explicit-capable server falls back to transparent for a transparent client', async () => { + const serverSK = generateSecretKey(); + const serverPrivateKey = bytesToHex(serverSK); + const serverPublicKey = getPublicKey(serverSK); + + const mcpServer = new McpServer({ + name: 'explicit-capable-server', + version: '1.0.0', + }); + let toolCallCount = 0; + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + toolCallCount++; + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + const processor = new FakePaymentProcessor({ verifyDelayMs: 50 }); + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + }, + ], + // Server advertises explicit_gating, but it is opt-in per session. + paymentInteraction: 'optional', + }, + ); + await mcpServer.connect(serverTransport); + + const clientSK = generateSecretKey(); + const clientPrivateKey = bytesToHex(clientSK); + const clientTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(clientPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + // No paymentInteraction option β†’ transparent client with an auto-satisfying + // handler. The server must NOT gate this session with -32042. + const paidClientTransport = withClientPayments(clientTransport, { + handlers: [new FakePaymentHandler({ pmi: 'fake', delayMs: 50 })], + }); + + const client = new Client({ + name: 'transparent-client', + version: '1.0.0', + }); + await client.connect(paidClientTransport); + + const result = await client.callTool({ + name: 'add', + arguments: { a: 2, b: 3 }, + }); + const typedResult = result as { + content: Array<{ type: string; text?: string }>; + }; + expect(typedResult.content[0]).toMatchObject({ type: 'text', text: '5' }); + expect(toolCallCount).toBe(1); + + await client.close(); + await mcpServer.close(); + }, 20000); + + // CEP-8 optional default: when `paymentInteraction` is omitted the server + // defaults to the optional policy and mirrors each client's requested + // lifecycle. A transparent client (no tag) gets the notification flow, while + // an explicit-gating client is gated. Locks the new default + mirror behavior. + test('optional default (omitted paymentInteraction): server mirrors the lifecycle each client requests', async () => { + const serverSK = generateSecretKey(); + const serverPublicKey = getPublicKey(serverSK); + const serverPrivateKey = bytesToHex(serverSK); + + const mcpServer = new McpServer({ + name: 'optional-default-server', + version: '1.0.0', + }); + let toolCallCount = 0; + mcpServer.registerTool( + 'add', + { + title: 'Addition Tool', + description: 'Add two numbers', + inputSchema: { a: z.number(), b: z.number() }, + }, + async ({ a, b }: { a: number; b: number }) => { + toolCallCount++; + return { content: [{ type: 'text', text: String(a + b) }] }; + }, + ); + + const processor = new FakePaymentProcessor({ verifyDelayMs: 50 }); + // NOTE: no paymentInteraction option β†’ defaults to 'optional'. + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(serverPrivateKey), + relayHandler: new ApplesauceRelayPool([relayUrl]), + encryptionMode: EncryptionMode.DISABLED, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'add', + amount: 1, + currencyUnit: 'test', + }, + ], + }, + ); + await mcpServer.connect(serverTransport); + + // --- Transparent client (omits payment_interaction): stays on notifications --- + await sleepPastSecondBoundary(); + const transparentSK = generateSecretKey(); + const transparentTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(bytesToHex(transparentSK)), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const transparentPaid = withClientPayments(transparentTransport, { + handlers: [new FakePaymentHandler({ pmi: 'fake', delayMs: 10 })], + }); + const transparentClient = new Client({ + name: 'transparent-client', + version: '1.0.0', + }); + await transparentClient.connect(transparentPaid); + + const transparentResult = await transparentClient.callTool({ + name: 'add', + arguments: { a: 1, b: 2 }, + }); + const transparentTyped = transparentResult as { + content: Array<{ type: string; text?: string }>; + }; + expect(transparentTyped.content[0]).toMatchObject({ + type: 'text', + text: '3', + }); + + await transparentClient.close(); + + // --- Explicit-gating client (requests payment_interaction=explicit_gating): gated --- + await sleepPastSecondBoundary(); + let explicitHandled = false; + const explicitSK = generateSecretKey(); + const explicitTransport = new NostrClientTransport({ + signer: new PrivateKeySigner(bytesToHex(explicitSK)), + relayHandler: new ApplesauceRelayPool([relayUrl]), + serverPubkey: serverPublicKey, + encryptionMode: EncryptionMode.DISABLED, + }); + const explicitPaid = withClientPayments(explicitTransport, { + handlers: [], + paymentInteraction: 'explicit_gating', + onPaymentRequired: async () => { + await sleepPastSecondBoundary(); + explicitHandled = true; + return { paid: true }; + }, + }); + const explicitClient = new Client({ + name: 'explicit-client', + version: '1.0.0', + }); + await explicitClient.connect(explicitPaid); + + const explicitResult = await explicitClient.callTool({ + name: 'add', + arguments: { a: 4, b: 5 }, + }); + const explicitTyped = explicitResult as { + content: Array<{ type: string; text?: string }>; + }; + expect(explicitTyped.content[0]).toMatchObject({ + type: 'text', + text: '9', + }); + expect(explicitHandled).toBe(true); + + // Both lifecycles reached the underlying handler exactly once. + expect(toolCallCount).toBe(2); + + await explicitClient.close(); + await mcpServer.close(); + }, 20000); });