diff --git a/.changeset/atomic-payment-state.md b/.changeset/atomic-payment-state.md new file mode 100644 index 0000000..a43019c --- /dev/null +++ b/.changeset/atomic-payment-state.md @@ -0,0 +1,13 @@ +--- +'@contextvm/sdk': minor +--- + +Prevent duplicate payment offers and stale authorization changes using atomic, generation-fenced payment state and a retained transparent-request replay ledger. + +**Breaking change:** `AuthorizationStore` now implements the asynchronous `PaymentStateStore` contract (`admit`, `transition`, and `read`). Replace the former synchronous grant/claim/pending/clear methods and `maxEntries` option with the atomic store operations and `PaymentStatePolicy` limits. Inject the store through `ServerPaymentsOptions.stateStore`; both payment modes must use the same store. + +The SDK bundles only an in-memory store, with no added storage dependencies. One paying server identity must run in one process with a shared store. Restarts lose its invoice bindings, grants, and replay records. Applications needing persistence or cross-process coordination can inject their own `PaymentStateStore` and manage its dependencies and lifecycle. + +Server quotes and processor output are validated before payment or execution. Expired paid grants return `payment_expired` with instructions to reconcile the payment rather than request another payment automatically. + +Client and server boundaries share a non-empty, 2,048-character PMI schema. Identifiers are opaque and matched exactly, preserving custom rails and supporting HTTPS method identifiers without adding a protocol grammar restriction. diff --git a/src/__mocks__/client-payments-harness.ts b/src/__mocks__/client-payments-harness.ts new file mode 100644 index 0000000..c3a68f2 --- /dev/null +++ b/src/__mocks__/client-payments-harness.ts @@ -0,0 +1,143 @@ +import type { Transport } from '@contextvm/mcp-sdk/shared/transport'; +import type { + JSONRPCMessage, + JSONRPCRequest, +} from '@contextvm/mcp-sdk/types.js'; +import { + withClientPayments, + type ClientPaymentsOptions, +} from '../payments/client-payments.js'; +import type { PaymentOption } from '../payments/types.js'; +import { waitFor as waitForValue } from '../core/utils/test.utils.js'; + +/** Coordinates isolated test work without changing the global clock. */ +export async function waitFor( + condition: () => boolean, + options: { timeoutMs?: number } = {}, +): Promise { + await waitForValue({ + produce: () => (condition() ? true : undefined), + timeoutMs: options.timeoutMs ?? 1000, + intervalMs: 2, + }); +} + +/** Builds or advances isolated payment test state. */ +export const tick = (): Promise => + new Promise((resolve) => setTimeout(resolve, 0)); +/** Builds or advances isolated payment test state. */ +export const wait = (ms: number): Promise => + new Promise((resolve) => setTimeout(resolve, ms)); +/** Builds or advances isolated payment test state. */ +export const request = (id: string | number = 1): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'paid', arguments: { value: id } }, +}); +/** Builds or advances isolated payment test state. */ +export const option = ( + pay_req = 'invoice', + amount = 5, + pmi = 'fake', +): PaymentOption => ({ amount, pmi, pay_req }); +/** Builds or advances isolated payment test state. */ +export const offer = (input: unknown = option()): JSONRPCMessage => + ({ + jsonrpc: '2.0', + method: 'notifications/payment_required', + params: input, + }) as JSONRPCMessage; +/** Builds or advances isolated payment test state. */ +export const required = ( + id: string | number = 1, + options: unknown = [option()], +): JSONRPCMessage => ({ + jsonrpc: '2.0', + id, + error: { + code: -32042, + message: 'Payment Required', + data: { payment_options: options }, + }, +}); +/** Builds or advances isolated payment test state. */ +export const pending = ( + id: string | number = 1, + retry_after?: unknown, +): JSONRPCMessage => ({ + jsonrpc: '2.0', + id, + error: { + code: -32043, + message: 'Payment Pending', + data: retry_after === undefined ? {} : { retry_after }, + }, +}); +/** Lets a test decide when an external payment action completes. */ +export function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} +/** One transport, its wrapper, and all observable test outcomes. */ +export interface Harness { + paid: Transport; + base: Transport; + sent: JSONRPCMessage[]; + received: JSONRPCMessage[]; + errors: Error[]; + deliver: (message: JSONRPCMessage) => void; +} +/** Coordinates isolated test work without changing the global clock. */ +export async function run( + options: ClientPaymentsOptions, + body: (harness: Harness) => Promise, +): Promise { + const sent: JSONRPCMessage[] = []; + const received: JSONRPCMessage[] = []; + const errors: Error[] = []; + const base: Transport = { + async start(): Promise {}, + async send(message): Promise { + sent.push(message); + }, + async close(): Promise {}, // Deliberately does not fire onclose. + }; + const paid = withClientPayments(base, { minRetryDelayMs: 2, ...options }); + paid.onmessage = (message): void => { + received.push(message); + }; + paid.onerror = (error): void => { + errors.push(error); + }; + await paid.start(); + try { + await body({ + paid, + base, + sent, + received, + errors, + deliver: (message): void => base.onmessage?.(message), + }); + } finally { + await paid.close(); + } +} +/** Collects terminal errors observed by the test caller. */ +export function failures( + harness: Harness, +): Array<{ error: { data?: unknown } }> { + return harness.received.filter((message) => 'error' in message) as Array<{ + error: { data?: unknown }; + }>; +} diff --git a/src/__mocks__/payment-state.ts b/src/__mocks__/payment-state.ts new file mode 100644 index 0000000..6b5df83 --- /dev/null +++ b/src/__mocks__/payment-state.ts @@ -0,0 +1,76 @@ +import type { CanonicalInvocationIdentity } from '../payments/types.js'; +import type { + PaymentStateAdmission, + PaymentStateRecord, + PaymentStateStore, +} from '../payments/payment-state-store.js'; +import { paymentAuthorizationKey } from '../payments/payment-state-store.js'; + +export function authorizationInput( + identity: CanonicalInvocationIdentity, + leaseMs = 10000, +): PaymentStateAdmission { + return { + key: paymentAuthorizationKey( + identity.clientPubkey, + identity.invocationHash, + ), + mode: 'explicit_gating', + clientPubkey: identity.clientPubkey, + invocationHash: identity.invocationHash, + requestEventId: `seed-${crypto.randomUUID()}`, + leaseMs, + }; +} + +export async function seedPending( + store: PaymentStateStore, + identity: CanonicalInvocationIdentity, + leaseMs: number, +): Promise { + const reserved = await store.admit(authorizationInput(identity, leaseMs)); + if (reserved.status !== 'reserved') + throw new Error(`Seed admission failed: ${reserved.status}`); + return reserved.record; +} + +export async function seedGrant( + store: PaymentStateStore, + identity: CanonicalInvocationIdentity, + ttlMs: number, +): Promise { + const record = await seedPending(store, identity, 10000); + const fence = { key: record.key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + await store.transition({ + ...fence, + type: 'bind', + invoice: { pmi: 'fake', amount: 10, pay_req: 'seed-invoice' }, + leaseMs: 10000, + }); + await store.transition({ ...fence, type: 'grant', ttlMs }); +} + +export async function hasGrant( + store: PaymentStateStore, + identity: CanonicalInvocationIdentity, +): Promise { + const record = await store.read( + paymentAuthorizationKey(identity.clientPubkey, identity.invocationHash), + ); + return ( + record?.phase === 'granted' && (record.grantExpiresAtMs ?? 0) > Date.now() + ); +} + +export async function pendingRemainingMs( + store: PaymentStateStore, + identity: CanonicalInvocationIdentity, +): Promise { + const record = await store.read( + paymentAuthorizationKey(identity.clientPubkey, identity.invocationHash), + ); + return record && ['reserved', 'issuing', 'pending'].includes(record.phase) + ? Math.max(0, record.leaseExpiresAtMs - Date.now()) + : 0; +} diff --git a/src/__mocks__/payment-store-contract.ts b/src/__mocks__/payment-store-contract.ts new file mode 100644 index 0000000..343c5ce --- /dev/null +++ b/src/__mocks__/payment-store-contract.ts @@ -0,0 +1,521 @@ +import { describe, expect, test } from 'bun:test'; +import type { + PaymentStateAdmission, + PaymentStatePolicy, + PaymentStateRecord, + PaymentStateStore, +} from '../payments/payment-state-store.js'; +import { + paymentAuthorizationKey, + paymentEventKey, +} from '../payments/payment-state-store.js'; + +export interface PaymentStoreHarness { + store: PaymentStateStore; + peer: PaymentStateStore; + advance: (ms: number) => Promise; + close: () => Promise; +} + +export function admission( + mode: 'transparent' | 'explicit_gating' = 'explicit_gating', + eventId = 'event-1', +): PaymentStateAdmission { + const clientPubkey = 'client'; + const invocationHash = 'hash'; + return { + key: + mode === 'transparent' + ? paymentEventKey(clientPubkey, eventId) + : paymentAuthorizationKey(clientPubkey, invocationHash), + mode, + clientPubkey, + invocationHash, + requestEventId: eventId, + leaseMs: 10000, + }; +} + +export async function bind( + store: PaymentStateStore, + input: PaymentStateAdmission, +): Promise { + const result = await store.admit(input); + expect(result.status).toBe('reserved'); + const record = required(result.record); + const fence = { key: record.key, generation: record.generation }; + expect(await store.transition({ ...fence, type: 'issuing' })).toBe(true); + expect( + await store.transition({ + ...fence, + type: 'bind', + invoice: { + pmi: 'fake', + amount: 10, + pay_req: `invoice-${record.generation}`, + }, + leaseMs: input.leaseMs, + }), + ).toBe(true); + return required(await store.read(record.key)); +} + +export function paymentStoreContract( + name: string, + create: ( + policy?: Partial, + ) => Promise, +): void { + describe(name, () => { + async function run( + fn: (h: PaymentStoreHarness) => Promise, + policy?: Partial, + ): Promise { + const h = await create(policy); + try { + await fn(h); + } finally { + await h.close(); + } + } + + test('fractional millisecond durations persist as integer timestamps in every backend', async () => + run(async ({ store }) => { + const input = { ...admission(), leaseMs: 10000.75 }; + const result = await store.admit(input); + const record = required(result.record); + expect(Number.isInteger(record.leaseExpiresAtMs)).toBe(true); + const fence = { key: input.key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + expect( + await store.transition({ + ...fence, + type: 'bind', + invoice: { + pmi: 'fake', + amount: 10, + pay_req: 'invoice', + ttl: 10.00075, + }, + leaseMs: input.leaseMs, + }), + ).toBe(true); + const pending = required(await store.read(input.key)); + expect(Number.isInteger(pending.leaseExpiresAtMs)).toBe(true); + expect(Number.isInteger(pending.invoiceExpiresAtMs)).toBe(true); + expect( + await store.transition({ ...fence, type: 'grant', ttlMs: 10000.75 }), + ).toBe(true); + expect( + Number.isInteger((await store.read(input.key))?.grantExpiresAtMs), + ).toBe(true); + })); + + test('late explicit offers and pending retries remain deduplicated after grant consumption', async () => + run( + async ({ store, peer }) => { + const input = admission(); + const record = await bind(store, input); + expect( + (await peer.admit({ ...input, requestEventId: 'pending-retry-1' })) + .status, + ).toBe('pending'); + expect( + (await peer.admit({ ...input, requestEventId: 'pending-retry-2' })) + .status, + ).toBe('pending'); + expect( + (await peer.admit({ ...input, requestEventId: 'overflow-retry' })) + .status, + ).toBe('capacity'); + expect( + (await peer.admit({ ...input, requestEventId: 'pending-retry-1' })) + .status, + ).toBe('pending'); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 10000, + }); + const claimed = await peer.admit({ + ...input, + requestEventId: 'paid-retry', + }); + expect(claimed.status).toBe('claimed'); + expect(required(claimed.record).eventIds).toHaveLength(4); + for (const id of [ + 'event-1', + 'pending-retry-1', + 'pending-retry-2', + 'paid-retry', + ]) + expect( + (await store.admit({ ...input, requestEventId: id })).status, + ).toBe('duplicate'); + expect( + ( + await peer.admit({ + ...input, + requestEventId: 'independent-invocation', + }) + ).status, + ).toBe('reserved'); + }, + { maxEventsPerPayment: 4 }, + )); + + test('consumed rights retain future-dated offer aliases until their last admission deadline', async () => + run(async ({ store }) => { + const now = Math.floor(Date.now() / 1000); + const input = { + ...admission(), + event: { id: 'event-1', author: 'client', createdAt: now + 30 }, + }; + const record = await bind(store, input); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 10000, + }); + const retry = { + ...input, + requestEventId: 'paid-retry', + event: { id: 'paid-retry', author: 'client', createdAt: now }, + }; + const claimed = await store.admit(retry); + expect(claimed.status).toBe('claimed'); + expect(required(claimed.record).retainUntilMs).toBeGreaterThanOrEqual( + (now + 30) * 1000 + store.policy.replayWindowMs, + ); + expect((await store.admit(input)).status).toBe('duplicate'); + })); + + test('one worker reserves an invoice; matching requests stay pending at capacity', async () => + run( + async ({ store, peer }) => { + const input = admission(); + const results = await Promise.all([ + store.admit(input), + peer.admit(input), + ]); + expect(results.map((r) => r.status).sort()).toEqual([ + 'pending', + 'reserved', + ]); + const other = { + ...input, + key: paymentAuthorizationKey('other', 'hash'), + clientPubkey: 'other', + }; + expect((await peer.admit(other)).status).toBe('capacity'); + expect((await peer.admit(input)).status).toBe('pending'); + }, + { maxPendingPayments: 1 }, + )); + + test('capacity zero refuses both lifecycles before an attempt is reserved', async () => + run( + async ({ store }) => { + for (const mode of ['transparent', 'explicit_gating'] as const) + expect((await store.admit(admission(mode))).status).toBe( + 'capacity', + ); + }, + { maxPendingPayments: 0 }, + )); + + test('a paid grant remains claimable when retained-record capacity is one', async () => + run( + async ({ store }) => { + const input = admission(); + const record = await bind(store, input); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 10000, + }); + const retry = { ...input, requestEventId: 'paid-retry' }; + expect((await store.admit(retry)).status).toBe('claimed'); + expect((await store.admit(retry)).status).toBe('duplicate'); + expect( + ( + await store.read( + paymentEventKey(input.clientPubkey, retry.requestEventId), + ) + )?.phase, + ).toBe('consumed'); + }, + { maxPaymentRecords: 1 }, + )); + + test('commands snapshot admission and invoice values before awaiting the backend', async () => + run(async ({ store }) => { + const input = admission(); + const key = input.key; + const pending = store.admit(input); + input.key = paymentAuthorizationKey('changed', 'hash'); + input.clientPubkey = 'changed'; + const result = await pending; + expect(result.status).toBe('reserved'); + const record = required(result.record); + expect(record.key).toBe(key); + expect(record.clientPubkey).toBe('client'); + const fence = { key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + const invoice = { + pmi: 'fake', + amount: 10, + pay_req: 'original-invoice', + }; + const binding = store.transition({ + ...fence, + type: 'bind', + invoice, + leaseMs: 10000, + }); + invoice.pay_req = 'changed-invoice'; + expect(await binding).toBe(true); + expect((await store.read(key))?.invoice?.pay_req).toBe( + 'original-invoice', + ); + })); + + test('an expired creation lease remains uncertain beyond normal record retention', async () => + run( + async ({ store, advance }) => { + const input = { ...admission(), leaseMs: 150 }; + const record = required((await store.admit(input)).record); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'issuing', + }); + await advance(220); + expect((await store.admit(input)).status).toBe('unknown'); + expect((await store.read(input.key))?.generation).toBe( + record.generation, + ); + }, + { replayWindowMs: 30 }, + )); + + test('one grant consumer wins; replaying its event never consumes a later grant', async () => + run(async ({ store, peer }) => { + const input = admission(); + const record = await bind(store, input); + expect( + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 10000, + }), + ).toBe(true); + const retry = { ...input, requestEventId: 'retry-event' }; + const results = await Promise.all([ + store.admit(retry), + peer.admit(retry), + ]); + expect(results.map((r) => r.status).sort()).toEqual([ + 'claimed', + 'duplicate', + ]); + const next = await bind(peer, { + ...input, + requestEventId: 'next-payment', + }); + await peer.transition({ + key: input.key, + generation: next.generation, + type: 'grant', + ttlMs: 10000, + }); + expect((await store.admit(retry)).status).toBe('duplicate'); + expect((await store.read(input.key))?.phase).toBe('granted'); + })); + + test('expired reservations fence slow pricing and every old verifier mutation', async () => + run(async ({ store, peer, advance }) => { + const input = { ...admission(), leaseMs: 150 }; + const old = await bind(store, input); + await advance(220); + const next = await peer.admit({ + ...input, + requestEventId: 'replacement', + leaseMs: 10000, + }); + expect(next.status).toBe('reserved'); + expect(required(next.record).generation).not.toBe(old.generation); + const fence = { key: input.key, generation: old.generation }; + expect( + await store.transition({ ...fence, type: 'grant', ttlMs: 10000 }), + ).toBe(false); + expect(await store.transition({ ...fence, type: 'expire' })).toBe( + false, + ); + expect(await store.transition({ ...fence, type: 'release' })).toBe( + false, + ); + expect( + await store.transition({ + ...fence, + type: 'bind', + invoice: required(old.invoice), + leaseMs: 10000, + }), + ).toBe(false); + expect((await peer.read(input.key))?.generation).toBe( + required(next.record).generation, + ); + expect( + (await store.read(JSON.stringify(['attempt', old.generation]))) + ?.invoice?.pay_req, + ).toBe(required(old.invoice).pay_req); + })); + + test('an expired invoice-creation lease cannot mint another invoice', async () => + run(async ({ store, peer, advance }) => { + const input = { ...admission(), leaseMs: 150 }; + const record = required((await store.admit(input)).record); + const fence = { key: input.key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + await advance(220); + expect((await peer.admit(input)).status).toBe('unknown'); + expect( + await store.transition({ + ...fence, + type: 'bind', + invoice: { amount: 10, pmi: 'fake', pay_req: 'late-invoice' }, + leaseMs: 10000, + }), + ).toBe(false); + expect((await peer.read(input.key))?.invoice?.pay_req).toBe( + 'late-invoice', + ); + expect((await peer.read(input.key))?.phase).toBe('unknown'); + })); + + test('transparent replay after invoice expiry retains one invoice and one execution right', async () => + run(async ({ store, peer, advance }) => { + const input = { ...admission('transparent'), leaseMs: 150 }; + const record = await bind(store, input); + const fence = { key: input.key, generation: record.generation }; + expect(await store.transition({ ...fence, type: 'consume' })).toBe( + true, + ); + expect(await peer.transition({ ...fence, type: 'consume' })).toBe( + false, + ); + await advance(220); + expect((await peer.admit(input)).status).toBe('duplicate'); + expect((await store.read(input.key))?.invoice?.pay_req).toBe( + required(record.invoice).pay_req, + ); + expect(await store.transition({ ...fence, type: 'dispatch' })).toBe( + true, + ); + expect((await peer.admit(input)).status).toBe('duplicate'); + })); + + test('pending leases expire independently of retained replay records', async () => + run( + async ({ store, advance }) => { + const input = { ...admission('transparent'), leaseMs: 150 }; + await bind(store, input); + await advance(220); + expect((await store.admit(input)).status).toBe('duplicate'); + expect( + (await store.admit(admission('transparent', 'new-event'))).status, + ).toBe('reserved'); + }, + { maxPendingPayments: 1 }, + )); + + test('live and expired paid grants are retained under record capacity pressure', async () => + run( + async ({ store, peer, advance }) => { + const input = admission(); + const record = await bind(store, input); + await store.transition({ + key: input.key, + generation: record.generation, + type: 'grant', + ttlMs: 40, + }); + expect( + (await peer.admit(admission('transparent', 'new-event'))).status, + ).toBe('capacity'); + await advance(220); + expect((await peer.admit(input)).status).toBe('expired'); + expect((await store.read(input.key))?.phase).toBe('granted'); + }, + { maxPaymentRecords: 1 }, + )); + + test('a failed pre-invoice quote can release only its own generation', async () => + run(async ({ store, peer }) => { + const input = admission(); + const old = required((await store.admit(input)).record); + expect( + await store.transition({ + key: input.key, + generation: old.generation, + type: 'release', + }), + ).toBe(true); + const current = required((await peer.admit(input)).record); + expect( + await store.transition({ + key: input.key, + generation: old.generation, + type: 'issuing', + }), + ).toBe(false); + expect((await peer.read(input.key))?.generation).toBe( + current.generation, + ); + })); + + test('unknown invoice outcomes survive admission and cannot release their reservation', async () => + run(async ({ store, peer }) => { + const input = admission(); + const record = required((await store.admit(input)).record); + const fence = { key: input.key, generation: record.generation }; + await store.transition({ ...fence, type: 'issuing' }); + await store.transition({ + ...fence, + type: 'uncertain', + reason: 'acknowledgement_lost', + }); + expect(await store.transition({ ...fence, type: 'release' })).toBe( + false, + ); + expect((await peer.admit(input)).status).toBe('unknown'); + })); + + test('rejects old, future, and mismatched authenticated event identities', async () => + run(async ({ store }) => { + const input = admission('transparent'); + const now = Math.floor(Date.now() / 1000); + for (const event of [ + { + id: input.requestEventId, + author: 'client', + createdAt: now - 100000, + }, + { id: input.requestEventId, author: 'client', createdAt: now + 120 }, + { id: 'different', author: 'client', createdAt: now }, + { id: input.requestEventId, author: 'other', createdAt: now }, + ]) + expect((await store.admit({ ...input, event })).status).toBe('stale'); + })); + }); +} + +function required(value: T | undefined): T { + if (value === undefined) throw new Error('Expected payment state fixture'); + return value; +} diff --git a/src/payments/authorization-store.test.ts b/src/payments/authorization-store.test.ts index 8e0610f..743e028 100644 --- a/src/payments/authorization-store.test.ts +++ b/src/payments/authorization-store.test.ts @@ -1,178 +1,54 @@ import { describe, expect, test } from 'bun:test'; import { AuthorizationStore } from './authorization-store.js'; -import type { CanonicalInvocationIdentity } from './types.js'; - -describe('AuthorizationStore', () => { - const identity: CanonicalInvocationIdentity = { - clientPubkey: 'client-1', - invocationHash: 'hash-1', +import { paymentStoreContract } from '../__mocks__/payment-store-contract.js'; + +paymentStoreContract('in-memory atomic payment store', async (policy) => { + let now = Date.now(); + const store = new AuthorizationStore(policy, () => now); + return { + store, + peer: store, + advance: async (ms) => { + now += ms; + }, + close: async () => {}, }; +}); - test('grant and claim a single authorization', () => { - const store = new AuthorizationStore(); - - expect(store.claim(identity)).toBe(false); - - store.grant(identity, 10000); - - expect(store.claim(identity)).toBe(true); - expect(store.claim(identity)).toBe(false); - }); - - test('claim fails after TTL expires', async () => { - const store = new AuthorizationStore(); - - store.grant(identity, 50); - - await new Promise((resolve) => setTimeout(resolve, 75)); - - expect(store.claim(identity)).toBe(false); - }); - - test('trySetPending prevents concurrent duplicates', () => { - const store = new AuthorizationStore(); - - // First call transitions to pending -> true - expect(store.trySetPending(identity, 10000)).toBe(true); - - // Second call is blocked -> false - expect(store.trySetPending(identity, 10000)).toBe(false); - - // Pending state is observable via getPendingRemainingMs - expect(store.getPendingRemainingMs(identity)).toBeGreaterThan(0); - }); - - test('trySetPending allows setting again after clearPending', () => { - const store = new AuthorizationStore(); - - expect(store.trySetPending(identity, 10000)).toBe(true); - expect(store.trySetPending(identity, 10000)).toBe(false); - - store.clearPending(identity); - - expect(store.trySetPending(identity, 10000)).toBe(true); - }); - - test('trySetPending allows setting again after pending state expires', async () => { - const store = new AuthorizationStore(); - - expect(store.trySetPending(identity, 50)).toBe(true); - expect(store.trySetPending(identity, 50)).toBe(false); - - await new Promise((resolve) => setTimeout(resolve, 75)); - - expect(store.trySetPending(identity, 50)).toBe(true); - }); - - test('grant clears pending state', () => { - const store = new AuthorizationStore(); - - expect(store.trySetPending(identity, 10000)).toBe(true); - store.grant(identity, 10000); - // grant cleared pending, so a fresh trySetPending succeeds again - expect(store.trySetPending(identity, 10000)).toBe(true); - }); - - test('LRU eviction works when maxEntries is exceeded', () => { - const store = new AuthorizationStore({ maxEntries: 2 }); - - const id1 = { clientPubkey: 'client', invocationHash: 'h1' }; - const id2 = { clientPubkey: 'client', invocationHash: 'h2' }; - const id3 = { clientPubkey: 'client', invocationHash: 'h3' }; - - store.grant(id1, 10000); - store.grant(id2, 10000); - store.grant(id3, 10000); // This should evict id1 - - expect(store.claim(id1)).toBe(false); - expect(store.claim(id2)).toBe(true); - expect(store.claim(id3)).toBe(true); - }); - - test('pending entries are never evicted while live; capacity refuses instead', () => { - const store = new AuthorizationStore({ maxEntries: 2 }); - - const id1 = { clientPubkey: 'client', invocationHash: 'p1' }; - const id2 = { clientPubkey: 'client', invocationHash: 'p2' }; - const id3 = { clientPubkey: 'client', invocationHash: 'p3' }; - - store.trySetPending(id1, 10000); - store.trySetPending(id2, 10000); - // At capacity with only live entries: refuse rather than evict — an - // evicted live pending entry disarms the payment dedup (double charge). - expect(store.trySetPending(id3, 10000)).toBe(false); - - expect(store.getPendingRemainingMs(id1)).toBeGreaterThan(0); - expect(store.getPendingRemainingMs(id2)).toBeGreaterThan(0); - expect(store.getPendingRemainingMs(id3)).toBe(0); - }); - - test('expired pending entries are purged under capacity pressure', async () => { - const store = new AuthorizationStore({ maxEntries: 2 }); - - const id1 = { clientPubkey: 'client', invocationHash: 'p1' }; - const id2 = { clientPubkey: 'client', invocationHash: 'p2' }; - const id3 = { clientPubkey: 'client', invocationHash: 'p3' }; - - store.trySetPending(id1, 20); - store.trySetPending(id2, 10000); - - await new Promise((resolve) => setTimeout(resolve, 40)); - - // id1 expired: capacity pressure purges it and makes room for id3. - expect(store.trySetPending(id3, 10000)).toBe(true); - expect(store.getPendingRemainingMs(id1)).toBe(0); - expect(store.getPendingRemainingMs(id2)).toBeGreaterThan(0); - expect(store.getPendingRemainingMs(id3)).toBeGreaterThan(0); - }); - - test('expired grants are purged before capacity-driven eviction', async () => { - const store = new AuthorizationStore({ maxEntries: 2 }); - - const id1 = { clientPubkey: 'client', invocationHash: 'g1' }; // stays live - const id2 = { clientPubkey: 'client', invocationHash: 'g2' }; // expires - const id3 = { clientPubkey: 'client', invocationHash: 'g3' }; - - store.grant(id1, 10000); - store.grant(id2, 20); - await new Promise((resolve) => setTimeout(resolve, 40)); - - // id2's grant expired: purging it makes room for id3, so the LIVE id1 - // grant survives instead of being blindly evicted as the LRU victim. - store.grant(id3, 10000); - - expect(store.claim(id1)).toBe(true); - expect(store.claim(id2)).toBe(false); - expect(store.claim(id3)).toBe(true); +describe('payment store configuration', () => { + test('rejects invalid capacity/retention settings', () => { + for (const value of [-1, NaN, Infinity, 1.5]) { + expect( + () => new AuthorizationStore({ maxPendingPayments: value }), + ).toThrow(); + expect( + () => new AuthorizationStore({ maxPaymentRecords: value }), + ).toThrow(); + } + expect(() => new AuthorizationStore({ replayWindowMs: 0 })).toThrow(); }); +}); - test('updatePendingTtl and getPendingRemainingMs behave correctly', async () => { - const store = new AuthorizationStore(); - - // (1) verify getPendingRemainingMs right after trySetPending - expect(store.trySetPending(identity, 100)).toBe(true); - const remainingAfterSet = store.getPendingRemainingMs(identity); - expect(remainingAfterSet).toBeGreaterThan(0); - expect(remainingAfterSet).toBeLessThanOrEqual(100); - - // (2) verify updatePendingTtl extends the pending TTL - store.updatePendingTtl(identity, 500); - const remainingAfterUpdate = store.getPendingRemainingMs(identity); - expect(remainingAfterUpdate).toBeGreaterThan(100); - expect(remainingAfterUpdate).toBeLessThanOrEqual(500); - - // (3) verify getPendingRemainingMs returns 0 after waiting past TTL - await new Promise((resolve) => setTimeout(resolve, 550)); - expect(store.getPendingRemainingMs(identity)).toBe(0); - - // (4) verify updatePendingTtl is a no-op when there is no active pending entry - store.updatePendingTtl(identity, 1000); - expect(store.getPendingRemainingMs(identity)).toBe(0); - - // And after clearPending - store.trySetPending(identity, 1000); - store.clearPending(identity); - store.updatePendingTtl(identity, 1000); - expect(store.getPendingRemainingMs(identity)).toBe(0); - }); +test('clock rollback cannot readmit a collected event', async () => { + let now = 1000000; + const store = new AuthorizationStore({ replayWindowMs: 1000 }, () => now); + const input = { + key: '["event","client","event"]', + mode: 'transparent' as const, + clientPubkey: 'client', + requestEventId: 'event', + invocationHash: 'hash', + leaseMs: 100, + event: { id: 'event', author: 'client', createdAt: 1000 }, + }; + const admitted = await store.admit(input); + expect(admitted.status).toBe('reserved'); + const generation = admitted.record!.generation; + await store.transition({ key: input.key, generation, type: 'consume' }); + await store.transition({ key: input.key, generation, type: 'dispatch' }); + now += 1001; + expect((await store.admit(input)).status).toBe('stale'); + expect(await store.read(input.key)).toBeUndefined(); + now -= 1001; + expect((await store.admit(input)).status).toBe('stale'); }); diff --git a/src/payments/authorization-store.ts b/src/payments/authorization-store.ts index 5ea5703..15d098f 100644 --- a/src/payments/authorization-store.ts +++ b/src/payments/authorization-store.ts @@ -1,199 +1,7 @@ -import type { CanonicalInvocationIdentity } from './types.js'; -import { LruCache } from '../core/utils/lru-cache.js'; -import { createLogger } from '../core/utils/logger.js'; - -interface PaidAuthorization { - /** Composite key: `${clientPubkey}:${invocationHash}` */ - key: string; - expiresAtMs: number; -} +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; /** - * A bounded, TTL-aware store for explicit gating authorizations. - * It manages both the pending state (waiting for payment verification) - * and the granted state (paid and ready to consume). - * - * Capacity safety: at capacity, expired entries are purged first and live - * pending entries are NEVER silently evicted — an evicted live pending entry - * disarms the payment dedup and a retry would mint a second invoice - * (CEP-8: MUST NOT charge twice). `trySetPending` refuses instead. - * Verified grants cannot be refused (the money is already taken), so `grant()` - * purges expired grants first and only then falls back to LRU eviction. - * - * NOTE: The atomicity provided by `trySetPending` relies on in-memory maps, - * meaning it is strictly single-process. For multi-process horizontal scaling, - * implementers should use a distributed lock (e.g. Redis Redlock) keyed by - * the canonical invocation identity to prevent duplicate payments. - * - * NOTE: Atomic reserve-then-dispatch (`claim()` here, `trySetPending()` in the - * gating middlewares) relies on JavaScript run-to-completion: there is no - * interleaving point between the two calls. Porters to await-capable runtimes - * must compose them into a single critical section. + * In-memory authorization and replay storage. Use the atomic admit/transition + * API; the former unfenced grant/clear methods are intentionally unavailable. */ -export class AuthorizationStore { - private readonly authorizations: LruCache; - private readonly pending: LruCache; // Map of key -> expiresAtMs - private readonly maxEntries: number; - private readonly logger = createLogger('authorization-store'); - - constructor(opts?: { maxEntries?: number }) { - this.maxEntries = opts?.maxEntries ?? 5000; - this.authorizations = new LruCache(this.maxEntries); - this.pending = new LruCache(this.maxEntries); - } - - private getKey(identity: CanonicalInvocationIdentity): string { - return `${identity.clientPubkey}:${identity.invocationHash}`; - } - - /** - * Records a paid authorization. Each grant authorizes exactly one future - * execution (CEP-8: "each successful payment SHOULD authorize one future - * execution unless server policy explicitly grants a different number"). - */ - public grant(identity: CanonicalInvocationIdentity, ttlMs: number): void { - const key = this.getKey(identity); - const expiresAtMs = Date.now() + ttlMs; - - // Purge expired grants before any capacity-driven eviction: an evicted - // unconsumed grant is paid-but-unusable and the client would be charged - // again on retry. - if (this.authorizations.size >= this.maxEntries) { - this.purgeExpiredAuthorizations(); - } - - this.authorizations.set(key, { key, expiresAtMs }); - - // Once granted, it's no longer pending - this.pending.delete(key); - - this.logger.debug('authorization granted', { key, ttlMs }); - } - - /** - * Atomically claims the single execution authorization. - * Returns true if claimed, false if none available or expired. - */ - public claim(identity: CanonicalInvocationIdentity): boolean { - const key = this.getKey(identity); - const auth = this.authorizations.get(key); - - if (!auth) { - return false; - } - - if (Date.now() > auth.expiresAtMs) { - this.authorizations.delete(key); - return false; - } - - // Single-use: consume the authorization atomically. - this.authorizations.delete(key); - this.logger.debug('authorization claimed', { key }); - return true; - } - - /** Removes expired pending entries (capacity-pressure cleanup). */ - private purgeExpiredPending(): void { - const now = Date.now(); - for (const [key, expiry] of this.pending.entries()) { - if (expiry <= now) { - this.pending.delete(key); - } - } - } - - /** Removes expired grants (capacity-pressure cleanup). */ - private purgeExpiredAuthorizations(): void { - const now = Date.now(); - for (const [key, auth] of this.authorizations.entries()) { - if (auth.expiresAtMs <= now) { - this.authorizations.delete(key); - } - } - } - - /** - * Atomically checks whether a payment is already pending for this identity - * and, if not, marks it as pending. Returns `true` if this call transitioned - * the identity to pending (caller should emit -32042). Returns `false` if - * already pending (caller should emit -32043) or the store is at capacity - * with only live entries (caller should emit a capacity refusal) — the two - * are distinguished via {@link getPendingRemainingMs}. - * - * This atomic check-and-set prevents concurrent requests from both receiving - * -32042 and triggering duplicate payment flows. Live entries are never - * evicted to make room: an evicted pending entry disarms the dedup and a - * retry would be charged twice. - * NOTE: This is single-process only. Distributed setups must use an external lock. - */ - public trySetPending( - identity: CanonicalInvocationIdentity, - ttlMs: number, - ): boolean { - const key = this.getKey(identity); - const now = Date.now(); - - const existingExpiry = this.pending.get(key); - if (existingExpiry !== undefined) { - if (now > existingExpiry) { - // Expired pending state, we can overwrite it - this.pending.delete(key); - } else { - // Already pending and active - return false; - } - } - - if (this.pending.size >= this.maxEntries) { - this.purgeExpiredPending(); - if (this.pending.size >= this.maxEntries) { - // Refuse rather than evict a live payment's dedup entry. - this.logger.warn('pending authorization capacity reached, refusing', { - key, - maxEntries: this.maxEntries, - }); - return false; - } - } - - this.pending.set(key, now + ttlMs); - this.logger.debug('authorization marked pending', { key, ttlMs }); - return true; - } - - /** - * Updates the TTL of an already pending authorization. No-op if not pending. - * - * @param identity The canonical invocation identity. - * @param ttlMs The new TTL in milliseconds to apply from now. - * @returns void - */ - public updatePendingTtl( - identity: CanonicalInvocationIdentity, - ttlMs: number, - ): void { - const key = this.getKey(identity); - const existingExpiry = this.pending.get(key); - if (existingExpiry !== undefined && Date.now() <= existingExpiry) { - this.pending.set(key, Date.now() + ttlMs); - this.logger.debug('authorization pending TTL updated', { key, ttlMs }); - } - } - - /** Gets the remaining TTL in milliseconds for a pending authorization, or 0 if not pending. */ - public getPendingRemainingMs(identity: CanonicalInvocationIdentity): number { - const key = this.getKey(identity); - const expiry = this.pending.get(key); - if (expiry === undefined) return 0; - const remaining = expiry - Date.now(); - return remaining > 0 ? remaining : 0; - } - - /** Clears pending state (e.g. on verification failure or expiry). */ - public clearPending(identity: CanonicalInvocationIdentity): void { - const key = this.getKey(identity); - this.pending.delete(key); - this.logger.debug('authorization pending state cleared', { key }); - } -} +export class AuthorizationStore extends InMemoryPaymentStateStore {} diff --git a/src/payments/canonical-identity.test.ts b/src/payments/canonical-identity.test.ts index dfd9d1b..38ceedd 100644 --- a/src/payments/canonical-identity.test.ts +++ b/src/payments/canonical-identity.test.ts @@ -1,10 +1,28 @@ import { describe, expect, test } from 'bun:test'; +import vectors from './fixtures/canonical-invocations.json'; import { computeCanonicalInvocationHash, computeCanonicalInvocationIdentity, } from './canonical-identity.js'; describe('Canonical Invocation Identity', () => { + for (const vector of vectors) { + test(`published JCS vector: ${vector.name}`, () => { + expect(computeCanonicalInvocationHash(vector.method, vector.params)).toBe( + vector.sha256, + ); + expect( + computeCanonicalInvocationIdentity( + vector.clientPubkey, + vector.method, + vector.params, + ), + ).toEqual({ + clientPubkey: vector.clientPubkey, + invocationHash: vector.sha256, + }); + }); + } describe('computeCanonicalInvocationHash', () => { test('is deterministic regardless of object key order', () => { const hash1 = computeCanonicalInvocationHash('tools/call', { diff --git a/src/payments/client-payment-attempts.test.ts b/src/payments/client-payment-attempts.test.ts new file mode 100644 index 0000000..8d72853 --- /dev/null +++ b/src/payments/client-payment-attempts.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'bun:test'; +import { ClientPaymentAttempts } from './client-payment-attempts.js'; +import { option } from '../__mocks__/client-payments-harness.js'; + +describe('client payment attempt retention under capacity pressure', () => { + for (const outcome of ['handled', 'declined'] as const) { + test(`evicts ${outcome} history least recently used first`, () => { + const attempts = new ClientPaymentAttempts(2); + const signal = new AbortController().signal; + const first = attempts.reserve([option('first')], signal); + first.complete(outcome); + const second = attempts.reserve([option('second')], signal); + second.complete(outcome); + + expect(attempts.find([option('first')])).toBe(first); + const third = attempts.reserve([option('third')], signal); + expect(attempts.find([option('first')])).toBe(first); + expect(attempts.find([option('second')])).toBeUndefined(); + expect(attempts.find([option('third')])).toBe(third); + third.complete(outcome); + }); + } + + test('skips in-flight and unknown pins when evicting older history', () => { + const attempts = new ClientPaymentAttempts(3); + const signal = new AbortController().signal; + const inFlight = attempts.reserve([option('pending')], signal); + const unknown = attempts.reserve([option('unknown')], signal); + unknown.complete('unknown'); + const handled = attempts.reserve([option('handled')], signal); + handled.complete('handled'); + + const replacement = attempts.reserve([option('replacement')], signal); + expect(attempts.find([option('handled')])).toBeUndefined(); + expect(attempts.find([option('pending')])).toBe(inFlight); + expect(attempts.find([option('unknown')])).toBe(unknown); + expect(attempts.find([option('replacement')])).toBe(replacement); + inFlight.complete('handled'); + replacement.complete('handled'); + }); + + test('refuses atomically when a multi-option reservation cannot fit beside pins', () => { + const attempts = new ClientPaymentAttempts(2); + const signal = new AbortController().signal; + const unknown = attempts.reserve([option('unknown')], signal); + unknown.complete('unknown'); + const handled = attempts.reserve([option('handled')], signal); + handled.complete('handled'); + + expect(() => + attempts.reserve([option('new-one'), option('new-two')], signal), + ).toThrow(expect.objectContaining({ reason: 'payment_attempt_capacity' })); + expect(attempts.find([option('unknown')])).toBe(unknown); + expect(attempts.find([option('handled')])).toBe(handled); + expect( + attempts.find([option('new-one'), option('new-two')]), + ).toBeUndefined(); + }); + + test('pins every distinct option of an in-flight or unknown explicit attempt', async () => { + const attempts = new ClientPaymentAttempts(2); + const controller = new AbortController(); + const attempt = attempts.reserve( + [option('one'), option('one'), option('two')], + controller.signal, + ); + expect(attempts.find([option('one')])).toBe(attempt); + expect(attempts.find([option('two')])).toBe(attempt); + expect(() => + attempts.reserve([option('three')], controller.signal), + ).toThrow(expect.objectContaining({ reason: 'payment_attempt_capacity' })); + + controller.abort(); + await attempt.completion; + attempt.complete('handled'); + expect(attempt.outcome).toBe('unknown'); + expect(() => + attempts.reserve([option('three')], new AbortController().signal), + ).toThrow(expect.objectContaining({ reason: 'payment_attempt_capacity' })); + expect(attempts.find([option('one')])).toBe(attempt); + expect(attempts.find([option('two')])).toBe(attempt); + }); + + test('reclaims enough terminal identities for a multi-option reservation', () => { + const attempts = new ClientPaymentAttempts(2); + const signal = new AbortController().signal; + const prior = attempts.reserve([option('one'), option('two')], signal); + prior.complete('declined'); + const next = attempts.reserve([option('three'), option('four')], signal); + expect(attempts.find([option('one'), option('two')])).toBeUndefined(); + expect(attempts.find([option('three')])).toBe(next); + expect(attempts.find([option('four')])).toBe(next); + next.complete('handled'); + }); +}); diff --git a/src/payments/client-payment-attempts.ts b/src/payments/client-payment-attempts.ts new file mode 100644 index 0000000..7bef17b --- /dev/null +++ b/src/payments/client-payment-attempts.ts @@ -0,0 +1,101 @@ +import type { PaymentOption } from './types.js'; +import { LruCache } from '../core/utils/lru-cache.js'; +import { ClientPaymentError } from './client-payment-validation.js'; + +type AttemptOutcome = 'in_flight' | 'handled' | 'declined' | 'unknown'; + +/** A local payment action; handled means wallet callback returned, not settlement. */ +export interface ClientPaymentAttempt { + outcome: AttemptOutcome; + completion: Promise; + complete: (outcome: Exclude) => void; +} + +/** + * Wrapper-scoped attempt history. A Nostr wrapper is bound to one authenticated + * server; generic wrappers use their transport instance as the peer boundary. + * In-flight and unknown attempts stay pinned; handled and declined identities + * may be evicted least recently used first under capacity pressure. + */ +export class ClientPaymentAttempts { + private readonly entries: LruCache<{ + amount: number; + attempt: ClientPaymentAttempt; + }>; + + public constructor(private readonly maxEntries: number) { + this.entries = new LruCache(maxEntries); + } + + private key(option: PaymentOption): string { + return JSON.stringify([option.pmi, option.pay_req]); + } + + /** Finds a prior action without reserving or paying any new option. */ + public find( + options: readonly PaymentOption[], + ): ClientPaymentAttempt | undefined { + let found: ClientPaymentAttempt | undefined; + for (const option of options) { + const prior = this.entries.get(this.key(option)); + if (prior) { + if (prior.amount !== option.amount) { + throw new ClientPaymentError( + 'payment_offer_changed', + 'Payment amount changed for an existing payment identity', + ); + } + if ( + !found || + (found.outcome !== 'unknown' && + (prior.attempt.outcome === 'unknown' || + prior.attempt.outcome === 'in_flight')) + ) + found = prior.attempt; + } + } + return found; + } + + /** Atomically reserves all selectable options before external approval work. */ + public reserve( + options: readonly PaymentOption[], + signal: AbortSignal, + ): ClientPaymentAttempt { + const unique = new Map(options.map((option) => [this.key(option), option])); + const needed = this.entries.size + unique.size - this.maxEntries; + const evictable: string[] = []; + for (const [key, { attempt }] of this.entries.entries()) { + if (evictable.length >= needed) break; + if (attempt.outcome === 'handled' || attempt.outcome === 'declined') + evictable.push(key); + } + if (evictable.length < needed) { + throw new ClientPaymentError( + 'payment_attempt_capacity', + 'Client payment attempt capacity exceeded by unresolved payments or offered options', + ); + } + for (const key of evictable) this.entries.delete(key); + let resolve!: () => void; + const attempt: ClientPaymentAttempt = { + outcome: 'in_flight', + completion: new Promise((done) => { + resolve = done; + }), + complete: (outcome): void => { + if (attempt.outcome !== 'in_flight') return; + attempt.outcome = outcome; + signal.removeEventListener('abort', onAbort); + resolve(); + }, + }; + const onAbort = (): void => attempt.complete('unknown'); + for (const [key, option] of unique) { + this.entries.set(key, { amount: option.amount, attempt }); + } + signal.addEventListener('abort', onAbort, { once: true }); + if (signal.aborted) onAbort(); + return attempt; + } +} diff --git a/src/payments/client-payment-lifecycle.ts b/src/payments/client-payment-lifecycle.ts new file mode 100644 index 0000000..ccc4cd2 --- /dev/null +++ b/src/payments/client-payment-lifecycle.ts @@ -0,0 +1,218 @@ +import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; +import { LruCache } from '../core/utils/lru-cache.js'; +import type { ClientPaymentLimits } from './client-payment-options.js'; +import type { PaymentOption } from './types.js'; +import { ClientPaymentError } from './client-payment-validation.js'; + +/** One caller request generation, including its automatic payment retries. */ +export interface ClientPaymentRequest { + key: string; + id?: string | number; + request?: JSONRPCRequest; + controller: AbortController; + active: boolean; + retries: number; + attempts: number; + paymentInFlight: boolean; + amounts: Map; + deadline?: number; + deadlineTimer?: ReturnType; + retryTimer?: ReturnType; +} + +/** Owns request generations, deadlines, limits, and retry timer cancellation. */ +export class ClientPaymentLifecycle { + private readonly requests: LruCache; + private closed = false; + + public constructor( + private readonly limits: ClientPaymentLimits | undefined, + private readonly onFailure: ( + request: ClientPaymentRequest, + error: ClientPaymentError, + ) => void, + private readonly onFinish: (request: ClientPaymentRequest) => void, + ) { + this.requests = new LruCache(1000, (_key, request) => { + if (!request.active) return; + this.finish(request); + this.onFailure( + request, + new ClientPaymentError( + 'payment_request_capacity', + 'Client request capacity reached', + ), + ); + }); + } + + /** Makes future generations usable; closed generations remain inactive. */ + public open(): void { + this.closed = false; + } + + /** Whether the wrapper has begun closing. */ + public get isClosed(): boolean { + return this.closed; + } + + /** Finds the current generation using a type-aware JSON-RPC identifier. */ + public get(id: string | number): ClientPaymentRequest | undefined { + return this.requests.get(JSON.stringify(['request', id])); + } + + private create( + key: string, + id?: string | number, + request?: JSONRPCRequest, + ): ClientPaymentRequest { + const prior = this.requests.get(key); + if (prior) this.finish(prior); + const state: ClientPaymentRequest = { + key, + id, + request, + active: true, + controller: new AbortController(), + retries: 0, + attempts: 0, + paymentInFlight: false, + amounts: new Map(), + }; + this.requests.set(key, state); + return state; + } + + /** Records the wire-shaped request before publishing and isolates it from callbacks. */ + public register(request: JSONRPCRequest): ClientPaymentRequest { + if (this.closed) throw new Error('Client payments transport is closed'); + const copy = JSON.parse(JSON.stringify(request)) as JSONRPCRequest; + return this.create( + JSON.stringify(['request', request.id]), + request.id, + copy, + ); + } + + /** Adopts correlation supplied by a transport when no original request was cached. */ + public correlated(id: string | number): ClientPaymentRequest { + return this.get(id) ?? this.create(JSON.stringify(['request', id]), id); + } + + /** Returns the sole live invocation for an uncorrelated generic transport. */ + public onlyRequest(): ClientPaymentRequest | undefined { + const active = [...this.requests.entries()] + .map(([, state]) => state) + .filter((state) => state.active && state.request); + return active.length === 1 ? active[0] : undefined; + } + + /** Whether a generic notification is ambiguous between multiple invocations. */ + public hasRequests(): boolean { + return [...this.requests.entries()].some( + ([, state]) => state.active && state.request !== undefined, + ); + } + + /** Tracks legacy standalone notifications in the generic transport's scope. */ + public detached(eventId: string): ClientPaymentRequest { + const key = JSON.stringify(['event', eventId]); + return this.requests.get(key) ?? this.create(key); + } + + /** Checks generation ownership and expires an elapsed payment deadline. */ + public isActive(state: ClientPaymentRequest): boolean { + if (this.closed || !state.active || this.requests.get(state.key) !== state) + return false; + if (state.deadline !== undefined && Date.now() >= state.deadline) { + this.expire(state); + return false; + } + return true; + } + + private expire(state: ClientPaymentRequest): void { + if (!state.active) return; + this.finish(state); + this.onFailure( + state, + new ClientPaymentError( + 'payment_deadline_exceeded', + 'Client payment deadline exceeded', + ), + ); + } + + /** Starts one deadline spanning every offer and pending response for this invocation. */ + public startPayment(state: ClientPaymentRequest): void { + if (this.limits?.timeoutMs === undefined || state.deadline !== undefined) + return; + state.deadline = Date.now() + this.limits.timeoutMs; + state.deadlineTimer = setTimeout( + () => this.expire(state), + this.limits.timeoutMs, + ); + } + + /** Reserves a conservative per-PMI approval budget before external code runs. */ + public approve( + state: ClientPaymentRequest, + options: readonly PaymentOption[], + ): PaymentOption[] { + const limits = this.limits; + if ( + limits?.maxAttempts !== undefined && + state.attempts >= limits.maxAttempts + ) { + throw new ClientPaymentError( + 'payment_limit_exceeded', + 'Client payment attempt limit exceeded', + ); + } + const allowed = options.filter((option) => { + if (limits?.maxAmountByPmi === undefined) return true; + const bound = limits.maxAmountByPmi[option.pmi]; + return ( + bound !== undefined && + option.amount <= bound - (state.amounts.get(option.pmi) ?? 0) + ); + }); + if (allowed.length === 0) { + throw new ClientPaymentError( + 'payment_limit_exceeded', + 'Client payment amount limit exceeded', + ); + } + const reserved = new Map(); + for (const option of allowed) + reserved.set( + option.pmi, + Math.max(reserved.get(option.pmi) ?? 0, option.amount), + ); + for (const [pmi, amount] of reserved) + state.amounts.set(pmi, (state.amounts.get(pmi) ?? 0) + amount); + state.attempts++; + return allowed; + } + + /** Reaps request-owned timers and signals before any terminal callback is delivered. */ + public finish(state: ClientPaymentRequest): void { + if (!state.active) return; + state.active = false; + clearTimeout(state.retryTimer); + clearTimeout(state.deadlineTimer); + state.retryTimer = undefined; + state.controller.abort(); + // Retain an inactive generation so late offers cannot revive a terminal id. + state.request = undefined; + state.amounts.clear(); + this.onFinish(state); + } + + /** Stops every current generation synchronously, even when transport close awaits I/O. */ + public close(): void { + this.closed = true; + for (const [, state] of [...this.requests.entries()]) this.finish(state); + this.requests.clear(); + } +} diff --git a/src/payments/client-payment-options.ts b/src/payments/client-payment-options.ts new file mode 100644 index 0000000..f16d6b0 --- /dev/null +++ b/src/payments/client-payment-options.ts @@ -0,0 +1,139 @@ +import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; +import type { OriginalRequestContext } from '../transport/nostr-client/correlation-store.js'; +import type { + PaymentHandler, + PaymentHandlerRequest, + PaymentInteractionMode, + PaymentOption, +} from './types.js'; + +/** Limits for one request lifecycle, including replacement invoices. */ +export interface ClientPaymentLimits { + /** + * Cumulative advertised amount approved per PMI, in that PMI's amount unit. + * When set, unlisted PMIs cannot be paid automatically. Multi-option explicit + * callbacks reserve the largest offered amount for each PMI conservatively. + * Wallet handlers must verify the settlement payload amount; this generic + * policy cannot decode or constrain arbitrary PMI payloads or external wallets. + */ + maxAmountByPmi?: Readonly>; + /** Maximum distinct offers passed to policy/approval work. Zero refuses payment. */ + maxAttempts?: number; + /** Payment deadline from the first offer/pending response, in milliseconds. */ + timeoutMs?: number; +} + +/** Client-side CEP-8 payment handlers, approval policy, and lifecycle limits. */ +export interface ClientPaymentsOptions { + /** + * Payment handlers for in-band (programmatic) payment, indexed by their + * {@link PaymentHandler.pmi}. Each handler's `pmi` is advertised to the + * server so it can pick a matching rail (the wallet-client fast path). + * + * **Omit entirely** for a PMI-agnostic client that pays out-of-band: no PMIs + * are advertised, so per CEP-8 the server sends `payment_required` for any of + * its processors. The notification is forwarded to the application via + * `onmessage`, synthetic progress keeps the original MCP request alive while + * the payment settles out-of-band, and the server's TTL is the timeout. + */ + handlers?: readonly PaymentHandler[]; + /** Optional limits across all automatic payment offers for one invocation. */ + limits?: ClientPaymentLimits; + /** + * Maximum distinct payment identities retained by this wrapper. In-flight and + * unknown attempts stay pinned; handled and declined identities are evicted + * least recently used first. New attempts fail before wallet work if pinned + * identities and offered options cannot fit. Zero refuses payment. @default 1000 + * Explicit callbacks reserve every offered identity because option selection + * is opaque; they must pay only one of the supplied options. + */ + maxTrackedPaymentAttempts?: number; + /** + * Interval for periodic synthetic progress heartbeats (milliseconds). + * + * One heartbeat is also emitted immediately when `payment_required` is + * received so the MCP timeout is always reset as soon as the payment flow + * begins. + * @default DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS (30_000 ms) + */ + syntheticProgressIntervalMs?: number; + /** + * Duration in ms for synthetic progress when `payment_required` carries no `ttl`. + * + * Mirrors the server-side `paymentTtlMs` default so the client keeps the MCP + * request alive for at least as long as the server will wait. + * @default DEFAULT_PAYMENT_TTL_MS (300_000 ms) + */ + defaultPaymentTtlMs?: number; + /** + * Minimum delay before an explicit-gating retry (`-32042` after a satisfied + * payment, or a `-32043` Payment Pending retry) is re-sent (milliseconds). + * For `-32043` it is applied after the server-provided `retry_after` backoff. + * + * Guards against `retry_after: 0`: an immediate retry within the same second + * can produce a byte-identical Nostr event (same content, same tags, same + * `created_at` at second resolution), which relays and servers swallow as a + * duplicate — silently losing the retry. + * Nostr transports always enforce at least 1000 ms, including paid retries. + * Shorter delays are supported only by other transports. + * @default 1000 + */ + minRetryDelayMs?: number; + + /** + * Optional policy hook invoked when a `payment_required` notification is received. + * + * This is evaluated before checking rail-specific `canHandle` methods. + * + * When the underlying transport supports request correlation (e.g. {@link NostrClientTransport}), + * `originalRequestContext` provides minimal details about the original JSON-RPC request that + * triggered the payment. + */ + paymentPolicy?: ( + req: PaymentHandlerRequest, + originalRequestContext?: OriginalRequestContext, + ) => boolean | Promise; + + /** Requested payment interaction mode. @default 'transparent' */ + paymentInteraction?: PaymentInteractionMode; + + /** + * Maximum number of -32043 (Payment Pending) retries before giving up. + * + * With retry_after=2 and 1.5× exponential backoff capped at 10s, the default + * of 10 retries gives ~76s of cumulative wait — enough for typical verification + * flows. Increase for slow payment processors (e.g. on-chain confirmation). + * @default 10 + */ + maxPendingRetries?: number; + + /** + * Handler for explicit-gating -32042 errors. + * Called when a priced invocation returns Payment Required. + * The handler should pay one option and signal completion. + * + * **Error handling contract**: + * - If the promise resolves with `{ paid: true }`, the wrapper auto-retries the + * original request with the same `method` and `params`. + * - If the promise resolves with `{ paid: false, reason }`, the wrapper synthesizes + * a JSON-RPC error to the caller with code `-32042` and `data: { reason }`. + * Use `reason: 'user_cancelled'` for user-initiated cancellations. + * - If the promise **rejects**, the wrapper MUST NOT silently fall back. + * It synthesizes a JSON-RPC error with code `-32042` and + * `data: { reason: error.message, type: 'payment_handler_error' }`. + * - Transient payment-provider failures should reject with an Error whose + * `message` contains the provider error details. + * + * **Verify-timeout window**: if the server's verification times out or fails + * after the client paid, its pending state is cleared and the client's retry + * can receive a fresh `-32042` with a new invoice. Distinct invoices still + * pass through the invocation limits; an uncertain wallet outcome is terminal. + */ + onPaymentRequired?: (params: { + options: PaymentOption[]; + instructions?: string; + originalRequest: JSONRPCRequest; + /** Aborted on completion, cancellation, deadline, or close. Check before paying. */ + abortSignal: AbortSignal; + }) => Promise<{ paid: boolean; reason?: string }>; +} diff --git a/src/payments/client-payment-progress.ts b/src/payments/client-payment-progress.ts new file mode 100644 index 0000000..71bc2ab --- /dev/null +++ b/src/payments/client-payment-progress.ts @@ -0,0 +1,56 @@ +import type { JSONRPCMessage } from '@contextvm/mcp-sdk/types.js'; + +/** Maintains bounded synthetic MCP progress while transparent settlement is pending. */ +export class ClientPaymentProgress { + private readonly entries = new Map< + string, + { token: string | number; stopAt: number } + >(); + private timer?: ReturnType; + + public constructor( + private readonly intervalMs: number, + private readonly emit: (message: JSONRPCMessage) => void, + ) {} + + private tick(): void { + for (const [key, entry] of this.entries) { + if (Date.now() >= entry.stopAt) this.stop(key); + else this.heartbeat(entry.token); + } + } + + private heartbeat(token: string | number): void { + this.emit({ + jsonrpc: '2.0', + method: 'notifications/progress', + params: { progressToken: token, progress: 0 }, + }); + } + + /** Starts an immediate heartbeat and periodic resets for the request's wire token. */ + public start(key: string, token: string, ttlMs: number): void { + if (this.entries.has(key)) return; + const wireToken = Number.isFinite(Number(token)) ? Number(token) : token; + this.entries.set(key, { token: wireToken, stopAt: Date.now() + ttlMs }); + if (!this.timer) + this.timer = setInterval(() => this.tick(), this.intervalMs); + this.heartbeat(wireToken); + } + + /** Stops only this generation's progress and reaps an idle scheduler. */ + public stop(key: string): void { + this.entries.delete(key); + if (this.entries.size === 0 && this.timer) { + clearInterval(this.timer); + this.timer = undefined; + } + } + + /** Reaps every heartbeat during wrapper teardown. */ + public clear(): void { + this.entries.clear(); + clearInterval(this.timer); + this.timer = undefined; + } +} diff --git a/src/payments/client-payment-validation.ts b/src/payments/client-payment-validation.ts new file mode 100644 index 0000000..ec4aed7 --- /dev/null +++ b/src/payments/client-payment-validation.ts @@ -0,0 +1,135 @@ +import { z } from 'zod'; +import type { ClientPaymentsOptions } from './client-payment-options.js'; +import type { + PaymentOption, + PaymentPendingErrorData, + PaymentRequiredErrorData, +} from './types.js'; + +import { + paymentAmountSchema as amountSchema, + paymentPmiSchema as pmiSchema, + paymentOptionSchema, +} from './payment-option-schema.js'; +export { paymentOptionSchema as clientPaymentOptionSchema } from './payment-option-schema.js'; + +const MAX_TIMER_MS = 2_147_483_647; +const timerSchema = z.number().finite().positive().max(MAX_TIMER_MS); + +/** Internal payment failure with a stable application-visible reason. */ +export class ClientPaymentError extends Error { + public constructor( + public readonly reason: string, + message: string, + ) { + super(message); + this.name = 'ClientPaymentError'; + } +} + +const requiredSchema = z.object({ + instructions: z.string().optional(), + payment_options: z + .array(paymentOptionSchema) + .min(1) + .max(100) + .refine((options) => { + const amounts = new Map(); + for (const option of options) { + const key = JSON.stringify([option.pmi, option.pay_req]); + if (amounts.has(key) && amounts.get(key) !== option.amount) + return false; + amounts.set(key, option.amount); + } + return true; + }), +}); +const pendingSchema = z.object({ + instructions: z.string().optional(), + retry_after: z + .number() + .finite() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER / 1000) + .optional(), +}); + +function parse(schema: z.ZodType, input: unknown): T { + const result = schema.safeParse(input); + if (!result.success) { + throw new ClientPaymentError('invalid_payment', 'Invalid payment message'); + } + return result.data; +} + +/** Validates a transparent payment offer before invoking policy or wallet code. */ +export function parseClientPaymentOption(input: unknown): PaymentOption { + return parse(paymentOptionSchema, input); +} + +/** Validates all explicit options, including options that might not be selected. */ +export function parseClientPaymentRequired( + input: unknown, +): PaymentRequiredErrorData { + return parse(requiredSchema, input); +} + +/** Validates pending backoff; omitted hints use the client's default. */ +export function parseClientPaymentPending( + input: unknown, +): PaymentPendingErrorData { + return parse(pendingSchema, input === undefined ? {} : input); +} + +/** Rejects invalid client configuration synchronously, before transport mutation. */ +export function validateClientPaymentOptions( + options: ClientPaymentsOptions, +): void { + const schema = z.object({ + syntheticProgressIntervalMs: timerSchema.optional(), + defaultPaymentTtlMs: timerSchema.optional(), + minRetryDelayMs: z + .number() + .finite() + .nonnegative() + .max(MAX_TIMER_MS) + .optional(), + maxPendingRetries: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .optional(), + maxTrackedPaymentAttempts: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .optional(), + paymentInteraction: z.enum(['transparent', 'explicit_gating']).optional(), + handlers: z.array(z.object({ pmi: pmiSchema })).optional(), + onPaymentRequired: z + .unknown() + .refine((value) => typeof value === 'function') + .optional(), + paymentPolicy: z + .unknown() + .refine((value) => typeof value === 'function') + .optional(), + limits: z + .object({ + maxAmountByPmi: z.record(pmiSchema, amountSchema).optional(), + maxAttempts: z + .number() + .int() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER) + .optional(), + timeoutMs: timerSchema.optional(), + }) + .optional(), + }); + if (!schema.safeParse(options).success) { + throw new TypeError('Invalid client payment options'); + } +} diff --git a/src/payments/client-payments-limits.test.ts b/src/payments/client-payments-limits.test.ts new file mode 100644 index 0000000..62488a7 --- /dev/null +++ b/src/payments/client-payments-limits.test.ts @@ -0,0 +1,387 @@ +import { describe, expect, test } from 'bun:test'; +import type { Transport } from '@contextvm/mcp-sdk/shared/transport'; +import { withClientPayments } from './client-payments.js'; +import type { ClientPaymentsOptions } from './client-payment-options.js'; +import type { PaymentOption } from './types.js'; +import { + run, + waitFor, + tick, + wait, + request, + option, + offer, + required, + pending, + deferred, + failures, +} from '../__mocks__/client-payments-harness.js'; + +describe('client payment limits and validation', () => { + test('replacement invoices share an attempt allowance', async () => { + let calls = 0; + await run( + { + limits: { maxAttempts: 1 }, + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + h.deliver(required(1, [option('replacement')])); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_limit_exceeded', + }); + }, + ); + }); + + test('replacement invoices share a cumulative per-PMI amount limit', async () => { + let calls = 0; + await run( + { + limits: { maxAmountByPmi: { fake: 8 } }, + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + h.deliver(required(1, [option('replacement', 4)])); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(1); + }, + ); + }); + + test('multi-option offers reserve a conservative maximum per PMI and exclude unlisted rails', async () => { + const approved: PaymentOption[][] = []; + await run( + { + limits: { maxAmountByPmi: { fake: 10 } }, + onPaymentRequired: async ({ options }) => { + approved.push(options); + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver( + required(1, [ + option('low', 2), + option('high', 8), + option('other', 1, 'other'), + ]), + ); + await waitFor(() => h.sent.length === 2); + expect(approved[0]).toEqual([option('low', 2), option('high', 8)]); + h.deliver(required(1, [option('replacement', 3)])); + await waitFor(() => failures(h).length === 1); + expect(approved).toHaveLength(1); + }, + ); + }); + + test('limits are independent for genuinely new invocations', async () => { + let calls = 0; + await run( + { + limits: { maxAttempts: 1, maxAmountByPmi: { fake: 5 } }, + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + h.deliver({ jsonrpc: '2.0', id: 1, result: {} }); + await h.paid.send(request(2)); + h.deliver(required(2, [option('independent')])); + await waitFor(() => calls === 2); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('zero limits refuse wallet work before policy approval', async () => { + let policies = 0; + let payments = 0; + await run( + { + limits: { maxAttempts: 0 }, + paymentPolicy: () => { + policies++; + return true; + }, + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + payments++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + await waitFor(() => failures(h).length === 1); + expect(policies).toBe(0); + expect(payments).toBe(0); + }, + ); + }); + + test('deadline aborts awaiting approval and prevents a later retry', async () => { + const decision = deferred<{ paid: boolean }>(); + let signal: AbortSignal | undefined; + await run( + { + limits: { timeoutMs: 10 }, + onPaymentRequired: async ({ abortSignal }) => { + signal = abortSignal; + return decision.promise; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => failures(h).length === 1); + expect(signal?.aborted).toBe(true); + decision.resolve({ paid: true }); + await wait(10); + expect(h.sent).toHaveLength(1); + expect(failures(h)).toHaveLength(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_deadline_exceeded', + }); + }, + ); + }); + + for (const invalid of [ + { ...option(), amount: -1 }, + { ...option(), amount: NaN }, + { ...option(), amount: Infinity }, + { ...option(), amount: Number.MAX_SAFE_INTEGER + 1 }, + { ...option(), amount: '5' }, + { ...option(), pmi: '' }, + { ...option(), pmi: 1 }, + { ...option(), pay_req: '' }, + { ...option(), pay_req: ' ' }, + { ...option(), ttl: 0 }, + { ...option(), ttl: -1 }, + { ...option(), ttl: Infinity }, + { ...option(), _meta: [] }, + null, + ]) { + test(`rejects malformed transparent offer ${JSON.stringify(invalid)}`, async () => { + let calls = 0; + await run( + { + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer(invalid)); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(0); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'invalid_payment', + }); + }, + ); + }); + } + + for (const mode of ['transparent', 'explicit'] as const) { + test(`accepts a non-kebab PMI in ${mode} offers, handlers, and amount limits`, async () => { + const pmi = 'pmi:A'; + const paid: PaymentOption[] = []; + await run( + { + limits: { maxAmountByPmi: { [pmi]: 5 } }, + handlers: [ + { + pmi, + async handle(req): Promise { + paid.push(req); + }, + }, + ], + onPaymentRequired: async ({ options }) => { + paid.push(...options); + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver( + mode === 'transparent' + ? offer(option('invoice', 5, pmi)) + : required(1, [option('invoice', 5, pmi)]), + ); + await waitFor(() => paid.length === 1); + expect(paid[0]).toMatchObject(option('invoice', 5, pmi)); + expect(failures(h)).toHaveLength(0); + h.deliver( + mode === 'transparent' + ? offer(option('replacement', 1, pmi)) + : required(1, [option('replacement', 1, pmi)]), + ); + await waitFor(() => failures(h).length === 1); + expect(paid).toHaveLength(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_limit_exceeded', + }); + }, + ); + }); + } + + for (const invalid of [ + [], + [option(), { ...option('invalid'), amount: -1 }], + [{ ...option(), pmi: '' }], + [option(), option('invoice', 6)], + ]) { + test(`validates every explicit option ${JSON.stringify(invalid)}`, async () => { + let calls = 0; + await run( + { + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required(1, invalid)); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(0); + }, + ); + }); + } + + for (const invalid of [-1, NaN, Infinity, '1', null]) { + test(`invalid retry hint ${String(invalid)} cannot schedule a send`, async () => { + await run({}, async (h) => { + await h.paid.send(request()); + h.deliver(pending(1, invalid)); + await waitFor(() => failures(h).length === 1); + await wait(5); + expect(h.sent).toHaveLength(1); + }); + }); + } + + test('omitted retry hint uses a finite default delay', async () => { + await run({}, async (h) => { + await h.paid.send(request()); + h.deliver(pending()); + await waitFor(() => h.sent.length === 2, { timeoutMs: 1500 }); + expect(failures(h)).toHaveLength(0); + }); + }); + + test('unknown metadata is preserved without influencing validation', async () => { + let metadata: unknown; + await run( + { + handlers: [ + { + pmi: 'fake', + async handle(req): Promise { + metadata = req._meta; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver( + offer({ + ...option(), + _meta: { custom: { future: true } }, + extra: 'ignored', + }), + ); + await waitFor(() => metadata !== undefined); + expect(metadata).toEqual({ custom: { future: true } }); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('ambiguous uncorrelated notifications never choose between concurrent requests', async () => { + let calls = 0; + await run( + { + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + await h.paid.send(request(2)); + h.deliver(offer()); + await tick(); + expect(calls).toBe(0); + expect(h.errors).toHaveLength(1); + }, + ); + }); + + for (const options of [ + { minRetryDelayMs: -1 }, + { minRetryDelayMs: NaN }, + { maxPendingRetries: 0.5 }, + { maxTrackedPaymentAttempts: -1 }, + { syntheticProgressIntervalMs: 0 }, + { limits: { timeoutMs: 0 } }, + { limits: { timeoutMs: 2_147_483_648 } }, + { limits: { maxAttempts: -1 } }, + { limits: { maxAmountByPmi: { fake: Infinity } } }, + { handlers: [{ pmi: '', handle: async () => {} }] }, + { limits: { maxAmountByPmi: { '': 1 } } }, + ...['onPaymentRequired', 'paymentPolicy'].flatMap((key) => + [true, false, null, 1, 'typo', {}].map((value) => ({ [key]: value })), + ), + ]) { + test(`configuration validation rejects ${JSON.stringify(options)} before wrapping`, () => { + const base: Transport = { + async start(): Promise {}, + async send(): Promise {}, + async close(): Promise {}, + }; + expect(() => + withClientPayments(base, options as ClientPaymentsOptions), + ).toThrow('Invalid client payment options'); + expect(() => withClientPayments(base, {})).not.toThrow(); + }); + } +}); diff --git a/src/payments/client-payments-reliability.test.ts b/src/payments/client-payments-reliability.test.ts new file mode 100644 index 0000000..84b0d2b --- /dev/null +++ b/src/payments/client-payments-reliability.test.ts @@ -0,0 +1,598 @@ +import { describe, expect, test } from 'bun:test'; +import type { JSONRPCMessage } from '@contextvm/mcp-sdk/types.js'; +import { + run, + waitFor, + tick, + wait, + request, + option, + offer, + required, + pending, + deferred, + failures, +} from '../__mocks__/client-payments-harness.js'; + +describe('client payment attempt reliability', () => { + test('retains transparent attempt history after handler return and invoice TTL', async () => { + let calls = 0; + await run( + { + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer({ ...option(), ttl: 1 })); + await waitFor(() => calls === 1); + await wait(1100); + h.deliver(offer(option())); + await tick(); + expect(calls).toBe(1); + }, + ); + }); + + test('deduplicates concurrent and sequential explicit approvals', async () => { + let calls = 0; + const decision = deferred<{ paid: boolean }>(); + await run( + { + onPaymentRequired: async () => { + calls++; + return decision.promise; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + h.deliver(required()); + expect(calls).toBe(1); + decision.resolve({ paid: true }); + await waitFor(() => h.sent.length === 2); + h.deliver(required()); + await waitFor(() => h.sent.length === 3); + expect(calls).toBe(1); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('an uncertain wallet result cannot be paid again on another request id', async () => { + let calls = 0; + await run( + { + maxTrackedPaymentAttempts: 1, + onPaymentRequired: async () => { + calls++; + throw new Error('wallet response lost'); + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => failures(h).length === 1); + await h.paid.send(request(2)); + h.deliver(required(2)); + await waitFor(() => failures(h).length === 2); + expect(calls).toBe(1); + expect(failures(h)[1]?.error.data).toMatchObject({ + reason: 'payment_outcome_unknown', + }); + await h.paid.send(request(3)); + h.deliver(required(3, [option('replacement')])); + await waitFor(() => failures(h).length === 3); + expect(calls).toBe(1); + expect(failures(h)[2]?.error.data).toMatchObject({ + reason: 'payment_attempt_capacity', + }); + }, + ); + }); + + test('different PMIs with the same opaque request remain independent', async () => { + let calls = 0; + await run( + { + handlers: ['fake', 'other'].map((pmi) => ({ + pmi, + async handle(): Promise { + calls++; + }, + })), + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + await waitFor(() => calls === 1); + h.deliver({ jsonrpc: '2.0', id: 1, result: {} }); + await h.paid.send(request(2)); + h.deliver(offer(option('invoice', 5, 'other'))); + await waitFor(() => calls === 2); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('attempt history is isolated between stable transport peers', async () => { + let calls = 0; + const options = { + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }; + await run(options, async (one) => { + await run(options, async (two) => { + await one.paid.send(request()); + await two.paid.send(request()); + one.deliver(offer()); + two.deliver(offer()); + await waitFor(() => calls === 2); + expect(failures(one)).toHaveLength(0); + expect(failures(two)).toHaveLength(0); + }); + }); + }); + + test('completed history does not exhaust a long-lived client payment capacity', async () => { + let calls = 0; + await run( + { + maxTrackedPaymentAttempts: 1, + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + for (let id = 1; id <= 3; id++) { + await h.paid.send(request(id)); + h.deliver(required(id, [option(`invoice-${id}`)])); + await waitFor(() => h.sent.length === id * 2); + h.deliver({ jsonrpc: '2.0', id, result: {} }); + } + expect(calls).toBe(3); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('changed amount for a known invoice never triggers another approval', async () => { + let calls = 0; + await run( + { + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + h.deliver(required(1, [option('invoice', 6)])); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_offer_changed', + }); + }, + ); + }); + + test('different offers cannot run approval callbacks concurrently for one invocation', async () => { + let calls = 0; + const decision = deferred<{ paid: boolean }>(); + await run( + { + onPaymentRequired: async () => { + calls++; + return decision.promise; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + h.deliver(required(1, [option('replacement')])); + await waitFor(() => failures(h).length === 1); + decision.resolve({ paid: true }); + await tick(); + expect(calls).toBe(1); + expect(h.sent).toHaveLength(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_outcome_unknown', + }); + }, + ); + }); +}); + +test('an unknown option takes precedence over another pending payment attempt', async () => { + const pendingDecision = deferred<{ paid: boolean }>(); + let approvals = 0; + await run( + { + onPaymentRequired: async ({ options }) => { + approvals++; + if (options[0]?.pay_req === 'unknown') + throw new Error('lost wallet result'); + return pendingDecision.promise; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required(1, [option('unknown')])); + await waitFor(() => failures(h).length === 1); + await h.paid.send(request(2)); + h.deliver(required(2, [option('pending')])); + await h.paid.send(request(3)); + h.deliver(required(3, [option('unknown'), option('pending')])); + await waitFor(() => failures(h).length === 2); + expect(failures(h)[1]?.error.data).toMatchObject({ + reason: 'payment_outcome_unknown', + }); + expect(approvals).toBe(2); + pendingDecision.resolve({ paid: false }); + await tick(); + }, + ); +}); + +describe('wallet eligibility and attempt retention', () => { + test('policy mutations cannot change the validated invoice or amount sent to a handler', async () => { + let handled: unknown; + await run( + { + paymentPolicy: (req) => { + req.amount = 100; + req.pay_req = 'changed'; + return true; + }, + handlers: [ + { + pmi: 'fake', + async handle(req): Promise { + handled = req; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + await waitFor(() => handled !== undefined); + expect(handled).toMatchObject(option()); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('expiry during an eligibility check prevents a wallet action', async () => { + const eligibility = deferred(); + let calls = 0; + await run( + { + handlers: [ + { + pmi: 'fake', + canHandle: () => eligibility.promise, + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer({ ...option(), ttl: 0.001 })); + await wait(10); + eligibility.resolve(true); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(0); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_offer_expired', + }); + }, + ); + }); + + test('a failed initial send releases request state before late payment offers', async () => { + let calls = 0; + await run( + { + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + h.base.send = async (): Promise => { + throw new Error('publish failed'); + }; + await expect(h.paid.send(request())).rejects.toThrow('publish failed'); + h.deliver(required()); + await tick(); + expect(calls).toBe(0); + expect(h.received).toHaveLength(0); + }, + ); + }); + + test('closed/restarted wrappers retain completed offer history', async () => { + let calls = 0; + await run( + { + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + await h.paid.close(); + await h.paid.start(); + await h.paid.send(request(2)); + h.deliver(required(2)); + await waitFor(() => h.sent.length === 4); + expect(calls).toBe(1); + }, + ); + }); + + test('standalone generic notifications cannot bypass per-invocation limits', async () => { + let calls = 0; + await run( + { + limits: { maxAttempts: 1 }, + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + h.deliver(offer()); + await waitFor(() => h.errors.length === 1); + expect(calls).toBe(0); + expect(h.errors[0]).toMatchObject({ + reason: 'payment_request_uncorrelated', + }); + }, + ); + }); + + test('zero attempt-history capacity refuses a fresh offer before approval', async () => { + let calls = 0; + await run( + { + maxTrackedPaymentAttempts: 0, + onPaymentRequired: async () => { + calls++; + return { paid: true }; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await waitFor(() => failures(h).length === 1); + expect(calls).toBe(0); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_attempt_capacity', + }); + }, + ); + }); +}); + +describe('client payment request lifecycle', () => { + for (const terminal of [ + { jsonrpc: '2.0', id: 1, result: {} }, + { jsonrpc: '2.0', id: 1, error: { code: -32000, message: 'done' } }, + ] as JSONRPCMessage[]) { + test(`terminal ${'result' in terminal ? 'result' : 'error'} cancels a queued retry`, async () => { + await run({ minRetryDelayMs: 20 }, async (h) => { + await h.paid.send(request()); + h.deliver(pending(1, 0)); + h.deliver(terminal); + await wait(40); + expect(h.sent).toHaveLength(1); + expect(h.received).toEqual([terminal]); + }); + }); + } + + test('outbound MCP cancellation stops retries for numeric id zero only', async () => { + await run({ minRetryDelayMs: 20 }, async (h) => { + await h.paid.send(request(0)); + await h.paid.send(request('0')); + h.deliver(pending(0, 0)); + h.deliver(pending('0', 0)); + await h.paid.send({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 0 }, + }); + await wait(40); + const retries = h.sent.slice(3); + expect(retries).toHaveLength(1); + expect(retries[0]).toMatchObject({ id: '0' }); + }); + }); + + test('inbound cancellation prevents a delayed policy from starting the wallet', async () => { + let calls = 0; + const policy = deferred(); + await run( + { + paymentPolicy: () => policy.promise, + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + h.deliver({ + jsonrpc: '2.0', + method: 'notifications/cancelled', + params: { requestId: 1 }, + }); + policy.resolve(true); + await tick(); + expect(calls).toBe(0); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('close fences approval even when the underlying transport never fires onclose', async () => { + const decision = deferred<{ paid: boolean }>(); + let signal: AbortSignal | undefined; + await run( + { + onPaymentRequired: async ({ abortSignal }) => { + signal = abortSignal; + return decision.promise; + }, + }, + async (h) => { + await h.paid.send(request()); + h.deliver(required()); + await h.paid.close(); + expect(signal?.aborted).toBe(true); + decision.resolve({ paid: true }); + await wait(10); + expect(h.sent).toHaveLength(1); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('terminal result fences an awaiting handler eligibility check', async () => { + let calls = 0; + const eligibility = deferred(); + await run( + { + handlers: [ + { + pmi: 'fake', + canHandle: () => eligibility.promise, + async handle(): Promise { + calls++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + h.deliver({ jsonrpc: '2.0', id: 1, result: {} }); + eligibility.resolve(true); + await tick(); + expect(calls).toBe(0); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('request-id reuse does not allow the previous generation to pay or fail the replacement', async () => { + const oldPolicy = deferred(); + let policies = 0; + let payments = 0; + await run( + { + paymentPolicy: () => (++policies === 1 ? oldPolicy.promise : true), + handlers: [ + { + pmi: 'fake', + async handle(): Promise { + payments++; + }, + }, + ], + }, + async (h) => { + await h.paid.send(request()); + h.deliver(offer()); + await h.paid.send({ ...request(), params: { name: 'replacement' } }); + h.deliver(offer(option('new-invoice'))); + oldPolicy.resolve(false); + await waitFor(() => payments === 1); + expect(failures(h)).toHaveLength(0); + }, + ); + }); + + test('approval cannot mutate the cached retry invocation', async () => { + await run( + { + onPaymentRequired: async ({ originalRequest }) => { + originalRequest.id = 'changed'; + originalRequest.params = { name: 'changed' }; + return { paid: true }; + }, + }, + async (h) => { + const original = request(); + await h.paid.send(original); + original.params = { name: 'also-changed' }; + h.deliver(required()); + await waitFor(() => h.sent.length === 2); + expect(h.sent[1]).toEqual(request()); + }, + ); + }); + + test('duplicate pending responses queue one retry and consume one retry allowance', async () => { + await run({ maxPendingRetries: 1, minRetryDelayMs: 20 }, async (h) => { + await h.paid.send(request()); + h.deliver(pending(1, 0)); + h.deliver(pending(1, 0)); + await waitFor(() => h.sent.length === 2); + expect(failures(h)).toHaveLength(0); + h.deliver(pending(1, 0)); + expect(failures(h)).toHaveLength(1); + await wait(30); + expect(h.sent).toHaveLength(2); + }); + }); + + test('a retry publish failure terminates the request once', async () => { + await run({}, async (h) => { + await h.paid.send(request()); + h.base.send = async (): Promise => { + throw new Error('relay failed'); + }; + h.deliver(pending(1, 0)); + await waitFor(() => failures(h).length === 1); + h.deliver(required()); + await tick(); + expect(failures(h)).toHaveLength(1); + expect(failures(h)[0]?.error.data).toMatchObject({ + reason: 'payment_retry_failed', + }); + }); + }); +}); diff --git a/src/payments/client-payments.test.ts b/src/payments/client-payments.test.ts index 5123dca..6de7b96 100644 --- a/src/payments/client-payments.test.ts +++ b/src/payments/client-payments.test.ts @@ -66,6 +66,7 @@ describe('withClientPayments()', () => { pmi: 'fake', description: undefined, requestEventId: 'req-id', + abortSignal: expect.any(AbortSignal), }); }); @@ -396,6 +397,7 @@ describe('withClientPayments()', () => { amount: 1, method: 'tools/call', capability: 'tool:add', + reason: 'payment_declined', }); }); @@ -457,6 +459,7 @@ describe('withClientPayments()', () => { amount: 2, method: 'prompts/get', capability: 'prompt:hi', + reason: 'payment_declined', }); }); @@ -525,6 +528,7 @@ describe('withClientPayments()', () => { amount: 1, method: 'tools/call', capability: 'tool:paid', + reason: 'payment_negotiation_failed', }); }); @@ -884,8 +888,8 @@ describe('withClientPayments()', () => { { eventId: 'evt4', correlatedEventId: 'req-event-id-3' }, ); - // Wait for async processing (retry is floored at minRetryDelayMs) - await new Promise((r) => setTimeout(r, 25)); + // Wait for the Nostr retry floor. + await new Promise((r) => setTimeout(r, 1100)); // Error should not be delivered to caller expect(observed).toHaveLength(0); @@ -1020,8 +1024,8 @@ describe('withClientPayments()', () => { expect(observed).toHaveLength(0); expect(sentMessage).toBeUndefined(); - // Wait for retry_after timer to fire - await new Promise((r) => setTimeout(r, 60)); + // Nostr transport enforces a one-second floor despite a shorter hint. + await new Promise((r) => setTimeout(r, 1100)); // Error should not be delivered to caller expect(observed).toHaveLength(0); @@ -1149,11 +1153,11 @@ describe('withClientPayments()', () => { // First two: intercepted and retried (not observed by caller). deliverPending(); - await new Promise((r) => setTimeout(r, 20)); + await new Promise((r) => setTimeout(r, 1100)); expect(observed).toHaveLength(0); deliverPending(); - await new Promise((r) => setTimeout(r, 25)); + await new Promise((r) => setTimeout(r, 1100)); expect(observed).toHaveLength(0); // Third: retry budget exhausted → -32043 reaches the caller. @@ -1231,8 +1235,8 @@ describe('withClientPayments()', () => { await new Promise((r) => setTimeout(r, 10)); expect(sentMessage).toBeUndefined(); - // After the floor: the original request is retried. - await new Promise((r) => setTimeout(r, 120)); + // Nostr retries always wait at least one second. + await new Promise((r) => setTimeout(r, 1100)); expect(sentMessage as unknown).toEqual({ jsonrpc: '2.0', id: 77, diff --git a/src/payments/client-payments.ts b/src/payments/client-payments.ts index 6370f55..a13ca64 100644 --- a/src/payments/client-payments.ts +++ b/src/payments/client-payments.ts @@ -1,33 +1,41 @@ import type { Transport } from '@contextvm/mcp-sdk/shared/transport'; import { isJSONRPCNotification, + isJSONRPCRequest, isJSONRPCResultResponse, isJSONRPCErrorResponse, - type JSONRPCNotification, type JSONRPCMessage, - type JSONRPCRequest, type JSONRPCErrorResponse, } from '@contextvm/mcp-sdk/types.js'; -import { NostrClientTransport } from '../transport/nostr-client-transport.js'; -import type { TransportWithContext } from '../transport/nostr-client-transport.js'; +import { + NostrClientTransport, + type TransportWithContext, +} from '../transport/nostr-client-transport.js'; +import type { PendingRequest } from '../transport/nostr-client/correlation-store.js'; +import { createLogger } from '../core/utils/logger.js'; import type { PaymentHandler, - PaymentRejectedNotification, - PaymentRequiredNotification, PaymentHandlerRequest, - PaymentInteractionMode, PaymentOption, - PaymentRequiredErrorData, - PaymentPendingErrorData, + PaymentRequiredNotification, } from './types.js'; -import { LruCache } from '../core/utils/lru-cache.js'; -import { createLogger } from '../core/utils/logger.js'; -import { sleep } from '../core/utils/utils.js'; -import type { - OriginalRequestContext, - PendingRequest, -} from '../transport/nostr-client/correlation-store.js'; - +import type { ClientPaymentsOptions } from './client-payment-options.js'; +import { + ClientPaymentAttempts, + type ClientPaymentAttempt, +} from './client-payment-attempts.js'; +import { + ClientPaymentLifecycle, + type ClientPaymentRequest, +} from './client-payment-lifecycle.js'; +import { ClientPaymentProgress } from './client-payment-progress.js'; +import { + ClientPaymentError, + parseClientPaymentOption, + parseClientPaymentRequired, + parseClientPaymentPending, + validateClientPaymentOptions, +} from './client-payment-validation.js'; import { DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS, DEFAULT_PAYMENT_TTL_MS, @@ -38,754 +46,558 @@ import { PAYMENT_PENDING_ERROR_CODE, } from './constants.js'; -export interface ClientPaymentsOptions { - /** - * Payment handlers for in-band (programmatic) payment, indexed by their - * {@link PaymentHandler.pmi}. Each handler's `pmi` is advertised to the - * server so it can pick a matching rail (the wallet-client fast path). - * - * **Omit entirely** for a PMI-agnostic client that pays out-of-band: no PMIs - * are advertised, so per CEP-8 the server sends `payment_required` for any of - * its processors. The notification is forwarded to the application via - * `onmessage`, synthetic progress keeps the original MCP request alive while - * the payment settles out-of-band, and the server's TTL is the timeout. - */ - handlers?: readonly PaymentHandler[]; - /** - * Interval for periodic synthetic progress heartbeats (milliseconds). - * - * One heartbeat is also emitted immediately when `payment_required` is - * received so the MCP timeout is always reset as soon as the payment flow - * begins. - * @default DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS (30_000 ms) - */ - syntheticProgressIntervalMs?: number; - /** - * Duration in ms for synthetic progress when `payment_required` carries no `ttl`. - * - * Mirrors the server-side `paymentTtlMs` default so the client keeps the MCP - * request alive for at least as long as the server will wait. - * @default DEFAULT_PAYMENT_TTL_MS (300_000 ms) - */ - defaultPaymentTtlMs?: number; - /** - * Minimum delay before an explicit-gating retry (`-32042` after a satisfied - * payment, or a `-32043` Payment Pending retry) is re-sent (milliseconds). - * For `-32043` it is applied after the server-provided `retry_after` backoff. - * - * Guards against `retry_after: 0`: an immediate retry within the same second - * can produce a byte-identical Nostr event (same content, same tags, same - * `created_at` at second resolution), which relays and servers swallow as a - * duplicate — silently losing the retry. - * @default 1000 - */ - minRetryDelayMs?: number; - - /** - * Optional policy hook invoked when a `payment_required` notification is received. - * - * This is evaluated before checking rail-specific `canHandle` methods. - * - * When the underlying transport supports request correlation (e.g. {@link NostrClientTransport}), - * `originalRequestContext` provides minimal details about the original JSON-RPC request that - * triggered the payment. - */ - paymentPolicy?: ( - req: PaymentHandlerRequest, - originalRequestContext?: OriginalRequestContext, - ) => boolean | Promise; - - /** Requested payment interaction mode. @default 'transparent' */ - paymentInteraction?: PaymentInteractionMode; - - /** - * Maximum number of -32043 (Payment Pending) retries before giving up. - * - * With retry_after=2 and 1.5× exponential backoff capped at 10s, the default - * of 10 retries gives ~45s of cumulative wait — enough for typical verification - * flows. Increase for slow payment processors (e.g. on-chain confirmation). - * @default 10 - */ - maxPendingRetries?: number; - - /** - * Handler for explicit-gating -32042 errors. - * Called when a priced invocation returns Payment Required. - * The handler should pay one option and signal completion. - * - * **Error handling contract**: - * - If the promise resolves with `{ paid: true }`, the wrapper auto-retries the - * original request with the same `method` and `params`. - * - If the promise resolves with `{ paid: false, reason }`, the wrapper synthesizes - * a JSON-RPC error to the caller with code `-32042` and `data: { reason }`. - * Use `reason: 'user_cancelled'` for user-initiated cancellations. - * - If the promise **rejects**, the wrapper MUST NOT silently fall back. - * It synthesizes a JSON-RPC error with code `-32042` and - * `data: { reason: error.message, type: 'payment_handler_error' }`. - * - Transient payment-provider failures should reject with an Error whose - * `message` contains the provider error details. - * - * **Verify-timeout window**: if the server's verification times out or fails - * after the client paid, its pending state is cleared and the client's retry - * receives a fresh `-32042` with a new invoice (CEP-8-compliant). The wrapper - * does not dedup across distinct `pay_req` values. - */ - onPaymentRequired?: (params: { - options: PaymentOption[]; - instructions?: string; - originalRequest: JSONRPCRequest; - }) => Promise<{ paid: boolean; reason?: string }>; -} +export type { + ClientPaymentsOptions, + ClientPaymentLimits, +} from './client-payment-options.js'; -type ProgressToken = string; - -type SyntheticProgressEntry = { - stopAtMs: number; - wireProgressToken: string | number; -}; - -function supportsOnmessageWithContext( - transport: Transport, -): transport is TransportWithContext { - // Avoid using the `in` operator here; prefer an ES3-compatible check. - return Object.prototype.hasOwnProperty.call( - transport, - 'onmessageWithContext', - ); -} +const clientPaymentsWrapped = new WeakSet(); -function isExplicitPaymentRequiredError( - msg: JSONRPCMessage, -): msg is JSONRPCErrorResponse { +function isPaymentOffer( + message: JSONRPCMessage, +): message is PaymentRequiredNotification { + // Classify by envelope before schema helpers can reject malformed params. return ( - isJSONRPCErrorResponse(msg) && - msg.error.code === PAYMENT_REQUIRED_ERROR_CODE && - typeof msg.error.data === 'object' && - msg.error.data !== null && - Array.isArray( - (msg.error.data as { payment_options?: unknown }).payment_options, - ) && - (msg.error.data as { payment_options: unknown[] }).payment_options.length > - 0 + 'method' in message && + message.method === PAYMENT_REQUIRED_METHOD && + !('id' in message) ); } -function isExplicitPaymentPendingError( - msg: JSONRPCMessage, -): msg is JSONRPCErrorResponse { +function isExplicitPaymentError( + message: JSONRPCMessage, +): message is JSONRPCErrorResponse { return ( - isJSONRPCErrorResponse(msg) && - msg.error.code === PAYMENT_PENDING_ERROR_CODE && - typeof msg.error.data === 'object' && - msg.error.data !== null && - typeof (msg.error.data as { retry_after?: unknown }).retry_after === - 'number' + isJSONRPCErrorResponse(message) && + (message.error.code === PAYMENT_REQUIRED_ERROR_CODE || + message.error.code === PAYMENT_PENDING_ERROR_CODE) ); } -function isPaymentRequiredNotification( - msg: JSONRPCMessage, -): msg is PaymentRequiredNotification { - return isJSONRPCNotification(msg) && msg.method === PAYMENT_REQUIRED_METHOD; +function cancelledRequestId( + message: JSONRPCMessage, +): string | number | undefined { + if ( + !isJSONRPCNotification(message) || + message.method !== 'notifications/cancelled' + ) + return undefined; + const id = message.params?.requestId; + return typeof id === 'string' || typeof id === 'number' ? id : undefined; } -/** Transports already wrapped by {@link withClientPayments}. */ -const clientPaymentsWrapped = new WeakSet(); - /** - * Wraps a transport to automatically handle CEP-8 payment requests. - * - * When `transport` is a {@link NostrClientTransport}, PMI advertisement and - * synthetic progress injection are enabled automatically. + * Wraps a transport with validated CEP-8 offers, bounded payment attempts, and + * request-owned retries. Nostr peers are authenticated by their transport; + * generic transports isolate attempt history per wrapper and must keep their + * peer identity stable. In-flight and unknown attempts stay pinned; terminal + * history is bounded and may be evicted under capacity pressure. * - * Note: `payment_rejected` → JSON-RPC error synthesis requires a - * {@link NostrClientTransport} because correlation (requestEventId → MCP request id) - * depends on Nostr event tagging. On plain transports a rejected payment surfaces - * as an unhandled notification and the MCP request times out naturally. + * Unmatched handlers leave transparent payment to the application. Generic + * notifications without request correlation support legacy handler execution, + * but per-invocation limits require an identifiable original request. */ export function withClientPayments( transport: Transport, options: ClientPaymentsOptions, ): Transport { - // A second wrap silently double-pays every offer (each wrapper runs its own - // pipeline with its own dedup on separate closures) or, for sibling wraps, - // silently kills the first wrapper's trampolines. Refuse outright. - if (clientPaymentsWrapped.has(transport)) { + if (clientPaymentsWrapped.has(transport)) throw new Error('withClientPayments already called on this transport'); - } + validateClientPaymentOptions(options); const logger = createLogger('client-payments'); - - const syntheticProgressIntervalMs = - options.syntheticProgressIntervalMs ?? - DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS; - - const defaultPaymentTtlMs = - options.defaultPaymentTtlMs ?? DEFAULT_PAYMENT_TTL_MS; - - const minRetryDelayMs = options.minRetryDelayMs ?? 1000; - - const syntheticProgress = new Map(); - - let syntheticProgressScheduler: ReturnType | undefined; - - const maybeStopScheduler = (): void => { - if (syntheticProgress.size > 0) return; - if (!syntheticProgressScheduler) return; - clearInterval(syntheticProgressScheduler); - syntheticProgressScheduler = undefined; + const nostr = + transport instanceof NostrClientTransport ? transport : undefined; + const minRetryDelayMs = Math.max( + options.minRetryDelayMs ?? 1000, + nostr ? 1000 : 0, + ); + const maxRetries = options.maxPendingRetries ?? 10; + const defaultTtlMs = options.defaultPaymentTtlMs ?? DEFAULT_PAYMENT_TTL_MS; + const limits = options.limits && { + ...options.limits, + maxAmountByPmi: options.limits.maxAmountByPmi && { + ...options.limits.maxAmountByPmi, + }, }; + let onmessage: Transport['onmessage']; + let onerror: Transport['onerror']; + let onclose: Transport['onclose']; + const progress = new ClientPaymentProgress( + options.syntheticProgressIntervalMs ?? + DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS, + (message) => onmessage?.(message), + ); - const tickSyntheticProgress = (): void => { - if (syntheticProgress.size === 0) { - maybeStopScheduler(); + const emitFailure = ( + state: ClientPaymentRequest, + error: ClientPaymentError, + code = -32000, + data: Record = {}, + ): void => { + logger.info('Client payment failed', { + requestId: state.id, + reason: data.type === 'payment_handler_error' ? data.type : error.reason, + outcome: data.outcome, + }); + if (state.id === undefined) { + onerror?.(error); return; } - - const now = Date.now(); - for (const [token, entry] of syntheticProgress) { - if (now >= entry.stopAtMs) { - syntheticProgress.delete(token); - continue; - } - - onmessage?.({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken: entry.wireProgressToken, - progress: 0, - }, - } as JSONRPCNotification); - } - - maybeStopScheduler(); - }; - - const ensureSchedulerStarted = (): void => { - if (syntheticProgressScheduler) return; - syntheticProgressScheduler = setInterval( - tickSyntheticProgress, - syntheticProgressIntervalMs, - ); - }; - - const stopSyntheticProgress = (token: ProgressToken): void => { - if (!syntheticProgress.delete(token)) return; - maybeStopScheduler(); - }; - - const pendingTimers = new Set>(); - // Bounded together with rawRequestCache: an abandoned request's counter must - // not accumulate forever (terminal responses and close() clear these, but a - // request that never terminates otherwise would leak). - const retryCounts = new LruCache(1000); - const rawRequestCache = new LruCache(1000); - const MAX_RETRIES = options.maxPendingRetries ?? 10; - - /** - * Disposes all client-side payment state: synthetic progress, pending retry - * timers, retry counters, and the raw-request cache. Called on transport close. - */ - const disposeClientState = (): void => { - syntheticProgress.clear(); - if (syntheticProgressScheduler) { - clearInterval(syntheticProgressScheduler); - syntheticProgressScheduler = undefined; - } - for (const timer of pendingTimers) { - clearTimeout(timer); - } - pendingTimers.clear(); - retryCounts.clear(); - rawRequestCache.clear(); - }; - - // Ensure CEP-8 discovery/negotiation: when using Nostr transports, always - // advertise the handler PMIs in preference order. Omitting handlers entirely - // advertises no PMI, so the server sends payment_required for any processor - // (CEP-8: client that specified no PMI). - const advertisedPmis = (options.handlers ?? []).map((h) => h.pmi); - if (transport instanceof NostrClientTransport) { - transport.setClientPmis(advertisedPmis); - logger.debug('advertised client PMIs', { pmis: advertisedPmis }); - if (options.paymentInteraction) { - transport.setPaymentInteraction(options.paymentInteraction); - logger.debug('advertised requested payment interaction mode', { - mode: options.paymentInteraction, - }); - } - } - - // Index handlers by PMI. Warn on duplicates — Map construction silently keeps - // only the last. - const handlersByPmi = new Map(); - for (const h of options.handlers ?? []) { - if (handlersByPmi.has(h.pmi)) { - logger.warn('duplicate PMI handler registered, last one wins', { - pmi: h.pmi, - }); - } - handlersByPmi.set(h.pmi, h); - } - - // Prevent double-paying if relays or servers deliver duplicate payment_required notifications. - const inFlightPayReqs = new Set(); - - let onmessage: ((message: JSONRPCMessage) => void) | undefined; - let onerror: ((error: Error) => void) | undefined; - let onclose: (() => void) | undefined; - - /** Emits a synthesized JSON-RPC error to the upstream consumer via `onmessage`. */ - const synthesizePaymentError = (params: { - id: string | number | undefined; - code: number; - message: string; - data: Record; - }): void => { onmessage?.({ jsonrpc: '2.0', - id: params.id, + id: state.id, error: { - code: params.code, - message: params.message, - data: params.data, + code, + message: error.message, + data: { reason: error.reason, ...data }, }, - } as JSONRPCMessage); + }); + }; + const lifecycle = new ClientPaymentLifecycle(limits, emitFailure, (state) => { + progress.stop(state.key); + if (state.id !== undefined) + nostr?.forgetPendingRequestsForRequestId(state.id); + }); + const attempts = new ClientPaymentAttempts( + options.maxTrackedPaymentAttempts ?? 1000, + ); + const handlers = new Map(); + for (const handler of options.handlers ?? []) { + if (handlers.has(handler.pmi)) + logger.warn('duplicate PMI handler registered, last one wins', { + pmi: handler.pmi, + }); + handlers.set(handler.pmi, handler); + } + nostr?.setClientPmis([...handlers.keys()]); + if (options.paymentInteraction) + nostr?.setPaymentInteraction(options.paymentInteraction); + + const fail = ( + state: ClientPaymentRequest, + error: ClientPaymentError, + code = -32000, + data: Record = {}, + ): void => { + if (!lifecycle.isActive(state)) return; + lifecycle.finish(state); + emitFailure(state, error, code, data); }; - /** - * Synthesize a generic `-32000` decline for the original request, carrying the - * PMI/amount and the original request's method/capability. No-op when there - * is no correlated pending request to fail. Shared by the no-handler, - * explicit-gating-rejected, and payment_rejected paths. - */ - const synthesizePaymentDecline = ( - pending: PendingRequest | undefined, - message: string, - pmi: string, - amount: number, + const scheduleRetry = ( + state: ClientPaymentRequest, + delayMs: number, + code: number, ): void => { - if (pending?.originalRequestId == null) { + if ( + !lifecycle.isActive(state) || + !state.request || + state.retryTimer !== undefined + ) return; - } - synthesizePaymentError({ - id: pending.originalRequestId, - code: -32000, - message, - data: { - pmi, - amount, - method: pending.originalRequestContext?.method, - capability: pending.originalRequestContext?.capability, + state.retryTimer = setTimeout( + () => { + state.retryTimer = undefined; + if (!lifecycle.isActive(state) || !state.request) return; + void transport.send(state.request).catch((error: unknown) => { + fail( + state, + new ClientPaymentError( + 'payment_retry_failed', + 'Failed to retry payment request', + ), + code, + { detail: error instanceof Error ? error.message : String(error) }, + ); + }); }, + Math.max(delayMs, minRetryDelayMs), + ); + logger.debug('Payment retry scheduled', { + requestId: state.id, + delayMs: Math.max(delayMs, minRetryDelayMs), + code, }); }; - /** - * Handles explicit-gating -32042 (invoke `onPaymentRequired`, then retry) and - * -32043 (backoff, then retry). Both are intercepted here, never forwarded. - */ - async function handleExplicitPaymentError( - message: JSONRPCMessage, - requestEventId: string, - ): Promise { - if (isExplicitPaymentRequiredError(message)) { - const errorMsg = message; - const data = errorMsg.error.data as PaymentRequiredErrorData; + const priorOutcome = async ( + state: ClientPaymentRequest, + attempt: ClientPaymentAttempt, + explicit: boolean, + ): Promise => { + logger.debug('Payment offer deduplicated', { + requestId: state.id, + outcome: attempt.outcome, + }); + await attempt.completion; + if (!lifecycle.isActive(state)) return; + if (attempt.outcome === 'handled') { + if (explicit) + scheduleRetry(state, minRetryDelayMs, PAYMENT_PENDING_ERROR_CODE); + } else { + fail( + state, + new ClientPaymentError( + attempt.outcome === 'unknown' + ? 'payment_outcome_unknown' + : 'payment_previously_declined', + attempt.outcome === 'unknown' + ? 'Payment outcome is uncertain; reconcile before paying again' + : 'Payment was previously declined', + ), + explicit ? PAYMENT_REQUIRED_ERROR_CODE : -32000, + { outcome: attempt.outcome }, + ); + } + }; - if (!options.onPaymentRequired) { + async function explicitPayment(message: JSONRPCErrorResponse): Promise { + const state = message.id != null ? lifecycle.get(message.id) : undefined; + if (!state) { + onmessage?.(message); + return; + } + if (!lifecycle.isActive(state)) return; + lifecycle.startPayment(state); + if (message.error.code === PAYMENT_PENDING_ERROR_CODE) { + const data = parseClientPaymentPending(message.error.data); + if (!state.request) { + lifecycle.finish(state); onmessage?.(message); return; } - - const requestId = errorMsg.id; - const rawRequest = - requestId != null - ? rawRequestCache.get(JSON.stringify(requestId)) - : undefined; - if (!rawRequest) { - logger.warn( - 'missing raw original request, cannot retry explicit payment', - { requestEventId }, - ); + if (state.retryTimer !== undefined) return; + if (state.retries >= maxRetries) { + lifecycle.finish(state); onmessage?.(message); return; } - - logger.info('invoking onPaymentRequired for explicit gating', { - requestEventId, - optionsCount: data.payment_options.length, - }); - - try { - const result = await options.onPaymentRequired({ - options: data.payment_options, - instructions: data.instructions, - originalRequest: rawRequest, - }); - - if (result.paid) { - logger.info('explicit payment satisfied, retrying original request', { - requestEventId, - method: rawRequest.method, - }); - // Same floor as the -32043 retry: a sub-second retry can produce a - // byte-identical Nostr event (created_at has second resolution), - // which servers de-duplicate by event id. - await sleep(minRetryDelayMs); - await transport.send(rawRequest); - return; - } - - logger.debug('onPaymentRequired returned paid=false', { - requestEventId, - reason: result.reason, - }); - synthesizePaymentError({ - id: errorMsg.id, - code: PAYMENT_REQUIRED_ERROR_CODE, - message: 'Payment Required', - data: { reason: result.reason || 'user_cancelled' }, - }); - } catch (err) { - logger.error('onPaymentRequired callback failed', { - requestEventId, - error: err instanceof Error ? err.message : String(err), - }); - synthesizePaymentError({ - id: errorMsg.id, - code: PAYMENT_REQUIRED_ERROR_CODE, - message: 'Payment Required', - data: { - reason: err instanceof Error ? err.message : String(err), - type: 'payment_handler_error', - }, - }); - } + const multiplier = Math.pow(1.5, state.retries++); + const baseDelay = (data.retry_after ?? 1) * 1000; + const delay = + baseDelay === 0 ? 0 : Math.min(baseDelay * multiplier, 10000); + scheduleRetry(state, delay, PAYMENT_PENDING_ERROR_CODE); return; } - - // -32043 Payment Pending - if (isExplicitPaymentPendingError(message)) { - const errorMsg = message; - const data = errorMsg.error.data as PaymentPendingErrorData; - const retryAfterSeconds = data.retry_after; - - const requestId = errorMsg.id; - const rawRequest = - requestId != null - ? rawRequestCache.get(JSON.stringify(requestId)) - : undefined; - if (!rawRequest) { - logger.warn( - 'missing raw original request, cannot retry explicit payment pending', - { requestEventId }, + const data = parseClientPaymentRequired(message.error.data); + if (!options.onPaymentRequired || !state.request) { + lifecycle.finish(state); + onmessage?.(message); + return; + } + const prior = attempts.find(data.payment_options); + if (prior) { + await priorOutcome(state, prior, true); + return; + } + if (state.paymentInFlight) { + throw new ClientPaymentError( + 'payment_outcome_unknown', + 'Another payment decision is still in progress for this invocation', + ); + } + const allowed = lifecycle.approve(state, data.payment_options); + const attempt = attempts.reserve(allowed, state.controller.signal); + state.paymentInFlight = true; + logger.debug('Explicit payment offer accepted; invoking approval handler', { + requestId: state.id, + optionCount: allowed.length, + }); + try { + const result = await options.onPaymentRequired({ + options: allowed, + instructions: data.instructions, + originalRequest: JSON.parse( + JSON.stringify(state.request), + ) as NonNullable, + abortSignal: state.controller.signal, + }); + if (!lifecycle.isActive(state)) return; + if (result.paid) { + attempt.complete('handled'); + scheduleRetry(state, minRetryDelayMs, PAYMENT_REQUIRED_ERROR_CODE); + } else { + attempt.complete('declined'); + fail( + state, + new ClientPaymentError( + result.reason || 'user_cancelled', + 'Payment Required', + ), + PAYMENT_REQUIRED_ERROR_CODE, ); - onmessage?.(message); - return; } + } catch (error: unknown) { + attempt.complete('unknown'); + if (lifecycle.isActive(state)) + fail( + state, + new ClientPaymentError( + error instanceof Error ? error.message : String(error), + 'Payment Required', + ), + PAYMENT_REQUIRED_ERROR_CODE, + { type: 'payment_handler_error', outcome: 'unknown' }, + ); + } finally { + state.paymentInFlight = false; + } + } - // Type-aware key: JSON.stringify keeps numeric 5 and string "5" - // distinct, and must be used consistently for both maps below. - const requestIdKey = JSON.stringify(errorMsg.id); - const retries = retryCounts.get(requestIdKey) ?? 0; - if (retries >= MAX_RETRIES) { - logger.error('max explicit payment retries exceeded', { - requestEventId, - id: requestIdKey, - maxRetries: MAX_RETRIES, - }); - onmessage?.(message); - return; - } - - retryCounts.set(requestIdKey, retries + 1); - - logger.info('payment pending, retrying after backoff', { - requestEventId, - retryAfterSeconds, - retryCount: retries + 1, - }); - - const baseDelayMs = (retryAfterSeconds ?? 1) * 1000; - const exponentialMultiplier = Math.pow(1.5, retries); - // Floor at minRetryDelayMs: a sub-second retry can produce a - // byte-identical Nostr event (created_at has second resolution), which - // relays and servers swallow as a duplicate. - const delayMs = Math.min( - Math.max(baseDelayMs * exponentialMultiplier, minRetryDelayMs), - 10000, + function transparentState( + eventId: string, + pending?: PendingRequest, + ): ClientPaymentRequest | undefined { + if (nostr && !pending) return undefined; + if (pending?.originalRequestId != null) + return lifecycle.correlated(pending.originalRequestId); + const state = lifecycle.onlyRequest(); + if (state) return state; + if (lifecycle.hasRequests()) { + onerror?.( + new ClientPaymentError( + 'payment_request_uncorrelated', + 'Payment notification is ambiguous between active requests', + ), ); - - const timer = setTimeout(() => { - pendingTimers.delete(timer); - transport.send(rawRequest).catch((err) => { - logger.error('failed to retry pending request', { - requestEventId, - error: err instanceof Error ? err.message : String(err), - }); - synthesizePaymentError({ - id: rawRequest.id, - code: PAYMENT_PENDING_ERROR_CODE, - message: 'Failed to retry pending request', - data: { - reason: err instanceof Error ? err.message : String(err), - }, - }); - }); - }, delayMs); - pendingTimers.add(timer); + return undefined; } + return lifecycle.detached(eventId); } - /** - * Handles transparent `notifications/payment_required`: satisfies the request - * in-band via configured handlers, gated by `paymentPolicy`, `canHandle`, and - * the effective-mode guard. - */ - async function handleTransparentPaymentRequired( - message: PaymentRequiredNotification, - requestEventId: string, + async function transparentPayment( + option: PaymentOption, + eventId: string, + state: ClientPaymentRequest, + pending?: PendingRequest, ): Promise { - const isNostrTransport = transport instanceof NostrClientTransport; - - const pending = isNostrTransport - ? transport.getPendingRequestForEventId(requestEventId) - : undefined; - - if (isNostrTransport && !pending) { - logger.warn('dropping uncorrelated payment_required notification', { - requestEventId, - pmi: message.params.pmi, - amount: message.params.amount, - }); - return; - } - - // CEP-8: a client that required explicit_gating SHOULD NOT auto-satisfy a - // transparent payment_required when the server did not accept it. + if (!lifecycle.isActive(state)) return; if ( - isNostrTransport && + nostr && options.paymentInteraction === 'explicit_gating' && - transport.getEffectivePaymentInteraction() !== 'explicit_gating' + nostr.getEffectivePaymentInteraction() !== 'explicit_gating' ) { - logger.warn( - 'declining transparent payment_required: explicit_gating was not accepted by the server', - { requestEventId, pmi: message.params.pmi }, - ); - synthesizePaymentDecline( - pending, - 'Payment declined: explicit_gating was not accepted by the server', - message.params.pmi, - message.params.amount, - ); - return; - } - - // If the transport can provide the original request's progressToken, emit synthetic - // progress notifications locally to keep the upstream MCP request alive while the - // payment settles (CEP-8 TTL can exceed the default MCP timeout). - if (isNostrTransport) { - const token = pending?.progressToken; - // Fall back to defaultPaymentTtlMs when the server omits ttl so the client - // keeps the MCP request alive for the same duration the server will wait. - const ttlSeconds = - message.params.ttl !== undefined - ? message.params.ttl - : defaultPaymentTtlMs / 1000; - if (token && Number.isFinite(ttlSeconds)) { - // Guard against nonsensical TTLs. - const ttlMs = Math.floor(ttlSeconds * 1000); - if (ttlMs > 0 && !syntheticProgress.has(token)) { - const stopAtMs = Date.now() + ttlMs; - - const wireProgressToken = Number.isFinite(Number(token)) - ? Number(token) - : token; - syntheticProgress.set(token, { - stopAtMs, - wireProgressToken, - }); - ensureSchedulerStarted(); - - // Reset the MCP timeout immediately — don't wait for the interval tick - onmessage?.({ - jsonrpc: '2.0', - method: 'notifications/progress', - params: { - progressToken: wireProgressToken, - progress: 0, - }, - } as JSONRPCNotification); - - logger.debug('started synthetic progress', { - requestEventId, - progressToken: token, - ttlSeconds, - intervalMs: syntheticProgressIntervalMs, - }); - } - } - } - - // Resolve an in-band handler for this PMI. If none matches, the payment is - // left to the application: the notification was already forwarded via - // onmessage, synthetic progress above keeps the request alive while it - // settles out-of-band, and the server's TTL is the ultimate timeout. - const handler = handlersByPmi.get(message.params.pmi); - if (!handler) { - logger.debug( - 'no in-band handler for PMI; leaving payment to the application', + fail( + state, + new ClientPaymentError( + 'payment_negotiation_failed', + 'Payment declined: explicit_gating was not accepted by the server', + ), + -32000, { - pmi: message.params.pmi, - requestEventId, + pmi: option.pmi, + amount: option.amount, + method: pending?.originalRequestContext?.method, + capability: pending?.originalRequestContext?.capability, }, ); return; } - - // Best-effort client-side dedupe keyed by pay_req. - // IMPORTANT: claim synchronously before any await to avoid double-pay races. - if (inFlightPayReqs.has(message.params.pay_req)) { - logger.debug('duplicate pay_req detected, skipping', { - payReq: message.params.pay_req.substring(0, 20) + '...', - requestEventId, - }); + lifecycle.startPayment(state); + if (nostr && pending?.progressToken) + progress.start( + state.key, + pending.progressToken, + option.ttl !== undefined ? option.ttl * 1000 : defaultTtlMs, + ); + const handler = handlers.get(option.pmi); + if (!handler || !lifecycle.isActive(state)) return; + if (limits && state.id === undefined) + throw new ClientPaymentError( + 'payment_request_uncorrelated', + 'Payment limits require a correlated original request', + ); + const prior = attempts.find([option]); + if (prior) { + await priorOutcome(state, prior, false); return; } - - inFlightPayReqs.add(message.params.pay_req); - + if (state.paymentInFlight) + throw new ClientPaymentError( + 'payment_outcome_unknown', + 'Another payment decision is still in progress for this invocation', + ); + lifecycle.approve(state, [option]); + const attempt = attempts.reserve([option], state.controller.signal); + state.paymentInFlight = true; + logger.debug('Transparent payment offer accepted', { + requestId: state.id, + pmi: option.pmi, + amount: option.amount, + }); const req: PaymentHandlerRequest = { - amount: message.params.amount, - pay_req: message.params.pay_req, - pmi: message.params.pmi, - description: message.params.description, - ttl: message.params.ttl, - _meta: message.params._meta, - requestEventId, + amount: option.amount, + pmi: option.pmi, + pay_req: option.pay_req, + description: option.description, + ttl: option.ttl, + _meta: option._meta, + requestEventId: eventId, + abortSignal: state.controller.signal, }; - - const synthesizeClientDeclineError = (params: { - message: string; - }): void => { - if (pending?.progressToken) { - stopSyntheticProgress(pending.progressToken); - } - synthesizePaymentDecline(pending, params.message, req.pmi, req.amount); + const decline = (message: string): void => { + attempt.complete('declined'); + fail(state, new ClientPaymentError('payment_declined', message), -32000, { + pmi: option.pmi, + amount: option.amount, + method: pending?.originalRequestContext?.method, + capability: pending?.originalRequestContext?.capability, + }); }; - + let walletStarted = false; + const expiresAt = + option.ttl !== undefined ? Date.now() + option.ttl * 1000 : undefined; try { - logger.info('processing payment_required', { - requestEventId, - pmi: message.params.pmi, - amount: message.params.amount, - }); - if (options.paymentPolicy) { - const isApproved = await options.paymentPolicy( - req, + const approved = await options.paymentPolicy( + { ...req }, pending?.originalRequestContext, ); - if (!isApproved) { - logger.debug('paymentPolicy declined the payment', { - requestEventId, - pmi: message.params.pmi, - amount: message.params.amount, - }); - synthesizeClientDeclineError({ - message: 'Payment declined by client policy', - }); + if (!lifecycle.isActive(state)) return; + if (!approved) { + decline('Payment declined by client policy'); return; } } - - const canHandle = handler.canHandle ? await handler.canHandle(req) : true; + const canHandle = handler.canHandle + ? await handler.canHandle({ ...req }) + : await Promise.resolve(true); + if (!lifecycle.isActive(state)) return; if (!canHandle) { - logger.debug('handler declined to handle', { - requestEventId, - pmi: message.params.pmi, - }); - synthesizeClientDeclineError({ - message: 'Payment declined by client handler', - }); + decline('Payment declined by client handler'); return; } - - logger.debug('invoking payment handler', { - requestEventId, - handler: handler.constructor.name, - pmi: message.params.pmi, + if (expiresAt !== undefined && Date.now() >= expiresAt) { + attempt.complete('declined'); + throw new ClientPaymentError( + 'payment_offer_expired', + 'Payment offer expired before wallet action', + ); + } + walletStarted = true; + logger.debug('Invoking payment handler', { + requestId: state.id, + pmi: option.pmi, + amount: option.amount, }); - await handler.handle(req); - - logger.info('payment handler completed successfully', { - requestEventId, - pmi: message.params.pmi, - }); - } catch (error) { - logger.error('payment handler failed', { - requestEventId, - pmi: message.params.pmi, - error: error instanceof Error ? error.message : String(error), - }); - // Same-severity outcomes must behave the same: policy and canHandle - // declines resolve the pending request with a synthesized error. A - // handler crash must too, or the MCP request hangs until the server TTL. - synthesizeClientDeclineError({ - message: `Payment handler failed: ${ - error instanceof Error ? error.message : String(error) - }`, - }); - throw error; + if (lifecycle.isActive(state)) attempt.complete('handled'); + } catch (error: unknown) { + attempt.complete(walletStarted ? 'unknown' : 'declined'); + if (!lifecycle.isActive(state)) return; + const failure = error instanceof Error ? error : new Error(String(error)); + fail( + state, + error instanceof ClientPaymentError + ? error + : new ClientPaymentError( + 'payment_handler_error', + `Payment handler failed: ${failure.message}`, + ), + -32000, + { + pmi: option.pmi, + amount: option.amount, + outcome: walletStarted ? 'unknown' : 'declined', + }, + ); + if (state.id !== undefined && !(error instanceof ClientPaymentError)) + onerror?.(failure); } finally { - inFlightPayReqs.delete(message.params.pay_req); + state.paymentInFlight = false; } } - /** Classifies an inbound payment message and delegates to the relevant handler. */ - async function maybeHandlePaymentRequired( - message: JSONRPCMessage, - requestEventId: string, - ): Promise { - if ( - isExplicitPaymentRequiredError(message) || - isExplicitPaymentPendingError(message) - ) { - await handleExplicitPaymentError(message, requestEventId); + function dispatch(message: JSONRPCMessage, eventId: string): void { + if (lifecycle.isClosed) return; + const cancelled = cancelledRequestId(message); + if (cancelled !== undefined) { + const state = lifecycle.get(cancelled); + if (state) lifecycle.finish(state); + onmessage?.(message); return; } - if (isPaymentRequiredNotification(message)) { - await handleTransparentPaymentRequired(message, requestEventId); + if (isExplicitPaymentError(message)) { + const state = message.id != null ? lifecycle.get(message.id) : undefined; + void explicitPayment(message).catch((error: unknown) => { + const failure = + error instanceof ClientPaymentError + ? error + : new ClientPaymentError( + 'invalid_payment', + 'Invalid payment message', + ); + if (state) fail(state, failure, message.error.code); + else onerror?.(failure); + }); + return; + } + if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + const state = message.id != null ? lifecycle.get(message.id) : undefined; + if (state && !state.active) return; + if (state) lifecycle.finish(state); + onmessage?.(message); + return; + } + if (isPaymentOffer(message)) { + const pending = nostr?.getPendingRequestForEventId(eventId); + const state = transparentState(eventId, pending); + if (!state || !lifecycle.isActive(state)) { + onmessage?.(message); + return; + } + let option: PaymentOption; + try { + option = parseClientPaymentOption(message.params); + } catch (error: unknown) { + fail(state, error as ClientPaymentError); + return; + } + void transparentPayment(option, eventId, state, pending).catch( + (error: unknown) => { + fail( + state, + error instanceof ClientPaymentError + ? error + : new ClientPaymentError('payment_handler_error', String(error)), + ); + }, + ); + onmessage?.(message); return; } - } - - /** - * Runs the payment handler, then forwards to the upstream consumer unless the - * message is an explicit-gating error (those are re-emitted/retried internally). - */ - const dispatchAndForward = ( - message: JSONRPCMessage, - requestEventId: string, - ): void => { - void maybeHandlePaymentRequired(message, requestEventId).catch( - (err: unknown) => { - const error = err instanceof Error ? err : new Error(String(err)); - onerror?.(error); - }, - ); if ( - isExplicitPaymentRequiredError(message) || - isExplicitPaymentPendingError(message) + isJSONRPCNotification(message) && + (message.method === PAYMENT_ACCEPTED_METHOD || + message.method === PAYMENT_REJECTED_METHOD) ) { - return; + const pending = nostr?.getPendingRequestForEventId(eventId); + const state = + pending?.originalRequestId != null + ? lifecycle.correlated(pending.originalRequestId) + : lifecycle.onlyRequest(); + if (state) { + progress.stop(state.key); + if (message.method === PAYMENT_REJECTED_METHOD) { + const detail = + typeof message.params?.message === 'string' + ? message.params.message + : undefined; + fail( + state, + new ClientPaymentError( + 'payment_rejected', + detail ? `Payment rejected: ${detail}` : 'Payment rejected', + ), + ); + return; + } + } } onmessage?.(message); - }; + } - const wrapped = { + const wrapped: Transport = { get onmessage() { return onmessage; }, @@ -804,117 +616,60 @@ export function withClientPayments( set onclose(fn) { onclose = fn; }, - async start(): Promise { - // Only suppress notifications in `onmessage` when the transport delivers - // them through a separate context path (NostrClientTransport). - const hasContextPath = supportsOnmessageWithContext(transport); - - transport.onmessage = (message: JSONRPCMessage) => { - // `NostrClientTransport` delivers notifications through BOTH `onmessage` - // and `onmessageWithContext`. Forward only from the context path to avoid - // duplicate delivery to the upstream MCP Protocol. - if (hasContextPath && isJSONRPCNotification(message)) { - return; - } - - // Stop synthetic progress when the server responds (success or error). - // message.id was restored to originalRequestId by resolveResponse(). - // When onprogress was set, originalRequestId = progressToken, making this stop exact. - // Otherwise syntheticProgress has no entry and the call is a no-op. - if ( - isJSONRPCResultResponse(message) || - isJSONRPCErrorResponse(message) - ) { - stopSyntheticProgress(String(message.id)); - if ( - !isExplicitPaymentRequiredError(message) && - !isExplicitPaymentPendingError(message) - ) { - const reqId = message.id as string | number; - rawRequestCache.delete(JSON.stringify(reqId)); - retryCounts.delete(JSON.stringify(reqId)); - } - } - - if (hasContextPath) { - return; - } - - // Best-effort: execute handler asynchronously, but never block delivery. - dispatchAndForward(message, 'unknown'); + lifecycle.open(); + const hasContext = Object.prototype.hasOwnProperty.call( + transport, + 'onmessageWithContext', + ); + transport.onmessage = (message): void => { + if (!hasContext) dispatch(message, 'unknown'); }; - - if (hasContextPath) { - transport.onmessageWithContext = ( - message: JSONRPCMessage, - ctx: { eventId: string; correlatedEventId?: string }, - ) => { - const requestEventId = ctx.correlatedEventId ?? 'unknown'; - - // Stop synthetic progress on terminal outcomes (context path). - if (isJSONRPCNotification(message)) { - const isAccepted = message.method === PAYMENT_ACCEPTED_METHOD; - const isRejected = message.method === PAYMENT_REJECTED_METHOD; - if (isAccepted || isRejected) { - const pending = - transport instanceof NostrClientTransport - ? transport.getPendingRequestForEventId(requestEventId) - : undefined; - if (pending?.progressToken) { - stopSyntheticProgress(pending.progressToken); - } - // On rejection, synthesize a JSON-RPC error response so the caller's - // pending promise is rejected immediately rather than timing out after 60 s. - // The server never calls forward() for a rejected payment, so no real - // response will arrive. - if (isRejected && pending?.originalRequestId != null) { - const rejMsg = (message as PaymentRejectedNotification).params - ?.message; - onmessage?.({ - jsonrpc: '2.0', - id: pending.originalRequestId, - error: { - code: -32000, - message: rejMsg - ? `Payment rejected: ${rejMsg}` - : 'Payment rejected', - }, - } as JSONRPCMessage); - return; - } - } - } - - // Forward exactly once (see duplicate-delivery guard in `transport.onmessage`). - dispatchAndForward(message, requestEventId); + if (hasContext) + (transport as TransportWithContext).onmessageWithContext = ( + message, + ctx, + ): void => { + dispatch(message, ctx.correlatedEventId ?? 'unknown'); }; - } - - transport.onerror = (err: Error) => onerror?.(err); - transport.onclose = () => { - disposeClientState(); + transport.onerror = (error): void => onerror?.(error); + transport.onclose = (): void => { + lifecycle.close(); + progress.clear(); onclose?.(); }; - await transport.start(); + try { + await transport.start(); + } catch (error) { + lifecycle.close(); + progress.clear(); + throw error; + } }, - async send(message: JSONRPCMessage): Promise { - if ('method' in message && 'id' in message && message.id != null) { - rawRequestCache.set( - JSON.stringify(message.id), - message as JSONRPCRequest, - ); + if (lifecycle.isClosed) + throw new Error('Client payments transport is closed'); + const cancelled = cancelledRequestId(message); + if (cancelled !== undefined) { + const state = lifecycle.get(cancelled); + if (state) lifecycle.finish(state); + } + const state = isJSONRPCRequest(message) + ? lifecycle.register(message) + : undefined; + try { + await transport.send(message); + } catch (error) { + if (state) lifecycle.finish(state); + throw error; } - await transport.send(message); }, - async close(): Promise { - // disposeClientState is called via transport.onclose, no need to call it here + lifecycle.close(); + progress.clear(); await transport.close(); }, }; - clientPaymentsWrapped.add(transport); clientPaymentsWrapped.add(wrapped); return wrapped; diff --git a/src/payments/fixtures/canonical-invocations.json b/src/payments/fixtures/canonical-invocations.json new file mode 100644 index 0000000..af28267 --- /dev/null +++ b/src/payments/fixtures/canonical-invocations.json @@ -0,0 +1,150 @@ +[ + { + "name": "omitted params", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\"}", + "sha256": "8cd110067dc76f633f8f32c07f220feb0170ac161b319a7ba13310841149291c" + }, + { + "name": "empty params", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{}}", + "sha256": "fee95a27604e96d5011447647a735b90de7d370d5bd60a75aa114d8d9dccdedb", + "params": {} + }, + { + "name": "null params", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":null}", + "sha256": "0d42502f6ed2dab6f9ae9c3c6131a56816f0f195824f7899a2566e81fc979003", + "params": null + }, + { + "name": "nested key order", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"arguments\":{\"a\":1,\"b\":2},\"name\":\"add\"}}", + "sha256": "3d51151b6b6d37c9dbbfa39532bb68d51bf3d9a8a6763437ab179648a6f565ff", + "params": { + "name": "add", + "arguments": { + "b": 2, + "a": 1 + } + } + }, + { + "name": "transport metadata excluded", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"arguments\":{\"a\":1,\"b\":2},\"name\":\"add\"}}", + "sha256": "3d51151b6b6d37c9dbbfa39532bb68d51bf3d9a8a6763437ab179648a6f565ff", + "params": { + "_meta": { + "progressToken": 42, + "unrecognized": { + "nested": true + } + }, + "arguments": { + "a": 1, + "b": 2 + }, + "name": "add" + } + }, + { + "name": "another client same invocation hash", + "clientPubkey": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"arguments\":{\"a\":1,\"b\":2},\"name\":\"add\"}}", + "sha256": "3d51151b6b6d37c9dbbfa39532bb68d51bf3d9a8a6763437ab179648a6f565ff", + "params": { + "name": "add", + "arguments": { + "b": 2, + "a": 1 + } + } + }, + { + "name": "changed semantic argument", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"arguments\":{\"a\":2,\"b\":2},\"name\":\"add\"}}", + "sha256": "658de74e9b3235696bc41195377b366c6a051d1fa9a019fe0b8885f1cf337396", + "params": { + "name": "add", + "arguments": { + "b": 2, + "a": 2 + } + } + }, + { + "name": "unicode composed", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"text\":\"é 🌍\"}}", + "sha256": "04129d21e74bd5e67eb3b018f6daf35309e16226a574312e097d542741fcdfdd", + "params": { + "text": "é 🌍" + } + }, + { + "name": "unicode decomposed", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"text\":\"é 🌍\"}}", + "sha256": "5fa930fb9634dfb2022cf4711766e9385269397e54e43f73befb3a78177254ff", + "params": { + "text": "é 🌍" + } + }, + { + "name": "ECMAScript number serialization", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"numbers\":[0,1e-7,1e+21,0.000001,333333333.3333333]}}", + "sha256": "2abb616ec4a2c3756617c08e3f2f2eb519faad2360ad1c4d7120f55edc96941d", + "params": { + "numbers": [-0.0, 1e-7, 1e21, 1e-6, 333333333.3333333] + } + }, + { + "name": "positional params", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":[true,null,\"text\",{\"a\":1,\"z\":0}]}", + "sha256": "fa4d5aca8059f0825e90b7e4945a54b320a9ef855c0e5faf8bd080297c84e28b", + "params": [ + true, + null, + "text", + { + "z": 0, + "a": 1 + } + ] + }, + { + "name": "nested _meta remains semantic", + "clientPubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "method": "tools/call", + "canonical": "{\"method\":\"tools/call\",\"params\":{\"arguments\":{\"_meta\":{\"v\":1}}}}", + "sha256": "1e528551273124dd8b83dd7841fec364921c77b9c239bc7b5c3f15e62a47a1ce", + "params": { + "arguments": { + "_meta": { + "v": 1 + } + }, + "_meta": { + "progressToken": 1 + } + } + } +] diff --git a/src/payments/in-memory-payment-state-store.ts b/src/payments/in-memory-payment-state-store.ts new file mode 100644 index 0000000..886a3a5 --- /dev/null +++ b/src/payments/in-memory-payment-state-store.ts @@ -0,0 +1,98 @@ +import type { + PaymentStateAdmission, + PaymentStateAdmissionResult, + PaymentStatePolicy, + PaymentStateRecord, + PaymentStateStore, + PaymentStateTransition, +} from './payment-state-store.js'; +import { createPaymentStatePolicy } from './payment-state-store.js'; +import { + admitPaymentState, + canCollectPaymentRecord, + isLivePaymentLease, + transitionPaymentState, +} from './payment-state-machine.js'; + +let memoryStoreSequence = 0; + +/** Single-process default; live grants and uncertain outcomes are never evicted. */ +export class InMemoryPaymentStateStore implements PaymentStateStore { + public readonly policy: PaymentStatePolicy; + // Collect by replay deadlines, not LRU recency: evicting a tombstone can repeat payment or execution. + private readonly records = new Map(); + private observedAtMs = 0; + private readonly storeSequence = ++memoryStoreSequence; + private attemptSequence = 0; + + public constructor( + options: Partial = {}, + private readonly now: () => number = Date.now, + ) { + this.policy = createPaymentStatePolicy(options); + } + + /** Atomically claims a grant or reserves an invoice before any asynchronous work. */ + public async admit( + input: PaymentStateAdmission, + ): Promise { + const now = this.timestamp(); + if ( + !Number.isSafeInteger(this.storeSequence) || + this.attemptSequence >= Number.MAX_SAFE_INTEGER + ) + throw new Error('Payment generation space exhausted'); + for (const [key, record] of this.records) { + if (canCollectPaymentRecord(record, now)) this.records.delete(key); + } + const change = admitPaymentState( + input, + { + record: this.records.get(input.key), + execution: [...this.records.values()].find( + (record) => + record.key !== input.key && + record.clientPubkey === input.clientPubkey && + record.eventIds.includes(input.requestEventId), + ), + total: this.records.size, + pending: [...this.records.values()].filter((record) => + isLivePaymentLease(record, now), + ).length, + }, + this.policy, + now, + `${this.storeSequence}.${++this.attemptSequence}`, + ); + for (const key of change.remove) this.records.delete(key); + for (const record of change.put) this.records.set(record.key, record); + return change.result; + } + + /** Commits only transitions belonging to the current attempt generation. */ + public async transition(input: PaymentStateTransition): Promise { + const change = transitionPaymentState( + input, + this.records.get(input.key), + this.timestamp(), + this.policy, + ); + for (const key of change.remove) this.records.delete(key); + for (const record of change.put) this.records.set(record.key, record); + return change.result; + } + + /** Returns a defensive recovery snapshot without exposing mutable store state. */ + public async read(key: string): Promise { + const record = this.records.get(key); + return record && structuredClone(record); + } + + private timestamp(): number { + const now = this.now(); + if (!Number.isSafeInteger(now) || now < 0) + throw new Error('Invalid payment clock'); + this.observedAtMs = Math.max(now, this.observedAtMs); + return this.observedAtMs; + } +} diff --git a/src/payments/index.ts b/src/payments/index.ts index fe5f3eb..244c753 100644 --- a/src/payments/index.ts +++ b/src/payments/index.ts @@ -18,3 +18,7 @@ export * from './processors/ln-bolt11-nwc-payment-processor.js'; export * from './processors/ln-bolt11-lnbits-payment-processor.js'; export * from './processors/ln-bolt11-zap-payment-processor.js'; export * from './constants.js'; +export * from './payment-state-store.js'; +export * from './in-memory-payment-state-store.js'; +export * from './authorization-store.js'; +export * from './canonical-identity.js'; diff --git a/src/payments/payment-option-schema.test.ts b/src/payments/payment-option-schema.test.ts new file mode 100644 index 0000000..6c843d7 --- /dev/null +++ b/src/payments/payment-option-schema.test.ts @@ -0,0 +1,83 @@ +import { expect, test } from 'bun:test'; +import { + parseClientPaymentOption, + validateClientPaymentOptions, +} from './client-payment-validation.js'; +import { + validateProcessorPayment, + validateServerPaymentOptions, +} from './server-payment-validation.js'; +import { resolvePaymentProcessor } from './server-payments-utils.js'; +import type { PaymentProcessor } from './types.js'; + +function processor(pmi: string): PaymentProcessor { + return { + pmi, + async createPaymentRequired() { + return { amount: 1, pmi, pay_req: 'invoice' }; + }, + async verifyPayment() { + return {}; + }, + }; +} + +for (const pmi of [ + 'bitcoin-lightning-bolt11', + 'https://wallet.example/method', + 'pmi:A', + 'x'.repeat(2048), +]) { + test(`PMIs match exactly across client and server boundaries (${pmi.slice(0, 45)}, length=${pmi.length})`, () => { + const rail = processor(pmi); + const option = { amount: 1, pmi, pay_req: 'invoice' }; + expect(parseClientPaymentOption(option)).toEqual(option); + expect(() => + validateClientPaymentOptions({ + handlers: [{ pmi, async handle() {} }], + limits: { maxAmountByPmi: { [pmi]: 1 } }, + }), + ).not.toThrow(); + expect(() => + validateServerPaymentOptions({ + processors: [rail], + pricedCapabilities: [], + }), + ).not.toThrow(); + expect(validateProcessorPayment(option, rail, 1)).toEqual(option); + expect(resolvePaymentProcessor([pmi], new Map([[pmi, rail]]), [rail])).toBe( + rail, + ); + expect(() => + resolvePaymentProcessor([`${pmi}-different`], new Map([[pmi, rail]]), [ + rail, + ]), + ).toThrow(); + }); +} + +for (const pmi of ['', 'x'.repeat(2049)]) { + test(`PMI bounds apply to every client and server boundary (length=${pmi.length})`, () => { + const rail = processor(pmi); + const option = { amount: 1, pmi, pay_req: 'invoice' }; + expect(() => parseClientPaymentOption(option)).toThrow(); + expect(() => + validateClientPaymentOptions({ handlers: [{ pmi, async handle() {} }] }), + ).toThrow(); + expect(() => + validateClientPaymentOptions({ + limits: { maxAmountByPmi: { [pmi]: 1 } }, + }), + ).toThrow(); + expect(() => + validateServerPaymentOptions({ + processors: [rail], + pricedCapabilities: [], + }), + ).toThrow(); + expect(() => validateProcessorPayment(option, rail, 1)).toThrow(); + expect(() => + resolvePaymentProcessor([pmi], new Map([[pmi, rail]]), [rail]), + ).toThrow(); + }); +} diff --git a/src/payments/payment-option-schema.ts b/src/payments/payment-option-schema.ts new file mode 100644 index 0000000..793cd6c --- /dev/null +++ b/src/payments/payment-option-schema.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export const paymentAmountSchema = z + .number() + .finite() + .nonnegative() + .max(Number.MAX_SAFE_INTEGER); +/** Opaque, exact-match identifiers, including HTTPS methods and existing custom rails. */ +export const paymentPmiSchema = z.string().min(1).max(2048); + +/** Runtime offer schema; unknown extension metadata is preserved. */ +export const paymentOptionSchema = z.object({ + amount: paymentAmountSchema, + pmi: paymentPmiSchema, + pay_req: z + .string() + .min(1) + .refine((value) => value.trim().length > 0), + description: z.string().optional(), + ttl: z + .number() + .finite() + .positive() + .max(Number.MAX_SAFE_INTEGER / 1000) + .optional(), + _meta: z.record(z.string(), z.unknown()).optional(), +}); diff --git a/src/payments/payment-state-machine.ts b/src/payments/payment-state-machine.ts new file mode 100644 index 0000000..995af31 --- /dev/null +++ b/src/payments/payment-state-machine.ts @@ -0,0 +1,351 @@ +import type { + PaymentStateAdmission, + PaymentStateAdmissionResult, + PaymentStatePolicy, + PaymentStateRecord, + PaymentStateTransition, +} from './payment-state-store.js'; +import { + paymentAuthorizationKey, + paymentEventKey, +} from './payment-state-store.js'; +import { paymentOptionSchema } from './payment-option-schema.js'; + +/** Atomic backend view; implementations hold their lock while applying changes. */ +export interface PaymentStateView { + record?: PaymentStateRecord; + execution?: PaymentStateRecord; + total: number; + pending: number; +} + +/** Storage changes produced without external payment or application I/O. */ +export interface PaymentStateChange { + result: T; + put: PaymentStateRecord[]; + remove: string[]; +} + +/** Whether a record currently occupies a pending-payment slot. */ +export function isLivePaymentLease( + record: PaymentStateRecord, + now: number, +): boolean { + return ( + ['reserved', 'issuing', 'pending'].includes(record.phase) && + record.leaseExpiresAtMs > now + ); +} + +/** Tombstones are collected only after their events become inadmissible. */ +export function canCollectPaymentRecord( + record: PaymentStateRecord, + now: number, +): boolean { + return ( + record.retainUntilMs !== null && + record.retainUntilMs <= now && + !isLivePaymentLease(record, now) + ); +} + +/** Applies atomic admission/claim under the backend's namespace lock. */ +export function admitPaymentState( + input: PaymentStateAdmission, + view: PaymentStateView, + policy: PaymentStatePolicy, + now: number, + generation: string, +): PaymentStateChange { + const done = ( + status: Exclude< + PaymentStateAdmissionResult['status'], + 'reserved' | 'claimed' + >, + record = view.record, + ): PaymentStateChange => ({ + result: { status, ...(record && { record: structuredClone(record) }) }, + put: [], + remove: [], + }); + if ( + !Number.isFinite(input.leaseMs) || + input.leaseMs < 1 || + input.leaseMs > 2_147_483_647 || + !input.key || + !input.clientPubkey || + !input.requestEventId || + !input.invocationHash + ) { + throw new TypeError('Invalid payment admission'); + } + const expectedKey = + input.mode === 'transparent' + ? paymentEventKey(input.clientPubkey, input.requestEventId) + : paymentAuthorizationKey(input.clientPubkey, input.invocationHash); + if ( + !['transparent', 'explicit_gating'].includes(input.mode) || + input.key !== expectedKey + ) + throw new TypeError('Payment key does not match its identity'); + const event = input.event; + if ( + event && + (event.id !== input.requestEventId || + event.author !== input.clientPubkey || + !Number.isSafeInteger(event.createdAt) || + event.createdAt < 0 || + event.createdAt * 1000 <= now - policy.replayWindowMs || + event.createdAt * 1000 > now + policy.maxFutureSkewMs) + ) + return done('stale'); + if (view.execution) return done('duplicate'); + const eventDeadline = event + ? event.createdAt * 1000 + policy.replayWindowMs + : null; + const current = view.record; + if (current) { + if ( + current.clientPubkey !== input.clientPubkey || + current.invocationHash !== input.invocationHash + ) + return done('unknown'); + if (input.mode === 'transparent') return done('duplicate'); + if ( + current.phase === 'unknown' || + (current.phase === 'issuing' && current.leaseExpiresAtMs <= now) + ) + return done('unknown'); + if (current.phase === 'granted') { + if ((current.grantExpiresAtMs ?? 0) <= now) return done('expired'); + const eventIds = [ + ...new Set([...current.eventIds, input.requestEventId]), + ]; + const replayExpiresAtMs = + current.replayExpiresAtMs === null || eventDeadline === null + ? null + : Math.max(current.replayExpiresAtMs, eventDeadline); + const consumed: PaymentStateRecord = { + ...current, + phase: 'consumed', + eventIds, + replayExpiresAtMs, + retainUntilMs: + replayExpiresAtMs === null + ? null + : Math.max(replayExpiresAtMs, current.invoiceExpiresAtMs ?? 0), + }; + const execution: PaymentStateRecord = { + ...consumed, + key: paymentEventKey(input.clientPubkey, input.requestEventId), + requestEventId: input.requestEventId, + }; + // Move the paid right into its execution tombstone in the same transaction; + // claiming must not require extra capacity after money has been accepted. + return { + result: { status: 'claimed', record: structuredClone(execution) }, + put: [execution], + remove: [current.key], + }; + } + if (isLivePaymentLease(current, now)) { + if (current.eventIds.includes(input.requestEventId)) + return done('pending'); + if (current.eventIds.length >= policy.maxEventsPerPayment - 1) + return done('capacity'); + const updated = { + ...current, + eventIds: [...current.eventIds, input.requestEventId], + replayExpiresAtMs: + current.replayExpiresAtMs === null || eventDeadline === null + ? null + : Math.max(current.replayExpiresAtMs, eventDeadline), + }; + if (updated.retainUntilMs !== null && updated.replayExpiresAtMs !== null) + updated.retainUntilMs = Math.max( + updated.retainUntilMs, + updated.replayExpiresAtMs, + ); + return { + result: { status: 'pending', record: structuredClone(updated) }, + put: [updated], + remove: [], + }; + } + if (event && current.eventIds.includes(input.requestEventId)) + return done('duplicate'); + if ( + current.requestEventId === input.requestEventId && + ['consumed', 'dispatched', 'rejected'].includes(current.phase) + ) + return done('duplicate'); + } + const archive = current?.invoice + ? { + ...current, + key: JSON.stringify(['attempt', current.generation]), + phase: 'expired' as const, + retainUntilMs: current.retainUntilMs, + } + : undefined; + const additional = current ? (archive ? 1 : 0) : 1; + if ( + view.pending >= policy.maxPendingPayments || + view.total + additional > policy.maxPaymentRecords + ) + return done('capacity'); + const record: PaymentStateRecord = { + key: input.key, + generation, + mode: input.mode, + phase: 'reserved', + clientPubkey: input.clientPubkey, + requestEventId: input.requestEventId, + eventIds: [input.requestEventId], + replayExpiresAtMs: eventDeadline, + invocationHash: input.invocationHash, + leaseExpiresAtMs: now + Math.floor(input.leaseMs), + retainUntilMs: + event && input.mode === 'transparent' + ? event.createdAt * 1000 + policy.replayWindowMs + : input.mode === 'transparent' + ? null + : Math.max(now + policy.replayWindowMs, eventDeadline ?? 0), + }; + return { + result: { status: 'reserved', record: structuredClone(record) }, + put: [...(archive ? [archive] : []), record], + remove: [], + }; +} + +/** Applies a fenced state transition; expired/superseded workers cannot commit. */ +export function transitionPaymentState( + input: PaymentStateTransition, + current: PaymentStateRecord | undefined, + now: number, + policy: PaymentStatePolicy, +): PaymentStateChange { + const none: PaymentStateChange = { + result: false, + put: [], + remove: [], + }; + if (!current || current.generation !== input.generation) return none; + const record = structuredClone(current); + const live = isLivePaymentLease(record, now); + switch (input.type) { + case 'issuing': + if (record.phase !== 'reserved' || !live) return none; + record.phase = 'issuing'; + if (record.mode === 'explicit_gating') record.retainUntilMs = null; + break; + case 'bind': + if (record.phase !== 'issuing') return none; + if ( + !Number.isFinite(input.leaseMs) || + input.leaseMs < 1 || + input.leaseMs > 2_147_483_647 + ) + throw new TypeError('Invalid payment lease'); + record.invoice = paymentOptionSchema.parse(input.invoice); + JSON.stringify(record.invoice, (_key, value: unknown) => { + if ( + typeof value === 'function' || + typeof value === 'symbol' || + typeof value === 'bigint' || + (typeof value === 'number' && !Number.isFinite(value)) + ) + throw new TypeError('Invoice must be JSON serializable'); + return value; + }); + record.invoice = JSON.parse( + JSON.stringify(record.invoice), + ) as NonNullable; + record.invoiceExpiresAtMs = Math.min( + Number.MAX_SAFE_INTEGER, + now + + Math.floor( + record.invoice.ttl === undefined + ? input.leaseMs + : record.invoice.ttl * 1000, + ), + ); + if (!live) { + record.phase = 'unknown'; + if (record.mode === 'explicit_gating') record.retainUntilMs = null; + return { ...none, put: [record] }; + } + record.phase = 'pending'; + record.leaseExpiresAtMs = Math.min( + record.leaseExpiresAtMs, + now + Math.floor(input.leaseMs), + record.invoiceExpiresAtMs, + ); + record.retainUntilMs = + record.replayExpiresAtMs === null + ? null + : Math.max( + record.replayExpiresAtMs, + record.invoiceExpiresAtMs, + record.mode === 'explicit_gating' + ? now + policy.replayWindowMs + : 0, + ); + break; + case 'grant': + if ( + record.phase !== 'pending' || + !live || + record.mode !== 'explicit_gating' + ) + return none; + if ( + !Number.isFinite(input.ttlMs) || + input.ttlMs < 1 || + input.ttlMs > 2_147_483_647 + ) + throw new TypeError('Invalid authorization TTL'); + record.phase = 'granted'; + record.grantExpiresAtMs = now + Math.floor(input.ttlMs); + record.retainUntilMs = null; + break; + case 'consume': + if (!['pending', 'reserved'].includes(record.phase) || !live) return none; + record.phase = 'consumed'; + if (record.mode === 'explicit_gating') { + record.key = paymentEventKey( + record.clientPubkey, + record.requestEventId, + ); + record.retainUntilMs = record.replayExpiresAtMs; + return { result: true, put: [record], remove: [current.key] }; + } + break; + case 'dispatch': + if (record.phase !== 'consumed') return none; + record.phase = 'dispatched'; + break; + case 'release': + if (record.phase !== 'reserved') return none; + return { result: true, put: [], remove: [record.key] }; + case 'reject': + if (record.phase !== 'reserved' || !live) return none; + record.phase = 'rejected'; + break; + case 'expire': + if (record.phase !== 'pending') return none; + record.phase = 'expired'; + record.leaseExpiresAtMs = now; + break; + case 'uncertain': + if (!['issuing', 'pending'].includes(record.phase)) return none; + record.phase = 'unknown'; + record.reason = input.reason; + if (record.mode === 'explicit_gating') record.retainUntilMs = null; + break; + default: + throw new TypeError('Unknown payment state transition'); + } + return { result: true, put: [record], remove: [] }; +} diff --git a/src/payments/payment-state-store.ts b/src/payments/payment-state-store.ts new file mode 100644 index 0000000..ee30976 --- /dev/null +++ b/src/payments/payment-state-store.ts @@ -0,0 +1,142 @@ +import type { PaymentInteractionMode, PaymentOption } from './types.js'; + +/** Immutable identity captured from a verified, unwrapped Nostr event. */ +export interface VerifiedPaymentEvent { + readonly id: string; + readonly author: string; + /** Nostr creation time in seconds. */ + readonly createdAt: number; +} + +/** Bounded storage and replay-admission policy shared by every worker. */ +export interface PaymentStatePolicy { + /** Maximum live invoice/verification leases across both payment modes. */ + readonly maxPendingPayments: number; + /** Maximum retained records, including replay tombstones and old attempts. */ + readonly maxPaymentRecords: number; + /** Events this old are rejected before admission; default 24 hours. */ + readonly replayWindowMs: number; + /** Maximum accepted future timestamp skew; default 60 seconds. */ + readonly maxFutureSkewMs: number; + /** Bounded event IDs retained per attempt, including its eventual consumer. */ + readonly maxEventsPerPayment: number; +} + +/** Persisted stages. Dispatched means transport handoff, not handler completion or client receipt. */ +export type PaymentStatePhase = + | 'reserved' + | 'issuing' + | 'pending' + | 'granted' + | 'consumed' + | 'dispatched' + | 'expired' + | 'unknown' + | 'rejected'; + +/** Recovery record. Invoice data can be sensitive; never log this object. */ +export interface PaymentStateRecord { + key: string; + generation: string; + mode: PaymentInteractionMode; + phase: PaymentStatePhase; + clientPubkey: string; + requestEventId: string; + eventIds: string[]; + /** Last admission deadline of the attempt's events; null for untimed transports. */ + replayExpiresAtMs: number | null; + invocationHash: string; + leaseExpiresAtMs: number; + /** Null means retain until explicit recovery; never evict a paid grant. */ + retainUntilMs: number | null; + invoice?: PaymentOption; + /** Known invoice expiry, retained independently of the worker's verification lease. */ + invoiceExpiresAtMs?: number; + grantExpiresAtMs?: number; + reason?: string; +} + +/** Atomic claim-or-reserve request. Callers never compose a read and write. */ +export interface PaymentStateAdmission { + key: string; + mode: PaymentInteractionMode; + clientPubkey: string; + requestEventId: string; + invocationHash: string; + leaseMs: number; + event?: VerifiedPaymentEvent; +} + +/** Result of one atomic admission, including capacity and recovery outcomes. */ +export type PaymentStateAdmissionResult = + | { status: 'reserved' | 'claimed'; record: PaymentStateRecord } + | { + status: + 'pending' | 'duplicate' | 'capacity' | 'stale' | 'unknown' | 'expired'; + record?: PaymentStateRecord; + }; + +/** Generation-fenced change; false means the worker must stop side effects. */ +export type PaymentStateTransition = { + key: string; + generation: string; +} & ( + | { + type: + 'issuing' | 'consume' | 'dispatch' | 'release' | 'reject' | 'expire'; + } + | { type: 'bind'; invoice: PaymentOption; leaseMs: number } + | { type: 'grant'; ttlMs: number } + | { type: 'uncertain'; reason: string } +); + +/** + * Atomic payment storage contract. Backend errors propagate, including uncertain + * commit acknowledgement; callers must not retry external effects or fall back + * to a fresh memory store. A consumed execution right is never auto-restored. + * Applications may inject their own implementation through `stateStore` and + * own its dependencies and lifecycle. Only the in-memory implementation is bundled. + */ +export interface PaymentStateStore { + readonly policy: PaymentStatePolicy; + admit(input: PaymentStateAdmission): Promise; + transition(input: PaymentStateTransition): Promise; + read(key: string): Promise; +} + +/** Collision-free, peer-scoped transparent replay key. */ +export function paymentEventKey(clientPubkey: string, eventId: string): string { + return JSON.stringify(['event', clientPubkey, eventId]); +} + +/** Collision-free canonical authorization key. */ +export function paymentAuthorizationKey( + clientPubkey: string, + invocationHash: string, +): string { + return JSON.stringify(['authorization', clientPubkey, invocationHash]); +} + +/** Validates and freezes policy before any store or transport mutation. */ +export function createPaymentStatePolicy( + options: Partial = {}, +): PaymentStatePolicy { + const policy = { + maxPendingPayments: options.maxPendingPayments ?? 1000, + maxPaymentRecords: options.maxPaymentRecords ?? 10000, + replayWindowMs: options.replayWindowMs ?? 86_400_000, + maxFutureSkewMs: options.maxFutureSkewMs ?? 60_000, + maxEventsPerPayment: options.maxEventsPerPayment ?? 128, + }; + for (const [name, value] of Object.entries(policy)) { + if ( + !Number.isSafeInteger(value) || + value < 0 || + (name === 'replayWindowMs' && value === 0) || + (name === 'maxEventsPerPayment' && value < 2) + ) { + throw new TypeError(`Invalid payment state policy: ${name}`); + } + } + return Object.freeze(policy); +} diff --git a/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts b/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts index 70ee0d2..bb3a46f 100644 --- a/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts +++ b/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts @@ -31,6 +31,8 @@ export interface LnBolt11LnbitsPaymentProcessorOptions { * for settlement using the same API. */ export class LnBolt11LnbitsPaymentProcessor implements PaymentProcessor { + /** Native amount unit; conversion to millisatoshis happens inside the adapter. */ + public readonly currencyUnit = 'sats'; public readonly pmi = PMI_BITCOIN_LIGHTNING_BOLT11; private readonly lnbitsUrl: string; diff --git a/src/payments/processors/ln-bolt11-nwc-payment-processor.ts b/src/payments/processors/ln-bolt11-nwc-payment-processor.ts index 75a2ac0..0d90626 100644 --- a/src/payments/processors/ln-bolt11-nwc-payment-processor.ts +++ b/src/payments/processors/ln-bolt11-nwc-payment-processor.ts @@ -81,6 +81,8 @@ export interface LnBolt11NwcPaymentProcessorOptions { * CEP-8 server payment processor for PMI `bitcoin-lightning-bolt11` backed by NIP-47 (NWC). */ export class LnBolt11NwcPaymentProcessor implements PaymentProcessor { + /** Native amount unit; conversion to millisatoshis happens inside the adapter. */ + public readonly currencyUnit = 'sats'; public readonly pmi = PMI_BITCOIN_LIGHTNING_BOLT11; private readonly nwc: NwcClientLike; diff --git a/src/payments/processors/ln-bolt11-zap-payment-processor.ts b/src/payments/processors/ln-bolt11-zap-payment-processor.ts index 0b7f4db..871ceb1 100644 --- a/src/payments/processors/ln-bolt11-zap-payment-processor.ts +++ b/src/payments/processors/ln-bolt11-zap-payment-processor.ts @@ -47,6 +47,8 @@ type PendingInvoiceMeta = { * subscribing for zap receipts (kind 9735). */ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { + /** Native amount unit; conversion to millisatoshis happens inside the adapter. */ + public readonly currencyUnit = 'sats'; public readonly pmi = PMI_BITCOIN_LIGHTNING_BOLT11; // Defaults for the minimal constructor (lnAddress-only). These are public relays; diff --git a/src/payments/resolve-price.test.ts b/src/payments/resolve-price.test.ts index 86c5aaf..1449249 100644 --- a/src/payments/resolve-price.test.ts +++ b/src/payments/resolve-price.test.ts @@ -31,6 +31,7 @@ describe('resolvePrice (server payments)', () => { method: 'tools/call', name: 'add', amount: 1, + maxAmount: 123, currencyUnit: 'test', description: 'listed', }, @@ -138,6 +139,7 @@ describe('resolvePrice (server payments)', () => { method: 'tools/call', name: 'add', amount: 1, + maxAmount: 123, currencyUnit: 'test', }, ] as const; @@ -204,6 +206,7 @@ describe('resolvePrice (server payments)', () => { method: 'tools/call', name: 'add', amount: 1, + maxAmount: 123, currencyUnit: 'test', }, ] as const; @@ -361,6 +364,7 @@ describe('resolvePrice (server payments)', () => { method: 'tools/call', name: 'add', amount: 1, + maxAmount: 123, currencyUnit: 'test', }, ] as const; diff --git a/src/payments/server-explicit-gating.test.ts b/src/payments/server-explicit-gating.test.ts index 52d8117..897e947 100644 --- a/src/payments/server-explicit-gating.test.ts +++ b/src/payments/server-explicit-gating.test.ts @@ -1,3 +1,9 @@ +import { + seedGrant, + seedPending, + hasGrant, + pendingRemainingMs, +} from '../__mocks__/payment-state.js'; import { describe, expect, test } from 'bun:test'; import type { JSONRPCErrorResponse, @@ -165,7 +171,7 @@ describe('Explicit Gating Middleware', () => { message.method, message.params, ); - store.grant(identity, 10000); + await seedGrant(store, identity, 10000); let forwarded = false; await mw(message, ctx, async () => { @@ -175,9 +181,9 @@ describe('Explicit Gating Middleware', () => { expect(sentResponses.length).toBe(0); expect(forwarded).toBe(true); - // Auth should be consumed, second call should trigger payment required + // A distinct invocation needs a fresh payment; replaying the claimed event stays consumed. let forwarded2 = false; - await mw(message, ctx, async () => { + await mw({ ...message, id: 'independent-invocation' }, ctx, async () => { forwarded2 = true; }); @@ -315,7 +321,7 @@ describe('Explicit Gating Middleware', () => { // (5) Authorization is single-use: next call needs a fresh payment. let forwarded4 = false; - await mw(message, ctx, async () => { + await mw({ ...message, id: 'next-invocation' }, ctx, async () => { forwarded4 = true; }); expect(forwarded4).toBe(false); @@ -446,7 +452,8 @@ describe('Explicit Gating Middleware', () => { }); // Grant authorization for add({ a: 1, b: 2 }). - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -455,10 +462,10 @@ describe('Explicit Gating Middleware', () => { 10000, ); - // Different params: add({ a: 9, b: 9 }). + // Different params arrive as a distinct signed event. const otherMessage: JSONRPCRequest = { jsonrpc: '2.0', - id: 'event-id', + id: 'different-params-event', method: 'tools/call', params: { name: 'add', arguments: { a: 9, b: 9 } }, }; @@ -498,7 +505,8 @@ describe('Explicit Gating Middleware', () => { }); // Grant authorization scoped to 'test-client'. - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -549,7 +557,8 @@ describe('Explicit Gating Middleware', () => { // Identity is computed from method + params only; the original request's // id ('event-id') is intentionally excluded from the canonical form. - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -603,7 +612,8 @@ describe('Explicit Gating Middleware', () => { }); // Grant authorization for the invocation carrying progressToken 1. - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -639,7 +649,8 @@ describe('Explicit Gating Middleware', () => { }); // A single grant is available for this invocation. - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -654,17 +665,19 @@ describe('Explicit Gating Middleware', () => { }; // Fire two concurrent middleware calls for the same invocation. - await Promise.all([mw(message, ctx, forward), mw(message, ctx, forward)]); + await Promise.all([ + mw(message, ctx, forward), + mw({ ...message, id: 'independent-concurrent-event' }, ctx, forward), + ]); // Exactly one consumes the single-use grant and forwards; the other is - // gated with a fresh -32042. claim() is synchronous, so the first call - // to reach it always wins deterministically. + // gated with a fresh -32042. Claim and reservation share one atomic store operation. expect(forwards).toBe(1); expect(sentResponses).toHaveLength(1); expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); }); - test('expired grant yields a fresh -32042 instead of forwarding', async () => { + test('expired paid grant requires recovery instead of another payment', async () => { const store = new AuthorizationStore(); const sentResponses: JSONRPCErrorResponse[] = []; @@ -680,7 +693,8 @@ describe('Explicit Gating Middleware', () => { }); // Grant authorization with a very short TTL. - store.grant( + await seedGrant( + store, computeCanonicalInvocationIdentity( ctx.clientPubkey, message.method, @@ -692,8 +706,7 @@ describe('Explicit Gating Middleware', () => { // Wait past the grant TTL so it expires before the retry arrives. await new Promise((r) => setTimeout(r, 75)); - // The stale grant must NOT authorize the request: the middleware should - // treat it as unpaid and emit a fresh -32042 rather than forwarding. + // An expired paid right remains observable; do not silently ask the client to pay again. let forwarded = false; await mw(message, ctx, async () => { forwarded = true; @@ -701,7 +714,11 @@ describe('Explicit Gating Middleware', () => { expect(forwarded).toBe(false); expect(sentResponses).toHaveLength(1); - expect(sentResponses[0].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); + expect(sentResponses[0].error.code).toBe(-32000); + expect(sentResponses[0].error.data).toEqual({ reason: 'payment_expired' }); + expect(sentResponses[0].error.message).toContain( + 'contact the server operator', + ); }); test('non-priced capability passes through ungated in explicit gating mode', async () => { @@ -736,13 +753,11 @@ describe('Explicit Gating Middleware', () => { expect(sentResponses).toHaveLength(0); }); - test('falls back to the default grant TTL when the invoice carries ttl 0', async () => { + test('rejects processor output with ttl 0 before advertising it', async () => { const store = new AuthorizationStore(); const sentResponses: JSONRPCErrorResponse[] = []; - // ttl: 0 must not birth-expire the pending grant while the invoice stays - // payable. Verification hangs so the pending entry keeps the TTL the - // middleware applied via updatePendingTtl. + // Invalid processor TTLs never reach the client or verifier. const ttlZeroProcessor = { ...processor, async createPaymentRequired(params: { amount: number }) { @@ -769,22 +784,15 @@ describe('Explicit Gating Middleware', () => { }, }); - await mw(message, ctx, async () => {}); - - expect(sentResponses[0]?.error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); - const identity = computeCanonicalInvocationIdentity( - ctx.clientPubkey, - message.method, - message.params, - ); - // Falls back to the 5-minute default window, not 0. - expect(store.getPendingRemainingMs(identity)).toBeGreaterThan(60_000); + await expect(mw(message, ctx, async () => {})).rejects.toThrow(); + expect(sentResponses).toHaveLength(0); }); test('refuses with -32000 without minting an invoice at pending capacity', async () => { // Capacity 1, already occupied by a live pending entry for another identity. - const store = new AuthorizationStore({ maxEntries: 1 }); - store.trySetPending( + const store = new AuthorizationStore({ maxPendingPayments: 1 }); + await seedPending( + store, { clientPubkey: 'other-client', invocationHash: 'other' }, 10000, ); @@ -793,14 +801,12 @@ describe('Explicit Gating Middleware', () => { let invoicesMinted = 0; const countingProcessor = { ...processor, - async createPaymentRequired( - params: { - amount: number; - description?: string; - requestEventId: string; - clientPubkey: string; - }, - ) { + async createPaymentRequired(params: { + amount: number; + description?: string; + requestEventId: string; + clientPubkey: string; + }) { invoicesMinted += 1; return processor.createPaymentRequired(params); }, @@ -831,7 +837,7 @@ describe('Explicit Gating Middleware', () => { ); // The other client's live pending entry must not have been evicted. expect( - store.getPendingRemainingMs({ + await pendingRemainingMs(store, { clientPubkey: 'other-client', invocationHash: 'other', }), @@ -847,7 +853,7 @@ describe('Explicit Gating Middleware', () => { return new Promise(() => {}); }, }; - const store = new AuthorizationStore({ maxEntries: 1 }); + const store = new AuthorizationStore({ maxPendingPayments: 1 }); const sentResponses: JSONRPCErrorResponse[] = []; const mw = createExplicitGatingMiddleware({ @@ -975,7 +981,7 @@ describe('Explicit Gating Middleware transport shutdown', () => { }; } - test('a verify that settles after abort does not grant; the retry mints a fresh -32042', async () => { + test('a verify that settles after abort cannot grant or restart payment work', async () => { const unhandled = captureUnhandledRejections(); const store = new AuthorizationStore(); const sentResponses: JSONRPCErrorResponse[] = []; @@ -1010,15 +1016,14 @@ describe('Explicit Gating Middleware transport shutdown', () => { deferred.resolve(); await new Promise((r) => setTimeout(r, 5)); - expect(store.claim(identity)).toBe(false); + expect(await hasGrant(store, identity)).toBe(false); - // Same cleanup as a failed verify: pending is cleared, retry re-invoices. + // The closed middleware cannot create another invoice even when called again. await mw(message, ctx, async () => { forwards += 1; }); - expect(sentResponses).toHaveLength(2); - expect(sentResponses[1].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); - expect(deferred.createCount()).toBe(2); + expect(sentResponses).toHaveLength(1); + expect(deferred.createCount()).toBe(1); expect(forwards).toBe(0); expect(unhandled()).toEqual([]); }); @@ -1048,8 +1053,8 @@ describe('Explicit Gating Middleware transport shutdown', () => { shutdown.abort(); await new Promise((r) => setTimeout(r, 5)); - expect(store.claim(identity)).toBe(false); - expect(store.getPendingRemainingMs(identity)).toBe(0); + expect(await hasGrant(store, identity)).toBe(false); + expect(await pendingRemainingMs(store, identity)).toBe(0); expect(unhandled()).toEqual([]); }); diff --git a/src/payments/server-explicit-gating.ts b/src/payments/server-explicit-gating.ts index 43befe2..1ae2c7a 100644 --- a/src/payments/server-explicit-gating.ts +++ b/src/payments/server-explicit-gating.ts @@ -2,303 +2,224 @@ import type { JSONRPCErrorResponse } from '@contextvm/mcp-sdk/types.js'; import type { ServerMiddlewareFn, PaymentProcessor } from './types.js'; import { isJsonRpcRequest } from './types.js'; import type { ServerPaymentsOptions } from './server-payments.js'; -import type { AuthorizationStore } from './authorization-store.js'; -import { computeCanonicalInvocationIdentity } from './canonical-identity.js'; +import type { PaymentStateStore } from './payment-state-store.js'; +import { paymentEventKey } from './payment-state-store.js'; import { buildProcessorsByPmi, matchPricedCapability, + PaymentMethodUnavailableError, resolveAndInitiatePayment, verifyPaymentUnlessShutdown, } from './server-payments-utils.js'; import { createLogger } from '../core/utils/logger.js'; import { + DEFAULT_PAYMENT_TTL_MS, PAYMENT_CAPACITY_ERROR_CODE, PAYMENT_PENDING_ERROR_CODE, PAYMENT_REQUIRED_ERROR_CODE, } from './constants.js'; +import { + abandonServerPayment, + serverPaymentAdmission, +} from './server-payment-state.js'; +import { validateServerPaymentOptions } from './server-payment-validation.js'; +/** Explicit-gating dependencies; share this store with the transparent middleware. */ export interface ExplicitGatingMiddlewareParams { options: ServerPaymentsOptions; - authorizationStore: AuthorizationStore; + authorizationStore: PaymentStateStore; sendResponse: ( clientPubkey: string, response: JSONRPCErrorResponse, requestEventId: string, ) => Promise; - /** Pre-built PMI → processor map. Built locally when omitted (standalone use). */ processorsByPmi?: Map; - /** - * Transport shutdown signal (see `withServerPayments`). Once aborted, - * in-flight verification is cancelled and a verify that still settles is - * not granted; the pending entry is cleared instead. - */ abortSignal?: AbortSignal; } +/** Atomically claims one paid execution or reserves a generation-fenced invoice. */ export function createExplicitGatingMiddleware( params: ExplicitGatingMiddlewareParams, ): ServerMiddlewareFn { - const { options, authorizationStore, sendResponse, abortSignal } = params; + const { + options, + authorizationStore: store, + sendResponse, + abortSignal, + } = params; + validateServerPaymentOptions({ ...options, stateStore: store }); const logger = createLogger('server-explicit-gating'); const processorsByPmi = params.processorsByPmi ?? buildProcessorsByPmi(options.processors, logger); + const leaseMs = options.paymentTtlMs ?? DEFAULT_PAYMENT_TTL_MS; return async (message, ctx, forward) => { - // Only gate requests. - if (!isJsonRpcRequest(message)) { - await forward(message); - return; - } - - if (ctx.paymentInteraction !== 'explicit_gating') { + if ( + !isJsonRpcRequest(message) || + ctx.paymentInteraction !== 'explicit_gating' + ) { await forward(message); return; } - const priced = matchPricedCapability(message, options.pricedCapabilities); - if (!priced) { - await forward(message); + if (!priced || abortSignal?.aborted) { + if (!priced) await forward(message); return; } - - const requestEventId = String(message.id); - const identity = computeCanonicalInvocationIdentity( - ctx.clientPubkey, - message.method, - message.params, + const admission = serverPaymentAdmission( + message, + ctx, + 'explicit_gating', + leaseMs, ); - - // 1. Try to claim an existing authorization - if (authorizationStore.claim(identity)) { - logger.debug('authorization claimed, forwarding request', { - requestEventId, - method: message.method, - }); - await forward(message); - return; - } - - // Non-positive TTLs would birth-expire the pending grant while the - // invoice stays payable; fall back to the configured/default TTL. - const paymentTtlMs = - options.paymentTtlMs && options.paymentTtlMs > 0 - ? options.paymentTtlMs - : 300_000; - - // 2. Try to set pending state atomically. - // Refusal means either "already pending" (-32043, keep retrying — the - // payment is still verifying) or "store at capacity" (-32000, terminal): - // live entries are never evicted, so a same-identity retry must never - // observe the capacity answer even when the store is full. - if (!authorizationStore.trySetPending(identity, paymentTtlMs)) { - const pendingRemainingMs = - authorizationStore.getPendingRemainingMs(identity); - - if (pendingRemainingMs <= 0) { - logger.warn( - 'pending payment capacity reached, refusing priced request', - { - requestEventId, - method: message.method, - }, - ); - const capacityResponse: JSONRPCErrorResponse = { + const respond = async ( + code: number, + text: string, + data?: Record, + ): Promise => { + await sendResponse( + ctx.clientPubkey, + { jsonrpc: '2.0', id: message.id, - error: { - code: PAYMENT_CAPACITY_ERROR_CODE, - message: 'Payment capacity reached, retry later', - }, - }; - await sendResponse(ctx.clientPubkey, capacityResponse, requestEventId); - return; + error: { code, message: text, ...(data && { data }) }, + }, + admission.requestEventId, + ); + }; + const admitted = await store.admit(admission); + if (admitted.status === 'claimed') { + if (!abortSignal?.aborted) { + await forward(message); + await store.transition({ + key: admitted.record.key, + generation: admitted.record.generation, + type: 'dispatch', + }); } - - logger.debug('payment already pending, returning -32043', { - requestEventId, + return; + } + if (admitted.status === 'duplicate') return; + if (admitted.status === 'pending') { + await respond(PAYMENT_PENDING_ERROR_CODE, 'Payment Pending', { + retry_after: 2, }); - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: message.id, - error: { - code: PAYMENT_PENDING_ERROR_CODE, - message: 'Payment Pending', - data: { - instructions: - 'A payment is already pending for this invocation. Retry the same request later with exactly the same method and params.', - // Suggest a short polling interval (e.g. 2 seconds) rather than the full TTL - retry_after: Math.min( - 2, - Math.max(1, Math.ceil(pendingRemainingMs / 1000)), - ), - }, - }, - }; - await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); return; } - - // 3. Resolve price and initiate new payment + if (admitted.status !== 'reserved') { + await respond( + PAYMENT_CAPACITY_ERROR_CODE, + admitted.status === 'capacity' + ? 'Payment capacity reached, retry later' + : admitted.status === 'expired' + ? 'Paid authorization expired; contact the server operator to reconcile the payment before paying again' + : `Payment admission refused: ${admitted.status}`, + { reason: `payment_${admitted.status}` }, + ); + return; + } + const record = admitted.record; + const fence = { key: record.key, generation: record.generation }; try { - const initResult = await resolveAndInitiatePayment({ + if (abortSignal?.aborted) { + await store.transition({ ...fence, type: 'release' }); + return; + } + const initiated = await resolveAndInitiatePayment({ message, priced, - requestEventId, + requestEventId: admission.requestEventId, clientPubkey: ctx.clientPubkey, clientPmis: ctx.clientPmis, options, processorsByPmi, + beforeInvoice: async () => { + if (abortSignal?.aborted) return false; + const owned = await store.transition({ ...fence, type: 'issuing' }); + return owned && !abortSignal?.aborted; + }, }); - - if (initResult.kind === 'rejected') { - logger.info('payment rejected', { - requestEventId, - pmi: initResult.pmi, - amount: priced.amount, - reason: initResult.message, - }); - - authorizationStore.clearPending(identity); - - // Spec: When a capability is rejected by policy, return a standard error. - // We'll use -32000 (Internal error or application-defined error) since CEP-8 doesn't specify a special rejection code. - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: message.id, - error: { - code: -32000, - message: initResult.message || 'Payment rejected by policy', - }, - }; - await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); + if (initiated.kind === 'superseded') { + await store.transition({ ...fence, type: 'release' }); return; } - - if (initResult.kind === 'waived') { - logger.debug('payment waived, forwarding priced request', { - requestEventId, - method: message.method, - }); - - authorizationStore.clearPending(identity); - await forward(message); + if (initiated.kind === 'rejected') { + if (await store.transition({ ...fence, type: 'release' })) + await respond( + -32000, + initiated.message ?? 'Payment rejected by policy', + ); + return; + } + if (initiated.kind === 'waived') { + if ( + !abortSignal?.aborted && + (await store.transition({ ...fence, type: 'consume' })) + ) { + if (abortSignal?.aborted) return; + await forward(message); + await store.transition({ + ...fence, + key: paymentEventKey(ctx.clientPubkey, admission.requestEventId), + type: 'dispatch', + }); + } return; } - const { paymentRequired, mergedMeta, processor, verifyTimeoutMs } = - initResult; - - // Use the strict verification timeout bound for polling - const pollingTimeoutMs = Math.min(verifyTimeoutMs, paymentTtlMs); - - // Note: use the original payment request's TTL as the limit for the grant, not the verify timeout. - // verifyTimeoutMs includes standard bounds, but for grants we want to honor the - // payment option's TTL explicitly if it is smaller, or the fallback paymentTtlMs. - const grantTtlMs = - paymentRequired.ttl !== undefined && - Number.isFinite(paymentRequired.ttl) && - paymentRequired.ttl > 0 - ? paymentRequired.ttl * 1000 - : paymentTtlMs; - - // Update pending with the precise TTL - authorizationStore.updatePendingTtl(identity, grantTtlMs); - - const errorResponse: JSONRPCErrorResponse = { - jsonrpc: '2.0', - id: message.id, - error: { - code: PAYMENT_REQUIRED_ERROR_CODE, - message: 'Payment Required', - data: { - instructions: - 'Payment is required to process this request. Pay one of the offered options, then retry the same request with exactly the same method and params.', - payment_options: [ - { - amount: paymentRequired.amount, - pmi: paymentRequired.pmi, - pay_req: paymentRequired.pay_req, - description: paymentRequired.description, - ttl: paymentRequired.ttl, - _meta: mergedMeta, - }, - ], - }, - }, - }; - - logger.info('payment required error sent', { - requestEventId, - pmi: paymentRequired.pmi, - amount: paymentRequired.amount, - ttl: paymentRequired.ttl, + initiated; + const timeoutMs = Math.min(verifyTimeoutMs, leaseMs); + if ( + !(await store.transition({ + ...fence, + type: 'bind', + invoice: { ...paymentRequired, _meta: mergedMeta }, + leaseMs: timeoutMs, + })) + ) + return; + if (abortSignal?.aborted) return; + await respond(PAYMENT_REQUIRED_ERROR_CODE, 'Payment Required', { + instructions: + 'Pay one option, then retry with the same method and semantic params.', + payment_options: [{ ...paymentRequired, _meta: mergedMeta }], }); - - await sendResponse(ctx.clientPubkey, errorResponse, requestEventId); - - // Start async verification - // Do not await this, we must let the middleware chain return the error response. - (async () => { + void (async (): Promise => { + const verified = await verifyPaymentUnlessShutdown({ + processor, + verifyParams: { + pay_req: paymentRequired.pay_req, + requestEventId: admission.requestEventId, + clientPubkey: ctx.clientPubkey, + }, + timeoutMs, + shutdownSignal: abortSignal, + }); + if (!verified || abortSignal?.aborted) { + await store.transition({ ...fence, type: 'expire' }); + return; + } + await store.transition({ + ...fence, + type: 'grant', + ttlMs: options.authorizationTtlMs ?? leaseMs, + }); + })().catch(async () => { try { - logger.debug('verifying explicit payment', { - requestEventId, - pmi: paymentRequired.pmi, - timeoutMs: pollingTimeoutMs, - }); - - const verified = await verifyPaymentUnlessShutdown({ - processor, - verifyParams: { - pay_req: paymentRequired.pay_req, - requestEventId, - clientPubkey: ctx.clientPubkey, - }, - timeoutMs: pollingTimeoutMs, - shutdownSignal: abortSignal, + await store.transition({ ...fence, type: 'expire' }); + } catch { + logger.error('Payment verification state could not be persisted', { + requestEventId: admission.requestEventId, }); - - // Transport closed mid-verification: nobody will read a grant, so - // clear the pending entry like any other failed verification. - if (verified === undefined) { - logger.info( - 'transport closed during explicit payment verification', - { - requestEventId, - pmi: paymentRequired.pmi, - }, - ); - authorizationStore.clearPending(identity); - return; - } - - logger.info('explicit payment accepted, granting authorization', { - requestEventId, - pmi: paymentRequired.pmi, - amount: paymentRequired.amount, - }); - - authorizationStore.grant(identity, grantTtlMs); - } catch (err) { - logger.info('explicit payment verification failed or timed out', { - requestEventId, - error: err instanceof Error ? err.message : String(err), - }); - authorizationStore.clearPending(identity); } - })().catch((err) => { - // Belt-and-suspenders: if clearPending/grant itself threw inside the - // inner catch, pending would otherwise stick until TTL answering every - // retry with -32043. Idempotent on the normal path. - authorizationStore.clearPending(identity); - logger.error('unhandled exception in async payment verification', { - requestEventId, - pmi: paymentRequired.pmi, - error: err instanceof Error ? err.message : String(err), - }); }); - } catch (err) { - authorizationStore.clearPending(identity); - throw err; + } catch (error) { + await abandonServerPayment(store, record); + if (error instanceof PaymentMethodUnavailableError) { + await respond(-32000, error.message); + return; + } + throw error; } }; } diff --git a/src/payments/server-payment-reliability.test.ts b/src/payments/server-payment-reliability.test.ts new file mode 100644 index 0000000..10c5e0b --- /dev/null +++ b/src/payments/server-payment-reliability.test.ts @@ -0,0 +1,365 @@ +import { describe, expect, test } from 'bun:test'; +import type { + JSONRPCErrorResponse, + JSONRPCRequest, +} from '@contextvm/mcp-sdk/types.js'; +import { createExplicitGatingMiddleware } from './server-explicit-gating.js'; +import { createServerPaymentsMiddleware } from './server-payments.js'; +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; +import { + paymentAuthorizationKey, + paymentEventKey, + createPaymentStatePolicy, + type PaymentStateStore, +} from './payment-state-store.js'; +import { computeCanonicalInvocationHash } from './canonical-identity.js'; +import type { PaymentProcessor, ServerPaymentsContext } from './types.js'; +import type { ServerPaymentsOptions } from './server-payments.js'; + +function deferred(): { + promise: Promise; + resolve: (value: T) => void; + reject: (error: Error) => void; +} { + let resolve!: (value: T) => void; + let reject!: (error: Error) => void; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +} + +const request = (id = 'event-1'): JSONRPCRequest => ({ + jsonrpc: '2.0', + id, + method: 'tools/call', + params: { name: 'paid', arguments: { x: 1 }, _meta: { progressToken: 7 } }, +}); +const context: ServerPaymentsContext = { + clientPubkey: 'client', + paymentInteraction: 'explicit_gating', +}; +const priced = [ + { method: 'tools/call', name: 'paid', amount: 10, currencyUnit: 'sats' }, +]; +const authorizationKey = paymentAuthorizationKey( + 'client', + computeCanonicalInvocationHash(request().method, request().params), +); + +function processor(): PaymentProcessor { + return { + pmi: 'fake', + async createPaymentRequired(input) { + return { + pmi: 'fake', + amount: input.amount, + pay_req: `invoice-${input.requestEventId}`, + }; + }, + async verifyPayment() { + return {}; + }, + }; +} + +describe('server attempt generation fences', () => { + for (const outcome of ['resolve', 'reject'] as const) { + test(`old verifier ${outcome} cannot mutate a replacement generation`, async () => { + let now = Date.now(); + const store = new InMemoryPaymentStateStore({}, () => now); + const old = deferred>(); + const current = deferred>(); + let invoices = 0; + let verifications = 0; + const base = processor(); + const replies: JSONRPCErrorResponse[] = []; + const middleware = createExplicitGatingMiddleware({ + authorizationStore: store, + options: { + processors: [ + { + ...base, + async createPaymentRequired(input) { + invoices++; + return base.createPaymentRequired(input); + }, + verifyPayment: () => + ++verifications === 1 ? old.promise : current.promise, + }, + ], + pricedCapabilities: priced, + paymentTtlMs: 10000, + }, + sendResponse: async (_client, response) => { + replies.push(response); + }, + }); + await middleware(request(), context, async () => {}); + const oldGeneration = (await store.read(authorizationKey))!.generation; + now += 10001; + await middleware(request('replacement'), context, async () => {}); + const replacement = (await store.read(authorizationKey))!; + expect(replacement.generation).not.toBe(oldGeneration); + if (outcome === 'resolve') old.resolve({}); + else old.reject(new Error('old failure')); + await Bun.sleep(0); + expect((await store.read(authorizationKey))?.phase).toBe('pending'); + expect((await store.read(authorizationKey))?.generation).toBe( + replacement.generation, + ); + expect(invoices).toBe(2); + expect(replies.map((response) => response.error.code)).toEqual([ + -32042, -32042, + ]); + current.resolve({}); + await Bun.sleep(0); + let forwards = 0; + await middleware(request('paid-retry'), context, async () => { + forwards++; + }); + expect(forwards).toBe(1); + expect(invoices).toBe(2); + }); + } + + test('slow pricing cannot create an invoice after its reservation is replaced', async () => { + let now = Date.now(); + const store = new InMemoryPaymentStateStore({}, () => now); + const quote = deferred<{ amount: number }>(); + const started = deferred(); + let quotes = 0; + let invoices = 0; + const base = processor(); + const verify = deferred>(); + const middleware = createExplicitGatingMiddleware({ + authorizationStore: store, + options: { + processors: [ + { + ...base, + async createPaymentRequired(input) { + invoices++; + return base.createPaymentRequired(input); + }, + verifyPayment: () => verify.promise, + }, + ], + pricedCapabilities: priced, + paymentTtlMs: 10000, + resolvePrice: async () => { + if (++quotes === 1) { + started.resolve(undefined); + return quote.promise; + } + return { amount: 10 }; + }, + }, + sendResponse: async () => {}, + }); + const first = middleware(request(), context, async () => {}); + await started.promise; + now += 10001; + await middleware(request('replacement'), context, async () => {}); + const generation = (await store.read(authorizationKey))!.generation; + quote.resolve({ amount: 10 }); + await first; + expect(invoices).toBe(1); + expect((await store.read(authorizationKey))?.generation).toBe(generation); + expect((await store.read(authorizationKey))?.phase).toBe('pending'); + verify.resolve({}); + await Bun.sleep(0); + }); + + test('slow invoice creation becomes uncertain without creating a replacement invoice', async () => { + let now = Date.now(); + const store = new InMemoryPaymentStateStore({}, () => now); + const created = deferred<{ + pmi: string; + amount: number; + pay_req: string; + }>(); + const started = deferred(); + let invoices = 0; + const replies: JSONRPCErrorResponse[] = []; + const middleware = createExplicitGatingMiddleware({ + authorizationStore: store, + options: { + processors: [ + { + pmi: 'fake', + async createPaymentRequired() { + invoices++; + started.resolve(undefined); + return created.promise; + }, + async verifyPayment() { + throw new Error('Must not verify an obsolete invoice'); + }, + }, + ], + pricedCapabilities: priced, + paymentTtlMs: 10000, + }, + sendResponse: async (_client, response) => { + replies.push(response); + }, + }); + const first = middleware(request(), context, async () => {}); + await started.promise; + now += 10001; + await middleware(request('replacement'), context, async () => {}); + expect(replies[0]?.error.data).toEqual({ reason: 'payment_unknown' }); + created.resolve({ amount: 10, pmi: 'fake', pay_req: 'late-invoice' }); + await first; + expect(invoices).toBe(1); + expect(replies).toHaveLength(1); + expect((await store.read(authorizationKey))?.invoice?.pay_req).toBe( + 'late-invoice', + ); + expect((await store.read(authorizationKey))?.phase).toBe('unknown'); + }); +}); + +describe('server boundaries and transparent replay', () => { + test('transparent replay beyond the invoice TTL and middleware recreation never invoices or executes twice', async () => { + let now = Date.now(); + const store = new InMemoryPaymentStateStore({}, () => now); + let invoices = 0; + let executions = 0; + const base = processor(); + const options: ServerPaymentsOptions = { + stateStore: store, + paymentTtlMs: 100, + processors: [ + { + ...base, + async createPaymentRequired(input) { + invoices++; + return base.createPaymentRequired(input); + }, + }, + ], + pricedCapabilities: priced, + }; + const ctx: ServerPaymentsContext = { + clientPubkey: 'client', + paymentInteraction: 'transparent', + requestEvent: Object.freeze({ + id: 'event-1', + author: 'client', + createdAt: Math.floor(now / 1000), + }), + }; + const invoke = async (): Promise => { + await createServerPaymentsMiddleware({ + options, + sender: { sendNotification: async () => {} }, + })(request(), ctx, async () => { + executions++; + }); + }; + await invoke(); + now += 1000; + await invoke(); + expect(invoices).toBe(1); + expect(executions).toBe(1); + expect( + (await store.read(paymentEventKey('client', 'event-1')))?.phase, + ).toBe('dispatched'); + }); + + for (const mode of ['transparent', 'explicit_gating'] as const) { + test(`${mode}: waiver and rejection work without configured processors`, async () => { + for (const quote of [ + { waive: true } as const, + { reject: true, message: 'denied' } as const, + ]) { + const options: ServerPaymentsOptions = { + processors: [], + pricedCapabilities: priced, + resolvePrice: async () => quote, + }; + let forwarded = 0; + let notified = 0; + const middleware = + mode === 'transparent' + ? createServerPaymentsMiddleware({ + options, + sender: { + sendNotification: async () => { + notified++; + }, + }, + }) + : createExplicitGatingMiddleware({ + options, + authorizationStore: new InMemoryPaymentStateStore(), + sendResponse: async () => { + notified++; + }, + }); + await middleware( + request(), + { ...context, paymentInteraction: mode }, + async () => { + forwarded++; + }, + ); + expect(forwarded).toBe('waive' in quote ? 1 : 0); + expect(notified).toBe('reject' in quote ? 1 : 0); + } + }); + + test(`${mode}: backend admission failure cannot mint an invoice or dispatch`, async () => { + const store: PaymentStateStore = { + policy: createPaymentStatePolicy(), + async admit() { + throw new Error('storage unavailable'); + }, + async transition() { + return false; + }, + async read() { + return undefined; + }, + }; + let sideEffects = 0; + const options: ServerPaymentsOptions = { + processors: [ + { + ...processor(), + async createPaymentRequired() { + sideEffects++; + throw new Error('unexpected invoice'); + }, + }, + ], + pricedCapabilities: priced, + stateStore: store, + }; + const middleware = + mode === 'transparent' + ? createServerPaymentsMiddleware({ + options, + sender: { sendNotification: async () => {} }, + }) + : createExplicitGatingMiddleware({ + options, + authorizationStore: store, + sendResponse: async () => {}, + }); + await expect( + middleware( + request(), + { ...context, paymentInteraction: mode }, + async () => { + sideEffects++; + }, + ), + ).rejects.toThrow('storage unavailable'); + expect(sideEffects).toBe(0); + }); + } +}); diff --git a/src/payments/server-payment-state.ts b/src/payments/server-payment-state.ts new file mode 100644 index 0000000..b5f0564 --- /dev/null +++ b/src/payments/server-payment-state.ts @@ -0,0 +1,63 @@ +import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; +import type { ServerPaymentsOptions } from './server-payments.js'; +import type { PaymentInteractionMode, ServerPaymentsContext } from './types.js'; +import type { + PaymentStateAdmission, + PaymentStateRecord, + PaymentStateStore, +} from './payment-state-store.js'; +import { + paymentAuthorizationKey, + paymentEventKey, +} from './payment-state-store.js'; +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; +import { computeCanonicalInvocationHash } from './canonical-identity.js'; + +/** Uses the injected backend consistently for both lifecycles. */ +export function serverPaymentStore( + options: ServerPaymentsOptions, +): PaymentStateStore { + return options.stateStore ?? new InMemoryPaymentStateStore(options); +} + +/** Derives peer-scoped admission from authenticated event context, never params metadata. */ +export function serverPaymentAdmission( + message: JSONRPCRequest, + ctx: ServerPaymentsContext, + mode: PaymentInteractionMode, + leaseMs: number, +): PaymentStateAdmission { + const requestEventId = ctx.requestEvent?.id ?? String(message.id); + const invocationHash = computeCanonicalInvocationHash( + message.method, + message.params, + ); + return { + key: + mode === 'transparent' + ? paymentEventKey(ctx.clientPubkey, requestEventId) + : paymentAuthorizationKey(ctx.clientPubkey, invocationHash), + mode, + clientPubkey: ctx.clientPubkey, + requestEventId, + invocationHash, + leaseMs, + event: ctx.requestEvent, + }; +} + +/** Failed quote work can be retried; ambiguous invoice creation cannot be repeated. */ +export async function abandonServerPayment( + store: PaymentStateStore, + record: PaymentStateRecord, +): Promise { + const fence = { key: record.key, generation: record.generation }; + if ( + !(await store.transition({ + ...fence, + type: 'uncertain', + reason: 'payment_work_failed', + })) + ) + await store.transition({ ...fence, type: 'release' }); +} diff --git a/src/payments/server-payment-validation.test.ts b/src/payments/server-payment-validation.test.ts new file mode 100644 index 0000000..ea3e6f0 --- /dev/null +++ b/src/payments/server-payment-validation.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from 'bun:test'; +import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; +import { createServerPaymentsMiddleware } from './server-payments.js'; +import { + resolveAndInitiatePayment, + resolvePaymentProcessor, +} from './server-payments-utils.js'; +import { + validateServerPaymentOptions, + validateServerQuote, +} from './server-payment-validation.js'; +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; +import type { PaymentProcessor, ResolvePriceResult } from './types.js'; + +const priced = { + method: 'tools/call', + amount: 10, + maxAmount: 20, + currencyUnit: 'sats', +}; +const message: JSONRPCRequest = { + jsonrpc: '2.0', + id: 'event', + method: 'tools/call', + params: { name: 'paid', arguments: { x: 1 }, _meta: { progressToken: 3 } }, +}; +const processor: PaymentProcessor = { + pmi: 'fake', + currencyUnit: 'sats', + async createPaymentRequired(input) { + return { pmi: 'fake', pay_req: 'invoice', amount: input.amount }; + }, + async verifyPayment() { + return {}; + }, +}; + +function initiate( + override: { + quote?: unknown; + processor?: PaymentProcessor; + clientPmis?: string[]; + } = {}, +): ReturnType { + const rail = override.processor ?? processor; + return resolveAndInitiatePayment({ + message, + priced, + requestEventId: 'event', + clientPubkey: 'client', + clientPmis: override.clientPmis, + options: { + processors: [rail], + resolvePrice: async () => + (override.quote ?? { amount: 10 }) as ResolvePriceResult, + }, + processorsByPmi: new Map([[rail.pmi, rail]]), + }); +} + +describe('server payment boundary validation', () => { + test('inclusive quote endpoints are valid; fixed quotes cannot change their advertised amount', () => { + for (const amount of [10, 15, 20]) + expect( + validateServerQuote({ amount, currencyUnit: 'sats' }, priced), + ).toMatchObject({ amount }); + expect(() => + validateServerQuote({ amount: 11 }, { ...priced, maxAmount: undefined }), + ).toThrow('advertised'); + }); + + test('rejects malformed/out-of-range quotes and changed units before processor work', async () => { + let calls = 0; + const rail = { + ...processor, + async createPaymentRequired() { + calls++; + throw new Error('unexpected invoice'); + }, + }; + for (const quote of [ + { amount: -1 }, + { amount: NaN }, + { amount: Infinity }, + { amount: 9 }, + { amount: 21 }, + { amount: 10, currencyUnit: 'msats' }, + { amount: 10, meta: [] }, + { rejected: true }, + { waive: true, amount: 10 }, + { amount: 10, meta: { fn: () => {} } }, + ]) + await expect(initiate({ processor: rail, quote })).rejects.toThrow(); + expect(calls).toBe(0); + }); + + test('rejects incompatible native processor units before invoice creation', async () => { + await expect( + initiate({ processor: { ...processor, currencyUnit: 'msats' } }), + ).rejects.toThrow('unit'); + }); + + test('rejects mismatched or malformed processor output', async () => { + for (const invoice of [ + { amount: 11, pmi: 'fake', pay_req: 'invoice' }, + { amount: 10, pmi: 'other', pay_req: 'invoice' }, + { amount: 10, pmi: 'fake', pay_req: '' }, + { amount: 10, pmi: 'fake', pay_req: ' ' }, + { amount: 10, pmi: 'INVALID:PMI', pay_req: 'invoice' }, + { amount: 10, pmi: 'fake', pay_req: 'invoice', ttl: 0 }, + { amount: 10, pmi: 'fake', pay_req: 'invoice', ttl: NaN }, + { amount: 10, pmi: 'fake', pay_req: 'invoice', _meta: { fn: () => {} } }, + ]) + await expect( + initiate({ + processor: { + ...processor, + async createPaymentRequired() { + return invoice; + }, + }, + }), + ).rejects.toThrow(); + }); + + test('honors advertised PMI preference, refuses disjoint sets, and permits absent PMIs', () => { + const second = { ...processor, pmi: 'second' }; + const map = new Map([ + [processor.pmi, processor], + [second.pmi, second], + ]); + expect( + resolvePaymentProcessor(['unknown', 'second', 'fake'], map, [ + processor, + second, + ]), + ).toBe(second); + expect(resolvePaymentProcessor(undefined, map, [processor, second])).toBe( + processor, + ); + expect(() => + resolvePaymentProcessor(['unsupported'], map, [processor]), + ).toThrow('mutually'); + expect(() => resolvePaymentProcessor([], map, [processor])).toThrow( + 'mutually', + ); + }); + + test('disjoint PMI sets never reach invoice creation', async () => { + let invoices = 0; + await expect( + initiate({ + clientPmis: ['unsupported'], + processor: { + ...processor, + async createPaymentRequired() { + invoices++; + throw new Error('unexpected'); + }, + }, + }), + ).rejects.toThrow('mutually'); + expect(invoices).toBe(0); + }); + + test('invalid configuration and injected-store policy mismatch are rejected synchronously', () => { + const base = { processors: [processor], pricedCapabilities: [priced] }; + for (const config of [ + { paymentTtlMs: 0 }, + { paymentTtlMs: Infinity }, + { authorizationTtlMs: -1 }, + { maxPendingPayments: -1 }, + { maxPaymentRecords: 1.5 }, + { replayWindowMs: 0 }, + ]) + expect(() => + validateServerPaymentOptions({ ...base, ...config }), + ).toThrow(); + for (const capability of [ + { ...priced, amount: NaN }, + { ...priced, maxAmount: 9 }, + { ...priced, currencyUnit: '' }, + ]) + expect(() => + validateServerPaymentOptions({ + ...base, + pricedCapabilities: [capability], + }), + ).toThrow(); + expect(() => + validateServerPaymentOptions({ + ...base, + stateStore: new InMemoryPaymentStateStore({ maxPendingPayments: 1 }), + maxPendingPayments: 2, + }), + ).toThrow('mismatch'); + }); + + test('pricing cannot change the dispatched semantic arguments or metadata', async () => { + const original = { + ...message, + params: { + name: 'paid', + arguments: { x: 1 }, + _meta: { progressToken: 3 }, + }, + }; + const middleware = createServerPaymentsMiddleware({ + sender: { sendNotification: async () => {} }, + options: { + processors: [processor], + pricedCapabilities: [priced], + resolvePrice: async ({ request, capability }) => { + (request.params!.arguments as { x: number }).x = 999; + request.params!._meta!.progressToken = 100; + capability.amount = 500; + return { waive: true }; + }, + }, + }); + let forwarded: unknown; + await middleware(original, { clientPubkey: 'client' }, async (value) => { + forwarded = value; + }); + expect(forwarded).toBe(original); + expect(original.params).toEqual({ + name: 'paid', + arguments: { x: 1 }, + _meta: { progressToken: 3 }, + }); + expect(priced.amount).toBe(10); + }); +}); diff --git a/src/payments/server-payment-validation.ts b/src/payments/server-payment-validation.ts new file mode 100644 index 0000000..b2a3ed4 --- /dev/null +++ b/src/payments/server-payment-validation.ts @@ -0,0 +1,129 @@ +import { z } from 'zod'; +import type { ServerPaymentsOptions } from './server-payments.js'; +import type { + PaymentProcessor, + PaymentRequired, + PricedCapability, + ResolvePriceResult, +} from './types.js'; +import { + paymentOptionSchema, + paymentPmiSchema, +} from './payment-option-schema.js'; +import { + createPaymentStatePolicy, + type PaymentStatePolicy, +} from './payment-state-store.js'; + +const amount = z.number().finite().nonnegative().max(Number.MAX_SAFE_INTEGER); +const timer = z.number().finite().min(1).max(2_147_483_647); +const unit = z.string().trim().min(1); +const metadata = z.record(z.string(), z.unknown()); +const quoteSchema = z.union([ + z + .object({ reject: z.literal(true), message: z.string().optional() }) + .strict(), + z.object({ waive: z.literal(true), meta: metadata.optional() }).strict(), + z + .object({ + amount, + currencyUnit: unit.optional(), + description: z.string().optional(), + meta: metadata.optional(), + }) + .strict(), +]); + +/** Rejects values whose JSON representation would silently lose payment data. */ +export function assertPaymentJson(value: unknown): void { + JSON.stringify(value, (_key, item: unknown) => { + if ( + typeof item === 'function' || + typeof item === 'symbol' || + typeof item === 'bigint' || + (typeof item === 'number' && !Number.isFinite(item)) + ) + throw new TypeError('Payment data must be JSON serializable'); + return item; + }); +} + +/** Validates configuration before middleware registration or announcement mutation. */ +export function validateServerPaymentOptions( + options: ServerPaymentsOptions, +): void { + const capability = z + .object({ + method: z.string().min(1), + name: z.string().optional(), + amount, + maxAmount: amount.optional(), + currencyUnit: unit, + description: z.string().optional(), + }) + .refine( + (value) => + value.maxAmount === undefined || value.maxAmount >= value.amount, + ); + const schema = z.object({ + processors: z.array( + z.object({ + pmi: paymentPmiSchema, + currencyUnit: unit.optional(), + }), + ), + pricedCapabilities: z.array(capability), + paymentTtlMs: timer.optional(), + authorizationTtlMs: timer.optional(), + paymentInteraction: z.enum(['optional', 'transparent']).optional(), + }); + if (!schema.safeParse(options).success) + throw new TypeError('Invalid server payment options'); + const policy = createPaymentStatePolicy(options); + if (options.stateStore) { + for (const key of Object.keys(policy) as Array) { + if ( + options[key] !== undefined && + options[key] !== options.stateStore.policy[key] + ) + throw new TypeError(`Payment store policy mismatch: ${key}`); + } + } +} + +/** Checks inclusive advertised bounds and the unchanged configured amount unit. */ +export function validateServerQuote( + input: unknown, + priced: PricedCapability, +): ResolvePriceResult { + const quote = quoteSchema.parse(input); + assertPaymentJson(quote); + if ( + 'amount' in quote && + (quote.amount < priced.amount || + quote.amount > (priced.maxAmount ?? priced.amount) || + (quote.currencyUnit !== undefined && + quote.currencyUnit !== priced.currencyUnit)) + ) + throw new TypeError( + 'Payment quote is outside its advertised range or unit', + ); + return quote; +} + +/** Validates the processor's result before binding or advertising an invoice. */ +export function validateProcessorPayment( + input: unknown, + processor: PaymentProcessor, + quotedAmount: number, +): PaymentRequired { + const invoice = paymentOptionSchema.parse(input); + if ( + (invoice.ttl !== undefined && invoice.ttl * 1000 < 1) || + invoice.pmi !== processor.pmi || + invoice.amount !== quotedAmount + ) + throw new TypeError('Payment processor changed the quoted amount or PMI'); + assertPaymentJson(invoice); + return JSON.parse(JSON.stringify(invoice)) as PaymentRequired; +} diff --git a/src/payments/server-payments-utils.ts b/src/payments/server-payments-utils.ts index ebf1a68..230a2b8 100644 --- a/src/payments/server-payments-utils.ts +++ b/src/payments/server-payments-utils.ts @@ -1,6 +1,11 @@ import type { JSONRPCRequest } from '@contextvm/mcp-sdk/types.js'; import { type Logger } from '../core/utils/logger.js'; import { withTimeout } from '../core/utils/utils.js'; +import { paymentPmiSchema } from './payment-option-schema.js'; +import { + validateProcessorPayment, + validateServerQuote, +} from './server-payment-validation.js'; import type { PricedCapability, ResolvePriceRejection, @@ -11,6 +16,9 @@ import type { PaymentRequired, } from './types.js'; +/** A client-visible payment-method refusal, raised before provider side effects. */ +export class PaymentMethodUnavailableError extends Error {} + /** Builds the PMI → processor map, warning once on duplicate PMIs. */ export function buildProcessorsByPmi( processors: readonly PaymentProcessor[], @@ -93,6 +101,14 @@ export function resolvePaymentProcessor( processorsByPmi: Map, processors: readonly PaymentProcessor[], ): PaymentProcessor { + if ( + clientPmis !== undefined && + (!Array.isArray(clientPmis) || + clientPmis.some((pmi) => !paymentPmiSchema.safeParse(pmi).success)) + ) + throw new PaymentMethodUnavailableError( + 'Invalid client payment method identifier', + ); const chosenPmi = clientPmis ? clientPmis.find((pmi) => processorsByPmi.has(pmi)) : undefined; @@ -101,14 +117,21 @@ export function resolvePaymentProcessor( ? processorsByPmi.get(chosenPmi) : processors[0]; + if (clientPmis !== undefined && !chosenPmi) { + throw new PaymentMethodUnavailableError( + 'No mutually supported payment method', + ); + } + if (!chosenProcessor) { - throw new Error('No payment processors configured'); + throw new PaymentMethodUnavailableError('No payment processors configured'); } return chosenProcessor; } export type InitiationResult = + | { kind: 'superseded' } | { kind: 'rejected'; pmi: string; @@ -141,26 +164,23 @@ export async function resolveAndInitiatePayment(params: { }) => Promise; }; processorsByPmi: Map; + /** Persists invoice-creation ownership immediately before provider I/O. */ + beforeInvoice?: () => Promise; }): Promise { - const processor = resolvePaymentProcessor( - params.clientPmis, - params.processorsByPmi, - params.options.processors, - ); - - const quote = params.options.resolvePrice + const rawQuote = params.options.resolvePrice ? await params.options.resolvePrice({ - capability: params.priced, - request: params.message, + capability: { ...params.priced }, + request: copyPricingRequest(params.message), clientPubkey: params.clientPubkey, requestEventId: params.requestEventId, }) : { amount: params.priced.amount, description: params.priced.description }; + const quote = validateServerQuote(rawQuote, params.priced); if (isResolvePriceRejection(quote)) { return { kind: 'rejected', - pmi: processor.pmi, + pmi: params.options.processors[0]?.pmi ?? 'unspecified', amount: params.priced.amount, message: quote.message, quote, @@ -172,12 +192,30 @@ export async function resolveAndInitiatePayment(params: { } const resolvedQuote = quote; - const paymentRequired = await processor.createPaymentRequired({ - amount: resolvedQuote.amount, - description: resolvedQuote.description, - requestEventId: params.requestEventId, - clientPubkey: params.clientPubkey, - }); + const processor = resolvePaymentProcessor( + params.clientPmis, + params.processorsByPmi, + params.options.processors, + ); + if ( + processor.currencyUnit !== undefined && + processor.currencyUnit !== params.priced.currencyUnit + ) + throw new TypeError( + 'Processor amount unit differs from the advertised capability unit', + ); + if (params.beforeInvoice && !(await params.beforeInvoice())) + return { kind: 'superseded' }; + const paymentRequired = validateProcessorPayment( + await processor.createPaymentRequired({ + amount: resolvedQuote.amount, + description: resolvedQuote.description, + requestEventId: params.requestEventId, + clientPubkey: params.clientPubkey, + }), + processor, + resolvedQuote.amount, + ); const mergedMeta = resolvedQuote.meta === undefined && paymentRequired._meta === undefined @@ -200,6 +238,18 @@ export async function resolveAndInitiatePayment(params: { }; } +function copyPricingRequest(request: JSONRPCRequest): JSONRPCRequest { + if (!request.params) return { ...request }; + const { _meta, ...semantic } = request.params; + return { + ...request, + params: { + ...JSON.parse(JSON.stringify(semantic)), + ...(_meta && { _meta: { ..._meta } }), + }, + }; +} + /** * Runs `processor.verifyPayment` under `timeoutMs` with a per-request abort * controller, chained to the transport's shutdown signal so `close()` stops diff --git a/src/payments/server-payments.test.ts b/src/payments/server-payments.test.ts index a7ba5d6..79001d6 100644 --- a/src/payments/server-payments.test.ts +++ b/src/payments/server-payments.test.ts @@ -138,13 +138,8 @@ describe('createServerPaymentsMiddleware pending-payment retention', () => { ); expect(harness.invoiceCount('evt1')).toBe(1); - // Redelivery of the same request event while the entry is within its TTL: - // the duplicate awaits the (rejected) in-flight lifecycle instead of - // minting a second invoice. (After TTL expiry a retry re-invoices by - // design — idempotency is TTL-bounded.) - await expect(harness.run('evt1')).rejects.toThrow( - 'payment rail unreachable', - ); + // The durable event record refuses another invoice even after the local run ends. + await harness.run('evt1'); expect(harness.invoiceCount('evt1')).toBe(1); }); @@ -258,24 +253,12 @@ describe('createServerPaymentsMiddleware pending capacity', () => { expect(harness.forwards()).toBe(0); }); - test('paymentTtlMs 0 falls back to the default so redelivery dedup survives', async () => { - // A zero TTL would birth-expire the pending entry while the invoice stays - // payable — disarming the duplicate-request dedup (CEP-8). It must fall - // back to the default window instead. + test('rejects paymentTtlMs 0 before creating payment work', () => { const spy = makeProcessor({ verify: 'throw' }); const { sender } = makeSender(); - const harness = buildHarness(spy, sender, { paymentTtlMs: 0 }); - - await expect(harness.run('evt-ttl0')).rejects.toThrow( - 'payment rail unreachable', - ); - - // Redelivery while the (paid) invoice is still outstanding: deduped by - // the surviving pending entry, not re-invoiced. - await expect(harness.run('evt-ttl0')).rejects.toThrow( - 'payment rail unreachable', + expect(() => buildHarness(spy, sender, { paymentTtlMs: 0 })).toThrow( + 'Invalid server payment options', ); - expect(harness.invoiceCount('evt-ttl0')).toBe(1); }); }); diff --git a/src/payments/server-payments.ts b/src/payments/server-payments.ts index 20912d2..3d43020 100644 --- a/src/payments/server-payments.ts +++ b/src/payments/server-payments.ts @@ -1,16 +1,16 @@ import { isJsonRpcRequest } from './types.js'; import type { CorrelatedNotificationSender, - PaymentAcceptedNotification, PaymentInteractionPolicy, PaymentProcessor, - PaymentRejectedNotification, - PaymentRequiredNotification, PricedCapability, ResolvePriceFn, ServerMiddlewareFn, } from './types.js'; -import { LruCache } from '../core/utils/lru-cache.js'; +import type { + PaymentStatePolicy, + PaymentStateStore, +} from './payment-state-store.js'; import { createLogger } from '../core/utils/logger.js'; import { DEFAULT_PAYMENT_TTL_MS, @@ -21,414 +21,230 @@ import { import { buildProcessorsByPmi, matchPricedCapability, + PaymentMethodUnavailableError, resolveAndInitiatePayment, - resolvePaymentProcessor, verifyPaymentUnlessShutdown, } from './server-payments-utils.js'; - -export interface ServerPaymentsOptions { +import { + abandonServerPayment, + serverPaymentAdmission, + serverPaymentStore, +} from './server-payment-state.js'; +import { validateServerPaymentOptions } from './server-payment-validation.js'; + +/** Server-side payment policy; both lifecycles share one atomic state store. */ +export interface ServerPaymentsOptions extends Partial { processors: readonly PaymentProcessor[]; pricedCapabilities: readonly PricedCapability[]; - - /** Optional dynamic pricing callback used to compute a per-request quote. */ + /** Optional per-request quote, constrained by the advertised inclusive range. */ resolvePrice?: ResolvePriceFn; - /** - * Maximum time to keep a request in pending-payment state. - * - * Note: if the payment request includes a CEP-8 `ttl` (seconds), the effective - * verification timeout will be derived from that TTL. This option is primarily - * a memory/DoS guardrail. - * - * @default 300_000 - */ + /** Invoice creation/verification lease in milliseconds. @default 300_000 */ paymentTtlMs?: number; - - /** - * Maximum number of concurrent pending-payment request ids to track. - * - * This is a DoS/memory-safety guardrail. Once reached, new priced requests - * are refused (best-effort `payment_rejected`, no invoice minted) rather than - * evicting a live entry — an evicted live payment loses its dedup and a - * redelivery would mint a second invoice (CEP-8: MUST NOT charge twice). - * `0` refuses all priced requests. - * - * @default 1000 - */ - maxPendingPayments?: number; - - /** - * Server-side policy for which payment interaction lifecycles this server - * accepts. `optional` mirrors the client's requested mode (the default); - * `transparent` makes the server transparent-only. - * @default 'optional' - */ + /** Lifetime of a verified execution grant, measured from settlement. @default paymentTtlMs */ + authorizationTtlMs?: number; + /** Application-owned atomic backend; defaults to process-local memory. The SDK does not initialize or close custom backends. */ + stateStore?: PaymentStateStore; + /** Accepted interaction modes. @default 'optional' */ paymentInteraction?: PaymentInteractionPolicy; } -function purgeExpiredPending(params: { - pending: LruCache; - nowMs: number; - maxToCheck: number; -}): void { - let checked = 0; - for (const [key, value] of params.pending.entries()) { - if (checked >= params.maxToCheck) { - break; - } - checked += 1; - if (value.expiresAtMs <= params.nowMs) { - params.pending.delete(key); - } - } -} - -type PendingPaymentState = { - expiresAtMs: number; - inFlight: Promise; -}; - -/** - * Extra lifetime granted to a transport route snapshot beyond the payment TTL, - * so a settled response can still be routed after a slow handler finishes. - */ -const ROUTE_SNAPSHOT_GRACE_MS = 60_000; - -function createPaymentRequiredNotification(params: { - amount: number; - pay_req: string; - pmi: string; - description?: string; - ttl?: number; - _meta?: Record; -}): PaymentRequiredNotification { - return { - jsonrpc: '2.0', - method: PAYMENT_REQUIRED_METHOD, - params, - }; -} - -function createPaymentAcceptedNotification(params: { - amount: number; - pmi: string; - _meta?: Record; -}): PaymentAcceptedNotification { - return { - jsonrpc: '2.0', - method: PAYMENT_ACCEPTED_METHOD, - params, - }; -} - -function createPaymentRejectedNotification(params: { - pmi: string; - amount?: number; - message?: string; -}): PaymentRejectedNotification { - return { - jsonrpc: '2.0', - method: PAYMENT_REJECTED_METHOD, - params, - }; -} - /** - * Creates a server-side middleware that gates priced requests until payment is verified. + * Gates transparent requests using a retained event ledger. Verified event IDs + * are admitted only within the store's replay window; invoice expiry never + * releases their tombstones. Custom transports without event timestamps retain + * their records indefinitely and fail closed when record capacity is reached. + * Verified grants (including expired ones) and uncertain invoice creation are + * retained for reconciliation. Expired known invoices can be replaced only by + * a new admitted event; consumed rights never trigger automatic re-execution. */ export function createServerPaymentsMiddleware(params: { sender: CorrelatedNotificationSender; options: ServerPaymentsOptions; - /** Pre-built PMI → processor map. Built locally when omitted (standalone use). */ processorsByPmi?: Map; - /** - * Fired once an invoice has been issued for a request, before verification - * begins. Lets the transport snapshot the correlation route/session a paid - * response may need after its route is popped (duplicate-delivery cleanup) - * or its idle session is LRU-evicted mid-payment (CEP-8). - */ onInvoiceIssued?: (params: { requestEventId: string; snapshotTtlMs: number; }) => void; - /** - * Transport shutdown signal (see `withServerPayments`). Once aborted, - * in-flight verification is cancelled and a verify that still settles is - * not acted on: no `payment_accepted`, no forward. - */ abortSignal?: AbortSignal; }): ServerMiddlewareFn { const { sender, options, abortSignal } = params; + validateServerPaymentOptions(options); const logger = createLogger('server-payments'); const processorsByPmi = params.processorsByPmi ?? buildProcessorsByPmi(options.processors, logger); - - // Non-positive TTLs would birth-expire every pending entry (disarming the - // redelivery dedup while invoices stay payable); fall back to the default, - // mirroring getVerificationTimeoutMs's guard. - const paymentTtlMs = - options.paymentTtlMs && options.paymentTtlMs > 0 - ? options.paymentTtlMs - : DEFAULT_PAYMENT_TTL_MS; - const maxPendingPayments = options.maxPendingPayments ?? 1000; - const pending = new LruCache( - Math.max(1, maxPendingPayments), - ); + const store = serverPaymentStore(options); + const leaseMs = options.paymentTtlMs ?? DEFAULT_PAYMENT_TTL_MS; + const running = new Map>(); return async (message, ctx, forward) => { - // Only gate requests. Never interfere with notifications. - if (!isJsonRpcRequest(message)) { - await forward(message); - return; - } - if ( - ctx.paymentInteraction !== undefined && - ctx.paymentInteraction !== 'transparent' + !isJsonRpcRequest(message) || + (ctx.paymentInteraction !== undefined && + ctx.paymentInteraction !== 'transparent') ) { await forward(message); return; } - const priced = matchPricedCapability(message, options.pricedCapabilities); - if (!priced) { - await forward(message); - return; - } - - logger.debug('priced capability matched', { - method: message.method, - requestEventId: String(message.id), - pricedMethod: priced.method, - pricedName: priced.name, - }); - - const requestEventId = String(message.id); - const now = Date.now(); - - // Opportunistic cleanup so one-shot spam doesn't accumulate until reuse. - purgeExpiredPending({ pending, nowMs: now, maxToCheck: 25 }); - - const existing = pending.get(requestEventId); - if (existing && existing.expiresAtMs > now) { - // Duplicate request event id: await the in-flight work deterministically. - // This avoids double-charge races and avoids black-holing duplicates. - logger.debug('duplicate request event detected, awaiting in-flight', { - requestEventId, - }); - await existing.inFlight; + if (!priced || abortSignal?.aborted) { + if (!priced) await forward(message); return; } - - // Capacity guard, before any invoice is minted: silently evicting a live - // entry here would disarm its dedup, and a redelivery of the evicted - // request would be charged twice (CEP-8: MUST NOT charge twice). - if (pending.size >= maxPendingPayments) { - purgeExpiredPending({ - pending, - nowMs: now, - maxToCheck: Number.POSITIVE_INFINITY, - }); - } - if (pending.size >= maxPendingPayments) { - logger.warn('pending payment capacity reached, refusing priced request', { - requestEventId, - method: message.method, - maxPendingPayments, - }); - // Refuse without charging: best-effort payment_rejected so the client - // stops waiting instead of hanging until its own TTL. - try { - const processor = resolvePaymentProcessor( - ctx.clientPmis, - processorsByPmi, - options.processors, - ); - await sender.sendNotification( - ctx.clientPubkey, - createPaymentRejectedNotification({ - pmi: processor.pmi, - amount: priced.amount, - message: 'payment capacity reached, retry later', - }), - requestEventId, - ); - } catch (err) { - logger.warn('failed to send payment capacity rejection', { - requestEventId, - error: err instanceof Error ? err.message : String(err), - }); - } + const admission = serverPaymentAdmission( + message, + ctx, + 'transparent', + leaseMs, + ); + const existing = running.get(admission.key); + if (existing) { + await existing; return; } - - // IMPORTANT: set pending state synchronously before any await to make idempotency atomic. - let invoiceIssued = false; - const inFlight = (async (): Promise => { - const initResult = await resolveAndInitiatePayment({ - message, - priced, - requestEventId, - clientPubkey: ctx.clientPubkey, - clientPmis: ctx.clientPmis, - options, - processorsByPmi, - }); - - // Handle rejection: emit payment_rejected and do not forward. - if (initResult.kind === 'rejected') { - logger.info('payment rejected', { - requestEventId, - pmi: initResult.pmi, - amount: priced.amount, - reason: initResult.message, - }); - - const rejectedNotification = createPaymentRejectedNotification({ - pmi: initResult.pmi, - amount: priced.amount, - message: initResult.message, - }); - - await sender.sendNotification( - ctx.clientPubkey, - rejectedNotification, - requestEventId, - ); - return; - } - - if (initResult.kind === 'waived') { - logger.debug('payment waived, forwarding priced request', { - requestEventId, - method: message.method, - }); - - await forward(message); - return; - } - - const { paymentRequired, mergedMeta, processor, verifyTimeoutMs } = - initResult; - - // An invoice now exists on the payment rail — from here on, every - // outcome keeps the pending entry until TTL: the client's money may - // already be gone, and a redelivery MUST NOT mint a second invoice. - invoiceIssued = true; - params.onInvoiceIssued?.({ - requestEventId, - snapshotTtlMs: paymentTtlMs + ROUTE_SNAPSHOT_GRACE_MS, - }); - - const requiredNotification = createPaymentRequiredNotification({ - amount: paymentRequired.amount, - pay_req: paymentRequired.pay_req, - pmi: paymentRequired.pmi, - description: paymentRequired.description, - ttl: paymentRequired.ttl, - _meta: mergedMeta, - }); - - logger.info('payment required notification sent', { - requestEventId, - pmi: paymentRequired.pmi, - amount: paymentRequired.amount, - ttl: paymentRequired.ttl, - }); - + const reject = async (reason?: string): Promise => { await sender.sendNotification( ctx.clientPubkey, - requiredNotification, - requestEventId, - ); - - // Use the strict verification timeout bound for polling - const pollingTimeoutMs = Math.min(verifyTimeoutMs, paymentTtlMs); - - logger.debug('verifying payment', { - requestEventId, - pmi: paymentRequired.pmi, - timeoutMs: pollingTimeoutMs, - }); - - const verified = await verifyPaymentUnlessShutdown({ - processor, - verifyParams: { - pay_req: paymentRequired.pay_req, - requestEventId, - clientPubkey: ctx.clientPubkey, + { + jsonrpc: '2.0', + method: PAYMENT_REJECTED_METHOD, + params: { + pmi: options.processors[0]?.pmi ?? 'unspecified', + amount: priced.amount, + message: reason, + }, }, - timeoutMs: pollingTimeoutMs, - shutdownSignal: abortSignal, - }); - - // Transport closed mid-verification: the invoice stays pending until - // TTL (same as any post-invoice outcome), but nothing is forwarded. - if (verified === undefined) { - logger.info('transport closed during payment verification', { - requestEventId, - pmi: paymentRequired.pmi, - }); + admission.requestEventId, + ); + }; + const work = (async (): Promise => { + const admitted = await store.admit(admission); + if (admitted.status !== 'reserved') { + if (admitted.status !== 'duplicate') + await reject(`Payment admission refused: ${admitted.status}`); return; } - - logger.info('payment accepted', { - requestEventId, - pmi: paymentRequired.pmi, - amount: paymentRequired.amount, - }); - - const acceptedNotification = createPaymentAcceptedNotification({ - amount: paymentRequired.amount, - pmi: paymentRequired.pmi, - _meta: verified._meta, - }); - - // payment_accepted is a SHOULD (CEP-8); the capability result is the - // point. A publish failure here (relay error, or the idle paying - // client's session LRU-evicted mid-payment) MUST NOT abort the forward — - // that would be paid-but-undelivered. Do not turn this back into an - // early return. + const record = admitted.record; + const fence = { key: record.key, generation: record.generation }; try { + if (abortSignal?.aborted) { + await store.transition({ ...fence, type: 'release' }); + return; + } + const initiated = await resolveAndInitiatePayment({ + message, + priced, + requestEventId: admission.requestEventId, + clientPubkey: ctx.clientPubkey, + clientPmis: ctx.clientPmis, + options, + processorsByPmi, + beforeInvoice: async () => { + if (abortSignal?.aborted) return false; + const owned = await store.transition({ ...fence, type: 'issuing' }); + return owned && !abortSignal?.aborted; + }, + }); + if (initiated.kind === 'superseded') { + await store.transition({ ...fence, type: 'release' }); + return; + } + if (initiated.kind === 'rejected') { + if (await store.transition({ ...fence, type: 'reject' })) + await reject(initiated.message); + return; + } + if (initiated.kind === 'waived') { + if ( + !abortSignal?.aborted && + (await store.transition({ ...fence, type: 'consume' })) + ) { + if (abortSignal?.aborted) return; + await forward(message); + await store.transition({ ...fence, type: 'dispatch' }); + } + return; + } + const { paymentRequired, mergedMeta, processor, verifyTimeoutMs } = + initiated; + const pollingTimeoutMs = Math.min(verifyTimeoutMs, leaseMs); + if ( + !(await store.transition({ + ...fence, + type: 'bind', + invoice: { ...paymentRequired, _meta: mergedMeta }, + leaseMs: pollingTimeoutMs, + })) + ) + return; + if (abortSignal?.aborted) return; + params.onInvoiceIssued?.({ + requestEventId: admission.requestEventId, + snapshotTtlMs: leaseMs + 60_000, + }); + if (abortSignal?.aborted) return; await sender.sendNotification( ctx.clientPubkey, - acceptedNotification, - requestEventId, + { + jsonrpc: '2.0', + method: PAYMENT_REQUIRED_METHOD, + params: { ...paymentRequired, _meta: mergedMeta }, + }, + admission.requestEventId, ); - } catch (err) { - logger.warn('failed to publish payment_accepted, forwarding anyway', { - requestEventId, - error: err instanceof Error ? err.message : String(err), + const verified = await verifyPaymentUnlessShutdown({ + processor, + verifyParams: { + pay_req: paymentRequired.pay_req, + requestEventId: admission.requestEventId, + clientPubkey: ctx.clientPubkey, + }, + timeoutMs: pollingTimeoutMs, + shutdownSignal: abortSignal, }); + if ( + !verified || + abortSignal?.aborted || + !(await store.transition({ ...fence, type: 'consume' })) + ) + return; + if (abortSignal?.aborted) return; + try { + await sender.sendNotification( + ctx.clientPubkey, + { + jsonrpc: '2.0', + method: PAYMENT_ACCEPTED_METHOD, + params: { + amount: paymentRequired.amount, + pmi: paymentRequired.pmi, + _meta: verified._meta, + }, + }, + admission.requestEventId, + ); + } catch { + logger.warn( + 'payment_accepted publication failed; execution right remains consumed', + { requestEventId: admission.requestEventId }, + ); + } + if (abortSignal?.aborted) return; + await forward(message); + await store.transition({ ...fence, type: 'dispatch' }); + } catch (error) { + await abandonServerPayment(store, record); + if (error instanceof PaymentMethodUnavailableError) { + await reject(error.message); + return; + } + throw error; } - - logger.debug('forwarding priced request after payment', { - requestEventId, - method: message.method, - }); - - await forward(message); })(); - - const state: PendingPaymentState = { - expiresAtMs: now + paymentTtlMs, - inFlight, - }; - pending.set(requestEventId, state); - + running.set(admission.key, work); try { - await inFlight; - // On success, keep the entry in `pending` until TTL expiry. - // This guards against relay redelivery triggering a second charge. - // purgeExpiredPending handles eventual cleanup. - } catch (err) { - // Pre-invoice failures never billed anyone — delete so the retry is free. - // Post-invoice failures keep the entry until TTL for the same reason as - // success: the client may already have paid, and a redelivery of the - // same request event MUST NOT mint a second invoice (CEP-8). - if (!invoiceIssued) { - pending.delete(requestEventId); - } - throw err; + await work; + } finally { + if (running.get(admission.key) === work) running.delete(admission.key); } }; } diff --git a/src/payments/server-transport-payments.shutdown.test.ts b/src/payments/server-transport-payments.shutdown.test.ts index 2e4e46c..b087306 100644 --- a/src/payments/server-transport-payments.shutdown.test.ts +++ b/src/payments/server-transport-payments.shutdown.test.ts @@ -10,7 +10,7 @@ import { waitFor } from '../core/utils/test.utils.js'; import { PrivateKeySigner } from '../signer/private-key-signer.js'; import { NostrClientTransport } from '../transport/nostr-client-transport.js'; import { NostrServerTransport } from '../transport/nostr-server-transport.js'; -import { AuthorizationStore } from './authorization-store.js'; +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; import { PAYMENT_ACCEPTED_METHOD, PAYMENT_REQUIRED_ERROR_CODE, @@ -21,7 +21,7 @@ import type { PaymentProcessorVerifyParams, } from './types.js'; -const PMI = 'pmi:controlled'; +const PMI = 'controlled'; /** * Processor whose verification never settles on its own. The test decides @@ -81,7 +81,10 @@ function createControlledProcessor(options: { rejectOnAbort: boolean }): { return { processor, verifyStarted, settle: () => settle(), outcomes }; } -async function startPaidServer(processor: PaymentProcessor): Promise<{ +async function startPaidServer( + processor: PaymentProcessor, + stateStore?: InMemoryPaymentStateStore, +): Promise<{ hub: MockRelayHub; serverPubkey: string; serverTransport: NostrServerTransport; @@ -110,6 +113,7 @@ async function startPaidServer(processor: PaymentProcessor): Promise<{ encryptionMode: EncryptionMode.DISABLED, }); withServerPayments(serverTransport, { + stateStore, processors: [processor], pricedCapabilities: [ { method: 'tools/call', name: 'add', amount: 21, currencyUnit: 'sats' }, @@ -237,13 +241,13 @@ describe('withServerPayments shutdown cancels in-flight verification', () => { }, 20_000); test('explicit gating: close() aborts the verify signal and a late settle clears pending instead of granting', async () => { - const grantSpy = spyOn(AuthorizationStore.prototype, 'grant'); - const clearPendingSpy = spyOn(AuthorizationStore.prototype, 'clearPending'); + const stateStore = new InMemoryPaymentStateStore(); + const transitionSpy = spyOn(stateStore, 'transition'); const { processor, verifyStarted, settle } = createControlledProcessor({ rejectOnAbort: false, }); const { hub, serverPubkey, serverTransport, toolCalls } = - await startPaidServer(processor); + await startPaidServer(processor, stateStore); const client = await connectClient({ hub, serverPubkey, @@ -257,7 +261,9 @@ describe('withServerPayments shutdown cancels in-flight verification', () => { const verifyParams = await verifyStarted; expect(verifyParams.abortSignal?.aborted).toBe(false); - expect(grantSpy).not.toHaveBeenCalled(); + expect( + transitionSpy.mock.calls.filter(([change]) => change.type === 'grant'), + ).toHaveLength(0); await serverTransport.close(); @@ -266,12 +272,15 @@ describe('withServerPayments shutdown cancels in-flight verification', () => { settle(); await Bun.sleep(100); - expect(grantSpy).not.toHaveBeenCalled(); - expect(clearPendingSpy).toHaveBeenCalled(); + expect( + transitionSpy.mock.calls.filter(([change]) => change.type === 'grant'), + ).toHaveLength(0); + expect( + transitionSpy.mock.calls.some(([change]) => change.type === 'expire'), + ).toBe(true); expect(toolCalls).toHaveLength(0); } finally { - grantSpy.mockRestore(); - clearPendingSpy.mockRestore(); + transitionSpy.mockRestore(); await client.close(); } }, 20_000); diff --git a/src/payments/server-transport-payments.ts b/src/payments/server-transport-payments.ts index ea497b2..be4a979 100644 --- a/src/payments/server-transport-payments.ts +++ b/src/payments/server-transport-payments.ts @@ -5,13 +5,16 @@ import { createCapTagsFromPricedCapabilities } from './cap-tags.js'; import { createPmiTagsFromProcessors } from './pmi-tags.js'; import { createServerPaymentsMiddleware } from './server-payments.js'; import { createExplicitGatingMiddleware } from './server-explicit-gating.js'; -import { AuthorizationStore } from './authorization-store.js'; +import { serverPaymentStore } from './server-payment-state.js'; +import { validateServerPaymentOptions } from './server-payment-validation.js'; import { buildProcessorsByPmi } from './server-payments-utils.js'; import { createLogger } from '../core/utils/logger.js'; import { NOSTR_TAGS } from '../core/constants.js'; /** * Attaches CEP-8 payments gating to a NostrServerTransport. + * One paying server identity must run in one process with the default in-memory + * store. It cannot coordinate payments across processes or retain state after restart. * * By default the server uses the `optional` policy: it advertises * `explicit_gating` support and mirrors each client's requested lifecycle, so a @@ -35,6 +38,9 @@ export function withServerPayments( 'withServerPayments already called on this transport; registering the payments lifecycle twice double-charges priced requests', ); } + validateServerPaymentOptions(options); + const stateStore = serverPaymentStore(options); + options = { ...options, stateStore }; paymentsRegistered.add(transport); // Build the PMI → processor map once and share it across both middlewares. @@ -87,7 +93,7 @@ export function withServerPayments( // it is safe to register the explicit-gating middleware alongside it. Each // request is routed to exactly one lifecycle based on the negotiated mode. if (supportsExplicitGating) { - const authorizationStore = new AuthorizationStore({}); + const authorizationStore = stateStore; transport.addInboundMiddleware( createExplicitGatingMiddleware({ options, diff --git a/src/payments/types.ts b/src/payments/types.ts index f9fe83d..f51835c 100644 --- a/src/payments/types.ts +++ b/src/payments/types.ts @@ -3,6 +3,7 @@ import type { JSONRPCNotification, JSONRPCRequest, } from '@contextvm/mcp-sdk/types.js'; +import type { VerifiedPaymentEvent } from './payment-state-store.js'; /** * A priced capability pattern, consistent with server-side authorization exclusions. @@ -158,6 +159,8 @@ export interface PaymentHandlerRequest { /** Transparency metadata from the server's `payment_required._meta`. */ _meta?: Record; requestEventId: string; + /** Aborted when the invocation ends; check before starting an awaited wallet action. */ + abortSignal?: AbortSignal; } /** @@ -195,6 +198,8 @@ export interface PaymentProcessorVerifyParams { export type ResolvePriceQuote = { /** Final amount to charge for this specific invocation. */ amount: number; + /** When supplied, must match the advertised capability unit; no implicit conversion. */ + currencyUnit?: string; /** Optional override for the payment request description. */ description?: string; /** Optional transparency metadata attached to `payment_required.params._meta`. */ @@ -244,10 +249,17 @@ export function waivePrice(meta?: Record): ResolvePriceWaiver { */ export function quotePrice( amount: number, - options?: { description?: string; meta?: Record }, + options?: { + description?: string; + meta?: Record; + currencyUnit?: string; + }, ): ResolvePriceQuote { return { amount, + ...(options?.currencyUnit !== undefined && { + currencyUnit: options.currencyUnit, + }), ...(options?.description !== undefined && { description: options.description, }), @@ -262,9 +274,7 @@ export function quotePrice( * - Return a rejection object to emit `payment_rejected` without asking for payment. */ export type ResolvePriceResult = - | ResolvePriceQuote - | ResolvePriceRejection - | ResolvePriceWaiver; + ResolvePriceQuote | ResolvePriceRejection | ResolvePriceWaiver; /** * Server-side callback for dynamic pricing. @@ -298,6 +308,8 @@ export interface PaymentRequired { export interface PaymentProcessor { /** The PMI this processor can issue/verify */ readonly pmi: string; + /** Native amount unit. Legacy processors without this field inherit the capability unit. */ + readonly currencyUnit?: string; /** Create a payment request for a specific capability invocation */ createPaymentRequired( @@ -313,6 +325,8 @@ export interface PaymentProcessor { export interface ServerPaymentsContext { /** The client pubkey associated with the inbound request. */ clientPubkey: string; + /** Immutable identity of the verified event, independent of mutable JSON-RPC IDs. */ + requestEvent?: VerifiedPaymentEvent; /** * Optional list of client-advertised PMIs (ordered by client preference). diff --git a/src/transport/middleware.ts b/src/transport/middleware.ts index ba2ddca..c5f0f20 100644 --- a/src/transport/middleware.ts +++ b/src/transport/middleware.ts @@ -1,6 +1,7 @@ import type { JSONRPCMessage } from '@contextvm/mcp-sdk/types.js'; import type { PaymentInteractionMode } from '../payments/types.js'; +import type { VerifiedPaymentEvent } from '../payments/payment-state-store.js'; /** * Inbound middleware hook for server transports. @@ -15,6 +16,7 @@ export type InboundMiddlewareFn = ( message: JSONRPCMessage, ctx: { clientPubkey: string; + requestEvent?: VerifiedPaymentEvent; clientPmis?: readonly string[]; paymentInteraction?: PaymentInteractionMode; }, diff --git a/src/transport/nostr-client-transport.test.ts b/src/transport/nostr-client-transport.test.ts index 8153738..be6e44c 100644 --- a/src/transport/nostr-client-transport.test.ts +++ b/src/transport/nostr-client-transport.test.ts @@ -739,7 +739,7 @@ describe.serial('NostrClientTransport', () => { withServerPayments(paidServerTransport, { processors: [ - new FakePaymentProcessor({ pmi: 'pmi:test', verifyDelayMs: 1 }), + new FakePaymentProcessor({ pmi: 'pmi-test', verifyDelayMs: 1 }), ], pricedCapabilities: [ { @@ -818,7 +818,7 @@ describe.serial('NostrClientTransport', () => { withServerPayments(paidServerTransport, { processors: [ - new FakePaymentProcessor({ pmi: 'pmi:test', verifyDelayMs: 1 }), + new FakePaymentProcessor({ pmi: 'pmi-test', verifyDelayMs: 1 }), ], pricedCapabilities: [ { @@ -907,7 +907,7 @@ describe.serial('NostrClientTransport', () => { withServerPayments(paidServerTransport, { processors: [ - new FakePaymentProcessor({ pmi: 'pmi:test', verifyDelayMs: 1 }), + new FakePaymentProcessor({ pmi: 'pmi-test', verifyDelayMs: 1 }), ], pricedCapabilities: [ { diff --git a/src/transport/nostr-client-transport.ts b/src/transport/nostr-client-transport.ts index 159429b..2dc5c3d 100644 --- a/src/transport/nostr-client-transport.ts +++ b/src/transport/nostr-client-transport.ts @@ -42,8 +42,10 @@ import { ServerMetadataStore } from './nostr-client/server-metadata-store.js'; import { ClientOutboundSender } from './nostr-client/outbound-sender.js'; import { ClientInboundNotificationDispatcher } from './nostr-client/inbound-notification-dispatcher.js'; import { ClientEventPipeline } from './nostr-client/event-pipeline.js'; -import { ClientOpenStreamFactory, - type ClientOpenStreamHandle,} from './nostr-client/open-stream-factory.js'; +import { + ClientOpenStreamFactory, + type ClientOpenStreamHandle, +} from './nostr-client/open-stream-factory.js'; import { DEFAULT_CHUNK_SIZE, DEFAULT_OVERSIZED_THRESHOLD, @@ -458,6 +460,11 @@ export class NostrClientTransport return this.correlationStore.getPendingRequest(eventId); } + /** Releases correlation for every wire retry of a terminal caller request. */ + public forgetPendingRequestsForRequestId(requestId: string | number): void { + this.correlationStore.removeRequestsForRequestId(requestId); + } + /** * Returns the CEP-41 stream session for a progress token, creating it lazily if needed. */ diff --git a/src/transport/nostr-client/correlation-store.test.ts b/src/transport/nostr-client/correlation-store.test.ts index dea7948..d97c9f7 100644 --- a/src/transport/nostr-client/correlation-store.test.ts +++ b/src/transport/nostr-client/correlation-store.test.ts @@ -74,6 +74,27 @@ describe('ClientCorrelationStore', () => { }); }); + it('releases all retries of a terminal request while preserving typed id isolation', () => { + const store = new ClientCorrelationStore(); + store.registerRequest('numeric-first', { + originalRequestId: 0, + isInitialize: false, + }); + store.registerRequest('numeric-retry', { + originalRequestId: 0, + isInitialize: false, + }); + store.registerRequest('string', { + originalRequestId: '0', + isInitialize: false, + }); + store.removeRequestsForRequestId(0); + expect(store.hasPendingRequest('numeric-first')).toBe(false); + expect(store.hasPendingRequest('numeric-retry')).toBe(false); + expect(store.hasPendingRequest('string')).toBe(true); + expect(store.pendingRequestCount).toBe(1); + }); + describe('clear', () => { it('removes all pending requests', () => { const store = new ClientCorrelationStore(); diff --git a/src/transport/nostr-client/correlation-store.ts b/src/transport/nostr-client/correlation-store.ts index f422bac..4a89c58 100644 --- a/src/transport/nostr-client/correlation-store.ts +++ b/src/transport/nostr-client/correlation-store.ts @@ -109,6 +109,14 @@ export class ClientCorrelationStore { return this.pendingRequests.delete(eventId); } + /** Removes all wire retries of a terminal or cancelled caller request. */ + removeRequestsForRequestId(requestId: string | number): void { + for (const [eventId, request] of this.pendingRequests.entries()) { + if (request.originalRequestId === requestId) + this.pendingRequests.delete(eventId); + } + } + /** Gets the current number of pending requests */ get pendingRequestCount(): number { return this.pendingRequests.size; diff --git a/src/transport/nostr-server-transport.test.ts b/src/transport/nostr-server-transport.test.ts index 7b7e99d..3c40dfc 100644 --- a/src/transport/nostr-server-transport.test.ts +++ b/src/transport/nostr-server-transport.test.ts @@ -197,8 +197,8 @@ describe.serial('NostrServerTransport', () => { withServerPayments(transport, { processors: [ - new FakePaymentProcessor({ pmi: 'pmi:B', verifyDelayMs: 1 }), - new FakePaymentProcessor({ pmi: 'pmi:C', verifyDelayMs: 1 }), + new FakePaymentProcessor({ pmi: 'pmi-b', verifyDelayMs: 1 }), + new FakePaymentProcessor({ pmi: 'pmi-c', verifyDelayMs: 1 }), ], pricedCapabilities: [ { @@ -250,8 +250,8 @@ describe.serial('NostrServerTransport', () => { expect(announcement).toBeDefined(); expect(announcement!.tags).toEqual( expect.arrayContaining([ - ['pmi', 'pmi:B'], - ['pmi', 'pmi:C'], + ['pmi', 'pmi-b'], + ['pmi', 'pmi-c'], ['cap', 'tool:add', '123', 'sats'], ]), ); diff --git a/src/transport/nostr-server/inbound-coordinator.test.ts b/src/transport/nostr-server/inbound-coordinator.test.ts index fd814a8..5e50cd9 100644 --- a/src/transport/nostr-server/inbound-coordinator.test.ts +++ b/src/transport/nostr-server/inbound-coordinator.test.ts @@ -185,3 +185,36 @@ describe('ServerInboundCoordinator resource subscriptions', () => { expect(subscriptions.getSubscribers(resourceUri)).toEqual(new Set()); }); }); + +test('payment event context is frozen before asynchronous authorization or middleware mutation', async () => { + const event = requestEvent('verified-event'); + const original = { + id: event.id, + author: event.pubkey, + createdAt: event.created_at, + }; + let observed: unknown; + const { coordinator } = createCoordinator({ + authorizationPolicy: new AuthorizationPolicy({ + isPubkeyAllowed: async () => { + event.created_at = 0; + return true; + }, + }), + inboundMiddlewares: [ + async (message, ctx, forward) => { + observed = ctx.requestEvent; + expect(Object.isFrozen(ctx.requestEvent)).toBe(true); + if ('id' in message) message.id = 'application-mutated-id'; + await forward(message); + }, + ], + }); + await coordinator.authorizeAndProcessEvent( + event, + false, + subscriptionRequest('rpc-id', 'resources/read', resourceUri), + ); + await sleep(0); + expect(observed).toEqual(original); +}); diff --git a/src/transport/nostr-server/inbound-coordinator.ts b/src/transport/nostr-server/inbound-coordinator.ts index 2b56141..eb413da 100644 --- a/src/transport/nostr-server/inbound-coordinator.ts +++ b/src/transport/nostr-server/inbound-coordinator.ts @@ -104,6 +104,11 @@ export class ServerInboundCoordinator { ): Promise { try { const inboundMessage: JSONRPCMessage = mcpMessage; + const requestEvent = Object.freeze({ + id: event.id, + author: event.pubkey, + createdAt: event.created_at, + }); const authDecision = await this.deps.authorizationPolicy.authorize( event.pubkey, @@ -281,6 +286,7 @@ export class ServerInboundCoordinator { const ctx = { clientPubkey: event.pubkey, + requestEvent, clientPmis: clientPmis.length > 0 ? clientPmis : undefined, paymentInteraction: session.effectivePaymentInteraction ?? 'transparent', diff --git a/src/transport/payment-client-reliability.e2e.test.ts b/src/transport/payment-client-reliability.e2e.test.ts new file mode 100644 index 0000000..fd253be --- /dev/null +++ b/src/transport/payment-client-reliability.e2e.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from 'bun:test'; +import { Client } from '@contextvm/mcp-sdk/client'; +import { McpServer } from '@contextvm/mcp-sdk/server/mcp'; +import { z } from 'zod'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { bytesToHex } from 'nostr-tools/utils'; +import { MockRelayHub } from '../__mocks__/mock-relay-handler.js'; +import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; +import { PrivateKeySigner } from '../signer/private-key-signer.js'; +import { NostrClientTransport } from './nostr-client-transport.js'; +import { NostrServerTransport } from './nostr-server-transport.js'; +import { withClientPayments, withServerPayments } from '../payments/index.js'; +import type { PaymentProcessor } from '../payments/types.js'; + +const cases = [ + { + name: 'cleartext', + encryptionMode: EncryptionMode.DISABLED, + giftWrapMode: GiftWrapMode.OPTIONAL, + kind: 25910, + }, + { + name: 'persistent encryption', + encryptionMode: EncryptionMode.REQUIRED, + giftWrapMode: GiftWrapMode.PERSISTENT, + kind: 1059, + }, + { + name: 'ephemeral encryption', + encryptionMode: EncryptionMode.REQUIRED, + giftWrapMode: GiftWrapMode.EPHEMERAL, + kind: 21059, + }, +]; + +describe('instant explicit payment retry delivery', () => { + for (const mode of cases) { + test(`instant payment executes once over ${mode.name} without callback sleeps`, async () => { + const hub = new MockRelayHub(); + const serverKey = generateSecretKey(); + const clientSigner = new PrivateKeySigner( + bytesToHex(generateSecretKey()), + ); + const requests: Array<{ id: string; createdAt: number }> = []; + const sign = clientSigner.signEvent.bind(clientSigner); + clientSigner.signEvent = async (event) => { + const signed = await sign(event); + if ( + event.kind === 25910 && + JSON.parse(event.content).method === 'tools/call' + ) { + requests.push({ id: signed.id, createdAt: event.created_at }); + } + return signed; + }; + let executions = 0; + let invoices = 0; + let approvals = 0; + const processor: PaymentProcessor = { + pmi: 'pmi:A', + async createPaymentRequired(params) { + invoices++; + return { + amount: params.amount, + pmi: 'pmi:A', + pay_req: `invoice-${invoices}`, + ttl: 60, + }; + }, + async verifyPayment() { + return {}; + }, + }; + const server = new McpServer({ + name: 'payment-retry-server', + version: '1.0.0', + }); + server.registerTool( + 'paid', + { inputSchema: { value: z.number() } }, + async ({ value }) => { + executions++; + return { content: [{ type: 'text', text: String(value) }] }; + }, + ); + const serverTransport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(bytesToHex(serverKey)), + relayHandler: hub.createRelayHandler(), + encryptionMode: mode.encryptionMode, + giftWrapMode: mode.giftWrapMode, + }), + { + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'paid', + amount: 5, + currencyUnit: 'sats', + }, + ], + }, + ); + const base = new NostrClientTransport({ + signer: clientSigner, + relayHandler: hub.createRelayHandler(), + serverPubkey: getPublicKey(serverKey), + encryptionMode: mode.encryptionMode, + giftWrapMode: mode.giftWrapMode, + }); + const paid = withClientPayments(base, { + paymentInteraction: 'explicit_gating', + minRetryDelayMs: 0, + limits: { maxAmountByPmi: { 'pmi:A': 5 } }, + async onPaymentRequired({ options }) { + expect(options[0]?.pmi).toBe('pmi:A'); + approvals++; + return { paid: true }; + }, + }); + const client = new Client({ + name: 'payment-retry-client', + version: '1.0.0', + }); + try { + await server.connect(serverTransport); + await client.connect(paid); + // Initialization finishes negotiation, so routine invocations have stable tags. + const result = await client.callTool( + { name: 'paid', arguments: { value: 7 } }, + undefined, + { timeout: 5000 }, + ); + expect(result.content).toEqual([{ type: 'text', text: '7' }]); + expect(approvals).toBe(1); + expect(invoices).toBe(1); + expect(executions).toBe(1); + expect(requests).toHaveLength(2); + expect(requests[1]!.id).not.toBe(requests[0]!.id); + expect(requests[1]!.createdAt).toBeGreaterThan(requests[0]!.createdAt); + expect(hub.getEvents().some((event) => event.kind === mode.kind)).toBe( + true, + ); + } finally { + await client.close(); + await server.close(); + } + }); + } +}); diff --git a/src/transport/payments-flow.test.ts b/src/transport/payments-flow.test.ts index 3bbd471..9ac00bb 100644 --- a/src/transport/payments-flow.test.ts +++ b/src/transport/payments-flow.test.ts @@ -90,13 +90,8 @@ async function captureNextCtxvmEvent(params: { } /** - * Nostr event ids are derived from (content, author, created_at, tags) at - * second granularity. An explicit-gating retry republishes the same JSON-RPC - * request; if it lands in the same second as the original, the identical event - * id is deduplicated by relay subscriptions and the server never sees the - * retry. Sleep past the current second boundary so the retry's event id - * differs. Real-world payment flows naturally take >1s (wallet interaction, - * settlement); this only affects instant-pay tests. + * Separates intentional test invocations at Nostr second granularity. + * Automatic payment retries are delayed by the client payment lifecycle. */ async function sleepPastSecondBoundary(): Promise { const waitMs = 1000 - (Date.now() % 1000) + 10; @@ -217,11 +212,11 @@ describe.serial('payments fake flow (transport-level)', () => { ); const processorB = new FakePaymentProcessor({ - pmi: 'pmi:B', + pmi: 'pmi-b', verifyDelayMs: 1, }); const processorC = new FakePaymentProcessor({ - pmi: 'pmi:C', + pmi: 'pmi-c', verifyDelayMs: 1, }); const pricedCapabilities = [ @@ -252,8 +247,8 @@ describe.serial('payments fake flow (transport-level)', () => { const clientPrivateKey = bytesToHex(clientSK); const clientPubkey = getPublicKey(clientSK); const handlers = [ - new FakePaymentHandler({ pmi: 'pmi:A', delayMs: 1 }), - new FakePaymentHandler({ pmi: 'pmi:B', delayMs: 1 }), + new FakePaymentHandler({ pmi: 'pmi-a', delayMs: 1 }), + new FakePaymentHandler({ pmi: 'pmi-b', delayMs: 1 }), ]; const clientTransport = new NostrClientTransport({ @@ -271,7 +266,7 @@ describe.serial('payments fake flow (transport-level)', () => { if (!event.content.includes('"method":"initialize"')) { return false; } - return event.tags.some((t) => t[0] === 'pmi' && t[1] === 'pmi:A'); + return event.tags.some((t) => t[0] === 'pmi' && t[1] === 'pmi-a'); }, }); @@ -285,8 +280,8 @@ describe.serial('payments fake flow (transport-level)', () => { const initializeEvent = await initializeEventPromise; const pmiTags = initializeEvent.tags.filter((t) => t[0] === 'pmi'); expect(pmiTags).toEqual([ - ['pmi', 'pmi:A'], - ['pmi', 'pmi:B'], + ['pmi', 'pmi-a'], + ['pmi', 'pmi-b'], ]); const originalOnMessage = paidClientTransport.onmessage; @@ -300,7 +295,7 @@ describe.serial('payments fake flow (transport-level)', () => { arguments: { a: 1, b: 2 }, }); - expect(observedPaymentPmi).toBe('pmi:B'); + expect(observedPaymentPmi).toBe('pmi-b'); await client.close(); await mcpServer.close(); @@ -324,7 +319,10 @@ describe.serial('payments fake flow (transport-level)', () => { }), ); - const processor = new FakePaymentProcessor({ verifyDelayMs: 1 }); + const processor = new FakePaymentProcessor({ + verifyDelayMs: 1, + pmi: 'pmi-a', + }); const pricedCapabilities = [ { method: 'tools/call', @@ -352,7 +350,7 @@ describe.serial('payments fake flow (transport-level)', () => { const clientSK = generateSecretKey(); const clientPrivateKey = bytesToHex(clientSK); const clientPubkey = getPublicKey(clientSK); - const handlers = [new FakePaymentHandler({ pmi: 'pmi:A', delayMs: 1 })]; + const handlers = [new FakePaymentHandler({ pmi: 'pmi-a', delayMs: 1 })]; const clientTransport = new NostrClientTransport({ signer: new PrivateKeySigner(clientPrivateKey), @@ -374,7 +372,7 @@ describe.serial('payments fake flow (transport-level)', () => { if (!event.content.includes('"method":"tools/call"')) { return false; } - return event.tags.some((t) => t[0] === 'pmi' && t[1] === 'pmi:A'); + return event.tags.some((t) => t[0] === 'pmi' && t[1] === 'pmi-a'); }, timeoutMs: 4000, }); @@ -388,13 +386,13 @@ describe.serial('payments fake flow (transport-level)', () => { const callEvent = await callEventPromise; const pmiTags = callEvent.tags.filter((t) => t[0] === 'pmi'); - expect(pmiTags).toEqual([['pmi', 'pmi:A']]); + expect(pmiTags).toEqual([['pmi', 'pmi-a']]); await client.close(); await mcpServer.close(); }, 20000); - test('PMI selection: falls back to server order when no intersection', async () => { + test('PMI selection: disjoint sets refuse before invoice creation or wallet work', async () => { const serverSK = generateSecretKey(); const serverPrivateKey = bytesToHex(serverSK); const serverPublicKey = getPublicKey(serverSK); @@ -413,13 +411,15 @@ describe.serial('payments fake flow (transport-level)', () => { ); const processorB = new FakePaymentProcessor({ - pmi: 'pmi:B', + pmi: 'pmi-b', verifyDelayMs: 1, }); const processorC = new FakePaymentProcessor({ - pmi: 'pmi:C', + pmi: 'pmi-c', verifyDelayMs: 1, }); + const createB = spyOn(processorB, 'createPaymentRequired'); + const createC = spyOn(processorC, 'createPaymentRequired'); const pricedCapabilities = [ { method: 'tools/call', @@ -445,7 +445,8 @@ describe.serial('payments fake flow (transport-level)', () => { await mcpServer.connect(serverTransport); const clientPrivateKey = bytesToHex(generateSecretKey()); - const handlers = [new FakePaymentHandler({ pmi: 'pmi:A', delayMs: 1 })]; + const handlers = [new FakePaymentHandler({ pmi: 'pmi-a', delayMs: 1 })]; + const wallet = spyOn(handlers[0], 'handle'); const clientTransport = new NostrClientTransport({ signer: new PrivateKeySigner(clientPrivateKey), @@ -467,15 +468,21 @@ describe.serial('payments fake flow (transport-level)', () => { originalOnMessage?.(msg); }; - await client.callTool({ - name: 'add', - arguments: { a: 1, b: 2 }, - }); - - expect(observedPaymentPmi).toBe('pmi:B'); - - await client.close(); - await mcpServer.close(); + try { + await expect( + client.callTool({ name: 'add', arguments: { a: 1, b: 2 } }), + ).rejects.toThrow('mutually supported'); + expect(observedPaymentPmi).toBeUndefined(); + expect(createB).not.toHaveBeenCalled(); + expect(createC).not.toHaveBeenCalled(); + expect(wallet).not.toHaveBeenCalled(); + } finally { + createB.mockRestore(); + createC.mockRestore(); + wallet.mockRestore(); + await client.close(); + await mcpServer.close(); + } }, 20000); test('PMI selection: when client advertises none, uses server order', async () => { @@ -497,11 +504,11 @@ describe.serial('payments fake flow (transport-level)', () => { ); const processorB = new FakePaymentProcessor({ - pmi: 'pmi:B', + pmi: 'pmi-b', verifyDelayMs: 1, }); const processorC = new FakePaymentProcessor({ - pmi: 'pmi:C', + pmi: 'pmi-c', verifyDelayMs: 1, }); const pricedCapabilities = [ @@ -553,7 +560,7 @@ describe.serial('payments fake flow (transport-level)', () => { arguments: { a: 1, b: 2 }, }); - expect(observedPaymentPmi).toBe('pmi:B'); + expect(observedPaymentPmi).toBe('pmi-b'); await client.close(); await mcpServer.close(); @@ -837,10 +844,12 @@ describe.serial('payments fake flow (transport-level)', () => { expect(acceptedSent).toBe(0); }); - test('verification timeout clears pending state (ttl-based)', async () => { + test('verification timeout preserves invoice identity for event replay', async () => { + let invoices = 0; const processor: PaymentProcessor = { pmi: 'fake', async createPaymentRequired(params) { + invoices++; return { amount: params.amount, pay_req: `fake:${params.requestEventId}:${params.clientPubkey}:${params.amount}`, @@ -904,10 +913,10 @@ describe.serial('payments fake flow (transport-level)', () => { expect(elapsed).toBeLessThan(3000); expect(forwarded).toBe(0); - // Retry should not be black-holed: it should attempt again and time out again. - await expect(mw(message, ctx, forward)).rejects.toThrow( - /verifyPayment timed out/, - ); + // The same admitted event cannot create a second payment after its lease expires. + await mw(message, ctx, forward); + expect(invoices).toBe(1); + expect(forwarded).toBe(0); }, 20000); test('client respects payment TTL without timing out even when TTL > MCP timeout (immediate-heartbeat regression)', async () => { @@ -1091,7 +1100,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitPaymentHandled = true; return { paid: true }; }, @@ -1196,8 +1204,7 @@ describe.serial('payments fake flow (transport-level)', () => { const event = await capturePromise; const piTag = event.tags.find((t) => t[0] === 'payment_interaction') as - | readonly string[] - | undefined; + readonly string[] | undefined; expect(piTag?.[1]).toBe('explicit_gating'); await client.close(); @@ -1268,7 +1275,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -1548,7 +1554,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -1620,7 +1625,8 @@ describe.serial('payments fake flow (transport-level)', () => { { method: 'tools/call', name: 'add', - amount: 1, // static fallback; resolvePrice overrides this + amount: 1, + maxAmount: 42, // Inclusive advertised range for dynamic quotes. currencyUnit: 'test', }, ], @@ -1646,7 +1652,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -1976,7 +1981,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitHandled = true; return { paid: true }; }, @@ -2082,7 +2086,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitHandled = true; return { paid: true }; }, @@ -2273,7 +2276,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -2413,7 +2415,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitHandled = true; return { paid: true }; },