Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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 };
}>;
}
89 changes: 89 additions & 0 deletions src/payments/client-payment-attempts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import type { PaymentOption } from './types.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.
* No live/completed/unknown entry is evicted and silently made payable again.
*/
export class ClientPaymentAttempts {
private readonly entries = new Map<
string,
{ amount: number; attempt: ClientPaymentAttempt }
>();

public constructor(private readonly maxEntries: number) {}

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]));
if (this.entries.size + unique.size > this.maxEntries) {
throw new ClientPaymentError(
'payment_attempt_capacity',
'Client payment attempt capacity reached',
);
}
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