From c8f44ddd903f270738b7b6bc53d19d60b17bc987 Mon Sep 17 00:00:00 2001 From: ContextVM Date: Thu, 30 Apr 2026 10:18:53 +0100 Subject: [PATCH] fix(payments): drop uncorrelated payment_required notifications on Nostr transports --- .changeset/old-lies-check.md | 5 +++ src/payments/client-payments.test.ts | 47 ++++++++++++++++++++++++++++ src/payments/client-payments.ts | 41 ++++++++++++++---------- 3 files changed, 77 insertions(+), 16 deletions(-) create mode 100644 .changeset/old-lies-check.md diff --git a/.changeset/old-lies-check.md b/.changeset/old-lies-check.md new file mode 100644 index 0000000..92ade92 --- /dev/null +++ b/.changeset/old-lies-check.md @@ -0,0 +1,5 @@ +--- +'@contextvm/sdk': patch +--- + +fix(payments): drop uncorrelated payment_required notifications on Nostr transports diff --git a/src/payments/client-payments.test.ts b/src/payments/client-payments.test.ts index 9d21282..d3cab64 100644 --- a/src/payments/client-payments.test.ts +++ b/src/payments/client-payments.test.ts @@ -344,6 +344,53 @@ describe('withClientPayments()', () => { }); }); + test('drops uncorrelated payment_required notifications on Nostr transports', async () => { + const transport = createMockNostrTransport(); + + let canHandleCalls = 0; + let handleCalls = 0; + const observed: JSONRPCMessage[] = []; + + const paid = withClientPayments(transport, { + handlers: [ + { + pmi: 'fake', + async canHandle(): Promise { + canHandleCalls += 1; + return true; + }, + async handle(): Promise { + handleCalls += 1; + }, + }, + ], + }); + + paid.onmessage = (msg) => observed.push(msg); + + await paid.start(); + + const paymentRequired: JSONRPCMessage = { + jsonrpc: '2.0', + method: 'notifications/payment_required', + params: { amount: 1, pay_req: 'x', pmi: 'fake' }, + }; + + (transport as unknown as TransportWithContext).onmessageWithContext?.( + paymentRequired, + { + eventId: 'evt', + correlatedEventId: undefined, + }, + ); + + await new Promise((r) => setTimeout(r, 0)); + + expect(observed).toContainEqual(paymentRequired); + expect(canHandleCalls).toBe(0); + expect(handleCalls).toBe(0); + }); + test('handler errors call onerror but do not block message delivery', async () => { const observed: JSONRPCMessage[] = []; const errors: Error[] = []; diff --git a/src/payments/client-payments.ts b/src/payments/client-payments.ts index 1ea4bfb..4d53105 100644 --- a/src/payments/client-payments.ts +++ b/src/payments/client-payments.ts @@ -209,11 +209,34 @@ export function withClientPayments( return; } + const handler = handlersByPmi.get(message.params.pmi); + if (!handler) { + logger.debug('no handler for PMI, ignoring payment_required', { + pmi: message.params.pmi, + requestEventId, + }); + return; + } + + 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; + } + // 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 (transport instanceof NostrClientTransport) { - const pending = transport.getPendingRequestForEventId(requestEventId); + 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. @@ -256,15 +279,6 @@ export function withClientPayments( } } - const handler = handlersByPmi.get(message.params.pmi); - if (!handler) { - logger.debug('no handler for PMI, ignoring payment_required', { - pmi: message.params.pmi, - requestEventId, - }); - 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)) { @@ -287,11 +301,6 @@ export function withClientPayments( requestEventId, }; - const pending = - transport instanceof NostrClientTransport - ? transport.getPendingRequestForEventId(requestEventId) - : undefined; - const synthesizeClientDeclineError = (params: { message: string; }): void => {