Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
0c0a6b1
feat: implement CEP-8 explicit gating lifecycle
1amKhush Jun 11, 2026
0f3b082
fix: address code review findings for explicit gating
1amKhush Jun 11, 2026
26acaf1
fix: address PR feedback for explicit gating
1amKhush Jun 11, 2026
670867e
fix(sdk): resolve explicit gating bugs and stabilize tests
1amKhush Jun 11, 2026
89ff84b
Reseolve minor issue findings
1amKhush Jun 11, 2026
f73b09d
fix: resolve explicit gating correlation race and CI failures
1amKhush Jun 13, 2026
3f605a3
fix: session state after -32602 rejection and unreachable payment_int…
1amKhush Jun 14, 2026
b2471f0
Fix final review findings for CEP-8 explicit gating
1amKhush Jun 16, 2026
c3be163
Fix lint issue in canonical-identity.test.ts
1amKhush Jun 16, 2026
723c520
Fix final consistency gaps and test coverage for CEP-8 explicit gating
1amKhush Jun 17, 2026
3095ed3
Export PaymentRequired type to fix build error
1amKhush Jun 17, 2026
eabfc69
chore: cleanup explicit gating after rebase
1amKhush Jun 19, 2026
27b9d34
chore: add regression test for getNegotiationTags and fix imports
1amKhush Jun 19, 2026
c106bbf
fix: replace remaining @modelcontextprotocol SDK imports with @contex…
1amKhush Jun 19, 2026
39eae38
refactor: un-export internal helpers to clean up API surface
1amKhush Jun 19, 2026
35f8774
fix(payments): finalize CEP-8 explicit-gating compliance and test cov…
ContextVM-org Jun 20, 2026
aabfb4f
test(payments): add CEP-8 explicit-gating security isolation tests
ContextVM-org Jun 22, 2026
fb319e0
Merge branch 'master' into feat/cep8-explicit-gating
ContextVM-org Jun 22, 2026
b469912
chore: add minor changeset for CEP-8 explicit gating
1amKhush Jun 22, 2026
b8537ef
feat(payments): add PaymentInteractionPolicy type and optional server…
ContextVM-org Jun 23, 2026
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
56 changes: 56 additions & 0 deletions .changeset/calm-wallets-gate.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
"@contextvm/sdk": minor
---

feat: CEP-8 Explicit Payment Gating lifecycle

Add full support for the CEP-8 Explicit Gating payment interaction mode (`explicit_gating`),
enabling servers to strictly gate priced MCP capabilities behind verifiable payments before
execution.

**Protocol**

- Servers and clients negotiate `payment_interaction` mode via Nostr event tags on the first
direct message. Servers disclose their effective mode on the first response event.
- `-32042 Payment Required`: returned with structured `payment_options` (PMI, amount, pay_req,
description, TTL) when a priced capability is invoked without authorization.
- `-32043 Payment Pending`: returned with `retry_after` backoff when a retry races against
active payment verification, preventing invoice-generation spam.
- `-32602 Invalid Params`: returned with `{ requested, supported }` when a client requests
`explicit_gating` on a transparent-only server.

**Server**

- New `createExplicitGatingMiddleware` with TTL-bounded `AuthorizationStore` for single-use,
atomic check-and-set execution grants scoped by canonical invocation identity
(SHA-256 over JCS-canonicalized method + params + client pubkey).
- Shared `resolveAndInitiatePayment` pipeline eliminates duplication between transparent and
explicit-gating server middlewares.
- New `PaymentInteractionPolicy` type (`'optional' | 'transparent'`) separates the server-side
policy from the wire-level `PaymentInteractionMode`. `withServerPayments` now defaults to
`'optional'`: a server that accepts payments advertises `explicit_gating` support and mirrors
each client's requested lifecycle, so explicit-gating clients are gated while transparent
clients keep the notification flow. Pass `paymentInteraction: 'transparent'` for a
transparent-only server.

**Client**

- `withClientPayments` intercepts `-32042`/`-32043` upstream, delegates to the user's
`onPaymentRequired` handler, and auto-retries the original request with configurable
`maxPendingRetries` and exponential backoff.
- Effective-mode guard prevents auto-satisfying transparent payments when the server rejected
explicit gating—synthesizes a local `-32000` decline instead.
- An inbound `payment_interaction` tag on a server response is now recorded as the session's
effective mode only when the client itself requested `explicit_gating`. Otherwise the tag is
treated as a server availability advertisement, preventing a transparent client from
incorrectly believing it is on the explicit-gating lifecycle.

**Backward Compatibility**

- Wire and client compatible: legacy clients not advertising the new mode continue using the
default `transparent` flow. Per-session middleware guards ensure explicit-gating behavior
only activates for sessions that opted in.
- The new `'optional'` server default is a behavioral change for server operators who relied on
the previous implicit transparent-only default: their server now also accepts
`explicit_gating` requests from clients that ask for it. Set `paymentInteraction: 'transparent'`
to restore transparent-only behavior.
3 changes: 3 additions & 0 deletions src/core/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,9 @@ export const NOSTR_TAGS = {
* Support CEP-41 open-ended stream transfer via notifications/progress framing.
*/
SUPPORT_OPEN_STREAM: 'support_open_stream',

/** CEP-8 payment interaction negotiation tag. */
PAYMENT_INTERACTION: 'payment_interaction',
} as const;

