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
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

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
150 changes: 150 additions & 0 deletions src/payments/authorization-store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
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('grant multiple executions', () => {
const store = new AuthorizationStore();

store.grant(identity, 10000, 2);

expect(store.claim(identity)).toBe(true);
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);

// hasPending should reflect the state
expect(store.hasPending(identity)).toBe(true);
});

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();

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

store.grant(identity, 10000);

expect(store.hasPending(identity)).toBe(false);
expect(store.claim(identity)).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.hasPending(id1)).toBe(false);
expect(store.hasPending(id2)).toBe(true);
expect(store.hasPending(id3)).toBe(true);
});

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);
});
});
180 changes: 180 additions & 0 deletions src/payments/authorization-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
import type { CanonicalInvocationIdentity } from './types.js';
import { LruCache } from '../core/utils/lru-cache.js';
import { createLogger } from '../core/utils/logger.js';

export interface PaidAuthorization {
/** Composite key: `${clientPubkey}:${invocationHash}` */
key: string;
expiresAtMs: number;
/** Number of remaining executions (usually 1). */
remaining: 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.
*/
public grant(
identity: CanonicalInvocationIdentity,
ttlMs: number,
count: number = 1,
): void {
if (count <= 0) {
throw new RangeError('Authorization count must be greater than 0');
}

const key = this.getKey(identity);
const expiresAtMs = Date.now() + ttlMs;

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

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

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

/**
* Atomically claims one execution authorization.
* Returns true if claimed, false if none available.
*/
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;
}

if (auth.remaining > 0) {
auth.remaining -= 1;
if (auth.remaining === 0) {
this.authorizations.delete(key);
} else {
// Explicitly delete and set to guarantee LRU position is refreshed
this.authorizations.delete(key);
this.authorizations.set(key, auth);
}
this.logger.debug('authorization claimed', { key, remaining: auth.remaining });
return true;
}

return false;
}

/**
* 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;
}

/** Checks if a payment is pending (not yet authorized). */
public hasPending(identity: CanonicalInvocationIdentity): boolean {
const key = this.getKey(identity);
const expiry = this.pending.get(key);

if (expiry === undefined) {
return false;
}

if (Date.now() > expiry) {
this.pending.delete(key);
return false;
}

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