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
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.
7 changes: 7 additions & 0 deletions .changeset/payment-settlement-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@contextvm/sdk': minor
---

Add bounded payment-adapter cancellation, authenticated settlement reconciliation, and retryable paid-result delivery through the injected `PaymentStateStore`.

The SDK bundles only in-memory storage and adds no storage-driver dependency. Result recovery with the default store requires keeping that same store alive; recovery across process restarts requires an application-provided persistent implementation. Retrying publication does not repeat payment or handler execution.
25 changes: 24 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,33 @@ jobs:
with:
bun-version: latest

- name: Setup minimum Node runtime
uses: actions/setup-node@v4
with:
node-version: '18'

- name: Install dependencies
run: bun install
run: bun install --frozen-lockfile

- name: Run tests
env:
NWC_INTEGRATION: 'false'
LNBITS_INTEGRATION: 'false'
ZAP_INTEGRATION: 'false'
NODE_ENV: test
run: bun test --concurrent --timeout=60000

- name: Check types and lint
run: |
bun run typecheck
bun run lint

- name: Build and check public exports
run: |
bun run build
bun run verify-exports

- name: Qualify Node 18 and browser entrypoints
run: |
bun run verify:payment-runtime
bun run verify:browser
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@
"format": "bun prettier --write src",
"release": "bun run build && bun publish",
"typecheck": "bun run tsc --noEmit",
"verify-exports": "bun run scripts/verify-exports.ts"
"verify-exports": "bun run scripts/verify-exports.ts",
"verify:payment-runtime": "node scripts/verify-payment-runtime.mjs",
"verify:browser": "bun build src/payments/index.ts src/transport/index.ts src/core/index.ts --target browser --outdir /tmp/contextvm-payment-browser"
},
"devDependencies": {
"@changesets/cli": "^2.31.1",
Expand Down
62 changes: 62 additions & 0 deletions scripts/verify-payment-runtime.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import assert from 'node:assert/strict';
import {
InMemoryPaymentStateStore,
PaymentResultRecovery,
paymentEventKey,
paymentInvoiceDigest,
} from '../dist/esm/payments/index.js';

// Execute as a real module: Node 18's crypto globals differ from `node -e`.
const store = new InMemoryPaymentStateStore();
const key = paymentEventKey('client', 'event');
const admitted = await store.admit({
key,
mode: 'transparent',
clientPubkey: 'client',
requestEventId: 'event',
invocationHash: 'hash',
leaseMs: 10000,
});
assert.equal(admitted.status, 'reserved');
const fence = { key, generation: admitted.record.generation };
assert(await store.transition({ ...fence, type: 'issuing' }));
assert(
await store.transition({
...fence,
type: 'bind',
invoice: { amount: 1, pmi: 'fake', pay_req: 'private-invoice' },
leaseMs: 10000,
}),
);
assert(await store.transition({ ...fence, type: 'settled', ttlMs: 10000 }));
assert(await store.transition({ ...fence, type: 'consume' }));
const recovery = new PaymentResultRecovery(store, {
publicationLeaseMs: 10000,
});
const result = { jsonrpc: '2.0', id: 'event', result: { ok: true } };
const snapshot = {
route: { clientPubkey: 'client', originalRequestId: 7 },
expiresAtMs: Number.MAX_SAFE_INTEGER,
};
const publication = await recovery.prepare('event', result, snapshot);
assert(publication.publish);
await publication.framePublished(1);
await publication.complete();
assert.equal((await store.read(key)).delivery.status, 'published');
assert.equal(paymentInvoiceDigest('test').length, 64);
assert.equal(
(
await store.admit({
key,
mode: 'transparent',
clientPubkey: 'client',
requestEventId: 'event',
invocationHash: 'hash',
leaseMs: 10000,
})
).status,
'duplicate',
);
console.log(
`${process.version}: payment lifecycle, result journal, and public exports passed`,
);
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