export const DEFAULT_LRU_SIZE = 5000;
Expand Down
137 changes: 137 additions & 0 deletions src/payments/authorization-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { describe, expect, test } from 'bun:test';
import { AuthorizationStore } from './authorization-store.js';
import type { CanonicalInvocationIdentity } from './types.js';

describe('AuthorizationStore', () => {
const identity: CanonicalInvocationIdentity = {
clientPubkey: 'client-1',
invocationHash: 'hash-1',
};

test('grant and claim a single authorization', () => {
const store = new AuthorizationStore();

expect(store.claim(identity)).toBe(false);

store.grant(identity, 10000);

expect(store.claim(identity)).toBe(true);
expect(store.claim(identity)).toBe(false);
});

test('claim fails after TTL expires', async () => {
const store = new AuthorizationStore();

store.grant(identity, 50);

await new Promise((resolve) => setTimeout(resolve, 75));

expect(store.claim(identity)).toBe(false);
});

test('trySetPending prevents concurrent duplicates', () => {
const store = new AuthorizationStore();

// First call transitions to pending -> true
expect(store.trySetPending(identity, 10000)).toBe(true);

// Second call is blocked -> false
expect(store.trySetPending(identity, 10000)).toBe(false);

// Pending state is observable via getPendingRemainingMs
expect(store.getPendingRemainingMs(identity)).toBeGreaterThan(0);
});

test('trySetPending allows setting again after clearPending', () => {
const store = new AuthorizationStore();

expect(store.trySetPending(identity, 10000)).toBe(true);
expect(store.trySetPending(identity, 10000)).toBe(false);

store.clearPending(identity);

expect(store.trySetPending(identity, 10000)).toBe(true);
});

test('trySetPending allows setting again after pending state expires', async () => {
const store = new AuthorizationStore();

expect(store.trySetPending(identity, 50)).toBe(true);
expect(store.trySetPending(identity, 50)).toBe(false);

await new Promise((resolve) => setTimeout(resolve, 75));

expect(store.trySetPending(identity, 50)).toBe(true);
});

test('grant clears pending state', () => {
const store = new AuthorizationStore();

expect(store.trySetPending(identity, 10000)).toBe(true);
store.grant(identity, 10000);
// grant cleared pending, so a fresh trySetPending succeeds again
expect(store.trySetPending(identity, 10000)).toBe(true);
});

test('LRU eviction works when maxEntries is exceeded', () => {
const store = new AuthorizationStore({ maxEntries: 2 });

const id1 = { clientPubkey: 'client', invocationHash: 'h1' };
const id2 = { clientPubkey: 'client', invocationHash: 'h2' };
const id3 = { clientPubkey: 'client', invocationHash: 'h3' };

store.grant(id1, 10000);
store.grant(id2, 10000);
store.grant(id3, 10000); // This should evict id1

expect(store.claim(id1)).toBe(false);
expect(store.claim(id2)).toBe(true);
expect(store.claim(id3)).toBe(true);
});

test('pending LRU eviction works when maxEntries is exceeded', () => {
const store = new AuthorizationStore({ maxEntries: 2 });

const id1 = { clientPubkey: 'client', invocationHash: 'p1' };
const id2 = { clientPubkey: 'client', invocationHash: 'p2' };
const id3 = { clientPubkey: 'client', invocationHash: 'p3' };

store.trySetPending(id1, 10000);
store.trySetPending(id2, 10000);
store.trySetPending(id3, 10000); // This should evict id1

expect(store.getPendingRemainingMs(id1)).toBe(0);
expect(store.getPendingRemainingMs(id2)).toBeGreaterThan(0);
expect(store.getPendingRemainingMs(id3)).toBeGreaterThan(0);
});

test('updatePendingTtl and getPendingRemainingMs behave correctly', async () => {
const store = new AuthorizationStore();

// (1) verify getPendingRemainingMs right after trySetPending
expect(store.trySetPending(identity, 100)).toBe(true);
const remainingAfterSet = store.getPendingRemainingMs(identity);
expect(remainingAfterSet).toBeGreaterThan(0);
expect(remainingAfterSet).toBeLessThanOrEqual(100);

// (2) verify updatePendingTtl extends the pending TTL
store.updatePendingTtl(identity, 500);
const remainingAfterUpdate = store.getPendingRemainingMs(identity);
expect(remainingAfterUpdate).toBeGreaterThan(100);
expect(remainingAfterUpdate).toBeLessThanOrEqual(500);

// (3) verify getPendingRemainingMs returns 0 after waiting past TTL
await new Promise((resolve) => setTimeout(resolve, 550));
expect(store.getPendingRemainingMs(identity)).toBe(0);

// (4) verify updatePendingTtl is a no-op when there is no active pending entry
store.updatePendingTtl(identity, 1000);
expect(store.getPendingRemainingMs(identity)).toBe(0);

// And after clearPending
store.trySetPending(identity, 1000);
store.clearPending(identity);
store.updatePendingTtl(identity, 1000);
expect(store.getPendingRemainingMs(identity)).toBe(0);
});
});
143 changes: 143 additions & 0 deletions src/payments/authorization-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import type { CanonicalInvocationIdentity } from './types.js';
import { LruCache } from '../core/utils/lru-cache.js';
import { createLogger } from '../core/utils/logger.js';

