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/.changeset/payment-settlement-recovery.md b/.changeset/payment-settlement-recovery.md new file mode 100644 index 0000000..5c4108f --- /dev/null +++ b/.changeset/payment-settlement-recovery.md @@ -0,0 +1,7 @@ +--- +'@contextvm/sdk': minor +--- + +Add bounded payment-adapter cancellation, authenticated settlement reconciliation, and retryable paid-result delivery through the injected `PaymentStateStore`. + +The SDK bundles only in-memory storage and adds no storage-driver dependency. Result recovery with the default store requires keeping that same store alive; recovery across process restarts requires an application-provided persistent implementation. Retrying publication does not repeat payment or handler execution. diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cab0d58..bfcf4ea 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -19,10 +19,33 @@ jobs: with: bun-version: latest + - name: Setup minimum Node runtime + uses: actions/setup-node@v4 + with: + node-version: '18' + - name: Install dependencies - run: bun install + run: bun install --frozen-lockfile - name: Run tests env: + NWC_INTEGRATION: 'false' + LNBITS_INTEGRATION: 'false' + ZAP_INTEGRATION: 'false' NODE_ENV: test run: bun test --concurrent --timeout=60000 + + - name: Check types and lint + run: | + bun run typecheck + bun run lint + + - name: Build and check public exports + run: | + bun run build + bun run verify-exports + + - name: Qualify Node 18 and browser entrypoints + run: | + bun run verify:payment-runtime + bun run verify:browser diff --git a/package.json b/package.json index 874a47e..8a978e4 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,9 @@ "format": "bun prettier --write src", "release": "bun run build && bun publish", "typecheck": "bun run tsc --noEmit", - "verify-exports": "bun run scripts/verify-exports.ts" + "verify-exports": "bun run scripts/verify-exports.ts", + "verify:payment-runtime": "node scripts/verify-payment-runtime.mjs", + "verify:browser": "bun build src/payments/index.ts src/transport/index.ts src/core/index.ts --target browser --outdir /tmp/contextvm-payment-browser" }, "devDependencies": { "@changesets/cli": "^2.31.1", diff --git a/scripts/verify-payment-runtime.mjs b/scripts/verify-payment-runtime.mjs new file mode 100644 index 0000000..a0ddd05 --- /dev/null +++ b/scripts/verify-payment-runtime.mjs @@ -0,0 +1,62 @@ +import assert from 'node:assert/strict'; +import { + InMemoryPaymentStateStore, + PaymentResultRecovery, + paymentEventKey, + paymentInvoiceDigest, +} from '../dist/esm/payments/index.js'; + +// Execute as a real module: Node 18's crypto globals differ from `node -e`. +const store = new InMemoryPaymentStateStore(); +const key = paymentEventKey('client', 'event'); +const admitted = await store.admit({ + key, + mode: 'transparent', + clientPubkey: 'client', + requestEventId: 'event', + invocationHash: 'hash', + leaseMs: 10000, +}); +assert.equal(admitted.status, 'reserved'); +const fence = { key, generation: admitted.record.generation }; +assert(await store.transition({ ...fence, type: 'issuing' })); +assert( + await store.transition({ + ...fence, + type: 'bind', + invoice: { amount: 1, pmi: 'fake', pay_req: 'private-invoice' }, + leaseMs: 10000, + }), +); +assert(await store.transition({ ...fence, type: 'settled', ttlMs: 10000 })); +assert(await store.transition({ ...fence, type: 'consume' })); +const recovery = new PaymentResultRecovery(store, { + publicationLeaseMs: 10000, +}); +const result = { jsonrpc: '2.0', id: 'event', result: { ok: true } }; +const snapshot = { + route: { clientPubkey: 'client', originalRequestId: 7 }, + expiresAtMs: Number.MAX_SAFE_INTEGER, +}; +const publication = await recovery.prepare('event', result, snapshot); +assert(publication.publish); +await publication.framePublished(1); +await publication.complete(); +assert.equal((await store.read(key)).delivery.status, 'published'); +assert.equal(paymentInvoiceDigest('test').length, 64); +assert.equal( + ( + await store.admit({ + key, + mode: 'transparent', + clientPubkey: 'client', + requestEventId: 'event', + invocationHash: 'hash', + leaseMs: 10000, + }) + ).status, + 'duplicate', +); +console.log( + `${process.version}: payment lifecycle, result journal, and public exports passed`, +); 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..0d70266 --- /dev/null +++ b/src/__mocks__/payment-store-contract.ts @@ -0,0 +1,523 @@ +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).toBeNull(); + expect( + required(claimed.record).replayExpiresAtMs, + ).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 = required((await store.admit(input)).record); + 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: { amount: 1, pmi: 'fake', pay_req: 'superseded-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])), + ).toBeUndefined(); + })); + + 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/core/utils/canonical-json.ts b/src/core/utils/canonical-json.ts new file mode 100644 index 0000000..3b1088b --- /dev/null +++ b/src/core/utils/canonical-json.ts @@ -0,0 +1,12 @@ +import canonicalizePackage from 'canonicalize'; +const encode = canonicalizePackage as unknown as ( + value: unknown, +) => string | undefined; + +/** Encodes JSON with stable key ordering across memory and JSONB storage. */ +export function canonicalJson(value: unknown): string { + const encoded = encode(value); + if (encoded === undefined) + throw new TypeError('Value cannot be encoded as canonical JSON'); + return encoded; +} diff --git a/src/gateway/gateway-payments.test.ts b/src/gateway/gateway-payments.test.ts index 255dc7c..9b3aef3 100644 --- a/src/gateway/gateway-payments.test.ts +++ b/src/gateway/gateway-payments.test.ts @@ -1,3 +1,5 @@ +import { InMemoryPaymentStateStore } from '../payments/in-memory-payment-state-store.js'; +import type { PaymentOutcome } from '../payments/payment-outcomes.js'; import { afterAll, afterEach, @@ -74,6 +76,8 @@ describe.serial('NostrMCPGateway payments wiring', () => { InMemoryTransport.createLinkedPair(); await mcpServer.connect(mcpTransport); + const outcomes: PaymentOutcome[] = []; + const stateStore = new InMemoryPaymentStateStore(); const gatewaySK = generateSecretKey(); const gateway = new NostrMCPGateway({ mcpClientTransport: gatewayMcpTransport, @@ -84,6 +88,10 @@ describe.serial('NostrMCPGateway payments wiring', () => { publishRelayList: false, }, paymentOptions: { + stateStore, + onPaymentOutcome: (outcome) => { + outcomes.push(outcome); + }, processors: [new FakePaymentProcessor()], pricedCapabilities: [ { @@ -122,6 +130,10 @@ describe.serial('NostrMCPGateway payments wiring', () => { data: { payment_options: expect.any(Array) }, }); + expect(outcomes.map((outcome) => outcome.kind)).toContain('offer'); + expect( + outcomes.every((outcome) => !JSON.stringify(outcome).includes('pay_req')), + ).toBe(true); await client.close(); await gateway.stop(); await mcpServer.close(); diff --git a/src/payments/authorization-store.test.ts b/src/payments/authorization-store.test.ts index 8e0610f..6c5c2c2 100644 --- a/src/payments/authorization-store.test.ts +++ b/src/payments/authorization-store.test.ts @@ -1,178 +1,53 @@ 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: 'reject' }); + 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..23361a7 --- /dev/null +++ b/src/payments/client-payment-attempts.ts @@ -0,0 +1,134 @@ +import { + emitPaymentOutcome, + paymentInvoiceDigest, + type PaymentOutcomeOptions, +} from './payment-outcomes.js'; +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, + private readonly outcomes: PaymentOutcomeOptions = {}, + ) { + 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) { + emitPaymentOutcome(this.outcomes, { + kind: 'exhausted', + atMs: Date.now(), + identity: 'client_payment_attempts', + }); + 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; + for (const option of unique.values()) + emitPaymentOutcome(this.outcomes, { + kind: + outcome === 'handled' + ? 'attempt_handled' + : outcome === 'declined' + ? 'attempt_declined' + : 'unknown', + atMs: Date.now(), + identity: paymentInvoiceDigest(this.key(option)), + invoiceDigest: paymentInvoiceDigest(option.pay_req), + ...(outcome === 'unknown' && { remedy: 'reconcile_settlement' }), + }); + 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 }); + } + for (const option of unique.values()) + emitPaymentOutcome(this.outcomes, { + kind: 'attempt', + atMs: Date.now(), + identity: paymentInvoiceDigest(this.key(option)), + invoiceDigest: paymentInvoiceDigest(option.pay_req), + }); + 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..4a9db0c --- /dev/null +++ b/src/payments/client-payment-options.ts @@ -0,0 +1,144 @@ +import type { PaymentOutcomeOptions } from './payment-outcomes.js'; +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 extends PaymentOutcomeOptions { + /** + * 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[]; + /** Dispose exclusively owned handlers when this wrapper closes. Shared handlers remain caller-owned by default. */ + disposeHandlersOnClose?: boolean; + /** 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; + /** Absolute invocation payment deadline, when configured. */ + deadlineMs?: number; + }) => 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..fd30861 --- /dev/null +++ b/src/payments/client-payment-validation.ts @@ -0,0 +1,136 @@ +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({ + disposeHandlersOnClose: z.boolean().optional(), + 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..68d23b9 100644 --- a/src/payments/client-payments.ts +++ b/src/payments/client-payments.ts @@ -1,33 +1,45 @@ +import { + emitPaymentOutcome, + paymentInvoiceDigest, +} from './payment-outcomes.js'; 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 +50,591 @@ 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; +export type { + ClientPaymentsOptions, + ClientPaymentLimits, +} from './client-payment-options.js'; - /** Requested payment interaction mode. @default 'transparent' */ - paymentInteraction?: PaymentInteractionMode; - - /** - * Maximum number of -32043 (Payment Pending) retries before giving up. - * - * With retry_after=2 and 1.5× exponential backoff capped at 10s, the default - * of 10 retries gives ~45s of cumulative wait — enough for typical verification - * flows. Increase for slow payment processors (e.g. on-chain confirmation). - * @default 10 - */ - maxPendingRetries?: number; - - /** - * Handler for explicit-gating -32042 errors. - * Called when a priced invocation returns Payment Required. - * The handler should pay one option and signal completion. - * - * **Error handling contract**: - * - If the promise resolves with `{ paid: true }`, the wrapper auto-retries the - * original request with the same `method` and `params`. - * - If the promise resolves with `{ paid: false, reason }`, the wrapper synthesizes - * a JSON-RPC error to the caller with code `-32042` and `data: { reason }`. - * Use `reason: 'user_cancelled'` for user-initiated cancellations. - * - If the promise **rejects**, the wrapper MUST NOT silently fall back. - * It synthesizes a JSON-RPC error with code `-32042` and - * `data: { reason: error.message, type: 'payment_handler_error' }`. - * - Transient payment-provider failures should reject with an Error whose - * `message` contains the provider error details. - * - * **Verify-timeout window**: if the server's verification times out or fails - * after the client paid, its pending state is cleared and the client's retry - * receives a fresh `-32042` with a new invoice (CEP-8-compliant). The wrapper - * does not dedup across distinct `pay_req` values. - */ - onPaymentRequired?: (params: { - options: PaymentOption[]; - instructions?: string; - originalRequest: JSONRPCRequest; - }) => Promise<{ paid: boolean; reason?: string }>; -} - -type ProgressToken = string; - -type SyntheticProgressEntry = { - stopAtMs: number; - wireProgressToken: string | number; -}; - -function supportsOnmessageWithContext( - transport: Transport, -): transport is TransportWithContext { - // Avoid using the `in` operator here; prefer an ES3-compatible check. - return Object.prototype.hasOwnProperty.call( - transport, - 'onmessageWithContext', - ); -} +const clientPaymentsWrapped = new WeakSet(); -function isExplicitPaymentRequiredError( - msg: JSONRPCMessage, -): msg is JSONRPCErrorResponse { +function isPaymentOffer( + message: JSONRPCMessage, +): message is PaymentRequiredNotification { + // Classify by envelope before schema helpers can reject malformed params. return ( - isJSONRPCErrorResponse(msg) && - msg.error.code === PAYMENT_REQUIRED_ERROR_CODE && - typeof msg.error.data === 'object' && - msg.error.data !== null && - Array.isArray( - (msg.error.data as { payment_options?: unknown }).payment_options, - ) && - (msg.error.data as { payment_options: unknown[] }).payment_options.length > - 0 + 'method' in message && + message.method === PAYMENT_REQUIRED_METHOD && + !('id' in message) ); } -function isExplicitPaymentPendingError( - msg: JSONRPCMessage, -): msg is JSONRPCErrorResponse { +function isExplicitPaymentError( + message: JSONRPCMessage, +): message is JSONRPCErrorResponse { return ( - isJSONRPCErrorResponse(msg) && - msg.error.code === PAYMENT_PENDING_ERROR_CODE && - typeof msg.error.data === 'object' && - msg.error.data !== null && - typeof (msg.error.data as { retry_after?: unknown }).retry_after === - 'number' + isJSONRPCErrorResponse(message) && + (message.error.code === PAYMENT_REQUIRED_ERROR_CODE || + message.error.code === PAYMENT_PENDING_ERROR_CODE) ); } -function isPaymentRequiredNotification( - msg: JSONRPCMessage, -): msg is PaymentRequiredNotification { - return isJSONRPCNotification(msg) && msg.method === PAYMENT_REQUIRED_METHOD; +function cancelledRequestId( + message: JSONRPCMessage, +): string | number | undefined { + if ( + !isJSONRPCNotification(message) || + message.method !== 'notifications/cancelled' + ) + return undefined; + const id = message.params?.requestId; + return typeof id === 'string' || typeof id === 'number' ? id : undefined; } -/** Transports already wrapped by {@link withClientPayments}. */ -const clientPaymentsWrapped = new WeakSet(); - /** - * Wraps a transport to automatically handle CEP-8 payment requests. + * Wraps a transport with validated CEP-8 offers, bounded payment attempts, and + * request-owned retries. Nostr peers are authenticated by their transport; + * generic transports isolate attempt history per wrapper and must keep their + * peer identity stable. In-flight and unknown attempts stay pinned; terminal + * history is bounded and may be evicted under capacity pressure. * - * When `transport` is a {@link NostrClientTransport}, PMI advertisement and - * synthetic progress injection are enabled automatically. - * - * Note: `payment_rejected` → JSON-RPC error synthesis requires a - * {@link NostrClientTransport} because correlation (requestEventId → MCP request id) - * depends on Nostr event tagging. On plain transports a rejected payment surfaces - * as an unhandled notification and the MCP request times out naturally. + * Unmatched handlers leave transparent payment to the application. Generic + * notifications without request correlation support legacy handler execution, + * but per-invocation limits require an identifiable original request. */ export function withClientPayments( transport: Transport, options: ClientPaymentsOptions, ): Transport { - // A second wrap silently double-pays every offer (each wrapper runs its own - // pipeline with its own dedup on separate closures) or, for sibling wraps, - // silently kills the first wrapper's trampolines. Refuse outright. - if (clientPaymentsWrapped.has(transport)) { + if (clientPaymentsWrapped.has(transport)) throw new Error('withClientPayments already called on this transport'); - } + validateClientPaymentOptions(options); const logger = createLogger('client-payments'); - - const syntheticProgressIntervalMs = - options.syntheticProgressIntervalMs ?? - DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS; - - const defaultPaymentTtlMs = - options.defaultPaymentTtlMs ?? DEFAULT_PAYMENT_TTL_MS; - - const minRetryDelayMs = options.minRetryDelayMs ?? 1000; - - const syntheticProgress = new Map(); - - let syntheticProgressScheduler: ReturnType | undefined; - - const maybeStopScheduler = (): void => { - if (syntheticProgress.size > 0) return; - if (!syntheticProgressScheduler) return; - clearInterval(syntheticProgressScheduler); - syntheticProgressScheduler = undefined; - }; - - const tickSyntheticProgress = (): void => { - if (syntheticProgress.size === 0) { - maybeStopScheduler(); - 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 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, + }, }; - - 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(); + let onmessage: Transport['onmessage']; + let onerror: Transport['onerror']; + let onclose: Transport['onclose']; + let disposing: Promise | undefined; + const disposeHandlers = (): Promise => { + disposing ??= options.disposeHandlersOnClose + ? Promise.allSettled( + [...new Set(options.handlers ?? [])].map((handler) => + Promise.resolve().then(() => handler.dispose?.()), + ), + ).then((results) => { + if (results.some((result) => result.status === 'rejected')) + onerror?.(new Error('Payment handler disposal failed')); + }) + : Promise.resolve(); + return disposing; }; + const progress = new ClientPaymentProgress( + options.syntheticProgressIntervalMs ?? + DEFAULT_SYNTHETIC_PROGRESS_INTERVAL_MS, + (message) => onmessage?.(message), + ); - // 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, + 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, + }); + const kind = + error.reason === 'payment_request_capacity' + ? 'evicted' + : error.reason === 'payment_deadline_exceeded' + ? 'expired' + : ['payment_limit_exceeded', 'payment_attempt_capacity'].includes( + error.reason, + ) + ? 'exhausted' + : undefined; + if (kind) + emitPaymentOutcome(options, { + kind, + atMs: Date.now(), + identity: paymentInvoiceDigest(state.key), }); + if (state.id === undefined) { + onerror?.(error); + return; } - 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, + options, + ); + 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, + deadlineMs: state.deadline, + }); + 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; - } - - // 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; } + } 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; + } + } - 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, + deadlineMs: state.deadline, }; - - 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 +653,65 @@ 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(); + void disposeHandlers(); 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 - await transport.close(); + lifecycle.close(); + progress.clear(); + try { + await transport.close(); + } finally { + await disposeHandlers(); + } }, }; - 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/handlers/ln-bolt11-lnbits-payment-handler.ts b/src/payments/handlers/ln-bolt11-lnbits-payment-handler.ts index add321a..e191452 100644 --- a/src/payments/handlers/ln-bolt11-lnbits-payment-handler.ts +++ b/src/payments/handlers/ln-bolt11-lnbits-payment-handler.ts @@ -1,3 +1,7 @@ +import { + runPaymentOperation, + type PaymentFetch, +} from '../payment-cancellation.js'; import type { PaymentHandler, PaymentHandlerRequest } from '../types.js'; import { PMI_BITCOIN_LIGHTNING_BOLT11 } from '../pmis.js'; import { encodeBase64 } from '../../core/utils/base64.js'; @@ -5,6 +9,8 @@ import { encodeBase64 } from '../../core/utils/base64.js'; export interface LnBolt11LnbitsPaymentHandlerOptions { /** LNbits instance base URL (e.g. `https://lnbits.example.com`). */ lnbitsUrl: string; + /** Optional HTTP implementation for custom runtimes and fault injection. */ + fetch?: PaymentFetch; /** LNbits wallet admin key (required for outgoing payments). */ lnbitsAdminKey: string; /** Optional HTTP Basic Auth credentials (`user:password`) for proxied instances. */ @@ -19,11 +25,14 @@ export interface LnBolt11LnbitsPaymentHandlerOptions { export class LnBolt11LnbitsPaymentHandler implements PaymentHandler { public readonly pmi = PMI_BITCOIN_LIGHTNING_BOLT11; + private readonly shutdown = new AbortController(); + private readonly fetch: PaymentFetch; private readonly lnbitsUrl: string; private readonly lnbitsAdminKey: string; private readonly lnbitsBasicAuth?: string; public constructor(options: LnBolt11LnbitsPaymentHandlerOptions) { + this.fetch = options.fetch ?? globalThis.fetch; this.lnbitsUrl = options.lnbitsUrl.replace(/\/+$/, ''); this.lnbitsAdminKey = options.lnbitsAdminKey; this.lnbitsBasicAuth = options.lnbitsBasicAuth; @@ -41,9 +50,21 @@ export class LnBolt11LnbitsPaymentHandler implements PaymentHandler { return headers; } + /** Stops this adapter's local requests; remote payment may remain uncertain. */ + public async dispose(): Promise { + this.shutdown.abort(); + } + public async handle(req: PaymentHandlerRequest): Promise { - const response = await fetch(`${this.lnbitsUrl}/api/v1/payments`, { + return runPaymentOperation(req, 60_000, this.shutdown.signal, (signal) => + this.pay({ ...req, abortSignal: signal }), + ); + } + + private async pay(req: PaymentHandlerRequest): Promise { + const response = await this.fetch(`${this.lnbitsUrl}/api/v1/payments`, { method: 'POST', + signal: req.abortSignal, headers: this.buildHeaders(), body: JSON.stringify({ out: true, @@ -52,8 +73,7 @@ export class LnBolt11LnbitsPaymentHandler implements PaymentHandler { }); if (!response.ok) { - const text = await response.text(); - throw new Error(`LNbits pay_invoice failed: ${response.status} ${text}`); + throw new Error(`LNbits pay_invoice failed: ${response.status}`); } const result = (await response.json()) as { diff --git a/src/payments/handlers/ln-bolt11-nwc-payment-handler.ts b/src/payments/handlers/ln-bolt11-nwc-payment-handler.ts index 1dc3059..31cd593 100644 --- a/src/payments/handlers/ln-bolt11-nwc-payment-handler.ts +++ b/src/payments/handlers/ln-bolt11-nwc-payment-handler.ts @@ -29,14 +29,22 @@ export class LnBolt11NwcPaymentHandler implements PaymentHandler { this.nwc = new NwcClient({ relayHandler, + ownsRelayHandler: !options.relayHandler, connection, responseTimeoutMs: options.responseTimeoutMs, }); } + /** Stops owned NWC work without disconnecting a shared caller pool. */ + public async dispose(): Promise { + await this.nwc.disconnect(); + } + public async handle(req: PaymentHandlerRequest): Promise { const response = await this.nwc.request({ method: 'pay_invoice', + abortSignal: req.abortSignal, + deadlineMs: req.deadlineMs, resultType: 'pay_invoice', request: { method: 'pay_invoice', @@ -47,9 +55,7 @@ export class LnBolt11NwcPaymentHandler implements PaymentHandler { }); if (response.error) { - throw new Error( - `NWC pay_invoice failed: ${response.error.code} (${response.error.message})`, - ); + throw new Error(`NWC pay_invoice failed: ${response.error.code}`); } } } 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..e0450a8 100644 --- a/src/payments/index.ts +++ b/src/payments/index.ts @@ -18,3 +18,11 @@ 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'; +export * from './payment-cancellation.js'; +export * from './payment-outcomes.js'; +export * from './payment-recovery.js'; +export * from './payment-delivery.js'; diff --git a/src/payments/nip47/nwc-client.test.ts b/src/payments/nip47/nwc-client.test.ts index 1782604..3f2981a 100644 --- a/src/payments/nip47/nwc-client.test.ts +++ b/src/payments/nip47/nwc-client.test.ts @@ -45,7 +45,9 @@ class MockRelayHandler implements RelayHandler { ): Promise<() => void> { this.subscribedFilters = filters; if (this.subscribeDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, this.subscribeDelayMs)); + await new Promise((resolve) => + setTimeout(resolve, this.subscribeDelayMs), + ); } this.onEvent = onEvent; return () => { @@ -181,15 +183,17 @@ describe('NwcClient', () => { JSON.stringify(payload), ); - relayHandler.emit({ - kind: NWC_NOTIFICATION_KIND, - created_at: Math.floor(Date.now() / 1000), - content: encryptedContent, - tags: [['p', clientPubkey]], - pubkey: walletPubkey, - id: 'e'.repeat(64), - sig: 'f'.repeat(128), - }); + relayHandler.emit( + finalizeEvent( + { + kind: NWC_NOTIFICATION_KIND, + created_at: Math.floor(Date.now() / 1000), + content: encryptedContent, + tags: [['p', clientPubkey]], + }, + walletSecretKey, + ), + ); await new Promise((r) => setTimeout(r, 0)); expect(received).toEqual([payload]); @@ -220,10 +224,46 @@ describe('NwcClient', () => { resultType: 'pay_invoice', request: { method: 'pay_invoice', params: { invoice: 'lnbc1...' } }, }), - ).rejects.toThrow(/NWC response timed out/); + ).rejects.toThrow(/timed out/); // Give the late-resolving subscribe().then() a chance to run. await new Promise((r) => setTimeout(r, 150)); expect(relayHandler.unsubscribeCount).toBe(1); }); }); + +test('close cancels a stalled connection without closing a shared caller pool', async () => { + let connected!: () => void; + let disconnects = 0; + const relay = new MockRelayHandler(); + relay.connect = () => + new Promise((resolve) => { + connected = resolve; + }); + relay.disconnect = async () => { + disconnects++; + }; + const client = new NwcClient({ + relayHandler: relay, + connection: { + walletPubkey: getPublicKey(generateSecretKey()), + clientSecretKeyHex: bytesToHex(generateSecretKey()), + relays: [], + }, + responseTimeoutMs: 100, + }); + const work = client + .request({ + method: 'pay_invoice', + resultType: 'pay_invoice', + request: { method: 'pay_invoice', params: { invoice: 'invoice' } }, + }) + .catch((error) => error); + await Bun.sleep(0); + await client.disconnect(); + expect(await work).toBeInstanceOf(Error); + expect(disconnects).toBe(0); + connected(); + await Bun.sleep(0); + expect(relay.published).toHaveLength(0); +}); diff --git a/src/payments/nip47/nwc-client.ts b/src/payments/nip47/nwc-client.ts index 7a343ee..31c00aa 100644 --- a/src/payments/nip47/nwc-client.ts +++ b/src/payments/nip47/nwc-client.ts @@ -1,6 +1,6 @@ import type { EventTemplate, Filter, NostrEvent } from 'nostr-tools'; import { kinds, nip04 } from 'nostr-tools'; -import { finalizeEvent, getPublicKey } from 'nostr-tools/pure'; +import { finalizeEvent, getPublicKey, verifyEvent } from 'nostr-tools/pure'; import { hexToBytes } from 'nostr-tools/utils'; import type { RelayHandler } from '../../core/interfaces.js'; import { createLogger, type Logger } from '../../core/utils/logger.js'; @@ -10,7 +10,12 @@ import type { NwcRequest, NwcResponse, } from './types.js'; -import { queryTags, withTimeout } from '../../core/utils/utils.js'; +import { subscribePaymentEvents } from '../payment-subscription.js'; +import { queryTags } from '../../core/utils/utils.js'; +import { + paymentOperation, + type PaymentOperationContext, +} from '../payment-cancellation.js'; // Per NIP-47. export const NWC_NOTIFICATION_KIND = 23197; @@ -23,6 +28,8 @@ function nowSeconds(): number { export interface NwcClientOptions { relayHandler: RelayHandler; connection: NwcConnection; + /** Disconnect the relay only when this client owns it. @default false */ + ownsRelayHandler?: boolean; /** How long to wait for a single NIP-47 response. @default 60_000 */ responseTimeoutMs?: number; @@ -42,6 +49,10 @@ export class NwcClient { private readonly responseTimeoutMs: number; private readonly logger: Logger; private connected = false; + private readonly shutdown = new AbortController(); + private readonly subscriptions = new Set<() => void>(); + private readonly ownsRelayHandler: boolean; + private connecting?: Promise; // NIP-47 is request/response over relays. To avoid cross-request interference, // we serialize NWC requests per client instance. @@ -49,37 +60,60 @@ export class NwcClient { public constructor(options: NwcClientOptions) { this.relayHandler = options.relayHandler; + this.ownsRelayHandler = options.ownsRelayHandler ?? false; this.connection = options.connection; this.responseTimeoutMs = options.responseTimeoutMs ?? 60_000; this.logger = createLogger('payments/nwc'); } public async connect(): Promise { + if (this.shutdown.signal.aborted) throw new Error('NWC client is closed'); if (this.connected) return; - await this.relayHandler.connect(); - this.connected = true; + if (!this.connecting) + this.connecting = this.relayHandler + .connect() + .then(async () => { + if (this.shutdown.signal.aborted && this.ownsRelayHandler) + await this.relayHandler.disconnect(); + else if (!this.shutdown.signal.aborted) this.connected = true; + }) + .finally(() => { + this.connecting = undefined; + }); + await this.connecting; } - public async fetchInfoNotificationTypes(): Promise> { - await this.connect(); - - let unsubscribe: (() => void) | undefined; - let settled = false; + public async fetchInfoNotificationTypes( + context: PaymentOperationContext = {}, + ): Promise> { + const operation = paymentOperation( + context, + this.responseTimeoutMs, + this.shutdown.signal, + ); + try { + await operation.wait(this.connect()); - const promise = new Promise>((resolve, reject) => { - const filters: Filter[] = [ - { - kinds: [kinds.NWCWalletInfo], - authors: [this.connection.walletPubkey], - limit: 1, - }, - ]; + const promise = new Promise>((resolve, reject) => { + const filters: Filter[] = [ + { + kinds: [kinds.NWCWalletInfo], + authors: [this.connection.walletPubkey], + limit: 1, + }, + ]; - void this.relayHandler - .subscribe( + void subscribePaymentEvents( + this.relayHandler, filters, + operation.signal, (event) => { - settled = true; + if ( + event.kind !== kinds.NWCWalletInfo || + event.pubkey !== this.connection.walletPubkey || + !verifyEvent(event) + ) + return; const tags = event.tags as string[][]; const raw = queryTags(tags, 'notifications').allValues.join(' '); const types = raw @@ -88,51 +122,62 @@ export class NwcClient { .filter(Boolean); resolve(new Set(types)); }, - () => { - if (settled) return; - settled = true; - resolve(new Set()); - }, - ) - .then((u) => { - unsubscribe = u; - if (settled) unsubscribe(); - }) - .catch((error: unknown) => { - settled = true; - reject(error); - }); - }); + () => resolve(new Set()), + ).catch(reject); + }); - return await withTimeout( - promise, - this.responseTimeoutMs, - 'NWC info event fetch timed out', - ).finally(() => unsubscribe?.()); + return await operation.wait(promise); + } finally { + operation.dispose(); + } } public async subscribeNotifications(params: { onNotification: (payload: NwcNotificationPayload) => void; + abortSignal?: AbortSignal; }): Promise<() => void> { - await this.connect(); - - const clientPubkey = getPublicKey( - hexToBytes(this.connection.clientSecretKeyHex), + const operation = paymentOperation( + params, + this.responseTimeoutMs, + this.shutdown.signal, ); + let stopped = false; + let release: (() => void) | undefined; + const unsubscribe = (): void => { + stopped = true; + release?.(); + release = undefined; + this.subscriptions.delete(unsubscribe); + params.abortSignal?.removeEventListener('abort', unsubscribe); + }; + params.abortSignal?.addEventListener('abort', unsubscribe, { once: true }); + this.subscriptions.add(unsubscribe); + try { + await operation.wait(this.connect()); + + const clientPubkey = getPublicKey( + hexToBytes(this.connection.clientSecretKeyHex), + ); - const filters: Filter[] = [ - { - kinds: [NWC_NOTIFICATION_KIND, NWC_NOTIFICATION_LEGACY_KIND], - '#p': [clientPubkey], - authors: [this.connection.walletPubkey], - since: nowSeconds() - 5, - }, - ]; + const filters: Filter[] = [ + { + kinds: [NWC_NOTIFICATION_KIND, NWC_NOTIFICATION_LEGACY_KIND], + '#p': [clientPubkey], + authors: [this.connection.walletPubkey], + since: nowSeconds() - 5, + }, + ]; - this.logger.debug('subscribe for notifications', { filters }); + this.logger.debug('subscribe for notifications', { filters }); - const unsubscribe = await this.relayHandler.subscribe(filters, (event) => { - void (async () => { + const subscription = this.relayHandler.subscribe(filters, (event) => { + if ( + stopped || + event.pubkey !== this.connection.walletPubkey || + !verifyEvent(event) || + !event.tags.some((t) => t[0] === 'p' && t[1] === clientPubkey) + ) + return; try { const decrypted = nip04.decrypt( this.connection.clientSecretKeyHex, @@ -153,16 +198,33 @@ export class NwcClient { error: error instanceof Error ? error.message : String(error), }); } - })(); - }); + }); - return unsubscribe; + void subscription.then( + (u) => { + release = u; + if (stopped || this.shutdown.signal.aborted) unsubscribe(); + }, + () => {}, + ); + await operation.wait(subscription); + return unsubscribe; + } catch (error) { + unsubscribe(); + throw error; + } finally { + operation.dispose(); + } } + /** Stops this client's work without closing a caller-owned relay pool. */ public async disconnect(): Promise { - this.relayHandler.unsubscribe(); - if (!this.connected) return; - await this.relayHandler.disconnect(); + if (this.shutdown.signal.aborted) return; + this.shutdown.abort(); + for (const unsubscribe of [...this.subscriptions]) unsubscribe(); + if (this.ownsRelayHandler) { + await this.relayHandler.disconnect(); + } this.connected = false; } @@ -172,10 +234,24 @@ export class NwcClient { resultType: M; responseResultGuard?: (result: unknown) => result is R; expirationSeconds?: number; + abortSignal?: AbortSignal; + deadlineMs?: number; }): Promise> { - const run = async (): Promise> => { + const operation = paymentOperation( + params, + this.responseTimeoutMs, + this.shutdown.signal, + ); + const prev = this.requestQueue; + let release!: () => void; + this.requestQueue = new Promise((resolve) => { + release = resolve; + }); + try { + await operation.wait(prev); + operation.signal.throwIfAborted(); const now = nowSeconds(); - await this.connect(); + await operation.wait(this.connect()); const clientSecretKey = hexToBytes(this.connection.clientSecretKeyHex); @@ -203,17 +279,7 @@ export class NwcClient { const signedRequest = finalizeEvent(eventTemplate, clientSecretKey); - let settled = false; - let unsubscribeResponse: (() => void) | undefined; - // Marks the attempt finished and releases the response subscription. - // Covers every exit path — including a response timeout or a publish - // failure that fires BEFORE relayHandler.subscribe() resolves — so a - // late-resolving subscription is always unsubscribed instead of leaking. - const settle = (): void => { - settled = true; - unsubscribeResponse?.(); - }; - + let subscribed!: Promise; const responsePromise = new Promise((resolve, reject) => { const filters: Filter[] = [ { @@ -230,29 +296,29 @@ export class NwcClient { filters, }); - void this.relayHandler - .subscribe(filters, (event) => { + subscribed = subscribePaymentEvents( + this.relayHandler, + filters, + operation.signal, + (event) => { + if ( + operation.signal.aborted || + event.kind !== kinds.NWCWalletResponse || + event.pubkey !== this.connection.walletPubkey || + !verifyEvent(event) || + queryTags(event.tags, 'e').firstValue !== signedRequest.id + ) + return; this.logger.debug('received response event', { method: params.method, requestId: signedRequest.id, eventId: event.id, pubkey: event.pubkey, }); - settled = true; resolve(event); - }) - .then((unsubscribe) => { - // Ensure the subscription is cleaned up when the promise settles, - // including the timeout/publish-failure paths handled by settle(). - unsubscribeResponse = unsubscribe; - if (settled) { - unsubscribeResponse(); - } - }) - .catch((error: unknown) => { - settled = true; - reject(error); - }); + }, + ); + void subscribed.catch(reject); }); this.logger.debug('publish request', { @@ -260,78 +326,45 @@ export class NwcClient { requestId: signedRequest.id, encryption: 'nip04', }); - const publishController = new AbortController(); - const publishTimeout = setTimeout( - () => publishController.abort(), - this.responseTimeoutMs, + void responsePromise.catch(() => {}); + await operation.wait(subscribed); + operation.signal.throwIfAborted(); + await operation.wait( + this.relayHandler.publish(signedRequest, { + abortSignal: operation.signal, + }), ); - try { - await this.relayHandler.publish(signedRequest, { - abortSignal: publishController.signal, - }); - if (publishController.signal.aborted) { - throw new Error(`NWC publish timed out for ${params.method}`); - } + const responseEvent = await operation.wait(responsePromise); - const responseEvent = await withTimeout( - responsePromise, - this.responseTimeoutMs, - `NWC response timed out for ${params.method}`, - ).finally(() => unsubscribeResponse?.()); + const decrypted = nip04.decrypt( + this.connection.clientSecretKeyHex, + responseEvent.pubkey, + responseEvent.content, + ); - // Validate correlation. - const eTag = queryTags(responseEvent.tags, 'e').firstValue; - if (eTag !== signedRequest.id) { - throw new Error('NWC response did not correlate to request'); - } + const parsed = JSON.parse(decrypted) as NwcResponse; - const decrypted = nip04.decrypt( - this.connection.clientSecretKeyHex, - responseEvent.pubkey, - responseEvent.content, + if (parsed.result_type !== params.resultType) { + throw new Error( + `Unexpected NWC result_type: ${String(parsed.result_type)} (expected ${params.resultType})`, ); + } - const parsed = JSON.parse(decrypted) as NwcResponse; - - if (parsed.result_type !== params.resultType) { - throw new Error( - `Unexpected NWC result_type: ${String(parsed.result_type)} (expected ${params.resultType})`, - ); - } - - if (parsed.error) { - return parsed as NwcResponse; - } - - if (params.responseResultGuard && parsed.result !== null) { - if (!params.responseResultGuard(parsed.result)) { - throw new Error('Unexpected NWC result shape'); - } - } - + if (parsed.error) { return parsed as NwcResponse; - } catch (error) { - // Release the response subscription on every failure path — including - // timeouts and publish failures that fire before subscribe() resolves, - // so a late-resolving subscription is unsubscribed instead of leaking. - settle(); - throw error; - } finally { - clearTimeout(publishTimeout); } - }; - const prev = this.requestQueue; - let release: (() => void) | undefined; - this.requestQueue = new Promise((r) => { - release = r; - }); + if (params.responseResultGuard && parsed.result !== null) { + if (!params.responseResultGuard(parsed.result)) { + throw new Error('Unexpected NWC result shape'); + } + } - await prev; - try { - return await run(); + return parsed as NwcResponse; } finally { - release?.(); + operation.dispose(); + // Keep queued operations serialized even when this caller leaves the queue early. + void prev.finally(() => release?.()); } } } diff --git a/src/payments/nip57/lnurl.ts b/src/payments/nip57/lnurl.ts index 2e4cb68..4af659c 100644 --- a/src/payments/nip57/lnurl.ts +++ b/src/payments/nip57/lnurl.ts @@ -1,3 +1,4 @@ +import type { PaymentFetch } from '../payment-cancellation.js'; export type LnurlPayParams = { callback: string; allowsNostr?: boolean; @@ -23,14 +24,19 @@ export function parseLnAddress(lnAddress: string): { export async function fetchLnurlPayParams(params: { lnAddress: string; + abortSignal?: AbortSignal; + fetch?: PaymentFetch; }): Promise { + params.abortSignal?.throwIfAborted(); const { username, domain } = parseLnAddress(params.lnAddress); const url = `https://${domain}/.well-known/lnurlp/${encodeURIComponent(username)}`; - const response = await fetch(url, { method: 'GET' }); + const response = await (params.fetch ?? globalThis.fetch)(url, { + method: 'GET', + signal: params.abortSignal, + }); if (!response.ok) { - const text = await response.text(); - throw new Error(`LNURL-pay fetch failed: ${response.status} ${text}`); + throw new Error(`LNURL-pay fetch failed: ${response.status}`); } const json = (await response.json()) as LnurlPayParams; @@ -44,22 +50,25 @@ export async function requestZapInvoice(params: { callback: string; amountMsats: number; zapRequestJson: string; + abortSignal?: AbortSignal; + fetch?: PaymentFetch; }): Promise<{ pr: string }> { + params.abortSignal?.throwIfAborted(); const url = new URL(params.callback); url.searchParams.set('amount', params.amountMsats.toString()); url.searchParams.set('nostr', params.zapRequestJson); - const response = await fetch(url.toString(), { method: 'GET' }); + const response = await (params.fetch ?? globalThis.fetch)(url.toString(), { + method: 'GET', + signal: params.abortSignal, + }); if (!response.ok) { - const text = await response.text(); - throw new Error(`LNURL callback failed: ${response.status} ${text}`); + throw new Error(`LNURL callback failed: ${response.status}`); } const json = (await response.json()) as { pr?: string; reason?: string }; if (!json.pr) { - throw new Error( - `LNURL callback missing pr${json.reason ? `: ${json.reason}` : ''}`, - ); + throw new Error('LNURL callback missing pr'); } return { pr: json.pr }; } diff --git a/src/payments/observed-payment-state-store.ts b/src/payments/observed-payment-state-store.ts new file mode 100644 index 0000000..5ea29ff --- /dev/null +++ b/src/payments/observed-payment-state-store.ts @@ -0,0 +1,111 @@ +import type { + PaymentStateStore, + PaymentStateAdmission, + PaymentStateAdmissionResult, + PaymentStateTransition, + PaymentStateRecord, +} from './payment-state-store.js'; +import { + recordPaymentOutcome, + emitPaymentOutcome, + paymentInvoiceDigest, + type PaymentOutcomeKind, + type PaymentOutcomeOptions, +} from './payment-outcomes.js'; + +/** Optional observation over the same atomic store; observer failures never become payment failures. */ +export class ObservedPaymentStateStore implements PaymentStateStore { + public readonly policy: PaymentStateStore['policy']; + public constructor( + private readonly backend: PaymentStateStore, + private readonly options: PaymentOutcomeOptions, + ) { + this.policy = backend.policy; + } + /** Observes admission after the backend has committed. */ + public async admit( + input: PaymentStateAdmission, + ): Promise { + let result: PaymentStateAdmissionResult; + try { + result = await this.backend.admit(input); + } catch (error) { + emitPaymentOutcome(this.options, { + kind: 'unknown', + atMs: Date.now(), + identity: paymentInvoiceDigest(input.key), + requestEventId: input.requestEventId, + remedy: 'reconcile_settlement', + }); + throw error; + } + if (result.record) { + const kind = + result.status === 'claimed' + ? 'claim' + : result.status === 'reserved' + ? 'attempt' + : result.status === 'expired' + ? 'expired' + : result.status === 'unknown' + ? 'unknown' + : undefined; + if (kind) recordPaymentOutcome(this.options, result.record, kind); + } + if (result.status === 'capacity') + emitPaymentOutcome(this.options, { + kind: 'exhausted', + atMs: Date.now(), + identity: paymentInvoiceDigest(input.key), + requestEventId: input.requestEventId, + }); + return result; + } + /** Correlation reads are best effort and cannot change the underlying transition's success. */ + public async transition(input: PaymentStateTransition): Promise { + let changed: boolean; + try { + changed = await this.backend.transition(input); + } catch (error) { + emitPaymentOutcome(this.options, { + kind: 'unknown', + atMs: Date.now(), + identity: paymentInvoiceDigest(input.key), + attempt: input.generation, + remedy: 'application_policy', + }); + throw error; + } + if (changed) { + const kinds: Partial< + Record + > = { + bind: 'offer', + grant: 'grant', + settled: 'verified', + consume: 'claim', + dispatch: 'execution', + expire: 'expired', + uncertain: 'unknown', + }; + const kind = kinds[input.type]; + if (kind) { + try { + const record = await this.backend.read(input.key); + if (record) { + recordPaymentOutcome(this.options, record, kind); + if (input.type === 'settled' && record.phase === 'granted') + recordPaymentOutcome(this.options, record, 'grant'); + } + } catch { + /* Observation is optional; committed state remains authoritative. */ + } + } + } + return changed; + } + /** Reads the original backend; no fallback or reconstruction of authorization. */ + public async read(key: string): Promise { + return this.backend.read(key); + } +} diff --git a/src/payments/payment-cancellation.ts b/src/payments/payment-cancellation.ts new file mode 100644 index 0000000..f5868fb --- /dev/null +++ b/src/payments/payment-cancellation.ts @@ -0,0 +1,85 @@ +/** Runtime-neutral HTTP contract; callers may inject a Web Fetch implementation. */ +export type PaymentFetch = ( + input: RequestInfo | URL, + init?: RequestInit, +) => Promise; + +/** Optional cancellation and absolute wall-clock deadline for provider operations. */ +export interface PaymentOperationContext { + abortSignal?: AbortSignal; + deadlineMs?: number; +} + +/** Runs provider work with cancellation, a bounded wait, and unconditional cleanup. */ +export async function runPaymentOperation( + context: PaymentOperationContext, + timeoutMs: number, + shutdownSignal: AbortSignal, + run: (signal: AbortSignal) => Promise, +): Promise { + const operation = paymentOperation(context, timeoutMs, shutdownSignal); + try { + operation.signal.throwIfAborted(); + return await operation.wait(run(operation.signal)); + } finally { + operation.dispose(); + } +} + +/** Owns a bounded operation; abort means the remote outcome may still be unknown. */ +export function paymentOperation( + context: PaymentOperationContext = {}, + timeoutMs = 60_000, + shutdownSignal?: AbortSignal, +): { + signal: AbortSignal; + wait: (promise: Promise) => Promise; + dispose: () => void; +} { + if ( + !Number.isFinite(timeoutMs) || + timeoutMs < 1 || + timeoutMs > 2_147_483_647 || + (context.deadlineMs !== undefined && + (!Number.isSafeInteger(context.deadlineMs) || context.deadlineMs < 0)) + ) + throw new TypeError('Invalid payment operation deadline'); + const controller = new AbortController(); + const abort = (): void => + controller.abort(new Error('Payment operation aborted')); + const signals = [context.abortSignal, shutdownSignal].filter( + (signal): signal is AbortSignal => signal !== undefined, + ); + for (const signal of signals) { + signal.addEventListener('abort', abort, { once: true }); + if (signal.aborted) abort(); + } + const delay = Math.min( + timeoutMs, + (context.deadlineMs ?? Infinity) - Date.now(), + ); + const timer = setTimeout(abort, Math.max(0, delay)); + if (delay <= 0) abort(); + return { + signal: controller.signal, + wait: async (promise: Promise): Promise => { + let onAbort: (() => void) | undefined; + const aborted = new Promise((_resolve, reject) => { + onAbort = (): void => + reject(new Error('Payment operation aborted or timed out')); + controller.signal.addEventListener('abort', onAbort, { once: true }); + if (controller.signal.aborted) onAbort(); + }); + try { + return await Promise.race([promise, aborted]); + } finally { + if (onAbort) controller.signal.removeEventListener('abort', onAbort); + } + }, + dispose: (): void => { + clearTimeout(timer); + for (const signal of signals) signal.removeEventListener('abort', abort); + abort(); + }, + }; +} diff --git a/src/payments/payment-delivery.ts b/src/payments/payment-delivery.ts new file mode 100644 index 0000000..99d01e8 --- /dev/null +++ b/src/payments/payment-delivery.ts @@ -0,0 +1,36 @@ +import type { JSONRPCResponse } from '@contextvm/mcp-sdk/types.js'; +import type { RouteSnapshot } from '../transport/nostr-server/correlation-store.js'; + +/** Recorded result and route. Applications must protect the store as they protect paid result content. */ +export interface PaymentResultDelivery { + response: JSONRPCResponse; + snapshot: RouteSnapshot; + status: 'pending' | 'unknown' | 'published'; + /** A publication lease never restores or repeats an execution right. */ + owner?: string; + leaseExpiresAtMs?: number; + acknowledgedFrames: number; + /** Fixed fragmentation contract, retained so retries use identical frame progress/digest. */ + chunkSizeBytes?: number; +} + +/** Router hooks keep transport mechanics independent of payment storage implementations. */ +export interface PaymentResultJournal { + retain( + eventId: string, + response: JSONRPCResponse, + snapshot: RouteSnapshot, + ): Promise; + prepare( + eventId: string, + response: JSONRPCResponse, + snapshot: RouteSnapshot, + ): Promise<{ + publish: boolean; + acknowledgedFrames: number; + chunkSizeBytes?: number; + framePublished: (count: number, chunkSizeBytes?: number) => Promise; + complete: () => Promise; + failed: () => Promise; + }>; +} 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-outcomes.ts b/src/payments/payment-outcomes.ts new file mode 100644 index 0000000..639d320 --- /dev/null +++ b/src/payments/payment-outcomes.ts @@ -0,0 +1,99 @@ +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; +import type { PaymentStateRecord } from './payment-state-store.js'; + +/** Milestones describe server/wallet observations; published never means client receipt. */ +export type PaymentOutcomeKind = + | 'offer' + | 'attempt' + | 'attempt_handled' + | 'attempt_declined' + | 'unknown' + | 'verified' + | 'grant' + | 'claim' + | 'execution' + | 'result_recorded' + | 'published' + | 'publication_failed' + | 'expired' + | 'evicted' + | 'exhausted' + | 'reconciliation_required' + | 'reconciled'; + +/** Redacted correlation envelope. It never includes invoices, secrets, preimages, or result bodies. */ +export interface PaymentOutcome { + readonly kind: PaymentOutcomeKind; + readonly atMs: number; + readonly identity: string; + readonly attempt?: string; + readonly requestEventId?: string; + readonly invoiceDigest?: string; + readonly remedy?: + 'retry_publication' | 'reconcile_settlement' | 'application_policy'; +} + +/** Optional journal sink; recovery-critical state lives in PaymentStateStore regardless of this sink. */ +export interface PaymentOutcomeJournal { + append(outcome: PaymentOutcome): void | Promise; +} + +/** Observer and journal failures are isolated from authorization and execution. */ +export interface PaymentOutcomeOptions { + onPaymentOutcome?: (outcome: PaymentOutcome) => void | Promise; + paymentJournal?: PaymentOutcomeJournal; +} + +/** Stable digest for correlation without exposing the payment request. */ +export function paymentInvoiceDigest(invoice: string): string { + return bytesToHex(sha256(new TextEncoder().encode(invoice))); +} + +/** Publishes only allowlisted fields and isolates failed or stalled application observers. */ +export function emitPaymentOutcome( + options: PaymentOutcomeOptions, + outcome: PaymentOutcome, +): void { + const redacted = Object.freeze({ + kind: outcome.kind, + atMs: outcome.atMs, + identity: outcome.identity, + ...(outcome.attempt && { attempt: outcome.attempt }), + ...(outcome.requestEventId && { requestEventId: outcome.requestEventId }), + ...(outcome.invoiceDigest && { invoiceDigest: outcome.invoiceDigest }), + ...(outcome.remedy && { remedy: outcome.remedy }), + }); + for (const sink of [ + options.onPaymentOutcome, + options.paymentJournal?.append.bind(options.paymentJournal), + ]) { + try { + void Promise.resolve(sink?.(redacted)).catch(() => {}); + } catch { + /* Optional observers cannot change payment state. */ + } + } +} + +/** Builds a redacted server milestone from an existing recorded attempt. */ +export function recordPaymentOutcome( + options: PaymentOutcomeOptions, + record: PaymentStateRecord, + kind: PaymentOutcomeKind, + remedy?: PaymentOutcome['remedy'], +): void { + emitPaymentOutcome(options, { + kind, + atMs: Date.now(), + identity: paymentInvoiceDigest( + JSON.stringify([record.clientPubkey, record.invocationHash]), + ), + attempt: record.generation, + requestEventId: record.requestEventId, + ...(record.invoice && { + invoiceDigest: paymentInvoiceDigest(record.invoice.pay_req), + }), + remedy, + }); +} diff --git a/src/payments/payment-recovery.test.ts b/src/payments/payment-recovery.test.ts new file mode 100644 index 0000000..ef24dc2 --- /dev/null +++ b/src/payments/payment-recovery.test.ts @@ -0,0 +1,288 @@ +import { describe, expect, test } from 'bun:test'; +import { InMemoryPaymentStateStore } from './in-memory-payment-state-store.js'; +import { + paymentAuthorizationKey, + paymentEventKey, + type PaymentStateRecord, +} from './payment-state-store.js'; +import { + PaymentResultRecovery, + reconcilePaymentAttempt, + paymentRecoveryStatus, +} from './payment-recovery.js'; +import { + emitPaymentOutcome, + paymentInvoiceDigest, + type PaymentOutcome, +} from './payment-outcomes.js'; +import type { PaymentProcessor } from './types.js'; +import type { RouteSnapshot } from '../transport/nostr-server/correlation-store.js'; + +async function issued( + store: InMemoryPaymentStateStore, + mode: 'explicit_gating' | 'transparent' = 'explicit_gating', +): Promise { + const key = + mode === 'transparent' + ? paymentEventKey('client', 'offer') + : paymentAuthorizationKey('client', 'hash'); + const result = await store.admit({ + key, + mode, + clientPubkey: 'client', + requestEventId: 'offer', + invocationHash: 'hash', + leaseMs: 10, + }); + const record = result.record!; + expect( + await store.transition({ + key, + generation: record.generation, + type: 'issuing', + }), + ).toBe(true); + expect( + await store.transition({ + key, + generation: record.generation, + type: 'bind', + invoice: { pmi: 'fake', pay_req: 'private-invoice', amount: 1 }, + leaseMs: 10, + }), + ).toBe(true); + return (await store.read(key))!; +} + +const snapshot: RouteSnapshot = { + route: { clientPubkey: 'client', originalRequestId: 42, wrapKind: 21059 }, + session: { + isInitialized: true, + isEncrypted: true, + hasSentCommonTags: true, + supportsEncryption: true, + supportsEphemeralEncryption: true, + supportsOversizedTransfer: false, + supportsOpenStream: false, + }, + expiresAtMs: Number.MAX_SAFE_INTEGER, +}; + +describe('recorded settlement and result recovery', () => { + test('late settlement recovers the same generation, one grant, no invoice creation', async () => { + let now = Date.now(); + const store = new InMemoryPaymentStateStore({}, () => now); + const record = await issued(store); + now += 20; + expect( + ( + await store.admit({ + key: record.key, + mode: record.mode, + clientPubkey: 'client', + requestEventId: 'retry', + invocationHash: 'hash', + leaseMs: 10, + }) + ).status, + ).toBe('unknown'); + let queries = 0; + const processor: PaymentProcessor = { + pmi: 'fake', + createPaymentRequired: async () => { + throw new Error('Must never issue another invoice'); + }, + verifyPayment: async (input) => { + expect(input.pay_req).toBe('private-invoice'); + queries++; + return {}; + }, + }; + const result = await reconcilePaymentAttempt({ + store, + key: record.key, + generation: record.generation, + processors: [processor], + authorizationTtlMs: 10000, + }); + expect(result.status).toBe('grant_available'); + expect(queries).toBe(1); + const claims = await Promise.all( + ['a', 'b'].map((requestEventId) => + store.admit({ + key: record.key, + mode: record.mode, + clientPubkey: 'client', + requestEventId, + invocationHash: 'hash', + leaseMs: 10, + }), + ), + ); + expect(claims.filter((x) => x.status === 'claimed')).toHaveLength(1); + expect( + await reconcilePaymentAttempt({ + store, + key: record.key, + generation: 'superseded', + processors: [processor], + authorizationTtlMs: 10000, + }), + ).toEqual({ status: 'superseded', remedy: 'application_policy' }); + expect(queries).toBe(1); + }); + + test('concurrent reconcilers query once and backend errors never request payment', async () => { + const store = new InMemoryPaymentStateStore(); + const record = await issued(store); + let settle!: () => void; + const ready = new Promise((resolve) => { + settle = resolve; + }); + let queries = 0; + const processor: PaymentProcessor = { + pmi: 'fake', + createPaymentRequired: async () => { + throw new Error('No new invoice'); + }, + verifyPayment: async () => { + queries++; + await ready; + return {}; + }, + }; + const params = { + store, + key: record.key, + generation: record.generation, + processors: [processor], + authorizationTtlMs: 10000, + }; + const a = reconcilePaymentAttempt(params); + const b = reconcilePaymentAttempt(params); + settle(); + await Promise.all([a, b]); + expect(queries).toBe(1); + }); + + test('paid result persists before send, retry after recreation publishes once without re-execution', async () => { + const store = new InMemoryPaymentStateStore(); + const record = await issued(store, 'transparent'); + const fence = { key: record.key, generation: record.generation }; + expect( + await store.transition({ ...fence, type: 'settled', ttlMs: 10000 }), + ).toBe(true); + expect(await store.transition({ ...fence, type: 'consume' })).toBe(true); + const outcomes: PaymentOutcome[] = []; + const recovery = new PaymentResultRecovery(store, { + publicationLeaseMs: 10000, + onPaymentOutcome: (event) => { + outcomes.push(event); + throw new Error('observer-secret'); + }, + }); + const response = { + jsonrpc: '2.0' as const, + id: 'offer', + result: { secretResult: 'content' }, + }; + const publication = await recovery.prepare('offer', response, snapshot); + expect((await store.read(record.key))?.delivery?.response).toEqual( + response, + ); + await publication.failed(); + expect(paymentRecoveryStatus((await store.read(record.key))!).status).toBe( + 'undelivered', + ); + const restarted = new PaymentResultRecovery(store, { + publicationLeaseMs: 10000, + }); + let sends = 0; + await restarted.replay( + 'client', + 'offer', + (restored) => { + expect(restored.session?.isEncrypted).toBe(true); + expect(restored.route.wrapKind).toBe(21059); + }, + async (saved) => { + expect(saved).toEqual(response); + const retry = await restarted.prepare('offer', saved, snapshot); + sends++; + await retry.framePublished(1); + await retry.complete(); + }, + ); + await restarted.replay( + 'client', + 'offer', + () => {}, + async () => { + sends++; + }, + ); + expect(sends).toBe(1); + expect(JSON.stringify(outcomes)).not.toContain('private-invoice'); + expect(JSON.stringify(outcomes)).not.toContain('secretResult'); + expect(JSON.stringify(outcomes)).not.toContain('observer-secret'); + }); + + test('observer/journal throws and rejections are isolated and secret fields never escape', async () => { + const collected: PaymentOutcome[] = []; + emitPaymentOutcome( + { + onPaymentOutcome: (event) => { + collected.push(event); + throw new Error('secret'); + }, + paymentJournal: { + append: async () => { + throw new Error('secret'); + }, + }, + }, + { + kind: 'verified', + atMs: 1, + identity: 'identity', + invoiceDigest: paymentInvoiceDigest('preimage-secret'), + ...{ preimage: 'preimage-secret', pay_req: 'invoice-secret' }, + }, + ); + await Bun.sleep(0); + expect(collected).toHaveLength(1); + expect(JSON.stringify(collected)).not.toContain('secret'); + }); + + test('a fresh default store cannot recover results retained by another store', async () => { + const store = new InMemoryPaymentStateStore(); + const record = await issued(store, 'transparent'); + const fence = { key: record.key, generation: record.generation }; + await store.transition({ ...fence, type: 'settled', ttlMs: 10000 }); + await store.transition({ ...fence, type: 'consume' }); + const response = { jsonrpc: '2.0' as const, id: 'offer', result: {} }; + const recovery = new PaymentResultRecovery(store, { + publicationLeaseMs: 10000, + }); + await recovery.retain('offer', response, snapshot); + expect((await store.read(record.key))?.delivery?.response).toEqual( + response, + ); + + const fresh = new PaymentResultRecovery(new InMemoryPaymentStateStore(), { + publicationLeaseMs: 10000, + }); + let publications = 0; + await expect( + fresh.replay( + 'client', + 'offer', + () => {}, + async () => { + publications++; + }, + ), + ).rejects.toThrow('No recorded paid result'); + expect(publications).toBe(0); + }); +}); diff --git a/src/payments/payment-recovery.ts b/src/payments/payment-recovery.ts new file mode 100644 index 0000000..ecf8fd4 --- /dev/null +++ b/src/payments/payment-recovery.ts @@ -0,0 +1,292 @@ +import type { JSONRPCResponse } from '@contextvm/mcp-sdk/types.js'; +import type { RouteSnapshot } from '../transport/nostr-server/correlation-store.js'; +import type { PaymentResultJournal } from './payment-delivery.js'; +import type { PaymentProcessor } from './types.js'; +import type { + PaymentStateRecord, + PaymentStateStore, +} from './payment-state-store.js'; +import { paymentEventKey } from './payment-state-store.js'; +import { + paymentOperation, + type PaymentOperationContext, +} from './payment-cancellation.js'; +import { + recordPaymentOutcome, + type PaymentOutcomeOptions, +} from './payment-outcomes.js'; + +/** An application-visible remedy, never an automatic refund or handler rerun. */ +export interface PaymentRecoveryResult { + status: + | 'settled' + | 'superseded' + | 'unknown' + | 'grant_available' + | 'grant_expired' + | 'consumed' + | 'undelivered' + | 'published'; + remedy: + | 'retry_invocation' + | 'retry_publication' + | 'reconcile_settlement' + | 'application_policy' + | 'none'; + record?: PaymentStateRecord; +} + +/** Reconciles a recorded invoice using its authenticated provider, without issuing or paying another invoice. */ +export async function reconcilePaymentAttempt( + params: PaymentOperationContext & { + store: PaymentStateStore; + key: string; + generation: string; + processors: readonly PaymentProcessor[]; + /** Explicit application policy for a recovered authorization lifetime. */ + authorizationTtlMs: number; + outcomes?: PaymentOutcomeOptions; + }, +): Promise { + const record = await params.store.read(params.key); + if (!record || record.generation !== params.generation) + return { status: 'superseded', remedy: 'application_policy' }; + const observed = paymentRecoveryStatus(record); + if ( + !['pending', 'unknown', 'expired'].includes(record.phase) || + record.settlement + ) + return observed; + const processor = params.processors.find( + (p) => p.pmi === record.invoice?.pmi, + ); + if (!processor || !record.invoice) { + recordPaymentOutcome( + params.outcomes ?? {}, + record, + 'reconciliation_required', + 'application_policy', + ); + return { status: 'unknown', remedy: 'application_policy', record }; + } + const deadlineMs = Math.min( + params.deadlineMs ?? Infinity, + Date.now() + 60_000, + ); + const leaseMs = Math.floor(deadlineMs - Date.now()); + if (leaseMs < 1 || params.abortSignal?.aborted) + return { status: 'unknown', remedy: 'reconcile_settlement', record }; + const owner = `${Date.now()}:${++publicationSequence}:${Math.random()}:${Math.random()}`; + if ( + !(await params.store.transition({ + key: record.key, + generation: record.generation, + type: 'reconciliation_claim', + owner, + leaseMs, + })) + ) + return { status: 'unknown', remedy: 'reconcile_settlement', record }; + const operation = paymentOperation({ ...params, deadlineMs }); + try { + operation.signal.throwIfAborted(); + await operation.wait( + processor.verifyPayment({ + pay_req: record.invoice.pay_req, + requestEventId: record.requestEventId, + clientPubkey: record.clientPubkey, + abortSignal: operation.signal, + deadlineMs: params.deadlineMs, + }), + ); + operation.signal.throwIfAborted(); + if ( + !(await params.store.transition({ + key: record.key, + generation: record.generation, + type: 'settled', + ttlMs: params.authorizationTtlMs, + owner, + })) + ) + return { status: 'superseded', remedy: 'application_policy' }; + const recovered = await params.store.read(record.key); + recordPaymentOutcome(params.outcomes ?? {}, record, 'reconciled'); + return recovered + ? paymentRecoveryStatus(recovered) + : { status: 'superseded', remedy: 'application_policy' }; + } finally { + operation.dispose(); + await params.store.transition({ + key: record.key, + generation: record.generation, + type: 'reconciliation_release', + owner, + }); + } +} + +/** Explains stored recovery state without interpreting publication as receipt. */ +export function paymentRecoveryStatus( + record: PaymentStateRecord, +): PaymentRecoveryResult { + if (record.delivery?.status === 'published') + return { status: 'published', remedy: 'none', record }; + if (record.delivery) + return { status: 'undelivered', remedy: 'retry_publication', record }; + if (['consumed', 'dispatched'].includes(record.phase)) + return { status: 'consumed', remedy: 'application_policy', record }; + if (record.phase === 'granted') + return (record.grantExpiresAtMs ?? 0) > Date.now() + ? { status: 'grant_available', remedy: 'retry_invocation', record } + : { status: 'grant_expired', remedy: 'application_policy', record }; + if (record.settlement) + return { status: 'settled', remedy: 'retry_invocation', record }; + return { status: 'unknown', remedy: 'reconcile_settlement', record }; +} + +let publicationSequence = 0; + +/** Store-backed publication journal; retries never invoke application handlers. + * The default in-memory store retains results only for the lifetime of that store. + */ +export class PaymentResultRecovery implements PaymentResultJournal { + public constructor( + private readonly store: PaymentStateStore, + private readonly options: PaymentOutcomeOptions & { + publicationLeaseMs: number; + }, + ) { + if ( + !Number.isSafeInteger(options.publicationLeaseMs) || + options.publicationLeaseMs < 1 || + options.publicationLeaseMs > 2_147_483_647 + ) + throw new TypeError('Invalid publication lease'); + } + + /** Persists a final result even when its publication is deferred behind an open stream. */ + public async retain( + eventId: string, + response: JSONRPCResponse, + snapshot: RouteSnapshot, + ): Promise { + const key = paymentEventKey(snapshot.route.clientPubkey, eventId); + const record = await this.store.read(key); + if (!record) return; + if ( + !(await this.store.transition({ + key, + generation: record.generation, + type: 'result', + delivery: { + response, + snapshot, + status: 'pending', + acknowledgedFrames: 0, + }, + })) + ) + throw new Error('Paid result differs from the recorded execution result'); + recordPaymentOutcome(this.options, record, 'result_recorded'); + } + + /** Records the full correlated result before any publication side effect and obtains one worker lease. */ + public async prepare( + eventId: string, + response: JSONRPCResponse, + snapshot: RouteSnapshot, + ): ReturnType { + const key = paymentEventKey(snapshot.route.clientPubkey, eventId); + const record = await this.store.read(key); + const noop = { + publish: true, + acknowledgedFrames: 0, + framePublished: async (): Promise => {}, + complete: async (): Promise => {}, + failed: async (): Promise => {}, + }; + if (!record) return noop; + const fence = { key, generation: record.generation }; + await this.retain(eventId, response, snapshot); + // An ownership fence, not an authentication secret. No crypto globals are required on Node 18. + const owner = `${Date.now()}:${++publicationSequence}:${Math.random()}:${Math.random()}`; + if ( + !(await this.store.transition({ + ...fence, + type: 'publication_claim', + owner, + leaseMs: this.options.publicationLeaseMs, + })) + ) { + const current = await this.store.read(key); + if (current?.delivery?.status === 'published') + return { ...noop, publish: false }; + throw new Error( + 'Paid result publication is already leased; reconcile before retrying', + ); + } + const current = await this.store.read(key); + if (current?.delivery?.owner !== owner) + throw new Error('Paid result publication ownership lost'); + return { + publish: true, + acknowledgedFrames: current.delivery.acknowledgedFrames, + chunkSizeBytes: current.delivery.chunkSizeBytes, + framePublished: async (frames, chunkSizeBytes): Promise => { + if ( + !(await this.store.transition({ + ...fence, + type: 'publication_progress', + leaseMs: this.options.publicationLeaseMs, + owner, + frames, + chunkSizeBytes, + })) + ) + throw new Error('Paid result publication fence lost'); + }, + complete: async (): Promise => { + if ( + !(await this.store.transition({ ...fence, type: 'published', owner })) + ) + throw new Error( + 'Paid publication acknowledgement could not be recorded', + ); + recordPaymentOutcome(this.options, record, 'published'); + }, + failed: async (): Promise => { + await this.store.transition({ + ...fence, + type: 'publication_failed', + owner, + }); + recordPaymentOutcome( + this.options, + record, + 'publication_failed', + 'retry_publication', + ); + }, + }; + } + + /** Restores a recorded result's route so callers can republish it without repeating execution. */ + public async replay( + clientPubkey: string, + eventId: string, + restore: (snapshot: RouteSnapshot) => void, + send: (response: JSONRPCResponse) => Promise, + ): Promise { + const record = await this.store.read( + paymentEventKey(clientPubkey, eventId), + ); + if (!record?.delivery) + throw new Error( + 'No recorded paid result; application recovery policy required', + ); + if (record.delivery.status === 'published') return; + restore(structuredClone(record.delivery.snapshot)); + await send(structuredClone(record.delivery.response)); + } +} diff --git a/src/payments/payment-state-machine.ts b/src/payments/payment-state-machine.ts new file mode 100644 index 0000000..74ff72b --- /dev/null +++ b/src/payments/payment-state-machine.ts @@ -0,0 +1,495 @@ +import { canonicalJson } from '../core/utils/canonical-json.js'; +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) && + !( + (record.invoice && !record.settlement) || + (record.invoice && + ['consumed', 'dispatched'].includes(record.phase) && + record.delivery?.status !== 'published') + ) + ); +} + +/** 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', view.execution); + 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.invoice && + ['pending', 'expired'].includes(current.phase) && + current.leaseExpiresAtMs <= now + ) + return done('unknown'); + 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: null, + }; + 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'; + 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'; + 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 = null; + 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.settlement = { verifiedAtMs: now }; + 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'; + record.retainUntilMs = record.invoice ? null : record.replayExpiresAtMs; + if (record.mode === 'explicit_gating') { + record.key = paymentEventKey( + record.clientPubkey, + record.requestEventId, + ); + record.retainUntilMs = record.invoice ? null : record.replayExpiresAtMs; + return { result: true, put: [record], remove: [current.key] }; + } + break; + case 'dispatch': + if (record.phase !== 'consumed') return none; + record.phase = 'dispatched'; + break; + case 'offer_published': + if ( + !record.invoice || + !['pending', 'unknown', 'expired'].includes(record.phase) + ) + return none; + record.offerStatus = 'published'; + break; + case 'reconciliation_claim': + if ( + !record.invoice || + record.settlement || + !['pending', 'expired', 'unknown'].includes(record.phase) || + (record.reconciliation && record.reconciliation.leaseExpiresAtMs > now) + ) + return none; + if ( + !input.owner || + !Number.isSafeInteger(input.leaseMs) || + input.leaseMs < 1 || + input.leaseMs > 2_147_483_647 + ) + throw new TypeError('Invalid reconciliation lease'); + record.reconciliation = { + owner: input.owner, + leaseExpiresAtMs: now + input.leaseMs, + }; + record.retainUntilMs = null; + break; + case 'reconciliation_release': + if (record.reconciliation?.owner !== input.owner) return none; + delete record.reconciliation; + break; + case 'settled': + if ( + input.owner && + (record.reconciliation?.owner !== input.owner || + record.reconciliation.leaseExpiresAtMs <= now) + ) + return none; + if ( + !record.invoice || + !['pending', 'expired', 'unknown'].includes(record.phase) + ) + return none; + if ( + !Number.isSafeInteger(input.ttlMs) || + input.ttlMs < 1 || + input.ttlMs > 2_147_483_647 + ) + throw new TypeError('Invalid recovery authorization TTL'); + record.settlement = { verifiedAtMs: now }; + delete record.reconciliation; + record.retainUntilMs = null; + if (record.mode === 'explicit_gating') { + record.phase = 'granted'; + record.grantExpiresAtMs = now + input.ttlMs; + } else { + // A reconciler recognizes money but never executes an application handler. + record.phase = 'pending'; + record.leaseExpiresAtMs = now + input.ttlMs; + } + break; + case 'result': + if (!['consumed', 'dispatched'].includes(record.phase)) return none; + if (record.delivery) + return canonicalJson(record.delivery.response) === + canonicalJson(input.delivery.response) + ? { result: true, put: [], remove: [] } + : none; + record.delivery = JSON.parse( + JSON.stringify(input.delivery), + ) as typeof input.delivery; + record.retainUntilMs = null; + break; + case 'publication_claim': + if ( + !record.delivery || + record.delivery.status === 'published' || + (record.delivery.owner && (record.delivery.leaseExpiresAtMs ?? 0) > now) + ) + return none; + if ( + !input.owner || + !Number.isSafeInteger(input.leaseMs) || + input.leaseMs < 1 || + input.leaseMs > 2_147_483_647 + ) + throw new TypeError('Invalid publication lease'); + record.delivery.owner = input.owner; + record.delivery.leaseExpiresAtMs = now + input.leaseMs; + break; + case 'publication_progress': + if ( + !record.delivery || + record.delivery.owner !== input.owner || + (record.delivery.leaseExpiresAtMs ?? 0) <= now || + record.delivery.status === 'published' + ) + return none; + if ( + !Number.isSafeInteger(input.frames) || + input.frames < record.delivery.acknowledgedFrames || + (input.chunkSizeBytes !== undefined && + (!Number.isSafeInteger(input.chunkSizeBytes) || + input.chunkSizeBytes < 1)) + ) + throw new TypeError('Invalid publication progress'); + if ( + record.delivery.chunkSizeBytes !== undefined && + input.chunkSizeBytes !== record.delivery.chunkSizeBytes + ) + return none; + if (input.leaseMs !== undefined) { + if ( + !Number.isSafeInteger(input.leaseMs) || + input.leaseMs < 1 || + input.leaseMs > 2_147_483_647 + ) + throw new TypeError('Invalid publication renewal'); + record.delivery.leaseExpiresAtMs = now + input.leaseMs; + } + record.delivery.acknowledgedFrames = input.frames; + if (input.chunkSizeBytes !== undefined) + record.delivery.chunkSizeBytes = input.chunkSizeBytes; + break; + case 'published': + case 'publication_failed': + if ( + !record.delivery || + record.delivery.owner !== input.owner || + record.delivery.status === 'published' + ) + return none; + record.delivery.status = + input.type === 'published' ? 'published' : 'unknown'; + delete record.delivery.owner; + delete record.delivery.leaseExpiresAtMs; + record.retainUntilMs = + input.type === 'published' ? record.replayExpiresAtMs : null; + 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; + 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..13ea1fe --- /dev/null +++ b/src/payments/payment-state-store.ts @@ -0,0 +1,166 @@ +import type { PaymentResultDelivery } from './payment-delivery.js'; +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; + reconciliation?: { owner: string; leaseExpiresAtMs: number }; + offerStatus?: 'published' | 'unknown'; + settlement?: { verifiedAtMs: number }; + delivery?: PaymentResultDelivery; +} + +/** 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' + | 'offer_published'; + } + | { type: 'bind'; invoice: PaymentOption; leaseMs: number } + | { type: 'grant'; ttlMs: number } + | { type: 'uncertain'; reason: string } + | { type: 'settled'; ttlMs: number; owner?: string } + | { type: 'reconciliation_claim'; owner: string; leaseMs: number } + | { type: 'reconciliation_release'; owner: string } + | { type: 'result'; delivery: PaymentResultDelivery } + | { type: 'publication_claim'; owner: string; leaseMs: number } + | { + type: 'publication_progress'; + leaseMs?: number; + owner: string; + frames: number; + chunkSizeBytes?: number; + } + | { type: 'published' | 'publication_failed'; owner: 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/payment-subscription.ts b/src/payments/payment-subscription.ts new file mode 100644 index 0000000..ed5e980 --- /dev/null +++ b/src/payments/payment-subscription.ts @@ -0,0 +1,38 @@ +import type { Filter, NostrEvent } from 'nostr-tools'; +import type { RelayHandler } from '../core/interfaces.js'; + +/** Owns one subscription until abort, including setup that resolves after cancellation. */ +export async function subscribePaymentEvents( + relay: RelayHandler, + filters: Filter[], + signal: AbortSignal, + onEvent: (event: NostrEvent) => void, + onEose?: () => void, +): Promise { + signal.throwIfAborted(); + let release: (() => void) | undefined; + const stop = (): void => { + signal.removeEventListener('abort', stop); + const unsubscribe = release; + release = undefined; + try { + unsubscribe?.(); + } catch { + /* Cleanup must not replace a payment's outcome. */ + } + }; + signal.addEventListener('abort', stop, { once: true }); + try { + release = await relay.subscribe( + filters, + (event) => { + if (!signal.aborted) onEvent(event); + }, + onEose, + ); + if (signal.aborted) stop(); + } catch (error) { + stop(); + throw error; + } +} diff --git a/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts b/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts index 70ee0d2..45528e7 100644 --- a/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts +++ b/src/payments/processors/ln-bolt11-lnbits-payment-processor.ts @@ -1,4 +1,9 @@ +import { + runPaymentOperation, + type PaymentFetch, +} from '../payment-cancellation.js'; import type { + PaymentRequired, PaymentProcessor, PaymentProcessorCreateParams, PaymentProcessorVerifyParams, @@ -11,6 +16,8 @@ import { encodeBase64 } from '../../core/utils/base64.js'; export interface LnBolt11LnbitsPaymentProcessorOptions { /** LNbits instance base URL (e.g. `https://lnbits.example.com`). */ lnbitsUrl: string; + /** Optional HTTP implementation for custom runtimes and fault injection. */ + fetch?: PaymentFetch; /** LNbits wallet invoice/read key. */ lnbitsApiKey: string; /** Optional HTTP Basic Auth credentials (`user:password`) for proxied instances. */ @@ -31,8 +38,12 @@ 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 shutdown = new AbortController(); + private readonly fetch: PaymentFetch; private readonly lnbitsUrl: string; private readonly lnbitsApiKey: string; private readonly lnbitsBasicAuth?: string; @@ -42,6 +53,7 @@ export class LnBolt11LnbitsPaymentProcessor implements PaymentProcessor { private readonly logger: Logger; public constructor(options: LnBolt11LnbitsPaymentProcessorOptions) { + this.fetch = options.fetch ?? globalThis.fetch; this.lnbitsUrl = options.lnbitsUrl.replace(/\/+$/, ''); this.lnbitsApiKey = options.lnbitsApiKey; this.lnbitsBasicAuth = options.lnbitsBasicAuth; @@ -63,15 +75,26 @@ export class LnBolt11LnbitsPaymentProcessor implements PaymentProcessor { return headers; } + /** Cancels work owned by this adapter. */ + public async dispose(): Promise { + this.shutdown.abort(); + } + + /** Creates an invoice under the supplied cancellation/deadline context. */ public async createPaymentRequired( params: PaymentProcessorCreateParams, - ): Promise<{ - amount: number; - pay_req: string; - description?: string; - pmi: string; - ttl?: number; - }> { + ): Promise { + return runPaymentOperation( + params, + this.ttlSeconds * 1000, + this.shutdown.signal, + (signal) => this.createInternal({ ...params, abortSignal: signal }), + ); + } + + private async createInternal( + params: PaymentProcessorCreateParams, + ): Promise { const body = JSON.stringify({ out: false, amount: params.amount, @@ -81,17 +104,15 @@ export class LnBolt11LnbitsPaymentProcessor implements PaymentProcessor { expiry: this.invoiceExpirySeconds, }); - const response = await fetch(`${this.lnbitsUrl}/api/v1/payments`, { + const response = await this.fetch(`${this.lnbitsUrl}/api/v1/payments`, { method: 'POST', headers: this.buildHeaders(), body, + signal: params.abortSignal, }); if (!response.ok) { - const text = await response.text(); - throw new Error( - `LNbits create invoice failed: ${response.status} ${text}`, - ); + throw new Error(`LNbits create invoice failed: ${response.status}`); } const result = (await response.json()) as { @@ -118,24 +139,40 @@ export class LnBolt11LnbitsPaymentProcessor implements PaymentProcessor { }; } + /** Authenticated receiver-side status lookup; does not pay or create invoices. */ public async verifyPayment( params: PaymentProcessorVerifyParams, + ): Promise<{ _meta?: Record }> { + return runPaymentOperation( + params, + this.ttlSeconds * 1000, + this.shutdown.signal, + (signal) => this.verifyInternal({ ...params, abortSignal: signal }), + ); + } + + private async verifyInternal( + params: PaymentProcessorVerifyParams, ): Promise<{ _meta?: Record }> { // We need the payment hash to check status. Derive it from the invoice // by first looking it up via the decoded invoice endpoint, or by checking // all recent payments. The simplest approach: decode the bolt11 to get the hash. - const paymentHash = await this.getPaymentHashFromInvoice(params.pay_req); + const paymentHash = await this.getPaymentHashFromInvoice( + params.pay_req, + params.abortSignal, + ); while (true) { if (params.abortSignal?.aborted) { throw new Error('verifyPayment aborted'); } - const response = await fetch( + const response = await this.fetch( `${this.lnbitsUrl}/api/v1/payments/${paymentHash}`, { method: 'GET', headers: this.buildHeaders(), + signal: params.abortSignal, }, ); @@ -148,7 +185,7 @@ export class LnBolt11LnbitsPaymentProcessor implements PaymentProcessor { paid: result.paid, }); - if (result.paid) { + if (result.paid === true) { return { _meta: { payment_hash: paymentHash } }; } } else { @@ -170,12 +207,19 @@ export class LnBolt11LnbitsPaymentProcessor implements PaymentProcessor { * LNbits returns the payment_hash when creating an invoice, but verifyPayment * only receives pay_req. We decode it via LNbits decode endpoint. */ - private async getPaymentHashFromInvoice(bolt11: string): Promise { - const response = await fetch(`${this.lnbitsUrl}/api/v1/payments/decode`, { - method: 'POST', - headers: this.buildHeaders(), - body: JSON.stringify({ data: bolt11 }), - }); + private async getPaymentHashFromInvoice( + bolt11: string, + abortSignal?: AbortSignal, + ): Promise { + const response = await this.fetch( + `${this.lnbitsUrl}/api/v1/payments/decode`, + { + method: 'POST', + headers: this.buildHeaders(), + body: JSON.stringify({ data: bolt11 }), + signal: abortSignal, + }, + ); if (response.ok) { const decoded = (await response.json()) as { payment_hash?: string }; @@ -185,11 +229,13 @@ export class LnBolt11LnbitsPaymentProcessor implements PaymentProcessor { } // Fallback: search recent payments for matching bolt11 - const paymentsResponse = await fetch( + abortSignal?.throwIfAborted(); + const paymentsResponse = await this.fetch( `${this.lnbitsUrl}/api/v1/payments?limit=50`, { method: 'GET', headers: this.buildHeaders(), + signal: abortSignal, }, ); diff --git a/src/payments/processors/ln-bolt11-nwc-payment-processor.test.ts b/src/payments/processors/ln-bolt11-nwc-payment-processor.test.ts index 096e482..c35d7ba 100644 --- a/src/payments/processors/ln-bolt11-nwc-payment-processor.test.ts +++ b/src/payments/processors/ln-bolt11-nwc-payment-processor.test.ts @@ -58,7 +58,9 @@ class MockNwcClient implements NwcClientLike { }): Promise<() => void> { this.subscribeCalls += 1; if (this.subscribeDelayMs > 0) { - await new Promise((resolve) => setTimeout(resolve, this.subscribeDelayMs)); + await new Promise((resolve) => + setTimeout(resolve, this.subscribeDelayMs), + ); } this.onNotification = params.onNotification; return () => { @@ -202,7 +204,7 @@ describe('LnBolt11NwcPaymentProcessor', () => { expect(client.calls[0]!.method).toBe('lookup_invoice'); }); - test('notification mode resolves verifyPayment from payment_received notification', async () => { + test('notification mode confirms a receipt using authenticated lookup', async () => { const client = mockClient; const processor = createProcessor({ enableNotificationVerification: true, @@ -224,6 +226,11 @@ describe('LnBolt11NwcPaymentProcessor', () => { description: 'x', }); + client.responses.push({ + result_type: 'lookup_invoice', + error: null, + result: { state: 'pending', payment_hash: 'd'.repeat(64) }, + }); const verifyPromise = processor.verifyPayment( makeVerifyParams({ payReq: pr.pay_req, @@ -232,7 +239,12 @@ describe('LnBolt11NwcPaymentProcessor', () => { ); await new Promise((r) => setTimeout(r, 0)); - expect(client.calls).toHaveLength(1); // only make_invoice + expect(client.calls).toHaveLength(2); + client.responses.push({ + result_type: 'lookup_invoice', + error: null, + result: { state: 'settled', payment_hash: 'd'.repeat(64) }, + }); client.onNotification?.({ notification_type: 'payment_received', @@ -287,9 +299,7 @@ describe('LnBolt11NwcPaymentProcessor', () => { setTimeout(() => controller.abort(), 10); const results = await verifies; - expect( - results.every((r) => r.status === 'rejected'), - ).toBe(true); + expect(results.every((r) => r.status === 'rejected')).toBe(true); // The concurrent subscribe window must produce exactly one subscription; // a second would leak its unsubscribe handle forever. expect(client.subscribeCalls).toBe(1); diff --git a/src/payments/processors/ln-bolt11-nwc-payment-processor.ts b/src/payments/processors/ln-bolt11-nwc-payment-processor.ts index 75a2ac0..e55aa1d 100644 --- a/src/payments/processors/ln-bolt11-nwc-payment-processor.ts +++ b/src/payments/processors/ln-bolt11-nwc-payment-processor.ts @@ -3,6 +3,7 @@ import type { PaymentProcessor, PaymentProcessorCreateParams, PaymentProcessorVerifyParams, + PaymentRequired, } from '../types.js'; import { PMI_BITCOIN_LIGHTNING_BOLT11 } from '../pmis.js'; import { parseNwcConnectionString } from '../nip47/connection.js'; @@ -10,25 +11,31 @@ import { NwcClient } from '../nip47/nwc-client.js'; import type { NwcConnection, NwcInvoiceResult, - NwcMakeInvoiceParams, NwcNotificationPayload, NwcResponse, } from '../nip47/types.js'; import { ApplesauceRelayPool } from '../../relay/applesauce-relay-pool.js'; -import { createLogger, type Logger } from '../../core/utils/logger.js'; import { satsToMsats } from '../nip47/utils.js'; import { LruCache } from '../../core/utils/lru-cache.js'; -import { sleepWithAbort } from '../../core/utils/utils.js'; - +import { + paymentOperation, + type PaymentOperationContext, +} from '../payment-cancellation.js'; export interface NwcClientLike { request(params: { method: M; request: { method: M; params: P }; resultType: M; + abortSignal?: AbortSignal; + deadlineMs?: number; }): Promise>; - fetchInfoNotificationTypes(): Promise>; + disconnect?(): Promise; + fetchInfoNotificationTypes( + context?: PaymentOperationContext, + ): Promise>; subscribeNotifications(params: { onNotification: (payload: NwcNotificationPayload) => void; + abortSignal?: AbortSignal; }): Promise<() => void>; } @@ -55,7 +62,7 @@ export interface LnBolt11NwcPaymentProcessorOptions { * Controls whether payment verification uses NWC notifications. * * - `false`: always poll using `lookup_invoice`. - * - `true`: verify using `payment_received` notifications only. + * - `true`: verify using `payment_received` wakeups plus authenticated lookup fallback. * - `undefined` (default): best-effort auto-detect on first use via the wallet's info event. */ enableNotificationVerification?: boolean; @@ -77,345 +84,339 @@ export interface LnBolt11NwcPaymentProcessorOptions { invoiceHashCacheSize?: number; } -/** - * CEP-8 server payment processor for PMI `bitcoin-lightning-bolt11` backed by NIP-47 (NWC). - */ +/** NWC settlement lookup with one shared notification subscription used as a wakeup hint. */ export class LnBolt11NwcPaymentProcessor implements PaymentProcessor { + public readonly currencyUnit = 'sats'; public readonly pmi = PMI_BITCOIN_LIGHTNING_BOLT11; - private readonly nwc: NwcClientLike; - private readonly ttlSeconds: number; - private readonly invoiceExpirySeconds: number; - private readonly pollIntervalMs: number; - private readonly logger: Logger; - - private notificationVerificationEnabled: boolean; - private initNotificationsPromise: Promise | undefined; - private notificationsSubscribePromise: Promise | undefined; - - private notificationsUnsubscribe: (() => void) | undefined; - private readonly notificationWaiters = new Map< + private readonly ownedClient: boolean; + private readonly shutdown = new AbortController(); + private readonly invoiceHashCache: LruCache; + private readonly receipts: LruCache; + private readonly waiters = new Map void>>(); + private readonly active = new Map< string, - Array<(paymentHash: string) => void> + { + promise: Promise<{ _meta?: Record }>; + controller: AbortController; + users: number; + } >(); - - // Dedupe concurrent verifyPayment() calls for the same pay_req to avoid amplifying - // relay + wallet load under duplicate delivery. - private readonly inFlightVerifications: LruCache< - Promise<{ _meta?: Record }> - >; - - // Cache invoice -> payment_hash when wallets provide it. - private readonly invoiceHashCache: LruCache; + private subscription?: Promise; + private unsubscribe?: () => void; + private initialization?: Promise; + private notificationEnabled: boolean; + private readonly ttlSeconds: number; + private readonly pollIntervalMs: number; + private readonly responseTimeoutMs: number; + private readonly invoiceExpirySeconds: number; + private readonly maxActive: number; public constructor(options: LnBolt11NwcPaymentProcessorOptions) { const connection = options.connection ?? parseNwcConnectionString(options.nwcConnectionString); - const relayHandler = - options.relayHandler ?? new ApplesauceRelayPool([...connection.relays]); - + this.ownedClient = !options.nwcClient; this.nwc = options.nwcClient ?? new NwcClient({ - relayHandler, + relayHandler: + options.relayHandler ?? + new ApplesauceRelayPool([...connection.relays]), + ownsRelayHandler: !options.relayHandler, connection, responseTimeoutMs: options.responseTimeoutMs, }); - this.ttlSeconds = options.ttlSeconds ?? 300; this.invoiceExpirySeconds = options.invoiceExpirySeconds ?? this.ttlSeconds; this.pollIntervalMs = options.pollIntervalMs ?? 1500; - this.logger = createLogger('payments/nwc-processor'); - - this.notificationVerificationEnabled = - options.enableNotificationVerification ?? false; - if (options.enableNotificationVerification === undefined) { - this.notificationVerificationEnabled = false; - this.initNotificationsPromise = this.initNotificationsBestEffort(); - } - - this.inFlightVerifications = new LruCache< - Promise<{ _meta?: Record }> - >(options.maxInFlightVerifications ?? 5000); - this.invoiceHashCache = new LruCache( + this.responseTimeoutMs = options.responseTimeoutMs ?? 60_000; + this.maxActive = options.maxInFlightVerifications ?? 5000; + for (const value of [ + this.ttlSeconds, + this.invoiceExpirySeconds, + this.pollIntervalMs, + this.responseTimeoutMs, + this.maxActive, options.invoiceHashCacheSize ?? 10000, - ); - } - - private async initNotificationsBestEffort(): Promise { - try { - const types = await this.nwc.fetchInfoNotificationTypes(); - this.notificationVerificationEnabled = types.has('payment_received'); - } catch { - this.notificationVerificationEnabled = false; + ]) { + if (!Number.isFinite(value) || value <= 0) + throw new TypeError('NWC limits must be positive and finite'); + } + this.invoiceHashCache = new LruCache(options.invoiceHashCacheSize ?? 10000); + this.receipts = new LruCache(options.invoiceHashCacheSize ?? 10000); + this.notificationEnabled = options.enableNotificationVerification ?? false; + if (options.enableNotificationVerification === undefined) { + const operation = paymentOperation( + {}, + this.responseTimeoutMs, + this.shutdown.signal, + ); + this.initialization = operation + .wait( + this.nwc.fetchInfoNotificationTypes({ + abortSignal: operation.signal, + }), + ) + .then((types) => { + this.notificationEnabled = types.has('payment_received'); + }) + .catch(() => {}) + .finally(() => operation.dispose()); } } - private async ensureInitResolved(): Promise { - if (!this.initNotificationsPromise) return; - await this.initNotificationsPromise; - this.initNotificationsPromise = undefined; - } - - private async ensureNotificationsSubscribed(): Promise { - if (!this.notificationVerificationEnabled) return; - if (this.notificationsUnsubscribe) return; - // Memoize the in-flight subscribe: concurrent verifications racing here - // would each subscribe, and only the last unsubscribe handle is kept — - // the first subscription would leak for the process lifetime. - if (!this.notificationsSubscribePromise) { - this.notificationsSubscribePromise = this.nwc - .subscribeNotifications({ - onNotification: (payload) => { - if (payload.notification_type !== 'payment_received') return; - const paymentHash = ( - payload.notification as { payment_hash?: unknown } - )?.payment_hash; - if (typeof paymentHash !== 'string' || paymentHash.length === 0) - return; - - const waiters = this.notificationWaiters.get(paymentHash); - if (!waiters || waiters.length === 0) return; - this.notificationWaiters.delete(paymentHash); - for (const w of waiters) w(paymentHash); - }, - }) - .then((unsubscribe) => { - this.notificationsUnsubscribe = unsubscribe; + private ensureSubscription(): Promise { + if ( + !this.notificationEnabled || + this.unsubscribe || + this.shutdown.signal.aborted + ) + return Promise.resolve(); + if (!this.subscription) { + const operation = paymentOperation( + {}, + this.responseTimeoutMs, + this.shutdown.signal, + ); + const pending = this.nwc.subscribeNotifications({ + abortSignal: this.shutdown.signal, + onNotification: (payload) => { + if ( + this.shutdown.signal.aborted || + payload.notification_type !== 'payment_received' + ) + return; + const hash = (payload.notification as { payment_hash?: unknown }) + ?.payment_hash; + if (typeof hash !== 'string' || !hash) return; + this.receipts.set(hash, true); + for (const wake of this.waiters.get(hash) ?? []) wake(); + }, + }); + let abandoned = false; + void pending.then( + (u) => { + if (abandoned || this.shutdown.signal.aborted) u(); + else this.unsubscribe = u; + }, + () => {}, + ); + this.subscription = operation + .wait(pending) + .then(() => {}) + .catch(() => { + abandoned = true; }) .finally(() => { - // Always clear: on success the unsubscribe handle gates re-entry, on - // failure the next call retries. Retaining a resolved promise would - // make a future re-entry (after the handle is released) silently - // skip resubscribing. - this.notificationsSubscribePromise = undefined; + operation.dispose(); + this.subscription = undefined; }); } - await this.notificationsSubscribePromise; - } - - private computeNextDelayMs(params: { attempt: number }): number { - // Prefer fast early checks (most invoices settle quickly), then back off. - // Keep a floor for legacy configs that rely on a predictable pollIntervalMs. - const scheduleMs = [500, 750, 1000, 1500, 2500, 4000, 6500, 10_000, 15_000]; - - const base = scheduleMs[Math.min(params.attempt, scheduleMs.length - 1)]; - const flooredBase = Math.max(this.pollIntervalMs, base); - - // Add small jitter to avoid stampeding on shared relay/wallet infra. - const jitter = Math.floor(Math.random() * 250); - return flooredBase + jitter; - } - - private isSettledInvoice(result: NwcInvoiceResult | null): boolean { - if (!result) return false; - if (result.state === 'settled') return true; - if (typeof result.settled_at === 'number' && result.settled_at > 0) - return true; - return false; - } - - private getReceiptFromInvoiceResult(params: { - result: NwcInvoiceResult | null; - requestEventId: string; - }): string { - const { result, requestEventId } = params; - - // Prefer stable identifiers; avoid leaking preimage. - if ( - typeof result?.payment_hash === 'string' && - result.payment_hash.length > 0 - ) { - return result.payment_hash; - } - - // Some wallets do not provide `payment_hash` on lookup results. Still produce a stable - // settlement identifier string for correlation. - const settledAt = - typeof result?.settled_at === 'number' ? result.settled_at : undefined; - return `settled:${requestEventId}${settledAt ? `:${settledAt}` : ''}`; + return this.subscription; } + /** Creates one invoice; a cancelled request can still have created it remotely. */ public async createPaymentRequired( params: PaymentProcessorCreateParams, - ): Promise<{ - amount: number; - pay_req: string; - description?: string; - pmi: string; - ttl?: number; - }> { - const request: NwcMakeInvoiceParams = { - amount: satsToMsats(params.amount), - description: params.description, - expiry: this.invoiceExpirySeconds, - }; - - const response = await this.nwc.request({ - method: 'make_invoice', - resultType: 'make_invoice', - request: { method: 'make_invoice', params: request }, - }); - - if (response.error) { - throw new Error( - `NWC make_invoice failed: ${response.error.code} (${response.error.message})`, + ): Promise { + const operation = paymentOperation( + params, + this.responseTimeoutMs, + this.shutdown.signal, + ); + try { + operation.signal.throwIfAborted(); + const response = await operation.wait( + this.nwc.request({ + method: 'make_invoice', + resultType: 'make_invoice', + abortSignal: operation.signal, + request: { + method: 'make_invoice', + params: { + amount: satsToMsats(params.amount), + description: params.description, + expiry: this.invoiceExpirySeconds, + }, + }, + }), ); + if (response.error) + throw new Error(`NWC make_invoice failed: ${response.error.code}`); + const result = response.result as NwcInvoiceResult | null; + if (!result?.invoice) + throw new Error('NWC make_invoice returned no invoice'); + if (result.payment_hash) + this.invoiceHashCache.set(result.invoice, result.payment_hash); + return { + amount: params.amount, + pay_req: result.invoice, + pmi: this.pmi, + description: params.description, + ttl: this.ttlSeconds, + }; + } finally { + operation.dispose(); } - - const invoice = (response.result as NwcInvoiceResult | null)?.invoice; - if (!invoice) { - throw new Error('NWC make_invoice returned no invoice'); - } - - const paymentHash = (response.result as NwcInvoiceResult | null) - ?.payment_hash; - if (typeof paymentHash === 'string' && paymentHash.length > 0) { - this.invoiceHashCache.set(invoice, paymentHash); - } - - // Keep TTL simple and predictable. Some providers return non-standard `expires_at`. - const ttl = this.ttlSeconds; - - return { - amount: params.amount, - pay_req: invoice, - description: params.description, - pmi: this.pmi, - ttl, - }; } + /** Shares provider work while each caller retains independent cancellation. */ public async verifyPayment( params: PaymentProcessorVerifyParams, ): Promise<{ _meta?: Record }> { - await this.ensureInitResolved(); - const existing = this.inFlightVerifications.get(params.pay_req); - if (existing) { - return await existing; + const caller = paymentOperation( + params, + this.ttlSeconds * 1000, + this.shutdown.signal, + ); + let entry = this.active.get(params.pay_req); + let joined = false; + try { + caller.signal.throwIfAborted(); + if (!entry) { + if (this.active.size >= this.maxActive) + throw new Error('NWC verification capacity reached'); + const controller = new AbortController(); + const current = { + controller, + users: 0, + promise: Promise.resolve({}) as Promise<{ + _meta?: Record; + }>, + }; + entry = current; + this.active.set(params.pay_req, current); + current.promise = this.verifyInternal({ + ...params, + deadlineMs: undefined, + abortSignal: controller.signal, + }).finally(() => { + if (this.active.get(params.pay_req) === current) + this.active.delete(params.pay_req); + }); + } + entry.users++; + joined = true; + return await caller.wait(entry.promise); + } finally { + caller.dispose(); + if (joined && entry && --entry.users === 0) entry.controller.abort(); } - - const run = this.verifyPaymentInternal(params).finally(() => { - this.inFlightVerifications.delete(params.pay_req); - }); - this.inFlightVerifications.set(params.pay_req, run); - return await run; } - private async verifyPaymentInternal( + private async verifyInternal( params: PaymentProcessorVerifyParams, ): Promise<{ _meta?: Record }> { - if (this.notificationVerificationEnabled) { - return await this.verifyPaymentWithNotifications(params); - } - - // Note: server-payments middleware already bounds overall time by the `ttl` it emitted. - // We still keep this loop tight and predictable. - let attempt = 0; - while (true) { - if (params.abortSignal?.aborted) { - throw new Error('verifyPayment aborted'); - } - - const cachedPaymentHash = this.invoiceHashCache.get(params.pay_req); - - const response = await this.nwc.request({ - method: 'lookup_invoice', - resultType: 'lookup_invoice', - request: { - method: 'lookup_invoice', - params: cachedPaymentHash - ? { payment_hash: cachedPaymentHash } - : { invoice: params.pay_req }, - }, - }); - - if (response.error) { - // NOT_FOUND can happen if the wallet is lagging; treat as pending. - if (response.error.code !== 'NOT_FOUND') { - throw new Error( - `NWC lookup_invoice failed: ${response.error.code} (${response.error.message})`, - ); - } - } else { - const result = response.result as NwcInvoiceResult | null; - this.logger.debug('lookup_invoice result', { - requestEventId: params.requestEventId, - state: result?.state, - hasPreimage: Boolean(result?.preimage), - hasSettledAt: typeof result?.settled_at === 'number', - hasPaymentHash: Boolean(result?.payment_hash), + const operation = paymentOperation( + params, + this.ttlSeconds * 1000, + this.shutdown.signal, + ); + try { + // Lookup begins even if info/subscription setup stalls. Notifications only accelerate it. + void this.initialization?.then(() => this.ensureSubscription()); + void this.ensureSubscription(); + while (true) { + operation.signal.throwIfAborted(); + const hash = this.invoiceHashCache.get(params.pay_req); + let wake!: () => void; + let timer: ReturnType | undefined; + const hint = new Promise((resolve) => { + wake = resolve; }); - - if ( - !cachedPaymentHash && - typeof result?.payment_hash === 'string' && - result.payment_hash.length > 0 - ) { - this.invoiceHashCache.set(params.pay_req, result.payment_hash); + if (hash) { + const set = this.waiters.get(hash) ?? new Set(); + set.add(wake); + this.waiters.set(hash, set); + if (this.receipts.get(hash)) { + this.receipts.delete(hash); + wake(); + } } - - if (this.isSettledInvoice(result)) { - // Prefer payment_hash as a stable identifier (avoid exposing preimages). - const paymentHash = this.getReceiptFromInvoiceResult({ - result, - requestEventId: params.requestEventId, - }); - return { _meta: { payment_hash: paymentHash } }; - } - - const state = result?.state; - if (state === 'expired' || state === 'failed') { - throw new Error(`Invoice ${state}`); + try { + let response: + NwcResponse<'lookup_invoice', NwcInvoiceResult> | undefined; + const request = paymentOperation( + { abortSignal: operation.signal }, + this.responseTimeoutMs, + ); + try { + response = await request.wait( + this.nwc.request({ + method: 'lookup_invoice', + resultType: 'lookup_invoice', + abortSignal: request.signal, + request: { + method: 'lookup_invoice', + params: hash + ? { payment_hash: hash } + : { invoice: params.pay_req }, + }, + }), + ); + } catch { + operation.signal.throwIfAborted(); + } finally { + request.dispose(); + } + if ( + response?.error && + ['UNAUTHORIZED', 'RESTRICTED', 'NOT_IMPLEMENTED'].includes( + response.error.code, + ) + ) + throw new Error( + `NWC lookup_invoice failed: ${response.error.code}`, + ); + const result = response?.result; + if (result?.payment_hash && hash && result.payment_hash !== hash) + throw new Error('NWC lookup invoice hash mismatch'); + if (result?.invoice && result.invoice !== params.pay_req) + throw new Error('NWC lookup invoice mismatch'); + if (result?.type === 'outgoing') + throw new Error('NWC lookup is not a receiver invoice'); + if (result?.payment_hash) + this.invoiceHashCache.set(params.pay_req, result.payment_hash); + if ( + !response?.error && + (result?.state === 'settled' || + (typeof result?.settled_at === 'number' && result.settled_at > 0)) + ) { + return { + _meta: { + payment_hash: + result.payment_hash ?? `settled:${params.requestEventId}`, + }, + }; + } + // Even an expired invoice can settle late. Only authenticated settlement completes verification. + timer = setTimeout(wake, this.pollIntervalMs); + await operation.wait(hint); + } finally { + clearTimeout(timer); + if (hash) { + const set = this.waiters.get(hash); + set?.delete(wake); + if (!set?.size) this.waiters.delete(hash); + } } } - - const delayMs = this.computeNextDelayMs({ attempt }); - attempt += 1; - await sleepWithAbort({ ms: delayMs, abortSignal: params.abortSignal }); + } finally { + operation.dispose(); } } - private async verifyPaymentWithNotifications( - params: PaymentProcessorVerifyParams, - ): Promise<{ _meta?: Record }> { - await this.ensureNotificationsSubscribed(); - - if (params.abortSignal?.aborted) { - throw new Error('verifyPayment aborted'); - } - - const paymentHash = this.invoiceHashCache.get(params.pay_req); - if (!paymentHash) { - throw new Error('NWC notification verification requires payment_hash'); - } - - const got = await new Promise((resolve, reject) => { - const existing = this.notificationWaiters.get(paymentHash); - const waiters = existing ?? []; - waiters.push(resolve); - this.notificationWaiters.set(paymentHash, waiters); - - if (!params.abortSignal) return; - const abortSignal = params.abortSignal; - - const onAbort = () => { - abortSignal.removeEventListener('abort', onAbort); - - // Remove this waiter so future notifications don't leak memory. - const current = this.notificationWaiters.get(paymentHash); - if (current) { - const next = current.filter((w) => w !== resolve); - if (next.length === 0) this.notificationWaiters.delete(paymentHash); - else this.notificationWaiters.set(paymentHash, next); - } - - reject(new Error('verifyPayment aborted')); - }; - abortSignal.addEventListener('abort', onAbort, { once: true }); - }); - - return { _meta: { payment_hash: got } }; + /** Stops verifications/subscriptions; injected clients and relay pools stay usable. */ + public async dispose(): Promise { + if (this.shutdown.signal.aborted) return; + this.shutdown.abort(); + this.unsubscribe?.(); + this.unsubscribe = undefined; + for (const entry of this.active.values()) entry.controller.abort(); + this.waiters.clear(); + this.invoiceHashCache.clear(); + this.receipts.clear(); + if (this.ownedClient) await this.nwc.disconnect?.(); } } diff --git a/src/payments/processors/ln-bolt11-zap-payment-processor.ts b/src/payments/processors/ln-bolt11-zap-payment-processor.ts index 0b7f4db..5f09170 100644 --- a/src/payments/processors/ln-bolt11-zap-payment-processor.ts +++ b/src/payments/processors/ln-bolt11-zap-payment-processor.ts @@ -1,5 +1,14 @@ +import { + runPaymentOperation, + type PaymentFetch, +} from '../payment-cancellation.js'; +import { subscribePaymentEvents } from '../payment-subscription.js'; import { kinds, type Filter, type NostrEvent } from 'nostr-tools'; -import { finalizeEvent, generateSecretKey } from 'nostr-tools/pure'; +import { + finalizeEvent, + generateSecretKey, + verifyEvent, +} from 'nostr-tools/pure'; import type { RelayHandler } from '../../core/interfaces.js'; import { ApplesauceRelayPool } from '../../relay/applesauce-relay-pool.js'; import { createLogger, type Logger } from '../../core/utils/logger.js'; @@ -11,6 +20,7 @@ import { getBolt11FromZapReceipt, } from '../nip57/zap-events.js'; import type { + PaymentRequired, PaymentProcessor, PaymentProcessorCreateParams, PaymentProcessorVerifyParams, @@ -19,6 +29,8 @@ import type { export interface LnBolt11ZapPaymentProcessorOptions { /** Lightning address (LUD-16), e.g. `alice@example.com`. */ lnAddress: string; + /** Optional HTTP implementation for custom runtimes and fault injection. */ + fetch?: PaymentFetch; /** Optional relay handler to reuse; otherwise a new ApplesauceRelayPool is created. */ relayHandler?: RelayHandler; @@ -28,7 +40,7 @@ export interface LnBolt11ZapPaymentProcessorOptions { /** Time-to-live in seconds returned in `payment_required`. @default 300 */ ttlSeconds?: number; - /** Maximum number of in-flight invoice verifications to dedupe. @default 5000 */ + /** Maximum concurrent independently cancellable receipt waiters. @default 5000 */ maxInFlightVerifications?: number; /** Maximum number of pending invoice issuances to remember. @default 10000 */ maxPendingInvoices?: number; @@ -47,6 +59,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; @@ -57,6 +71,9 @@ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { 'wss://nos.lol', ]; + private readonly shutdown = new AbortController(); + private readonly ownsRelayHandler: boolean; + private readonly fetch: PaymentFetch; private readonly lnAddress: string; private readonly relayHandler: RelayHandler; private readonly relayUrls: string[]; @@ -64,13 +81,14 @@ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { private readonly logger: Logger; private readonly signerSecretKey: Uint8Array; - private readonly inFlightVerifications: LruCache< - Promise<{ _meta?: Record }> - >; + private activeVerifications = 0; + private readonly maxInFlightVerifications: number; private readonly pendingInvoices: LruCache; public constructor(options: LnBolt11ZapPaymentProcessorOptions) { + this.fetch = options.fetch ?? globalThis.fetch; this.lnAddress = options.lnAddress; + this.ownsRelayHandler = !options.relayHandler; this.relayUrls = options.relayUrls ?? options.relayHandler?.getRelayUrls?.() ?? @@ -83,22 +101,32 @@ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { this.signerSecretKey = generateSecretKey(); - this.inFlightVerifications = new LruCache( - options.maxInFlightVerifications ?? 5000, - ); + this.maxInFlightVerifications = options.maxInFlightVerifications ?? 5000; this.pendingInvoices = new LruCache(options.maxPendingInvoices ?? 10000); } + /** Cancels adapter work; disconnects only the internally created relay pool. */ + public async dispose(): Promise { + if (this.shutdown.signal.aborted) return; + this.shutdown.abort(); + if (this.ownsRelayHandler) await this.relayHandler.disconnect(); + } + + /** Creates a zap invoice with bounded, cancellable HTTP work. */ public async createPaymentRequired( params: PaymentProcessorCreateParams, - ): Promise<{ - amount: number; - pay_req: string; - description?: string; - pmi: string; - ttl?: number; - _meta?: Record; - }> { + ): Promise { + return runPaymentOperation( + params, + this.ttlSeconds * 1000, + this.shutdown.signal, + (signal) => this.createInternal({ ...params, abortSignal: signal }), + ); + } + + private async createInternal( + params: PaymentProcessorCreateParams, + ): Promise { if (this.relayUrls.length === 0) { throw new Error( 'LnBolt11ZapPaymentProcessor requires relayUrls or a relayHandler with getRelayUrls() for verification', @@ -111,7 +139,11 @@ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { amountSats: params.amount, }); - const payParams = await fetchLnurlPayParams({ lnAddress: this.lnAddress }); + const payParams = await fetchLnurlPayParams({ + lnAddress: this.lnAddress, + abortSignal: params.abortSignal, + fetch: this.fetch, + }); if (!payParams.allowsNostr || !payParams.nostrPubkey) { throw new Error('LNURL-pay endpoint does not support NIP-57 zaps'); @@ -144,6 +176,8 @@ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { ); const { pr } = await requestZapInvoice({ + abortSignal: params.abortSignal, + fetch: this.fetch, callback: payParams.callback, amountMsats, zapRequestJson: JSON.stringify(zapRequest), @@ -170,18 +204,25 @@ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { public async verifyPayment( params: PaymentProcessorVerifyParams, ): Promise<{ _meta?: Record }> { - const existing = this.inFlightVerifications.get(params.pay_req); - if (existing) return await existing; - - const run = this.verifyPaymentInternal(params).finally(() => { - this.inFlightVerifications.delete(params.pay_req); - }); - this.inFlightVerifications.set(params.pay_req, run); - return await run; + // Each waiter owns its signal and subscription; aborting one cannot cancel a peer. + if (this.activeVerifications >= this.maxInFlightVerifications) + throw new Error('Zap verification capacity reached'); + this.activeVerifications++; + try { + return await runPaymentOperation( + params, + this.ttlSeconds * 1000, + this.shutdown.signal, + (signal) => + this.verifyPaymentInternal({ ...params, abortSignal: signal }), + ); + } finally { + this.activeVerifications--; + } } private async verifyPaymentInternal( - params: PaymentProcessorVerifyParams, + params: PaymentProcessorVerifyParams & { abortSignal: AbortSignal }, ): Promise<{ _meta?: Record }> { if (this.relayUrls.length === 0) { throw new Error( @@ -189,27 +230,23 @@ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { ); } - const pending = this.pendingInvoices.get(params.pay_req); - if (!pending) { + let meta = this.pendingInvoices.get(params.pay_req); + if (!meta) { // Stateless fallback: re-fetch pay params to get the expected zapper pubkey. const payParams = await fetchLnurlPayParams({ lnAddress: this.lnAddress, + abortSignal: params.abortSignal, + fetch: this.fetch, }); if (!payParams.nostrPubkey) { throw new Error('Cannot verify zap receipt: missing lnurl nostrPubkey'); } - this.pendingInvoices.set(params.pay_req, { + meta = { expectedZapperPubkey: payParams.nostrPubkey, - since: Math.floor(Date.now() / 1000) - this.ttlSeconds, + since: 0, amountMsats: NaN, - }); - } - - const meta = this.pendingInvoices.get(params.pay_req); - if (!meta) { - throw new Error( - 'Cannot verify zap receipt: missing pending invoice metadata', - ); + }; + this.pendingInvoices.set(params.pay_req, meta); } const filter: Filter = { @@ -226,29 +263,17 @@ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { }); const receiptPromise = new Promise((resolve, reject) => { - let unsubscribe: (() => void) | undefined; - let settled = false; - - const onAbort = () => { - settled = true; - cleanup(); - reject(new Error('verifyPayment aborted')); - }; - - const cleanup = () => { - try { - unsubscribe?.(); - } catch { - // best-effort - } - unsubscribe = undefined; - params.abortSignal?.removeEventListener('abort', onAbort); - }; - - params.abortSignal?.addEventListener('abort', onAbort, { once: true }); - - void this.relayHandler - .subscribe([filter], (event) => { + void subscribePaymentEvents( + this.relayHandler, + [filter], + params.abortSignal, + (event) => { + if ( + event.pubkey !== meta.expectedZapperPubkey || + event.kind !== kinds.Zap || + !verifyEvent(event) + ) + return; try { const bolt11 = getBolt11FromZapReceipt(event); if (bolt11 !== params.pay_req) return; @@ -257,28 +282,12 @@ export class LnBolt11ZapPaymentProcessor implements PaymentProcessor { requestEventId: params.requestEventId, zapReceiptEventId: event.id, }); - settled = true; - cleanup(); resolve(event); } catch (error: unknown) { - settled = true; - cleanup(); reject(error); } - }) - .then((u) => { - unsubscribe = u; - if (settled) { - // Event/abort/error may have fired before subscribe resolved. - unsubscribe(); - unsubscribe = undefined; - } - }) - .catch((error: unknown) => { - settled = true; - cleanup(); - reject(error); - }); + }, + ).catch(reject); }); const receipt = await receiptPromise; diff --git a/src/payments/processors/settlement-recovery.test.ts b/src/payments/processors/settlement-recovery.test.ts new file mode 100644 index 0000000..c318a4a --- /dev/null +++ b/src/payments/processors/settlement-recovery.test.ts @@ -0,0 +1,206 @@ +import { describe, expect, test } from 'bun:test'; +import { + LnBolt11NwcPaymentProcessor, + type NwcClientLike, +} from './ln-bolt11-nwc-payment-processor.js'; +import { LnBolt11LnbitsPaymentProcessor } from './ln-bolt11-lnbits-payment-processor.js'; +import { LnBolt11LnbitsPaymentHandler } from '../handlers/ln-bolt11-lnbits-payment-handler.js'; +import type { NwcNotificationPayload, NwcResponse } from '../nip47/types.js'; + +const identity = { requestEventId: 'event', clientPubkey: 'client' }; +function nwcFixture( + options: { + early?: boolean; + missing?: boolean; + delayedSubscribe?: boolean; + } = {}, +): { + processor: LnBolt11NwcPaymentProcessor; + stats: { + lookups: number; + subscriptions: number; + unsubscribes: number; + disconnects: number; + }; + setSettled: () => void; +} { + const stats = { + lookups: 0, + subscriptions: 0, + unsubscribes: 0, + disconnects: 0, + }; + let settled = !!options.early; + const client: NwcClientLike = { + async request(params: { + method: M; + request: { method: M; params: P }; + resultType: M; + }): Promise> { + if (params.method === 'lookup_invoice') stats.lookups++; + return { + result_type: params.method, + error: null, + result: + params.method === 'make_invoice' + ? { + invoice: 'invoice', + ...(options.missing ? {} : { payment_hash: 'hash' }), + } + : { + state: settled ? 'settled' : 'pending', + payment_hash: 'hash', + type: 'incoming', + }, + } as NwcResponse; + }, + async fetchInfoNotificationTypes() { + return new Set(['payment_received']); + }, + async subscribeNotifications(params: { + onNotification: (payload: NwcNotificationPayload) => void; + }) { + stats.subscriptions++; + if (options.early) + params.onNotification({ + notification_type: 'payment_received', + notification: { payment_hash: 'hash' }, + }); + if (options.delayedSubscribe) + await new Promise((resolve) => setTimeout(resolve, 15)); + return () => { + stats.unsubscribes++; + }; + }, + async disconnect() { + stats.disconnects++; + }, + }; + return { + stats, + setSettled: () => { + settled = true; + }, + processor: new LnBolt11NwcPaymentProcessor({ + nwcConnectionString: 'unused', + connection: { + walletPubkey: 'wallet', + clientSecretKeyHex: '0'.repeat(64), + relays: [], + }, + nwcClient: client, + enableNotificationVerification: true, + pollIntervalMs: 2, + ttlSeconds: 1, + }), + }; +} + +describe('settlement recognition and cancellation', () => { + for (const missing of [false, true]) + test(`early/missed notifications recover through lookup, missing hash=${missing}`, async () => { + const { processor, stats } = nwcFixture({ early: true, missing }); + const invoice = await processor.createPaymentRequired({ + ...identity, + amount: 1, + }); + expect( + await processor.verifyPayment({ + ...identity, + pay_req: invoice.pay_req, + }), + ).toEqual({ _meta: { payment_hash: 'hash' } }); + expect(stats.lookups).toBe(1); + await processor.dispose(); + await processor.dispose(); + expect(stats.unsubscribes).toBe(1); + expect(stats.disconnects).toBe(0); + }); + + test('broken notifications do not strand settlement; cancelling one shared waiter preserves the other', async () => { + const { processor, stats, setSettled } = nwcFixture(); + const abort = new AbortController(); + const first = processor.verifyPayment({ + ...identity, + pay_req: 'invoice', + abortSignal: abort.signal, + }); + const rejected = first.catch((error) => error); + const second = processor.verifyPayment({ ...identity, pay_req: 'invoice' }); + abort.abort(); + setSettled(); + expect(await rejected).toBeInstanceOf(Error); + expect(await second).toEqual({ _meta: { payment_hash: 'hash' } }); + expect(stats.subscriptions).toBe(1); + await processor.dispose(); + }); + + test('late subscription setup is released after disposal', async () => { + const { processor, stats } = nwcFixture({ delayedSubscribe: true }); + const work = processor.verifyPayment({ ...identity, pay_req: 'invoice' }); + const rejected = work.catch((error) => error); + await processor.dispose(); + expect(await rejected).toBeInstanceOf(Error); + await new Promise((resolve) => setTimeout(resolve, 25)); + expect(stats.unsubscribes).toBe(1); + }); + + for (const stage of ['create', 'decode', 'status', 'wallet'] as const) + test(`LNbits ${stage} receives abort and cannot continue after cancellation`, async () => { + const signals: AbortSignal[] = []; + let reached!: () => void; + const entered = new Promise((resolve) => { + reached = resolve; + }); + const http = (async ( + _input: RequestInfo | URL, + init?: RequestInit, + ): Promise => { + if (stage === 'status' && String(_input).endsWith('/decode')) + return Response.json({ payment_hash: 'hash' }); + signals.push(init?.signal as AbortSignal); + reached(); + // Models an HTTP implementation that ignores abort: the adapter must still return promptly. + return await new Promise(() => {}); + }) as typeof fetch; + const processor = new LnBolt11LnbitsPaymentProcessor({ + lnbitsUrl: 'https://wallet.invalid', + lnbitsApiKey: 'read-secret', + fetch: http, + }); + const handler = new LnBolt11LnbitsPaymentHandler({ + lnbitsUrl: 'https://wallet.invalid', + lnbitsAdminKey: 'admin-secret', + fetch: http, + }); + const controller = new AbortController(); + const work = + stage === 'create' + ? processor.createPaymentRequired({ + ...identity, + amount: 1, + abortSignal: controller.signal, + }) + : stage === 'wallet' + ? handler.handle({ + ...identity, + amount: 1, + pmi: processor.pmi, + pay_req: 'invoice', + abortSignal: controller.signal, + }) + : processor.verifyPayment({ + ...identity, + pay_req: 'invoice', + abortSignal: controller.signal, + }); + const rejected = work.catch((error) => error); + await entered; + controller.abort(); + expect(await rejected).toBeInstanceOf(Error); + expect(signals).toHaveLength(1); + expect(signals[0]?.aborted).toBe(true); + await processor.dispose(); + await handler.dispose(); + }); +}); diff --git a/src/payments/processors/zap-cancellation.test.ts b/src/payments/processors/zap-cancellation.test.ts new file mode 100644 index 0000000..494f18a --- /dev/null +++ b/src/payments/processors/zap-cancellation.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from 'bun:test'; +import { + finalizeEvent, + generateSecretKey, + getPublicKey, +} from 'nostr-tools/pure'; +import type { Filter, NostrEvent } from 'nostr-tools'; +import type { RelayHandler } from '../../core/interfaces.js'; +import { LnBolt11ZapPaymentProcessor } from './ln-bolt11-zap-payment-processor.js'; + +for (const earlyAbort of [false, true]) + test(`zap verification authenticates receipts and releases only owned subscriptions, abort=${earlyAbort}`, async () => { + const secret = generateSecretKey(); + const pubkey = getPublicKey(secret); + let receive: ((event: NostrEvent) => void) | undefined; + let unsubscribed = 0; + let disconnected = 0; + const relay: RelayHandler = { + async connect() {}, + async disconnect() { + disconnected++; + }, + async publish() {}, + unsubscribe() { + throw new Error('Must not unsubscribe shared connections'); + }, + getRelayUrls() { + return ['wss://example.invalid']; + }, + async subscribe( + _filters: Filter[], + callback: (event: NostrEvent) => void, + ) { + receive = callback; + await Bun.sleep(10); + return () => { + unsubscribed++; + }; + }, + }; + const http = async (): Promise => + Response.json({ + callback: 'https://example.invalid/callback', + nostrPubkey: pubkey, + allowsNostr: true, + }); + const processor = new LnBolt11ZapPaymentProcessor({ + lnAddress: 'receiver@example.invalid', + relayHandler: relay, + fetch: http, + ttlSeconds: 1, + }); + const abort = new AbortController(); + const pending = processor.verifyPayment({ + pay_req: 'invoice', + clientPubkey: 'client', + requestEventId: 'request', + abortSignal: abort.signal, + }); + const outcome = pending.catch((error) => error); + while (!receive) await Bun.sleep(1); + if (earlyAbort) abort.abort(); + else { + const receipt = finalizeEvent( + { + kind: 9735, + content: '', + created_at: Math.floor(Date.now() / 1000), + tags: [['bolt11', 'invoice']], + }, + secret, + ); + let finished = false; + void outcome.then(() => { + finished = true; + }); + receive( + JSON.parse( + JSON.stringify({ ...receipt, sig: '0'.repeat(128) }), + ) as NostrEvent, + ); + await Bun.sleep(1); + expect(finished).toBe(false); + receive(receipt); + } + const result = await outcome; + if (earlyAbort) expect(result).toBeInstanceOf(Error); + else expect(result._meta?.zap_receipt_event_id).toBeDefined(); + await processor.dispose(); + await processor.dispose(); + await Bun.sleep(15); + expect(unsubscribed).toBe(1); + expect(disconnected).toBe(0); + }); 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..9220c68 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); @@ -323,7 +329,7 @@ describe('Explicit Gating Middleware', () => { expect(sentResponses[2].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); }); - test('clears pending and returns fresh -32042 when verifyPayment rejects', async () => { + test('retains an uncertain invoice for reconciliation when verifyPayment rejects', async () => { const store = new AuthorizationStore(); const sentResponses: JSONRPCErrorResponse[] = []; let createCount = 0; @@ -364,15 +370,15 @@ describe('Explicit Gating Middleware', () => { // Let the async verifyPayment reject and clear pending state. await new Promise((r) => setTimeout(r, 5)); - // Retry: fresh -32042 (not -32043) with a brand-new payment request. + // Retry must reconcile the existing invoice without requesting another payment. await mw(message, ctx, async () => {}); expect(sentResponses).toHaveLength(2); - expect(sentResponses[1].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); - expect(createCount).toBe(2); + expect(sentResponses[1].error.code).toBe(PAYMENT_PENDING_ERROR_CODE); + expect(createCount).toBe(1); }); // Timeout-path counterpart to the test above. - test('clears pending and returns fresh -32042 when verifyPayment times out', async () => { + test('retains an uncertain invoice for reconciliation when verifyPayment times out', async () => { const store = new AuthorizationStore(); const sentResponses: JSONRPCErrorResponse[] = []; let createCount = 0; @@ -417,11 +423,11 @@ describe('Explicit Gating Middleware', () => { // Wait for the verify timeout (~200ms) + clearPending. await new Promise((r) => setTimeout(r, 300)); - // Retry: fresh -32042 (not -32043). + // Retry must reconcile the existing invoice without requesting another payment. await mw(message, ctx, async () => {}); expect(sentResponses).toHaveLength(2); - expect(sentResponses[1].error.code).toBe(PAYMENT_REQUIRED_ERROR_CODE); - expect(createCount).toBe(2); + expect(sentResponses[1].error.code).toBe(PAYMENT_PENDING_ERROR_CODE); + expect(createCount).toBe(1); }); // --- CEP-8 explicit-gating security invariants --- @@ -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..7cb6eb1 100644 --- a/src/payments/server-explicit-gating.ts +++ b/src/payments/server-explicit-gating.ts @@ -1,304 +1,290 @@ +import { reconcilePaymentAttempt } from './payment-recovery.js'; 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, + PaymentStateRecord, +} 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. - */ + onPaidDuplicate?: (record: PaymentStateRecord) => Promise; + onPaymentPending?: (requestEventId: string) => void; + onInvoiceIssued?: (params: { requestEventId: string }) => void; 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); + const respond = async ( + code: number, + text: string, + data?: Record, + ): Promise => { + await sendResponse( + ctx.clientPubkey, + { + jsonrpc: '2.0', + id: message.id, + error: { code, message: text, ...(data && { data }) }, + }, + admission.requestEventId, + ); + }; + params.onPaymentPending?.(admission.requestEventId); + const admitted = await store.admit(admission); + if (admitted.status === 'claimed') { + params.onInvoiceIssued?.({ requestEventId: admission.requestEventId }); + if (!abortSignal?.aborted) { + await forward(message); + await store.transition({ + key: admitted.record.key, + generation: admitted.record.generation, + type: 'dispatch', + }); + } 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, - }, + if (admitted.status === 'duplicate') { + if (admitted.record?.delivery) + await params.onPaidDuplicate?.(admitted.record); + return; + } + if ( + (admitted.status === 'unknown' || + (admitted.status === 'pending' && + admitted.record?.offerStatus !== 'published')) && + admitted.record?.invoice + ) { + const record = admitted.record; + if ( + record.offerStatus !== 'published' && + (record.invoiceExpiresAtMs ?? 0) > Date.now() + ) { + await respond(PAYMENT_REQUIRED_ERROR_CODE, 'Payment Required', { + payment_options: [ + { + ...record.invoice, + ttl: Math.min( + record.invoice?.ttl ?? Infinity, + ((record.invoiceExpiresAtMs ?? Date.now()) - Date.now()) / 1000, + ), + }, + ], + instructions: 'Resume the existing payment; do not pay it again.', + }); + } else { + await respond( + PAYMENT_PENDING_ERROR_CODE, + 'Settlement reconciliation required', + { retry_after: 2, reason: 'payment_reconciliation_required' }, ); - const capacityResponse: JSONRPCErrorResponse = { - 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; } - - logger.debug('payment already pending, returning -32043', { - requestEventId, + void reconcilePaymentAttempt({ + store, + key: record.key, + generation: record.generation, + processors: options.processors, + authorizationTtlMs: options.authorizationTtlMs ?? leaseMs, + abortSignal, + deadlineMs: Date.now() + leaseMs, + outcomes: options, + }).catch(() => {}); + 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, + context: { abortSignal, deadlineMs: record.leaseExpiresAtMs }, + 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 () => { - 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: 'offer_published' }); + 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: 'uncertain', + reason: 'verification_incomplete', }); - - // 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, + return; + } + await store.transition({ + ...fence, + type: 'settled', + ttlMs: options.authorizationTtlMs ?? leaseMs, + }); + })().catch(async () => { + try { + await store.transition({ + ...fence, + type: 'uncertain', + reason: 'verification_incomplete', }); - - authorizationStore.grant(identity, grantTtlMs); - } catch (err) { - logger.info('explicit payment verification failed or timed out', { - requestEventId, - error: err instanceof Error ? err.message : String(err), + } catch { + logger.error('Payment verification state could not be persisted', { + requestEventId: admission.requestEventId, }); - 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..a503daf --- /dev/null +++ b/src/payments/server-payment-reliability.test.ts @@ -0,0 +1,407 @@ +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(`late verifier ${outcome} cannot trigger a replacement invoice or duplicate grant`, 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).toBe(oldGeneration); + if (outcome === 'resolve') old.resolve({}); + else old.reject(new Error('old failure')); + await Bun.sleep(0); + expect(['pending', 'unknown', 'granted']).toContain( + (await store.read(authorizationKey))?.phase ?? 'missing', + ); + expect((await store.read(authorizationKey))?.generation).toBe( + replacement.generation, + ); + expect(invoices).toBe(1); + expect(replies.map((response) => response.error.code)).toEqual([ + -32042, -32043, + ]); + current.resolve({}); + await Bun.sleep(0); + let forwards = 0; + await middleware(request('paid-retry'), context, async () => { + forwards++; + }); + expect(forwards).toBe(1); + expect(invoices).toBe(1); + }); + } + + 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('a second worker leaves verification to the live invoice owner', async () => { + const settled = deferred>(); + const started = deferred(); + let verifications = 0; + let executions = 0; + const options: ServerPaymentsOptions = { + stateStore: new InMemoryPaymentStateStore(), + pricedCapabilities: priced, + processors: [ + { + ...processor(), + verifyPayment: () => { + verifications++; + started.resolve(undefined); + return settled.promise; + }, + }, + ], + }; + const invoke = (): Promise => + createServerPaymentsMiddleware({ + options, + sender: { sendNotification: async () => {} }, + })( + request(), + { ...context, paymentInteraction: 'transparent' }, + async () => { + executions++; + }, + ); + const first = invoke(); + await started.promise; + const second = invoke(); + await Bun.sleep(0); + settled.resolve({}); + await Promise.all([first, second]); + expect(verifications).toBe(1); + expect(executions).toBe(1); + }); + + 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..06fb301 --- /dev/null +++ b/src/payments/server-payment-validation.ts @@ -0,0 +1,130 @@ +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({ + disposeProcessorsOnClose: z.boolean().optional(), + 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..01a9655 100644 --- a/src/payments/server-payments-utils.ts +++ b/src/payments/server-payments-utils.ts @@ -1,6 +1,14 @@ +import { + paymentOperation, + type PaymentOperationContext, +} from './payment-cancellation.js'; 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 +19,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 +104,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 +120,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 +167,24 @@ export async function resolveAndInitiatePayment(params: { }) => Promise; }; processorsByPmi: Map; + context?: PaymentOperationContext; + /** 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 +196,41 @@ 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 operation = paymentOperation(params.context, 5 * 60 * 1000); + let paymentRequired: PaymentRequired; + try { + operation.signal.throwIfAborted(); + paymentRequired = validateProcessorPayment( + await operation.wait( + processor.createPaymentRequired({ + amount: resolvedQuote.amount, + description: resolvedQuote.description, + requestEventId: params.requestEventId, + clientPubkey: params.clientPubkey, + abortSignal: operation.signal, + deadlineMs: params.context?.deadlineMs, + }), + ), + processor, + resolvedQuote.amount, + ); + } finally { + operation.dispose(); + } const mergedMeta = resolvedQuote.meta === undefined && paymentRequired._meta === undefined @@ -200,6 +253,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 @@ -221,27 +286,19 @@ export async function verifyPaymentUnlessShutdown(params: { return undefined; } - const controller = new AbortController(); - const onShutdown = (): void => controller.abort(); - shutdownSignal?.addEventListener('abort', onShutdown, { once: true }); - + const operation = paymentOperation({}, params.timeoutMs, shutdownSignal); try { - const verified = await withTimeout( + const verified = await operation.wait( params.processor.verifyPayment({ ...params.verifyParams, - abortSignal: controller.signal, + abortSignal: operation.signal, }), - params.timeoutMs, - 'verifyPayment timed out', ); return shutdownSignal?.aborted ? undefined : verified; - } catch (err) { - if (shutdownSignal?.aborted) { - return undefined; - } - throw err; + } catch (error) { + if (shutdownSignal?.aborted) return undefined; + throw error; } finally { - shutdownSignal?.removeEventListener('abort', onShutdown); - controller.abort(); + operation.dispose(); } } diff --git a/src/payments/server-payments.test.ts b/src/payments/server-payments.test.ts index a7ba5d6..93da230 100644 --- a/src/payments/server-payments.test.ts +++ b/src/payments/server-payments.test.ts @@ -138,14 +138,12 @@ 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.) + // The retained event record refuses another invoice even after the local run ends. await expect(harness.run('evt1')).rejects.toThrow( 'payment rail unreachable', ); expect(harness.invoiceCount('evt1')).toBe(1); + expect(harness.forwards()).toBe(0); }); test('pre-invoice failure deletes the entry, so the retry mints exactly one invoice', async () => { @@ -258,24 +256,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); }); }); @@ -490,13 +476,13 @@ describe('createServerPaymentsMiddleware transport shutdown', () => { const run = harness.run('evt1'); await deferred.started; - expect(added).toEqual(['abort']); - expect(removed).toEqual([]); + expect(added).toEqual(['abort', 'abort']); + expect(removed).toEqual(['abort']); deferred.resolve(); await run; - expect(removed).toEqual(['abort']); + expect(removed).toEqual(added); expect(harness.forwards()).toBe(1); }); diff --git a/src/payments/server-payments.ts b/src/payments/server-payments.ts index 20912d2..ef6f49d 100644 --- a/src/payments/server-payments.ts +++ b/src/payments/server-payments.ts @@ -1,16 +1,18 @@ +import type { PaymentOutcomeOptions } from './payment-outcomes.js'; 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 { + PaymentStateRecord, + PaymentStatePolicy, + PaymentStateStore, +} from './payment-state-store.js'; import { createLogger } from '../core/utils/logger.js'; import { DEFAULT_PAYMENT_TTL_MS, @@ -21,414 +23,293 @@ 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'; +import { isLivePaymentLease } from './payment-state-machine.js'; + +/** Server-side payment policy; both lifecycles share one atomic state store. */ +export interface ServerPaymentsOptions + extends Partial, PaymentOutcomeOptions { processors: readonly PaymentProcessor[]; + /** Dispose configured processors on transport close only when they are exclusively owned. @default false */ + disposeProcessorsOnClose?: boolean; 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; + onPaymentPending?: (requestEventId: string) => void; + onPaidDuplicate?: (record: PaymentStateRecord) => Promise; }): 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); + if (!priced || abortSignal?.aborted) { + 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; + const admission = serverPaymentAdmission( + message, + ctx, + 'transparent', + leaseMs, + ); + params.onPaymentPending?.(admission.requestEventId); + const existing = running.get(admission.key); + if (existing) { + await existing; 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), - }); - } - 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); + const recovering = + admitted.status === 'duplicate' && + admitted.record?.invoice && + ['pending', 'unknown', 'expired'].includes(admitted.record.phase) && + (!isLivePaymentLease(admitted.record, Date.now()) || + admitted.record.settlement !== undefined); + if (admitted.status !== 'reserved' && !recovering) { + if (admitted.status === 'duplicate' && admitted.record?.delivery) + await params.onPaidDuplicate?.(admitted.record); + else 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; + if (!record) throw new Error('Payment admission record missing'); + const fence = { key: record.key, generation: record.generation }; try { - await sender.sendNotification( - ctx.clientPubkey, - acceptedNotification, - requestEventId, - ); - } catch (err) { - logger.warn('failed to publish payment_accepted, forwarding anyway', { - requestEventId, - error: err instanceof Error ? err.message : String(err), + if (abortSignal?.aborted) { + await store.transition({ ...fence, type: 'release' }); + return; + } + const recoveredInvoice = recovering ? record.invoice : undefined; + const recoveredProcessor = recoveredInvoice + ? processorsByPmi.get(recoveredInvoice.pmi) + : undefined; + if (recoveredInvoice && !recoveredProcessor) + throw new Error('Recorded payment processor is unavailable'); + const initiated = + recoveredInvoice && recoveredProcessor + ? { + kind: 'payment_required' as const, + paymentRequired: recoveredInvoice, + mergedMeta: recoveredInvoice._meta, + processor: recoveredProcessor, + verifyTimeoutMs: Math.min( + leaseMs, + (recoveredInvoice.ttl ?? leaseMs / 1000) * 1000, + ), + } + : await resolveAndInitiatePayment({ + message, + priced, + requestEventId: admission.requestEventId, + clientPubkey: ctx.clientPubkey, + clientPmis: ctx.clientPmis, + options, + processorsByPmi, + context: { abortSignal, deadlineMs: record.leaseExpiresAtMs }, + 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 ( + !recovering && + !(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; + if ( + !record.settlement && + (record.invoiceExpiresAtMs === undefined || + record.invoiceExpiresAtMs > Date.now()) + ) + await sender.sendNotification( + ctx.clientPubkey, + { + jsonrpc: '2.0', + method: PAYMENT_REQUIRED_METHOD, + params: { + ...paymentRequired, + ...(recovering && + record.invoiceExpiresAtMs !== undefined && { + ttl: Math.max( + 0.001, + (record.invoiceExpiresAtMs - Date.now()) / 1000, + ), + }), + _meta: mergedMeta, + }, + }, + admission.requestEventId, + ); + if (!record.settlement) + await store.transition({ ...fence, type: 'offer_published' }); + const verified = record.settlement + ? {} + : 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: 'settled', + ttlMs: leaseMs, + })) || + !(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..74c2d8f 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' }, @@ -236,14 +240,14 @@ 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'); + test('explicit gating: close() aborts the verify signal and a late settle retains an uncertain attempt instead of granting', async () => { + 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,17 @@ 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 === 'uncertain', + ), + ).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.test.ts b/src/payments/server-transport-payments.test.ts index 531a661..b6b676e 100644 --- a/src/payments/server-transport-payments.test.ts +++ b/src/payments/server-transport-payments.test.ts @@ -9,6 +9,7 @@ describe('withServerPayments registration guard', () => { } { let inbound = 0; const stub = { + setPaymentResultRecovery: () => {}, setAnnouncementExtraTags: () => {}, setAnnouncementPricingTags: () => {}, setSupportedPaymentInteraction: () => {}, diff --git a/src/payments/server-transport-payments.ts b/src/payments/server-transport-payments.ts index ea497b2..deb03a9 100644 --- a/src/payments/server-transport-payments.ts +++ b/src/payments/server-transport-payments.ts @@ -1,17 +1,23 @@ +import { PaymentResultRecovery } from './payment-recovery.js'; +import { ObservedPaymentStateStore } from './observed-payment-state-store.js'; import type { NostrServerTransport } from '../transport/nostr-server-transport.js'; import type { PaymentInteractionPolicy } from './types.js'; import type { ServerPaymentsOptions } from './server-payments.js'; +import type { PaymentStateRecord } from './payment-state-store.js'; import { createCapTagsFromPricedCapabilities } from './cap-tags.js'; import { createPmiTagsFromProcessors } from './pmi-tags.js'; import { createServerPaymentsMiddleware } from './server-payments.js'; import { createExplicitGatingMiddleware } from './server-explicit-gating.js'; -import { AuthorizationStore } from './authorization-store.js'; +import { 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 +41,19 @@ export function withServerPayments( 'withServerPayments already called on this transport; registering the payments lifecycle twice double-charges priced requests', ); } + validateServerPaymentOptions(options); + const backend = serverPaymentStore(options); + const stateStore = + options.onPaymentOutcome || options.paymentJournal + ? new ObservedPaymentStateStore(backend, options) + : backend; + transport.setPaymentResultRecovery( + new PaymentResultRecovery(stateStore, { + ...options, + publicationLeaseMs: options.paymentTtlMs ?? 300_000, + }), + ); + options = { ...options, stateStore }; paymentsRegistered.add(transport); // Build the PMI → processor map once and share it across both middlewares. @@ -69,29 +88,43 @@ export function withServerPayments( // middlewares stop polling and skip forward/grant once it fires. Not chained // through `onclose`, which consumers may reassign after registration. const abortSignal = transport.closeSignal; + if (options.disposeProcessorsOnClose) { + transport.addCloseHandler(async () => { + const results = await Promise.allSettled( + [...new Set(options.processors)].map((p) => + Promise.resolve().then(() => p.dispose?.()), + ), + ); + if (results.some((result) => result.status === 'rejected')) + throw new Error('Payment processor disposal failed'); + }); + } + const middlewareParams = { + options, + processorsByPmi, + abortSignal, + // Capture before provider I/O so eviction cannot lose a paying client's route. + onPaymentPending: (id: string): void => + transport.capturePaymentRouteSnapshot( + id, + Number.MAX_SAFE_INTEGER - Date.now(), + ), + onPaidDuplicate: (record: PaymentStateRecord): Promise => + transport.retryPaymentResult(record.clientPubkey, record.requestEventId), + }; transport.addInboundMiddleware( - createServerPaymentsMiddleware({ - sender: transport, - options, - processorsByPmi, - // Snapshot the correlation route when an invoice goes out so the paid - // response survives route-pop (duplicate cleanup) and session eviction. - onInvoiceIssued: ({ requestEventId, snapshotTtlMs }) => - transport.capturePaymentRouteSnapshot(requestEventId, snapshotTtlMs), - abortSignal, - }), + createServerPaymentsMiddleware({ ...middlewareParams, sender: transport }), ); // The transparent middleware self-gates on the per-session effective mode, so // it is safe to register the explicit-gating middleware alongside it. Each // request is routed to exactly one lifecycle based on the negotiated mode. if (supportsExplicitGating) { - const authorizationStore = new AuthorizationStore({}); transport.addInboundMiddleware( createExplicitGatingMiddleware({ - options, - authorizationStore, + ...middlewareParams, + authorizationStore: stateStore, sendResponse: async (clientPubkey, response, requestEventId) => { await transport.sendTargetedResponse( clientPubkey, @@ -99,8 +132,6 @@ export function withServerPayments( requestEventId, ); }, - processorsByPmi, - abortSignal, }), ); } diff --git a/src/payments/types.ts b/src/payments/types.ts index f9fe83d..e6355a5 100644 --- a/src/payments/types.ts +++ b/src/payments/types.ts @@ -1,8 +1,10 @@ +import type { PaymentOperationContext } from './payment-cancellation.js'; import type { JSONRPCMessage, 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. @@ -147,7 +149,7 @@ export type PaymentRejectedNotification = JSONRPCNotification & { }; }; -export interface PaymentHandlerRequest { +export interface PaymentHandlerRequest extends PaymentOperationContext { amount: number; pay_req: string; /** The PMI requested by the server (e.g. "bitcoin-lightning-bolt11"). */ @@ -158,6 +160,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; } /** @@ -172,18 +176,21 @@ export interface PaymentHandler { /** Optional policy check that can decline handling. */ canHandle?(req: PaymentHandlerRequest): boolean | Promise; + /** Dispose adapter-owned work; a local abort does not reverse a payment. */ + dispose?(): Promise; + /** Execute the payment (wallet action). */ handle(req: PaymentHandlerRequest): Promise; } -export interface PaymentProcessorCreateParams { +export interface PaymentProcessorCreateParams extends PaymentOperationContext { amount: number; description?: string; requestEventId: string; clientPubkey: string; } -export interface PaymentProcessorVerifyParams { +export interface PaymentProcessorVerifyParams extends PaymentOperationContext { pay_req: string; requestEventId: string; clientPubkey: string; @@ -195,6 +202,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 +253,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 +278,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,12 +312,17 @@ 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( params: PaymentProcessorCreateParams, ): Promise; + /** Dispose adapter-owned work; caller-owned connections remain usable. */ + dispose?(): Promise; + /** Wait for and/or verify settlement for a previously issued pay_req */ verifyPayment( params: PaymentProcessorVerifyParams, @@ -313,6 +332,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.dedup-response.test.ts b/src/transport/nostr-server-transport.dedup-response.test.ts index c1f8f07..3845f3b 100644 --- a/src/transport/nostr-server-transport.dedup-response.test.ts +++ b/src/transport/nostr-server-transport.dedup-response.test.ts @@ -503,3 +503,42 @@ describe.serial('NostrServerTransport duplicate response prevention', () => { }); }); }); + +it('close during signing prevents a late publication without mutating the response', async () => { + const counter = { publishCalls: 0 }; + const signer = new PrivateKeySigner('1'.repeat(64)); + let sign!: () => void; + let entered!: () => void; + const signing = new Promise((resolve) => { + entered = resolve; + }); + const paused = new Promise((resolve) => { + sign = resolve; + }); + const original = signer.signEvent.bind(signer); + signer.signEvent = async (event) => { + entered(); + await paused; + return original(event); + }; + const transport = new NostrServerTransport({ + signer, + relayHandler: makeCountingRelayHandler(counter), + encryptionMode: EncryptionMode.DISABLED, + }); + transport['sessionStore'].getOrCreateSession('client', false); + transport['correlationStore'].registerEventRoute('request', 'client', 7); + const response = { + jsonrpc: '2.0' as const, + id: 'request', + result: { ok: true }, + }; + const sending = transport.send(response).catch((error) => error); + await signing; + await transport.close(); + sign(); + expect(await sending).toBeInstanceOf(Error); + expect(counter.publishCalls).toBe(0); + expect(response.id).toBe('request'); + expect(transport['correlationStore'].hasEventRoute('request')).toBe(false); +}); 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-transport.ts b/src/transport/nostr-server-transport.ts index 4e53950..22be151 100644 --- a/src/transport/nostr-server-transport.ts +++ b/src/transport/nostr-server-transport.ts @@ -1,3 +1,4 @@ +import type { PaymentResultRecovery } from '../payments/payment-recovery.js'; import { type ListToolsResult, isJSONRPCNotification, @@ -210,6 +211,8 @@ export class NostrServerTransport private readonly inboundMiddlewares: InboundMiddlewareFn[] = []; /** Aborted at the top of `close()`; see `closeSignal`. */ private readonly closeController = new AbortController(); + private readonly closeHandlers = new Set<() => Promise>(); + private closing?: Promise; private readonly listToolsResultTransformers: ListToolsResultTransformer[] = []; private readonly listToolsAnnouncementTagsProducers: ListToolsAnnouncementTagsProducer[] = @@ -235,6 +238,7 @@ export class NostrServerTransport private readonly eventPipeline: ServerEventPipeline; private readonly inboundCoordinator: ServerInboundCoordinator; private readonly inboundNotificationDispatcher: InboundNotificationDispatcher; + private paymentResultRecovery?: PaymentResultRecovery; private readonly outboundResponseRouter: OutboundResponseRouter; private readonly outboundNotificationBroadcaster: OutboundNotificationBroadcaster; @@ -428,6 +432,7 @@ export class NostrServerTransport ); this.outboundResponseRouter = new OutboundResponseRouter({ + closeSignal: this.closeSignal, correlationStore: this.correlationStore, sessionStore: this.sessionStore, announcementManager: this.announcementManager, @@ -588,14 +593,39 @@ export class NostrServerTransport } } - /** - * Closes the transport, disconnecting from the relay. - */ - public async close(): Promise { + /** Prevents signing or queued result work from publishing after transport shutdown. */ + protected override async publishEvent(event: NostrEvent): Promise { + this.closeSignal.throwIfAborted(); + await this.relayHandler.publish(event, { abortSignal: this.closeSignal }); + this.closeSignal.throwIfAborted(); + } + + /** Registers cleanup for resources explicitly owned by a transport integration. */ + public addCloseHandler(handler: () => Promise): void { + if (this.closeSignal.aborted) throw new Error('Transport is closed'); + this.closeHandlers.add(handler); + } + + /** Closes the transport and disposes registered owned resources exactly once. */ + public close(): Promise { + this.closing ??= this.closeInternal(); + return this.closing; + } + + private async closeInternal(): Promise { try { // Signal shutdown before any await: a payment verification that settles // during teardown must not forward, publish, or grant. this.closeController.abort(); + const cleanup = await Promise.allSettled( + [...this.closeHandlers].map((handler) => + Promise.resolve().then(handler), + ), + ); + this.closeHandlers.clear(); + for (const result of cleanup) + if (result.status === 'rejected') + this.onerror?.(new Error('Integration cleanup failed')); // Shutdown the task queue to prevent new tasks from being queued // and clear pending tasks to avoid operating on stale state @@ -786,6 +816,29 @@ export class NostrServerTransport * popped by duplicate-delivery cleanup or the idle paying client's session * was LRU-evicted while payment settled (CEP-8). */ + /** Uses the authorization store for result publication; persistence depends on that store. */ + public setPaymentResultRecovery(recovery: PaymentResultRecovery): void { + this.paymentResultRecovery = recovery; + this.outboundResponseRouter.setPaymentResultJournal(recovery); + } + + /** Republishes a recorded paid result without calling the application handler. */ + public async retryPaymentResult( + clientPubkey: string, + requestEventId: string, + ): Promise { + if (this.closeSignal.aborted) throw new Error('Transport is closed'); + if (!this.paymentResultRecovery) + throw new Error('Payment result recovery is not configured'); + await this.paymentResultRecovery.replay( + clientPubkey, + requestEventId, + (snapshot) => + this.correlationStore.restoreRouteSnapshot(requestEventId, snapshot), + (response) => this.outboundResponseRouter.route(response), + ); + } + public capturePaymentRouteSnapshot( requestEventId: string, ttlMs: number, @@ -812,19 +865,25 @@ export class NostrServerTransport correlatedEventId?: string, wrapKindHint?: number, ): Promise { + const retained = correlatedEventId + ? this.correlationStore.peekRouteSnapshot(correlatedEventId) + : undefined; + const snapshot = + retained?.route.clientPubkey === clientPubkey ? retained : undefined; if (this.openStreamFactory.isClientEvicted(clientPubkey)) { // A probe-timeout eviction blacklists the pubkey until its pending // response routes (takePendingEviction). If the client has since // re-established a session, lift the blacklist instead of refusing — // otherwise a probe timeout whose response never routes permanently // breaks notification delivery (incl. payment_required) for that client. - if (!this.sessionStore.getSession(clientPubkey)) { + if (!snapshot?.session && !this.sessionStore.getSession(clientPubkey)) { throw new Error(`No active session found for client: ${clientPubkey}`); } this.openStreamFactory.clearClientEviction(clientPubkey); } - const session = this.sessionStore.getSession(clientPubkey); + const session = + snapshot?.session ?? this.sessionStore.getSession(clientPubkey); if (!session) { throw new Error(`No active session found for client: ${clientPubkey}`); } @@ -846,7 +905,10 @@ export class NostrServerTransport fallbackWrapKind: wrapKindHint ?? (correlatedEventId - ? this.correlationStore.getEventRoute(correlatedEventId)?.wrapKind + ? ( + snapshot?.route ?? + this.correlationStore.getEventRoute(correlatedEventId) + )?.wrapKind : undefined), }); diff --git a/src/transport/nostr-server/correlation-store.ts b/src/transport/nostr-server/correlation-store.ts index e1d2bbc..e7091ed 100644 --- a/src/transport/nostr-server/correlation-store.ts +++ b/src/transport/nostr-server/correlation-store.ts @@ -180,6 +180,14 @@ export class CorrelationStore { * @param eventId The Nostr event ID * @returns The snapshot, or undefined when absent or expired */ + /** Returns a defensive snapshot without consuming its delivery fallback. */ + peekRouteSnapshot(eventId: string): RouteSnapshot | undefined { + const snapshot = this.routeSnapshots.get(eventId); + return snapshot && snapshot.expiresAtMs > Date.now() + ? structuredClone(snapshot) + : undefined; + } + takeRouteSnapshot(eventId: string): RouteSnapshot | undefined { const snapshot = this.routeSnapshots.get(eventId); if (!snapshot) { diff --git a/src/transport/nostr-server/inbound-coordinator.test.ts b/src/transport/nostr-server/inbound-coordinator.test.ts index fd814a8..62621d9 100644 --- a/src/transport/nostr-server/inbound-coordinator.test.ts +++ b/src/transport/nostr-server/inbound-coordinator.test.ts @@ -66,6 +66,10 @@ function createCoordinator(options?: { options?.authorizationPolicy ?? new AuthorizationPolicy(), openStreamFactory: { createWriterIfEnabled: () => undefined, + admitInvocation: () => undefined, + reserveInvocation: () => undefined, + ownsReservation: () => true, + ownsInvocation: () => false, inputStreamIfEnabled: () => undefined, releaseUnusedWriter: () => undefined, } as unknown as ServerOpenStreamFactory, @@ -185,3 +189,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..934a695 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', @@ -293,6 +299,8 @@ export class ServerInboundCoordinator { ): Promise => { const mw = middlewares[index]; if (!mw) { + if (isJSONRPCRequest(msg)) + this.deps.openStreamFactory.admitInvocation(String(msg.id)); const forwarded = await this.deps.forwardMessage(msg, event.pubkey); if (forwarded) { this.trackSubscription(msg, event.pubkey); @@ -306,7 +314,12 @@ export class ServerInboundCoordinator { return forwarded; }; + const reservationOwner = {}; if (isJSONRPCRequest(inboundMessage)) { + this.deps.openStreamFactory.reserveInvocation( + event.id, + reservationOwner, + ); this.handleIncomingRequest( event, event.id, @@ -336,7 +349,7 @@ export class ServerInboundCoordinator { void dispatch(0, inboundMessage) .then((forwarded) => { if (!forwarded) { - this.cleanupDroppedRequest(inboundMessage); + this.cleanupDroppedRequest(inboundMessage, reservationOwner); } }) .catch((err: unknown) => { @@ -350,7 +363,7 @@ export class ServerInboundCoordinator { ); // A failed chain never forwards and (normally) never responds: the // request's reserved state must be released here too, or it leaks. - this.cleanupDroppedRequest(inboundMessage); + this.cleanupDroppedRequest(inboundMessage, reservationOwner); }); } catch (error) { this.deps.logger.error('Error in authorizeAndProcessEvent', { @@ -379,14 +392,15 @@ export class ServerInboundCoordinator { request.id = eventId; const progressToken = request.params?._meta?.progressToken; - this.deps.correlationStore.registerEventRoute( - eventId, - clientPubkey, - originalRequestId, - progressToken ? String(progressToken) : undefined, - wrapKind, - this.deps.shouldInjectRequestEventId ? event : undefined, - ); + if (!this.deps.correlationStore.hasEventRoute(eventId)) + this.deps.correlationStore.registerEventRoute( + eventId, + clientPubkey, + originalRequestId, + progressToken ? String(progressToken) : undefined, + wrapKind, + this.deps.shouldInjectRequestEventId ? event : undefined, + ); // Create the CEP-41 open-stream writer and bind it to the request's // `_meta.stream` at the single creation site, keyed by the same `eventId` @@ -456,13 +470,18 @@ export class ServerInboundCoordinator { * route and the open-stream writer reservation together, since a dropped * request never produces the normal-path response that would reap them. */ - public cleanupDroppedRequest(message: JSONRPCMessage): void { + public cleanupDroppedRequest(message: JSONRPCMessage, owner?: object): void { if (!isJSONRPCRequest(message)) { return; } const eventId = String(message.id); + if ( + (owner && !this.deps.openStreamFactory.ownsReservation(eventId, owner)) || + this.deps.openStreamFactory.ownsInvocation(eventId) + ) + return; this.deps.correlationStore.popEventRoute(eventId); - this.deps.openStreamFactory.releaseUnusedWriter(eventId); + this.deps.openStreamFactory.releaseUnusedWriter(eventId, true); } /** diff --git a/src/transport/nostr-server/open-stream-factory.test.ts b/src/transport/nostr-server/open-stream-factory.test.ts index f21ac54..a27951d 100644 --- a/src/transport/nostr-server/open-stream-factory.test.ts +++ b/src/transport/nostr-server/open-stream-factory.test.ts @@ -327,7 +327,11 @@ describe('ServerOpenStreamFactory.getOpenStreams', () => { const { factory } = createFactory(); const first = factory.createWriterIfEnabled('evt-dup', 'pk-1', 'token-dup'); - const second = factory.createWriterIfEnabled('evt-dup', 'pk-1', 'token-dup'); + const second = factory.createWriterIfEnabled( + 'evt-dup', + 'pk-1', + 'token-dup', + ); // A duplicate delivery re-enters the inbound path for the same event id; // overwriting the reservation would orphan the writer already bound to @@ -357,3 +361,23 @@ describe('ServerOpenStreamFactory.getOpenStreams', () => { expect(factory.getWriter('evt-live')).toBeUndefined(); }); }); + +test('a dropped duplicate cannot dispose a forwarded pre-write reservation', async () => { + const { factory, notifications } = createFactory(); + const first = {}; + const duplicate = {}; + factory.reserveInvocation('paid', first); + const writer = factory.createWriterIfEnabled('paid', 'client', 'token'); + expect(writer).toBeDefined(); + factory.admitInvocation('paid'); + factory.reserveInvocation('paid', duplicate); + expect(factory.ownsReservation('paid', duplicate)).toBe(false); + factory.releaseUnusedWriter('paid', true); + expect(writer?.hasStarted).toBe(false); + expect(writer?.isActive).toBe(true); + await writer?.write('paid data'); + await writer?.close(); + expect(notifications).toHaveLength(3); + expect(factory.getWriter('paid')).toBeUndefined(); + factory.clear(); +}); diff --git a/src/transport/nostr-server/open-stream-factory.ts b/src/transport/nostr-server/open-stream-factory.ts index 6d51592..3803643 100644 --- a/src/transport/nostr-server/open-stream-factory.ts +++ b/src/transport/nostr-server/open-stream-factory.ts @@ -31,6 +31,7 @@ export interface ServerOpenStreamFactoryDeps { sendNotification: ( clientPubkey: string, notification: JSONRPCMessage, + correlatedEventId?: string, ) => Promise; handleResponse: (response: JSONRPCResponse) => Promise; sessionStore: SessionStore; @@ -65,6 +66,8 @@ export interface ServerOpenStreamInfo { * Manages the lifecycle of CEP-41 OpenStream instances for the server transport. */ export class ServerOpenStreamFactory { + private readonly reservationOwners = new Map(); + private readonly admitted = new Set(); private readonly writers = new Map(); private readonly writerMeta = new Map< string, @@ -267,7 +270,10 @@ export class ServerOpenStreamFactory { } /** Drops the client's token counter once no live sender remains for it. */ - private pruneOutboundProgress(clientPubkey: string, progressToken: string): void { + private pruneOutboundProgress( + clientPubkey: string, + progressToken: string, + ): void { const key = ServerOpenStreamFactory.senderKey(clientPubkey, progressToken); if ((this.tokenEventIds.get(key)?.size ?? 0) > 0) return; const session = this.receiver.getSession(progressToken, clientPubkey); @@ -379,6 +385,8 @@ export class ServerOpenStreamFactory { for (const writer of this.writers.values()) { writer.dispose(); } + this.reservationOwners.clear(); + this.admitted.clear(); this.writers.clear(); this.writerMeta.clear(); this.tokenEventIds.clear(); @@ -435,6 +443,7 @@ export class ServerOpenStreamFactory { if (route?.progressToken) { this.evictInputStream(route.clientPubkey, String(route.progressToken)); } + this.reservationOwners.delete(eventId); const existingWriter = this.writers.get(eventId); if (existingWriter && existingWriter.hasStarted) { this.pendingResponses.set(eventId, response); @@ -454,18 +463,36 @@ export class ServerOpenStreamFactory { * reaper besides a routed response and full transport teardown, so every * drop path must call it or the entry leaks until close(). */ - public releaseUnusedWriter(eventId: string): void { + /** Marks a reservation as owned immediately before forwarding, independently of first write. */ + public admitInvocation(eventId: string): void { + if (this.writers.has(eventId)) this.admitted.add(eventId); + } + + /** Whether cleanup is attempting to remove resources owned by another delivery. */ + public ownsInvocation(eventId: string): boolean { + return this.admitted.has(eventId); + } + + /** Records the first delivery's reservation without transferring ownership to duplicates. */ + public reserveInvocation(eventId: string, owner: object): void { + if (!this.reservationOwners.has(eventId)) + this.reservationOwners.set(eventId, owner); + } + + /** Whether this delivery may release the original reservation. */ + public ownsReservation(eventId: string, owner: object): boolean { + const held = this.reservationOwners.get(eventId); + return held === undefined || held === owner; + } + + public releaseUnusedWriter(eventId: string, dropping = false): void { + if (dropping && this.admitted.has(eventId)) return; + this.reservationOwners.delete(eventId); const writer = this.writers.get(eventId); if (!writer) { return; } - // A started writer is owned by a running invocation: a dropped duplicate - // of the same request event must not dispose the live stream. The - // writer's own lifecycle reaps it (close/abort → flushPendingResponse, - // probe timeout, or teardown clear()). - // ponytail: a duplicate dropped before the handler's first frame still - // disposes the reservation the forwarded handler holds — the request then - // completes stream-less; refcount reservations if that window matters. + // Once forwarded, even an unwritten reservation belongs to the invocation. if (writer.hasStarted) { return; } @@ -505,11 +532,15 @@ export class ServerOpenStreamFactory { nextProgress: () => this.nextOutboundProgress(clientPubkey, progressToken), publishFrame: async (frame) => { - await this.deps.sendNotification(clientPubkey, { - jsonrpc: '2.0', - method: 'notifications/progress', - params: frame, - }); + await this.deps.sendNotification( + clientPubkey, + { + jsonrpc: '2.0', + method: 'notifications/progress', + params: frame, + }, + eventId, + ); return undefined; }, onClose: async (): Promise => { @@ -550,6 +581,8 @@ export class ServerOpenStreamFactory { public async flushPendingResponse(eventId: string): Promise { const pendingResponse = this.pendingResponses.get(eventId); this.pendingResponses.delete(eventId); + this.reservationOwners.delete(eventId); + this.admitted.delete(eventId); const token = this.writers.get(eventId)?.progressToken; const owner = this.writerMeta.get(eventId)?.clientPubkey; this.writers.delete(eventId); @@ -570,6 +603,7 @@ export class ServerOpenStreamFactory { /** Removes a writer's map bookings and drops the client's counter if last. */ private removeWriterBooking(eventId: string, progressToken: string): void { + this.admitted.delete(eventId); const owner = this.writerMeta.get(eventId)?.clientPubkey; this.writers.delete(eventId); this.writerMeta.delete(eventId); diff --git a/src/transport/nostr-server/outbound-response-router.test.ts b/src/transport/nostr-server/outbound-response-router.test.ts index ffddb60..5c05f20 100644 --- a/src/transport/nostr-server/outbound-response-router.test.ts +++ b/src/transport/nostr-server/outbound-response-router.test.ts @@ -401,3 +401,96 @@ describe('OutboundResponseRouter.route payment route snapshot fallback', () => { expect(errors[0].message).toContain('No pending request found'); }); }); + +// Each failure stage must be recoverable through the same response object after session eviction. +describe('transactional paid result publication', () => { + for (const failure of ['ordinary', 'start', 'chunk', 'end'] as const) { + test(`restores correlation and disclosure after ${failure} publication failure`, async () => { + const correlationStore = new CorrelationStore(); + correlationStore.registerEventRoute( + 'paid', + CLIENT_PUBKEY, + 7, + 'stream', + EPHEMERAL_GIFT_WRAP_KIND, + ); + const session = createSession({ + supportsOversizedTransfer: failure !== 'ordinary', + hasSentCommonTags: false, + requestedPaymentInteraction: 'explicit_gating', + effectivePaymentInteraction: 'transparent', + hasDisclosedPaymentInteraction: false, + }); + correlationStore.captureRouteSnapshot('paid', session, 10000); + correlationStore.popEventRoute('paid'); + const { deps } = createRouterWithCapturedDeps(correlationStore, session); + deps.sessionStore.getSession = () => undefined; + deps.oversizedConfig = { + enabled: failure !== 'ordinary', + threshold: 100, + chunkSize: 32, + }; + deps.measurePublishedMcpMessageSize = async () => 1000; + deps.resolveSafeOversizedChunkSize = async () => 32; + const published: JSONRPCMessage[] = []; + const tags: string[][][] = []; + let failing = true; + deps.buildOutboundTags = ({ baseTags, session: current }) => { + current.hasSentCommonTags = true; + return [...baseTags]; + }; + deps.sendMcpMessage = async ( + message, + target, + _kind, + frameTags, + encrypted, + _created, + wrap, + ) => { + expect(target).toBe(CLIENT_PUBKEY); + expect(encrypted).toBe(true); + expect(wrap).toBe(EPHEMERAL_GIFT_WRAP_KIND); + const stage = + 'method' in message + ? (message.params?.cvm as { frameType?: string })?.frameType + : 'ordinary'; + if (failing && stage === failure) throw new Error(`failed-${failure}`); + published.push(message); + tags.push(frameTags ?? []); + return 'sent'; + }; + const router = new OutboundResponseRouter(deps); + const response = { + jsonrpc: '2.0' as const, + id: 'paid', + result: { text: 'x'.repeat(500) }, + }; + await expect(router.route(response)).rejects.toThrow(`failed-${failure}`); + expect(response.id).toBe('paid'); + failing = false; + await router.route(response); + expect(response.id).toBe('paid'); + if (failure === 'ordinary') expect(published).toHaveLength(1); + else + expect( + published.filter( + (message) => + 'method' in message && + (message.params?.cvm as { frameType?: string })?.frameType === + 'end', + ), + ).toHaveLength(1); + const firstOfRetry = + failure === 'ordinary' + ? tags[0] + : tags.find((value) => + value.some((tag) => tag[0] === 'payment_interaction'), + ); + expect(firstOfRetry).toContainEqual([ + 'payment_interaction', + 'transparent', + ]); + }); + } +}); diff --git a/src/transport/nostr-server/outbound-response-router.ts b/src/transport/nostr-server/outbound-response-router.ts index a82e6c2..90f01d1 100644 --- a/src/transport/nostr-server/outbound-response-router.ts +++ b/src/transport/nostr-server/outbound-response-router.ts @@ -1,3 +1,5 @@ +import { canonicalJson } from '../../core/utils/canonical-json.js'; +import type { PaymentResultJournal } from '../../payments/payment-delivery.js'; import { type JSONRPCResponse, type JSONRPCErrorResponse, @@ -22,6 +24,7 @@ import type { ResolveSafeOversizedChunkSizeParams } from '../base-nostr-transpor * Dependencies for the OutboundResponseRouter. */ export interface OutboundResponseRouterDeps { + closeSignal?: AbortSignal; correlationStore: CorrelationStore; sessionStore: SessionStore; announcementManager: AnnouncementManager; @@ -75,6 +78,15 @@ export interface OutboundResponseRouterDeps { * Routes outbound JSON-RPC responses back to the original client. */ export class OutboundResponseRouter { + private journal?: PaymentResultJournal; + + /** Installs payment publication persistence without changing unpriced routing. */ + public setPaymentResultJournal(journal: PaymentResultJournal): void { + this.journal = journal; + } + + private readonly sending = new Map>(); + constructor(private deps: OutboundResponseRouterDeps) {} /** @@ -82,6 +94,22 @@ export class OutboundResponseRouter { */ public async route( response: JSONRPCResponse | JSONRPCErrorResponse, + ): Promise { + if (this.deps.closeSignal?.aborted) throw new Error('Transport is closed'); + response = structuredClone(response); + const key = String(response.id); + const prior = this.sending.get(key) ?? Promise.resolve(); + const work = prior.catch(() => {}).then(() => this.routeInternal(response)); + this.sending.set(key, work); + try { + await work; + } finally { + if (this.sending.get(key) === work) this.sending.delete(key); + } + } + + private async routeInternal( + response: JSONRPCResponse | JSONRPCErrorResponse, ): Promise { // Handle special announcement responses if (response.id === 'announcement') { @@ -99,6 +127,20 @@ export class OutboundResponseRouter { // Find the event route using O(1) lookup const nostrEventId = response.id as string; + if (this.journal) { + const saved = this.deps.correlationStore.peekRouteSnapshot(nostrEventId); + const route = + saved?.route ?? this.deps.correlationStore.getEventRoute(nostrEventId); + const session = + saved?.session ?? + (route && this.deps.sessionStore.getSession(route.clientPubkey)); + if (saved && route && session) + await this.journal.retain(nostrEventId, response, { + route: { ...route }, + session: { ...session }, + expiresAtMs: Number.MAX_SAFE_INTEGER, + }); + } if ( this.deps.openStreamFactory.deferIfStreamActive(nostrEventId, response) ) { @@ -106,19 +148,7 @@ export class OutboundResponseRouter { } const poppedRoute = this.deps.correlationStore.popEventRoute(nostrEventId); - if (poppedRoute) { - // The live route was used: drop any snapshot so a later duplicate - // response for this id cannot be delivered from it. - this.deps.correlationStore.dropRouteSnapshot(nostrEventId); - } - // Route miss fallback: a paid request's route may have been popped by - // duplicate-delivery cleanup or removed with its evicted session while - // payment settled. The snapshot captured at invoice issuance (CEP-8) - // carries the routing fields — and the session — needed to deliver the - // settled result anyway. - const snapshot = poppedRoute - ? undefined - : this.deps.correlationStore.takeRouteSnapshot(nostrEventId); + const snapshot = this.deps.correlationStore.takeRouteSnapshot(nostrEventId); const route = poppedRoute ?? snapshot?.route; if (!route) { @@ -138,172 +168,199 @@ export class OutboundResponseRouter { const pendingEviction = this.deps.openStreamFactory.takePendingEviction(nostrEventId); const session = - this.deps.sessionStore.getSession(route.clientPubkey) ?? snapshot?.session ?? + this.deps.sessionStore.getSession(route.clientPubkey) ?? pendingEviction?.session; if (!session) { this.deps.onerror?.( new Error(`No session found for client: ${route.clientPubkey}`), ); - return; + if (snapshot) + this.deps.correlationStore.restoreRouteSnapshot(nostrEventId, snapshot); + if (poppedRoute) + this.deps.correlationStore.registerEventRoute( + nostrEventId, + poppedRoute.clientPubkey, + poppedRoute.originalRequestId, + poppedRoute.progressToken, + poppedRoute.wrapKind, + poppedRoute.requestEvent, + ); + throw new Error('Response delivery requires a retained client session'); } - const parsedListToolsResult = isJSONRPCResultResponse(response) - ? ListToolsResultSchema.safeParse(response.result) - : null; - - const responseToSend = parsedListToolsResult?.success - ? { - ...response, - result: this.deps.applyListToolsResultTransformers( - parsedListToolsResult.data, - ), - } - : response; + const sessionBefore = { ...session }; + let publication: + Awaited> | undefined; + let acknowledgedFrames = 0; + try { + publication = snapshot + ? await this.journal?.prepare(nostrEventId, response, { + route: { ...route }, + session: { ...session }, + expiresAtMs: Number.MAX_SAFE_INTEGER, + }) + : undefined; + if (publication && !publication.publish) return; + acknowledgedFrames = publication?.acknowledgedFrames ?? 0; + const parsedListToolsResult = isJSONRPCResultResponse(response) + ? ListToolsResultSchema.safeParse(response.result) + : null; - // Restore the original request ID in the response - responseToSend.id = route.originalRequestId; + const responseToSend = parsedListToolsResult?.success + ? { + ...response, + result: this.deps.applyListToolsResultTransformers( + parsedListToolsResult.data, + ), + } + : { ...response }; - // discovery tags already computed for the oversized-measurement branch, if it - // ran without fragmenting. buildOutboundTags() consumes the per-session - // discovery latch as a side effect, so the send path must reuse these rather - // than rebuild (otherwise the response ships with no discovery tags). - let oversizedTags: string[][] | undefined; + // Restore the original request ID in the response + responseToSend.id = route.originalRequestId; - // CEP-22 Oversized Transfer (proactive path for server responses) - if ( - this.deps.oversizedConfig.enabled && - route.progressToken && - session.supportsOversizedTransfer - ) { const continuationFrameTags = this.deps.createResponseTags( route.clientPubkey, nostrEventId, ); - const startFrameTags = this.deps.buildOutboundTags({ + // Build once: discovery/disclosure tags consume session latches. + const tags = this.deps.buildOutboundTags({ baseTags: continuationFrameTags, session, }); + this.maybeAppendPaymentInteractionDisclosure(tags, session); const giftWrapKind = this.deps.chooseGiftWrapKind({ session, fallbackWrapKind: route.wrapKind, }); - // Measuring the full response can throw under gift-wrap encryption when - // the inner plaintext exceeds NIP-44's 65 535-byte cap. That is itself - // proof the response cannot be published as a single encrypted event, so - // treat the throw as "must fragment". - let publishedEventSize: number; - try { - publishedEventSize = await this.deps.measurePublishedMcpMessageSize( + // CEP-22 Oversized Transfer (proactive path for server responses) + if ( + route.progressToken && + (publication?.chunkSizeBytes !== undefined || + (this.deps.oversizedConfig.enabled && + session.supportsOversizedTransfer)) + ) { + // Measuring the full response can throw under gift-wrap encryption when + // the inner plaintext exceeds NIP-44's 65 535-byte cap. That is itself + // proof the response cannot be published as a single encrypted event, so + // treat the throw as "must fragment". + let publishedEventSize: number; + try { + publishedEventSize = await this.deps.measurePublishedMcpMessageSize( + responseToSend, + route.clientPubkey, + CTXVM_MESSAGES_KIND, + tags, + session.isEncrypted, + giftWrapKind, + ); + } catch { + publishedEventSize = Number.POSITIVE_INFINITY; + } + + if ( + publication?.chunkSizeBytes !== undefined || + publishedEventSize > this.deps.oversizedConfig.threshold + ) { + const chunkSizeBytes = + publication?.chunkSizeBytes ?? + (await this.deps.resolveSafeOversizedChunkSize({ + desiredChunkSizeBytes: this.deps.oversizedConfig.chunkSize, + maxPublishedEventBytes: this.deps.oversizedConfig.threshold, + recipientPublicKey: route.clientPubkey, + kind: CTXVM_MESSAGES_KIND, + progressToken: route.progressToken, + progress: 2, + tags: continuationFrameTags, + isEncrypted: session.isEncrypted, + giftWrapKind, + })); + + const serialized = canonicalJson(responseToSend); + await publication?.framePublished(acknowledgedFrames, chunkSizeBytes); + let frameIndex = 0; + await sendOversizedServerResponse( + { + serialized, + clientPubkey: route.clientPubkey, + progressToken: route.progressToken, + startFrameTags: tags, + continuationFrameTags, + isEncrypted: session.isEncrypted, + giftWrapKind, + }, + { + chunkSizeBytes, + }, + { + sendMcpMessage: async (...args) => { + frameIndex++; + if (frameIndex <= acknowledgedFrames) return ''; + await publication?.framePublished( + frameIndex - 1, + chunkSizeBytes, + ); + const sent = await this.deps.sendMcpMessage(...args); + await publication?.framePublished(frameIndex, chunkSizeBytes); + return sent; + }, + logger: this.deps.logger, + }, + ); + await publication?.complete(); + return; + } + } + + // Attach pricing tags to capability list responses so clients can access CEP-8 pricing + if (isJSONRPCResultResponse(responseToSend)) { + const result = responseToSend.result; + if ( + ListToolsResultSchema.safeParse(result).success || + ListResourcesResultSchema.safeParse(result).success || + ListResourceTemplatesResultSchema.safeParse(result).success || + ListPromptsResultSchema.safeParse(result).success + ) { + tags.push(...this.deps.announcementManager.getPricingTags()); + } + } + + if (acknowledgedFrames === 0) { + await publication?.framePublished(0); + await this.deps.sendMcpMessage( responseToSend, route.clientPubkey, CTXVM_MESSAGES_KIND, - startFrameTags, + tags, session.isEncrypted, + undefined, giftWrapKind, ); - } catch { - publishedEventSize = Number.POSITIVE_INFINITY; } - - if (publishedEventSize > this.deps.oversizedConfig.threshold) { - const chunkSizeBytes = await this.deps.resolveSafeOversizedChunkSize({ - desiredChunkSizeBytes: this.deps.oversizedConfig.chunkSize, - maxPublishedEventBytes: this.deps.oversizedConfig.threshold, - recipientPublicKey: route.clientPubkey, - kind: CTXVM_MESSAGES_KIND, - progressToken: route.progressToken, - progress: 2, - tags: continuationFrameTags, - isEncrypted: session.isEncrypted, - giftWrapKind, - }); - - const serialized = JSON.stringify(responseToSend); - await sendOversizedServerResponse( - { - serialized, - clientPubkey: route.clientPubkey, - progressToken: route.progressToken, - startFrameTags, - continuationFrameTags, - isEncrypted: session.isEncrypted, - giftWrapKind, - }, - { - chunkSizeBytes, - }, - { - sendMcpMessage: this.deps.sendMcpMessage, - logger: this.deps.logger, - }, - ); - // CEP-35 discovery tags ride on the start frame (startFrameTags) above. - // Payment-interaction disclosure is also covered: the server's - // availability advertisement (extraCommonTags, e.g. - // ['payment_interaction','explicit_gating']) flows through getCommonTags() - // into startFrameTags, and its value always equals the effective session - // mode when both are present — so no separate disclosure call is needed. - return; - } - - // Response fit in a single event: reuse the tags already built for - // measurement instead of rebuilding, which would drop discovery tags. - oversizedTags = startFrameTags; - } - - // Send the response back to the original requester - const tags = - oversizedTags ?? - this.deps.buildOutboundTags({ - baseTags: this.deps.createResponseTags( - route.clientPubkey, - nostrEventId, - ), - session, - }); - - this.maybeAppendPaymentInteractionDisclosure(tags, session); - - const giftWrapKind = this.deps.chooseGiftWrapKind({ - session, - fallbackWrapKind: route.wrapKind, - }); - - // Attach pricing tags to capability list responses so clients can access CEP-8 pricing - if (isJSONRPCResultResponse(responseToSend)) { - const result = responseToSend.result; - if ( - ListToolsResultSchema.safeParse(result).success || - ListResourcesResultSchema.safeParse(result).success || - ListResourceTemplatesResultSchema.safeParse(result).success || - ListPromptsResultSchema.safeParse(result).success - ) { - tags.push(...this.deps.announcementManager.getPricingTags()); + await publication?.framePublished(1); + await publication?.complete(); + } catch (error) { + try { + await publication?.failed(); + } catch { + /* The retained result remains recoverable after backend failure. */ } - } - - try { - await this.deps.sendMcpMessage( - responseToSend, - route.clientPubkey, - CTXVM_MESSAGES_KIND, - tags, - session.isEncrypted, - undefined, - giftWrapKind, + if (this.deps.closeSignal?.aborted) throw error; + // Tags consume session latches, so a failed attempt restores those as well as its route. + session.hasSentCommonTags = sessionBefore.hasSentCommonTags; + session.hasDisclosedPaymentInteraction = + sessionBefore.hasDisclosedPaymentInteraction; + this.deps.correlationStore.restoreRouteSnapshot( + nostrEventId, + snapshot ?? { + route: { ...route }, + session: { ...sessionBefore }, + expiresAtMs: Number.MAX_SAFE_INTEGER, + }, ); - } catch (error) { - // Restore what the attempt consumed so a retry can deliver. A - // snapshot-based attempt must get its snapshot back (route + session - // copy): re-registering only the live route would strand the retry in - // the no-session branch once the client's session was evicted, dropping - // the already-paid result. - if (snapshot) { - this.deps.correlationStore.restoreRouteSnapshot(nostrEventId, snapshot); - } else { + if (poppedRoute) this.deps.correlationStore.registerEventRoute( nostrEventId, route.clientPubkey, @@ -312,7 +369,6 @@ export class OutboundResponseRouter { route.wrapKind, route.requestEvent, ); - } throw error; } } @@ -331,7 +387,12 @@ export class OutboundResponseRouter { response: JSONRPCResponse | JSONRPCErrorResponse, requestEventId: string, ): Promise { - const session = this.deps.sessionStore.getSession(clientPubkey); + const retained = + this.deps.correlationStore.peekRouteSnapshot(requestEventId); + const snapshot = + retained?.route.clientPubkey === clientPubkey ? retained : undefined; + const session = + snapshot?.session ?? this.deps.sessionStore.getSession(clientPubkey); if (!session) { this.deps.logger.warn( 'Cannot route targeted response: no active session found', @@ -340,40 +401,50 @@ export class OutboundResponseRouter { return; } - const tags = this.deps.buildOutboundTags({ - baseTags: this.deps.createResponseTags(clientPubkey, requestEventId), - session, - }); + const sessionBefore = { ...session }; + try { + const tags = this.deps.buildOutboundTags({ + baseTags: this.deps.createResponseTags(clientPubkey, requestEventId), + session, + }); - this.maybeAppendPaymentInteractionDisclosure(tags, session); + this.maybeAppendPaymentInteractionDisclosure(tags, session); - const route = this.deps.correlationStore.getEventRoute(requestEventId); + const route = + snapshot?.route ?? + this.deps.correlationStore.getEventRoute(requestEventId); - const giftWrapKind = this.deps.chooseGiftWrapKind({ - session, - // Non-destructive read: the route must stay registered for the normal - // response/cleanup lifecycle that runs after this early rejection. - fallbackWrapKind: route?.wrapKind, - }); + const giftWrapKind = this.deps.chooseGiftWrapKind({ + session, + // Non-destructive read: the route must stay registered for the normal + // response/cleanup lifecycle that runs after this early rejection. + fallbackWrapKind: route?.wrapKind, + }); - // Restore the client's original request id before publishing: the inbound - // coordinator rewrites request ids to the event id for routing, and this - // early-rejection exit path must not leak that rewrite onto the wire - // (mirrors route()'s restore; JSON-RPC responses MUST echo the caller's - // id, and both SDK clients ignore the wire id anyway). - const responseToSend = route - ? { ...response, id: route.originalRequestId } - : response; + // Restore the client's original request id before publishing: the inbound + // coordinator rewrites request ids to the event id for routing, and this + // early-rejection exit path must not leak that rewrite onto the wire + // (mirrors route()'s restore; JSON-RPC responses MUST echo the caller's + // id, and both SDK clients ignore the wire id anyway). + const responseToSend = route + ? { ...response, id: route.originalRequestId } + : response; - await this.deps.sendMcpMessage( - responseToSend, - clientPubkey, - CTXVM_MESSAGES_KIND, - tags, - session.isEncrypted, - undefined, - giftWrapKind, - ); + await this.deps.sendMcpMessage( + responseToSend, + clientPubkey, + CTXVM_MESSAGES_KIND, + tags, + session.isEncrypted, + undefined, + giftWrapKind, + ); + } catch (error) { + session.hasSentCommonTags = sessionBefore.hasSentCommonTags; + session.hasDisclosedPaymentInteraction = + sessionBefore.hasDisclosedPaymentInteraction; + throw error; + } } private maybeAppendPaymentInteractionDisclosure( diff --git a/src/transport/open-stream-oversized-composition.e2e.test.ts b/src/transport/open-stream-oversized-composition.e2e.test.ts index 5b733df..5760108 100644 --- a/src/transport/open-stream-oversized-composition.e2e.test.ts +++ b/src/transport/open-stream-oversized-composition.e2e.test.ts @@ -1,3 +1,5 @@ +import { withServerPayments, withClientPayments } from '../payments/index.js'; +import type { PaymentProcessor } from '../payments/types.js'; import { describe, expect, test } from 'bun:test'; import { Client } from '@contextvm/mcp-sdk/client'; import { McpServer } from '@contextvm/mcp-sdk/server/mcp'; @@ -48,9 +50,17 @@ interface Fixture { serverTransport: NostrServerTransport; clientTransport: NostrClientTransport; serverPubkey: string; + connectClient: () => Promise; + paymentCounts: () => { invoices: number; approvals: number }; } -function createFixture(): Fixture { +function createFixture(paid = false): Fixture { + let invoices = 0; + let approvals = 0; + const settlements = new Map< + string, + { promise: Promise; settle: () => void } + >(); const relayHub = new MockRelayHub(); const serverPrivateKey = bytesToHex(generateSecretKey()); const serverPublicKey = getPublicKey(hexToBytes(serverPrivateKey)); @@ -87,6 +97,29 @@ function createFixture(): Fixture { }); const client = new Client({ name: 'composition-client', version: '1.0.0' }); + const processor: PaymentProcessor = { + pmi: 'fake', + async createPaymentRequired(params) { + const pay_req = `invoice-${++invoices}`; + let settle!: () => void; + const promise = new Promise((resolve) => { + settle = resolve; + }); + settlements.set(pay_req, { promise, settle }); + return { pmi: 'fake', amount: params.amount, pay_req, ttl: 10 }; + }, + async verifyPayment(params) { + await settlements.get(params.pay_req)?.promise; + return {}; + }, + }; + if (paid) + withServerPayments(serverTransport, { + processors: [processor], + pricedCapabilities: [ + { method: 'tools/call', amount: 1, currencyUnit: 'test' }, + ], + }); return { relayHub, @@ -95,6 +128,24 @@ function createFixture(): Fixture { serverTransport, clientTransport, serverPubkey: serverPublicKey, + connectClient: async () => { + await client.connect( + paid + ? withClientPayments(clientTransport, { + handlers: [ + { + pmi: 'fake', + async handle(req) { + approvals++; + settlements.get(req.pay_req)?.settle(); + }, + }, + ], + }) + : clientTransport, + ); + }, + paymentCounts: () => ({ invoices, approvals }), }; } @@ -105,7 +156,7 @@ async function cleanup(fixture: Fixture): Promise { } describe('open-stream + oversized transfer composition', () => { - test('an oversized tool response completes while a separate tool is streaming', async () => { + test('paid oversized results complete while a separate paid invocation owns a stream', async () => { // Validates that CEP-22 oversized transfers and CEP-41 open streams coexist // in one session: a streaming tool holds an active session while a plain // (non-streaming) tool returns a payload large enough to fragment as an @@ -114,7 +165,7 @@ describe('open-stream + oversized transfer composition', () => { // (Fix 1) and is instead fragmented/reassembled by the oversized path. // - The big tool's progress token does not steal the stream's session // placeholder (Fix 2), so the live stream keeps delivering. - const fixture = createFixture(); + const fixture = createFixture(true); let releaseStream: (() => void) | undefined; const bigPayload = makeLargeText('big-response', BIG_PAYLOAD_REPEAT); @@ -158,7 +209,7 @@ describe('open-stream + oversized transfer composition', () => { ); await fixture.server.connect(fixture.serverTransport); - await fixture.client.connect(fixture.clientTransport); + await fixture.connectClient(); // (1) Start the long-lived streaming subscription and read its first chunk. const call = await callToolStream({ @@ -221,6 +272,7 @@ describe('open-stream + oversized transfer composition', () => { content: [{ type: 'text', text: 'done:orders' }], }); + expect(fixture.paymentCounts()).toEqual({ invoices: 2, approvals: 2 }); await cleanup(fixture); }, 15_000); }); diff --git a/src/transport/open-stream/registry.ts b/src/transport/open-stream/registry.ts index f596558..e926fca 100644 --- a/src/transport/open-stream/registry.ts +++ b/src/transport/open-stream/registry.ts @@ -1,3 +1,4 @@ +import { LruCache } from '../../core/utils/lru-cache.js'; import type { Logger } from '../../core/utils/logger.js'; import { DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM, @@ -73,6 +74,7 @@ export class OpenStreamRegistry { senderPubkey?: string, ) => Partial>) | undefined; + private readonly completed: LruCache; private readonly sessions = new Map(); constructor(options: OpenStreamRegistryOptions) { @@ -92,6 +94,7 @@ export class OpenStreamRegistry { this.closeGracePeriodMs = options.closeGracePeriodMs ?? DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS; this.getSessionOptions = options.getSessionOptions; + this.completed = new LruCache(this.maxConcurrentStreams); } public static isOpenStreamProgress( @@ -136,6 +139,7 @@ export class OpenStreamRegistry { ); } + this.completed.delete(key); const session = new OpenStreamSession({ progressToken, maxBufferedChunks: @@ -175,6 +179,7 @@ export class OpenStreamRegistry { } } finally { this.sessions.delete(key); + this.completed.set(key, session); } }, onAbort: async (reason?: string) => { @@ -186,6 +191,7 @@ export class OpenStreamRegistry { } } finally { this.sessions.delete(key); + this.completed.set(key, session); } }, }); @@ -208,6 +214,15 @@ export class OpenStreamRegistry { // only ever route to that sender's own session. const existingSession = this.getSession(progressToken, senderPubkey); + const completed = this.completed.get( + OpenStreamRegistry.sessionKey(progressToken, senderPubkey), + ); + if ( + !existingSession && + completed?.isDuplicateFrame(frame.progress, frame.cvm) + ) + return completed; + if (!existingSession) { if (frame.cvm.frameType !== 'start') { throw new OpenStreamSequenceError( @@ -245,6 +260,7 @@ export class OpenStreamRegistry { } this.sessions.clear(); + this.completed.clear(); } public get size(): number { diff --git a/src/transport/open-stream/session.ts b/src/transport/open-stream/session.ts index 2e13cf2..256726f 100644 --- a/src/transport/open-stream/session.ts +++ b/src/transport/open-stream/session.ts @@ -1,3 +1,6 @@ +import { canonicalJson } from '../../core/utils/canonical-json.js'; +import { sha256 } from '@noble/hashes/sha2.js'; +import { bytesToHex } from '@noble/hashes/utils.js'; import { DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS, DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS, @@ -93,6 +96,7 @@ export class OpenStreamSession implements OpenStreamSessionLike { private closeState: CloseState | undefined; private nextExpectedChunkIndex = 0; private lastProgress = -1; + private lastFrameDigest?: string; private terminalError: Error | undefined; private controlNonce = 0; private pendingProbeNonce: string | undefined; @@ -230,8 +234,10 @@ export class OpenStreamSession implements OpenStreamSessionLike { progress: number, frame: OpenStreamFrame, ): Promise { + if (this.isDuplicateFrame(progress, frame)) return; this.assertActive(); this.assertProgress(progress); + this.lastFrameDigest = this.frameDigest(frame); switch (frame.frameType) { case 'start': @@ -343,6 +349,18 @@ export class OpenStreamSession implements OpenStreamSessionLike { } } + /** Exact retransmission of the last accepted frame is safe; conflicting progress remains an error. */ + public isDuplicateFrame(progress: number, frame: OpenStreamFrame): boolean { + return ( + progress === this.lastProgress && + this.lastFrameDigest === this.frameDigest(frame) + ); + } + + private frameDigest(frame: OpenStreamFrame): string { + return bytesToHex(sha256(new TextEncoder().encode(canonicalJson(frame)))); + } + private assertProgress(progress: number): void { if (!Number.isFinite(progress) || progress <= this.lastProgress) { throw new OpenStreamSequenceError( diff --git a/src/transport/open-stream/writer.test.ts b/src/transport/open-stream/writer.test.ts index d49d260..2d2d3be 100644 --- a/src/transport/open-stream/writer.test.ts +++ b/src/transport/open-stream/writer.test.ts @@ -237,7 +237,7 @@ describe('OpenStreamWriter', () => { ]); }); - test('runs close lifecycle cleanup when close publish fails', async () => { + test('retains the failed close frame while local resources stop', async () => { const lifecycle: string[] = []; const writer = new OpenStreamWriter({ progressToken: 'token-close-failure-cleanup', @@ -256,7 +256,8 @@ describe('OpenStreamWriter', () => { await expect(writer.close()).rejects.toThrow('close publish failed'); expect(writer.isActive).toBe(false); - expect(lifecycle).toEqual(['close']); + expect(lifecycle).toEqual([]); + writer.dispose(); }); test('runs abort lifecycle cleanup when abort publish fails', async () => { @@ -689,3 +690,83 @@ describe('OpenStreamWriter signal', () => { expect(writer.signal.aborted).toBe(true); }); }); + +describe('OpenStreamWriter frame recovery', () => { + for (const failingFrame of ['start', 'chunk', 'close'] as const) + test(`retries an identical failed ${failingFrame} frame without restarting production`, async () => { + const attempted: OpenStreamProgress[] = []; + let fail = true; + let closed = 0; + const writer = new OpenStreamWriter({ + progressToken: 'paid-stream', + publishFrame: async (frame) => { + attempted.push(structuredClone(frame)); + if (fail && frame.cvm.frameType === failingFrame) + throw new Error('offline'); + return 'sent'; + }, + onClose: async () => { + closed++; + }, + }); + if (failingFrame !== 'start') await writer.start(); + const operation = + failingFrame === 'close' ? writer.close() : writer.write('paid data'); + await expect(operation).rejects.toThrow('offline'); + const failed = attempted[attempted.length - 1]; + fail = false; + await writer.retryPendingFrame(); + expect(attempted[attempted.length - 1]).toEqual(failed); + if (failingFrame === 'start') await writer.write('paid data'); + if (failingFrame !== 'close') await writer.close(); + const count = attempted.length; + await writer.close(); + await writer.retryPendingFrame(); + expect(closed).toBe(1); + expect(attempted).toHaveLength(count); + writer.dispose(); + }); +}); + +test('lost frame acknowledgements resume without duplicating chunks or terminal delivery', async () => { + const { OpenStreamRegistry } = await import('./registry.js'); + const registry = new OpenStreamRegistry({ + logger: { + debug() {}, + info() {}, + warn() {}, + error() {}, + withModule() { + return this; + }, + }, + }); + const failed = new Set(); + const writer = new OpenStreamWriter({ + progressToken: 'lost-ack', + publishFrame: async (frame) => { + await registry.processFrame(frame); + if (!failed.has(frame.cvm.frameType)) { + failed.add(frame.cvm.frameType); + throw new Error('lost acknowledgement'); + } + return 'sent'; + }, + }); + await expect(writer.start()).rejects.toThrow('lost acknowledgement'); + await writer.retryPendingFrame(); + const receiver = registry.getSession('lost-ack')!; + await expect(writer.write('paid data')).rejects.toThrow( + 'lost acknowledgement', + ); + await writer.retryPendingFrame(); + await expect(writer.close()).rejects.toThrow('lost acknowledgement'); + await writer.retryPendingFrame(); + const values: string[] = []; + for await (const chunk of receiver) values.push(chunk.value); + expect(values).toEqual(['paid data']); + expect(registry.size).toBe(0); + await writer.retryPendingFrame(); + registry.clear(); + writer.dispose(); +}); diff --git a/src/transport/open-stream/writer.ts b/src/transport/open-stream/writer.ts index 8028d9f..fd56f40 100644 --- a/src/transport/open-stream/writer.ts +++ b/src/transport/open-stream/writer.ts @@ -63,6 +63,10 @@ export class OpenStreamWriter { private active = true; private operationQueue: Promise = Promise.resolve(); private abortPromise?: Promise; + private pendingPublication?: { + frame: OpenStreamProgress; + committed: () => void | Promise; + }; private readonly abortController = new AbortController(); private readonly externalNextProgress: (() => number) | undefined; private idleTimer: ReturnType | undefined; @@ -121,21 +125,27 @@ export class OpenStreamWriter { return; } - await this.publishFrame( + if (this.pendingPublication) + throw new Error( + 'Retry the pending stream frame before writing more data', + ); + await this.publishRecoverable( buildOpenStreamChunkFrame({ progressToken: this.progressToken, progress: this.nextProgress(), chunkIndex: this.chunkIndex, data, }), + () => { + this.chunkIndex += 1; + }, ); - this.chunkIndex += 1; }); } public async ping(): Promise { await this.enqueue(async () => { - if (!this.active) { + if (!this.active || this.pendingPublication) { return; } @@ -156,6 +166,7 @@ export class OpenStreamWriter { return; } + if (this.pendingPublication) return; // An inbound ping means the peer is alive; refresh the keepalive idle // window before responding. this.armIdle(); @@ -176,19 +187,19 @@ export class OpenStreamWriter { return; } + if (this.pendingPublication) + throw new Error('Retry the pending stream frame before closing'); this.markInactive(); - try { - await this.publishFrame( - buildOpenStreamCloseFrame({ - progressToken: this.progressToken, - progress: this.nextProgress(), - lastChunkIndex: - this.chunkIndex > 0 ? this.chunkIndex - 1 : undefined, - }), - ); - } finally { - await this.onClose?.(); - } + await this.publishRecoverable( + buildOpenStreamCloseFrame({ + progressToken: this.progressToken, + progress: this.nextProgress(), + lastChunkIndex: this.chunkIndex > 0 ? this.chunkIndex - 1 : undefined, + }), + async () => { + await this.onClose?.(); + }, + ); }); } @@ -202,6 +213,7 @@ export class OpenStreamWriter { return; } + this.pendingPublication = undefined; this.markInactive(); const progress = this.nextProgress(); @@ -241,6 +253,7 @@ export class OpenStreamWriter { * transport teardown so armed keepalive timers do not outlive the writer. */ public dispose(): void { + this.pendingPublication = undefined; this.markInactive(); } @@ -291,7 +304,11 @@ export class OpenStreamWriter { } private async handleIdleTimeout(): Promise { - if (!this.active || this.pendingProbeNonce !== undefined) { + if ( + !this.active || + this.pendingPublication || + this.pendingProbeNonce !== undefined + ) { return; } @@ -342,17 +359,52 @@ export class OpenStreamWriter { return; } - await this.publishFrame( + if (this.pendingPublication?.frame.cvm.frameType === 'start') { + const pending = this.pendingPublication; + await this.publishRecoverable(pending.frame, pending.committed); + return; + } + if (this.pendingPublication) + throw new Error('Retry the pending stream frame before starting'); + await this.publishRecoverable( buildOpenStreamStartFrame({ progressToken: this.progressToken, progress: this.nextProgress(), contentType: this.contentType, }), + () => { + this.started = true; + this.armIdle(); + }, ); - this.started = true; + } + + private async publishRecoverable( + frame: OpenStreamProgress, + committed: () => void | Promise, + ): Promise { + this.pendingPublication = { frame, committed }; + try { + await this.publishFrame(frame); + } catch (error) { + this.clearKeepalive(); + throw error; + } + // Clear before the application callback: a callback failure must never republish a terminal frame. + this.pendingPublication = undefined; + await committed(); this.armIdle(); } + /** Retransmits the identical failed frame. Does not restart a stream or rerun its producer. */ + public async retryPendingFrame(): Promise { + await this.enqueue(async () => { + const pending = this.pendingPublication; + if (pending) + await this.publishRecoverable(pending.frame, pending.committed); + }); + } + private async enqueue(operation: () => Promise): Promise { const pending = this.operationQueue.then(operation); this.operationQueue = pending.catch(() => undefined); diff --git a/src/transport/oversized-transfer/receiver.test.ts b/src/transport/oversized-transfer/receiver.test.ts index 2bd8d19..de0090e 100644 --- a/src/transport/oversized-transfer/receiver.test.ts +++ b/src/transport/oversized-transfer/receiver.test.ts @@ -641,3 +641,30 @@ describe('OversizedTransferReceiver', () => { ).rejects.toBeInstanceOf(OversizedTransferDigestError); }); }); + +test('an identical start retry preserves partial oversized reassembly', async () => { + const receiver = new OversizedTransferReceiver({}, testLogger); + const frames = await buildOversizedTransferFrames( + JSON.stringify({ + jsonrpc: '2.0', + id: 1, + result: { text: 'a'.repeat(100) }, + }), + { progressToken: 'resume', chunkSizeBytes: 32 }, + ); + await receiver.processFrame(toNotification(frames.startFrame)); + await receiver.processFrame(toNotification(frames.chunkFrames[0]!)); + await receiver.processFrame(toNotification(frames.startFrame)); + for (const frame of frames.chunkFrames) + await receiver.processFrame(toNotification(frame)); + const result = await receiver.processFrame(toNotification(frames.endFrame)); + expect(result).toEqual({ + jsonrpc: '2.0', + id: 1, + result: { text: 'a'.repeat(100) }, + }); + expect( + await receiver.processFrame(toNotification(frames.endFrame)), + ).toBeNull(); + receiver.clear(); +}); diff --git a/src/transport/oversized-transfer/receiver.ts b/src/transport/oversized-transfer/receiver.ts index aec9308..775baa4 100644 --- a/src/transport/oversized-transfer/receiver.ts +++ b/src/transport/oversized-transfer/receiver.ts @@ -206,7 +206,16 @@ export class OversizedTransferReceiver { progress: number, frame: StartFrame, ): null { - if (this.transfers.has(token)) { + const active = this.transfers.get(token); + if ( + active && + active.digest === frame.digest && + active.totalBytes === frame.totalBytes && + active.totalChunks === frame.totalChunks && + active.startProgress === progress + ) + return null; + if (active) { throw new OversizedTransferSequenceError( `Duplicate start frame for active transfer (token: ${token})`, ); diff --git a/src/transport/payment-client-eviction.e2e.test.ts b/src/transport/payment-client-eviction.e2e.test.ts new file mode 100644 index 0000000..6d8f783 --- /dev/null +++ b/src/transport/payment-client-eviction.e2e.test.ts @@ -0,0 +1,115 @@ +import { expect, test } from 'bun:test'; +import { Client } from '@contextvm/mcp-sdk/client'; +import { McpServer } from '@contextvm/mcp-sdk/server/mcp'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { bytesToHex } from 'nostr-tools/utils'; +import { MockRelayHub } from '../__mocks__/mock-relay-handler.js'; +import { EncryptionMode } from '../core/interfaces.js'; +import { PrivateKeySigner } from '../signer/private-key-signer.js'; +import { + withClientPayments, + withServerPayments, + InMemoryPaymentStateStore, + type PaymentProcessor, +} from '../payments/index.js'; +import { NostrClientTransport } from './nostr-client-transport.js'; +import { NostrServerTransport } from './nostr-server-transport.js'; + +test('many paying clients complete after session eviction and peer disconnects without re-execution', async () => { + const hub = new MockRelayHub(); + const key = generateSecretKey(); + const state = new InMemoryPaymentStateStore(); + const clients: Client[] = []; + let release!: () => void; + const pendingSettlement = new Promise((resolve) => { + release = resolve; + }); + let invoices = 0; + let approvals = 0; + let executions = 0; + const processor: PaymentProcessor = { + pmi: 'fake', + async createPaymentRequired(input) { + return { + amount: 1, + pmi: 'fake', + pay_req: `invoice-${input.requestEventId}-${++invoices}`, + ttl: 10, + }; + }, + async verifyPayment() { + await pendingSettlement; + return {}; + }, + }; + const server = new McpServer({ name: 'eviction-server', version: '1' }); + server.registerTool('paid', { inputSchema: {} }, async () => { + executions++; + return { content: [{ type: 'text', text: 'paid-result' }] }; + }); + const transport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(bytesToHex(key)), + relayHandler: hub.createRelayHandler(), + encryptionMode: EncryptionMode.REQUIRED, + maxSessions: 2, + }), + { + stateStore: state, + processors: [processor], + pricedCapabilities: [ + { method: 'tools/call', amount: 1, currencyUnit: 'test' }, + ], + }, + ); + try { + await server.connect(transport); + for (let i = 0; i < 8; i++) { + const client = new Client({ name: `paying-client-${i}`, version: '1' }); + await client.connect( + withClientPayments( + new NostrClientTransport({ + signer: new PrivateKeySigner(bytesToHex(generateSecretKey())), + relayHandler: hub.createRelayHandler(), + serverPubkey: getPublicKey(key), + encryptionMode: EncryptionMode.REQUIRED, + }), + { + handlers: [ + { + pmi: 'fake', + async handle() { + approvals++; + }, + }, + ], + }, + ), + ); + clients.push(client); + } + const results = clients.map((client) => + client + .callTool({ name: 'paid', arguments: {} }, undefined, { timeout: 5000 }) + .catch((error) => error), + ); + const deadline = Date.now() + 3000; + while (approvals < clients.length && Date.now() < deadline) + await Bun.sleep(5); + expect(approvals).toBe(clients.length); + await clients[0]?.close(); + await clients[1]?.close(); + release(); + const completed = await Promise.all(results); + expect(completed[0]).toBeInstanceOf(Error); + expect(completed[1]).toBeInstanceOf(Error); + for (const result of completed.slice(2)) + expect(result.content).toEqual([{ type: 'text', text: 'paid-result' }]); + expect([invoices, approvals, executions]).toEqual([8, 8, 8]); + } finally { + release(); + await Promise.all(clients.map((client) => client.close())); + await server.close(); + hub.clear(); + } +}, 10000); 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/payment-state-recovery.e2e.test.ts b/src/transport/payment-state-recovery.e2e.test.ts new file mode 100644 index 0000000..2eb935f --- /dev/null +++ b/src/transport/payment-state-recovery.e2e.test.ts @@ -0,0 +1,311 @@ +import { expect, test } from 'bun:test'; +import { Client } from '@contextvm/mcp-sdk/client'; +import { McpServer } from '@contextvm/mcp-sdk/server/mcp'; +import { generateSecretKey, getPublicKey } from 'nostr-tools/pure'; +import { bytesToHex } from 'nostr-tools/utils'; +import { z } from 'zod'; +import type { NostrEvent } from 'nostr-tools'; +import { withTimeout } from '../core/utils/utils.js'; +import { MockRelayHub } from '../__mocks__/mock-relay-handler.js'; +import { EncryptionMode, GiftWrapMode } from '../core/interfaces.js'; +import { PrivateKeySigner } from '../signer/private-key-signer.js'; +import { NostrClientTransport } from './nostr-client-transport.js'; +import { NostrServerTransport } from './nostr-server-transport.js'; +import { withClientPayments, withServerPayments } from '../payments/index.js'; +import { InMemoryPaymentStateStore } from '../payments/in-memory-payment-state-store.js'; +import { canonicalJson } from '../core/utils/canonical-json.js'; +import type { + PaymentProcessor, + PaymentStateRecord, + PaymentStateStore, +} from '../payments/index.js'; + +const modes = [ + { + name: 'cleartext', + encryptionMode: EncryptionMode.DISABLED, + giftWrapMode: GiftWrapMode.OPTIONAL, + }, + { + name: 'persistent encryption', + encryptionMode: EncryptionMode.REQUIRED, + giftWrapMode: GiftWrapMode.PERSISTENT, + }, + { + name: 'ephemeral encryption', + encryptionMode: EncryptionMode.REQUIRED, + giftWrapMode: GiftWrapMode.EPHEMERAL, + }, +]; + +for (const mode of modes) + for (const failResult of [ + false, + 'ordinary', + 'start', + 'chunk', + 'end', + 'result_ack', + 'publication_ack', + ] as const) + test(`injected payment store: two transports, failure=${failResult}, recreation/replay over ${mode.name}`, async () => { + const oversized = + failResult === 'start' || + failResult === 'chunk' || + failResult === 'end'; + const expectedText = oversized ? '7'.repeat(4000) : '7'; + // Application-owned memory survives transport recreation, not process restart. + const backend = new InMemoryPaymentStateStore(); + const hub = new MockRelayHub(); + const serverKey = generateSecretKey(); + const clientKey = generateSecretKey(); + const clientPubkey = getPublicKey(clientKey); + let invoices = 0; + let approvals = 0; + let verifications = 0; + let executions = 0; + let terminalPublications = 0; + let failedResult = false; + let admittedRecord: PaymentStateRecord | undefined; + const processor: PaymentProcessor = { + pmi: 'fake', + currencyUnit: 'sats', + async createPaymentRequired(input) { + invoices++; + return { + amount: input.amount, + pmi: 'fake', + pay_req: `invoice-${invoices}`, + ttl: 2, + }; + }, + async verifyPayment() { + verifications++; + return {}; + }, + }; + const servers: McpServer[] = []; + const createTransport = async ( + onReplay?: () => void, + ): Promise => { + // Exercise the public interface without requiring the bundled implementation's class. + const store: PaymentStateStore = { + policy: backend.policy, + admit: async (input) => { + const result = await backend.admit(input); + if (result.status === 'reserved') admittedRecord = result.record; + if (result.status === 'duplicate') onReplay?.(); + return result; + }, + transition: async (input) => { + const changed = await backend.transition(input); + if ( + changed && + !failedResult && + ((failResult === 'result_ack' && input.type === 'result') || + (failResult === 'publication_ack' && + input.type === 'published')) + ) { + failedResult = true; + throw new Error( + 'Injected storage acknowledgement loss after commit', + ); + } + return changed; + }, + read: async (key) => { + const record = await backend.read(key); + // An external backend may serialize and reorder JSON properties. + return record && JSON.parse(canonicalJson(record)); + }, + }; + const server = new McpServer({ + name: 'payment-store-transport', + version: '1.0.0', + }); + server.registerTool( + 'paid', + { inputSchema: { value: z.number() } }, + async ({ value }) => { + executions++; + return { + content: [ + { + type: 'text', + text: oversized ? String(value).repeat(4000) : String(value), + }, + ], + }; + }, + ); + servers.push(server); + const serverRelay = hub.createRelayHandler(); + const transport = withServerPayments( + new NostrServerTransport({ + signer: new PrivateKeySigner(bytesToHex(serverKey)), + relayHandler: serverRelay, + encryptionMode: mode.encryptionMode, + giftWrapMode: mode.giftWrapMode, + oversizedTransfer: { + enabled: oversized, + thresholdBytes: 4000, + chunkSizeBytes: 1000, + }, + }), + { + stateStore: store, + paymentTtlMs: 2000, + processors: [processor], + pricedCapabilities: [ + { + method: 'tools/call', + name: 'paid', + amount: 5, + currencyUnit: 'sats', + }, + ], + }, + ); + const router = transport['outboundResponseRouter']; + const publishResponse = router['deps'].sendMcpMessage; + router['deps'].sendMcpMessage = async (message, ...args) => { + const frameType = + 'method' in message + ? (message.params?.cvm as { frameType?: string } | undefined) + ?.frameType + : 'ordinary'; + const isPaidResponse = args[2]?.some( + (tag) => + tag[0] === 'e' && tag[1] === admittedRecord?.requestEventId, + ); + if ( + failResult && + !failedResult && + isPaidResponse && + frameType === failResult + ) { + failedResult = true; + throw new Error( + `Injected ${failResult} result publication failure`, + ); + } + const sent = await publishResponse(message, ...args); + if ( + isPaidResponse && + (frameType === 'end' || frameType === 'ordinary') + ) + terminalPublications++; + return sent; + }; + await server.connect(transport); + return transport; + }; + const clientEvents: NostrEvent[] = []; + const clientRelay = hub.createRelayHandler(); + const publish = clientRelay.publish.bind(clientRelay); + clientRelay.publish = async (event, options) => { + clientEvents.push(event); + await publish(event, options); + }; + const client = new Client({ + name: 'payment-store-client', + version: '1.0.0', + }); + try { + await createTransport(); + await createTransport(); + const base = new NostrClientTransport({ + signer: new PrivateKeySigner(bytesToHex(clientKey)), + relayHandler: clientRelay, + serverPubkey: getPublicKey(serverKey), + encryptionMode: mode.encryptionMode, + giftWrapMode: mode.giftWrapMode, + oversizedTransfer: { + enabled: oversized, + thresholdBytes: 4000, + chunkSizeBytes: 1000, + }, + }); + await client.connect( + withClientPayments(base, { + handlers: [ + { + pmi: 'fake', + async handle() { + approvals++; + }, + }, + ], + }), + ); + const resultPromise = client.callTool( + { name: 'paid', arguments: { value: 7 } }, + undefined, + { timeout: 20000, ...(oversized && { onprogress: () => {} }) }, + ); + void resultPromise.catch(() => {}); + if (failResult) { + const deadline = Date.now() + 10000; + const expectedStatus = + failResult === 'result_ack' + ? 'pending' + : failResult === 'publication_ack' + ? 'published' + : 'unknown'; + let retained: PaymentStateRecord | undefined; + while (Date.now() < deadline) { + if (admittedRecord) + retained = await backend.read(admittedRecord.key); + if (failedResult && retained?.delivery?.status === expectedStatus) + break; + await Bun.sleep(5); + } + expect(retained?.delivery?.status).toBe(expectedStatus); + expect(failedResult).toBe(true); + await Promise.all(servers.splice(0).map((server) => server.close())); + const recoveredTransport = await createTransport(); + await recoveredTransport.retryPaymentResult( + clientPubkey, + admittedRecord!.requestEventId, + ); + } + const result = await resultPromise; + expect(result.content).toEqual([{ type: 'text', text: expectedText }]); + expect([invoices, approvals, verifications, executions]).toEqual([ + 1, 1, 1, 1, + ]); + expect(admittedRecord?.clientPubkey).toBe(clientPubkey); + const publicationDeadline = Date.now() + 10000; + while ( + (await backend.read(admittedRecord!.key))?.delivery?.status !== + 'published' && + Date.now() < publicationDeadline + ) + await Bun.sleep(5); + expect( + (await backend.read(admittedRecord!.key))?.delivery?.status, + ).toBe('published'); + expect(terminalPublications).toBe(1); + await Promise.all(servers.splice(0).map((server) => server.close())); + await Bun.sleep( + Math.max(0, admittedRecord!.leaseExpiresAtMs - Date.now() + 25), + ); + let replayed!: () => void; + const replay = new Promise((resolve) => { + replayed = resolve; + }); + const restartedTransport = await createTransport(replayed); + // Deliver a relay replay directly into the real verification/decryption pipeline, + // independently of the new subscription's narrower `since` filter. + for (const event of clientEvents) + await restartedTransport['processIncomingEvent'](event); + await withTimeout(replay, 3000, 'Retained request was not replayed'); + expect([invoices, approvals, verifications, executions]).toEqual([ + 1, 1, 1, 1, + ]); + } finally { + await client.close(); + await Promise.all(servers.map((server) => server.close())); + hub.clear(); + } + }); diff --git a/src/transport/payments-flow.test.ts b/src/transport/payments-flow.test.ts index 3bbd471..0e58ef4 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}`, @@ -896,18 +905,16 @@ describe.serial('payments fake flow (transport-level)', () => { }; const startedAt = Date.now(); - await expect(mw(message, ctx, forward)).rejects.toThrow( - /verifyPayment timed out/, - ); + await expect(mw(message, ctx, forward)).rejects.toThrow(/timed out/); const elapsed = Date.now() - startedAt; expect(elapsed).toBeGreaterThanOrEqual(900); 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 expect(mw(message, ctx, forward)).rejects.toThrow(/timed out/); + 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 +1098,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitPaymentHandled = true; return { paid: true }; }, @@ -1196,8 +1202,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 +1273,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -1467,7 +1471,7 @@ describe.serial('payments fake flow (transport-level)', () => { // (distinct pay_req). The client pays twice; the tool runs exactly once. // Locks wire-level correlation across the verify-failure branch (the double- // charge window documented on onPaymentRequired). - test('explicit gating: verifyPayment failure yields a fresh invoice on retry', async () => { + test('explicit gating: verifyPayment failure reconciles the existing invoice on retry', async () => { const serverSK = generateSecretKey(); const serverPrivateKey = bytesToHex(serverSK); const serverPublicKey = getPublicKey(serverSK); @@ -1543,12 +1547,11 @@ describe.serial('payments fake flow (transport-level)', () => { serverPubkey: serverPublicKey, encryptionMode: EncryptionMode.DISABLED, }); - // Client pays unconditionally, so both invoices get paid. + // The existing invoice identity prevents a second approval while reconciliation runs. const paidClientTransport = withClientPayments(clientTransport, { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -1568,9 +1571,8 @@ describe.serial('payments fake flow (transport-level)', () => { }; expect(typedResult.content[0]).toMatchObject({ type: 'text', text: '12' }); - // Two distinct invoices issued; verify ran twice; the tool ran exactly once. - expect(issuedPayReqs).toHaveLength(2); - expect(issuedPayReqs[0]).not.toBe(issuedPayReqs[1]); + // One invoice; receiver lookup recovers settlement and the handler runs once. + expect(issuedPayReqs).toHaveLength(1); expect(verifyCount).toBe(2); expect(toolCallCount).toBe(1); @@ -1620,7 +1622,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 +1649,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -1976,7 +1978,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitHandled = true; return { paid: true }; }, @@ -2082,7 +2083,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitHandled = true; return { paid: true }; }, @@ -2273,7 +2273,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); return { paid: true }; }, }); @@ -2413,7 +2412,6 @@ describe.serial('payments fake flow (transport-level)', () => { handlers: [], paymentInteraction: 'explicit_gating', onPaymentRequired: async () => { - await sleepPastSecondBoundary(); explicitHandled = true; return { paid: true }; },