Skip to content
13 changes: 13 additions & 0 deletions .changeset/atomic-payment-state.md
Original file line number Diff line number Diff line change
@@ -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.
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 };
}>;
}
76 changes: 76 additions & 0 deletions src/__mocks__/payment-state.ts
Original file line number Diff line number Diff line change
@@ -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<PaymentStateRecord> {
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<void> {
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<boolean> {
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<number> {
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;
}
Loading
Loading