interface PaidAuthorization {
/** Composite key: `${clientPubkey}:${invocationHash}` */
key: string;
expiresAtMs: number;
}

/**
* A bounded, TTL-aware store for explicit gating authorizations.
* It manages both the pending state (waiting for payment verification)
* and the granted state (paid and ready to consume).
*
* NOTE: The atomicity provided by `trySetPending` relies on in-memory maps,
* meaning it is strictly single-process. For multi-process horizontal scaling,
* implementers should use a distributed lock (e.g. Redis Redlock) keyed by
* the canonical invocation identity to prevent duplicate payments.
*/
export class AuthorizationStore {
private readonly authorizations: LruCache<PaidAuthorization>;
private readonly pending: LruCache<number>; // Map of key -> expiresAtMs
private readonly logger = createLogger('authorization-store');

constructor(opts?: { maxEntries?: number }) {
const maxEntries = opts?.maxEntries ?? 5000;
this.authorizations = new LruCache<PaidAuthorization>(maxEntries);
this.pending = new LruCache<number>(maxEntries);
}

private getKey(identity: CanonicalInvocationIdentity): string {
return `${identity.clientPubkey}:${identity.invocationHash}`;
}

/**
* Records a paid authorization. Each grant authorizes exactly one future
* execution (CEP-8: "each successful payment SHOULD authorize one future
* execution unless server policy explicitly grants a different number").
*/
public grant(identity: CanonicalInvocationIdentity, ttlMs: number): void {
const key = this.getKey(identity);
const expiresAtMs = Date.now() + ttlMs;

this.authorizations.set(key, { key, expiresAtMs });

// Once granted, it's no longer pending
this.pending.delete(key);

this.logger.debug('authorization granted', { key, ttlMs });
}

/**
* Atomically claims the single execution authorization.
* Returns true if claimed, false if none available or expired.
*/
public claim(identity: CanonicalInvocationIdentity): boolean {
const key = this.getKey(identity);
const auth = this.authorizations.get(key);

if (!auth) {
return false;
}

if (Date.now() > auth.expiresAtMs) {
this.authorizations.delete(key);
return false;
}

// Single-use: consume the authorization atomically.
this.authorizations.delete(key);
this.logger.debug('authorization claimed', { key });
return true;
}

/**
* Atomically checks whether a payment is already pending for this identity
* and, if not, marks it as pending. Returns `true` if this call transitioned
* the identity to pending (caller should emit -32042). Returns `false` if
* already pending (caller should emit -32043).
*
* This atomic check-and-set prevents concurrent requests from both receiving
* -32042 and triggering duplicate payment flows.
* NOTE: This is single-process only. Distributed setups must use an external lock.
*/
public trySetPending(
identity: CanonicalInvocationIdentity,
ttlMs: number,
): boolean {
const key = this.getKey(identity);
const now = Date.now();

const existingExpiry = this.pending.get(key);
if (existingExpiry !== undefined) {
if (now > existingExpiry) {
// Expired pending state, we can overwrite it
this.pending.delete(key);
} else {
// Already pending and active
return false;
}
}

this.pending.set(key, now + ttlMs);
this.logger.debug('authorization marked pending', { key, ttlMs });
return true;
}

/**
* Updates the TTL of an already pending authorization. No-op if not pending.
*
* @param identity The canonical invocation identity.
* @param ttlMs The new TTL in milliseconds to apply from now.
* @returns void
*/
public updatePendingTtl(
identity: CanonicalInvocationIdentity,
ttlMs: number,
): void {
const key = this.getKey(identity);
const existingExpiry = this.pending.get(key);
if (existingExpiry !== undefined && Date.now() <= existingExpiry) {
this.pending.set(key, Date.now() + ttlMs);
this.logger.debug('authorization pending TTL updated', { key, ttlMs });
}
}

/** Gets the remaining TTL in milliseconds for a pending authorization, or 0 if not pending. */
public getPendingRemainingMs(identity: CanonicalInvocationIdentity): number {
const key = this.getKey(identity);
const expiry = this.pending.get(key);
if (expiry === undefined) return 0;
const remaining = expiry - Date.now();
return remaining > 0 ? remaining : 0;
}

/** Clears pending state (e.g. on verification failure or expiry). */
public clearPending(identity: CanonicalInvocationIdentity): void {
const key = this.getKey(identity);
this.pending.delete(key);
this.logger.debug('authorization pending state cleared', { key });
}
}
Loading
Loading