Skip to content
Open
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
143 changes: 143 additions & 0 deletions src/__mocks__/client-payments-harness.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
await waitForValue({
produce: () => (condition() ? true : undefined),
timeoutMs: options.timeoutMs ?? 1000,
intervalMs: 2,
});
}

/** Builds or advances isolated payment test state. */
export const tick = (): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, 0));
/** Builds or advances isolated payment test state. */
export const wait = (ms: number): Promise<void> =>
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<T>(): {
promise: Promise<T>;
resolve: (value: T) => void;
reject: (error: Error) => void;
} {
let resolve!: (value: T) => void;
let reject!: (error: Error) => void;
const promise = new Promise<T>((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<void>,
): Promise<void> {
const sent: JSONRPCMessage[] = [];
const received: JSONRPCMessage[] = [];
const errors: Error[] = [];
const base: Transport = {
async start(): Promise<void> {},
async send(message): Promise<void> {
sent.push(message);
},
async close(): Promise<void> {}, // 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 };
}>;
}
95 changes: 95 additions & 0 deletions src/payments/client-payment-attempts.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
101 changes: 101 additions & 0 deletions src/payments/client-payment-attempts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import type { PaymentOption } from './types.js';
import { LruCache } from '../core/utils/lru-cache.js';
import { ClientPaymentError } from './client-payment-validation.js';

type AttemptOutcome = 'in_flight' | 'handled' | 'declined' | 'unknown';

/** A local payment action; handled means wallet callback returned, not settlement. */
export interface ClientPaymentAttempt {
outcome: AttemptOutcome;
completion: Promise<void>;
complete: (outcome: Exclude<AttemptOutcome, 'in_flight'>) => void;
}

/**
* Wrapper-scoped attempt history. A Nostr wrapper is bound to one authenticated
* server; generic wrappers use their transport instance as the peer boundary.
* In-flight and unknown attempts stay pinned; handled and declined identities
* may be evicted least recently used first under capacity pressure.
*/
export class ClientPaymentAttempts {
private readonly entries: LruCache<{
amount: number;
attempt: ClientPaymentAttempt;
}>;

public constructor(private readonly maxEntries: number) {
this.entries = new LruCache(maxEntries);
}

private key(option: PaymentOption): string {
return JSON.stringify([option.pmi, option.pay_req]);
}

/** Finds a prior action without reserving or paying any new option. */
public find(
options: readonly PaymentOption[],
): ClientPaymentAttempt | undefined {
let found: ClientPaymentAttempt | undefined;
for (const option of options) {
const prior = this.entries.get(this.key(option));
if (prior) {
if (prior.amount !== option.amount) {
throw new ClientPaymentError(
'payment_offer_changed',
'Payment amount changed for an existing payment identity',
);
}
if (
!found ||
(found.outcome !== 'unknown' &&
(prior.attempt.outcome === 'unknown' ||
prior.attempt.outcome === 'in_flight'))
)
found = prior.attempt;
}
}
return found;
}

/** Atomically reserves all selectable options before external approval work. */
public reserve(
options: readonly PaymentOption[],
signal: AbortSignal,
): ClientPaymentAttempt {
const unique = new Map(options.map((option) => [this.key(option), option]));
const needed = this.entries.size + unique.size - this.maxEntries;
const evictable: string[] = [];
for (const [key, { attempt }] of this.entries.entries()) {
if (evictable.length >= needed) break;
if (attempt.outcome === 'handled' || attempt.outcome === 'declined')
evictable.push(key);
}
if (evictable.length < needed) {
throw new ClientPaymentError(
'payment_attempt_capacity',
'Client payment attempt capacity exceeded by unresolved payments or offered options',
);
}
for (const key of evictable) this.entries.delete(key);
let resolve!: () => void;
const attempt: ClientPaymentAttempt = {
outcome: 'in_flight',
completion: new Promise<void>((done) => {
resolve = done;
}),
complete: (outcome): void => {
if (attempt.outcome !== 'in_flight') return;
attempt.outcome = outcome;
signal.removeEventListener('abort', onAbort);
resolve();
},
};
const onAbort = (): void => attempt.complete('unknown');
for (const [key, option] of unique) {
this.entries.set(key, { amount: option.amount, attempt });
}
signal.addEventListener('abort', onAbort, { once: true });
if (signal.aborted) onAbort();
return attempt;
}
}
Loading
Loading