Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/old-lies-check.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@contextvm/sdk': patch
---

fix(payments): drop uncorrelated payment_required notifications on Nostr transports
47 changes: 47 additions & 0 deletions src/payments/client-payments.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
canHandleCalls += 1;
return true;
},
async handle(): Promise<void> {
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[] = [];
Expand Down
41 changes: 25 additions & 16 deletions src/payments/client-payments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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)) {
Expand All @@ -287,11 +301,6 @@ export function withClientPayments(
requestEventId,
};

const pending =
transport instanceof NostrClientTransport
? transport.getPendingRequestForEventId(requestEventId)
: undefined;

const synthesizeClientDeclineError = (params: {
message: string;
}): void => {
Expand Down
Loading