diff --git a/app/offline/page.tsx b/app/offline/page.tsx index 1f9de13e..2d388606 100644 --- a/app/offline/page.tsx +++ b/app/offline/page.tsx @@ -1,7 +1,7 @@ export default function OfflinePage() { return (
-

You're offline

+

You're offline

Open Stellar will reconnect when your network is back.

) diff --git a/lib/agents/task-queue.ts b/lib/agents/task-queue.ts index ddaa1091..2ceab58a 100644 --- a/lib/agents/task-queue.ts +++ b/lib/agents/task-queue.ts @@ -1,7 +1,7 @@ import type { AgentTask } from "@/lib/types" export const TASK_TIMEOUT_MS = 5 * 60 * 1000 -export const MAX_PENDING_PER_AGENT = 100 +export const MAX_PENDING_PER_AGENT = 250 export type TaskStatus = AgentTask["status"] diff --git a/lib/passport/validator-client.ts b/lib/passport/validator-client.ts index ac1efb4a..251435fc 100644 --- a/lib/passport/validator-client.ts +++ b/lib/passport/validator-client.ts @@ -26,8 +26,8 @@ export * as contract from "@stellar/stellar-sdk/contract"; export * as rpc from "@stellar/stellar-sdk/rpc"; if (typeof window !== "undefined") { - //@ts-ignore Buffer exists - window.Buffer = window.Buffer || Buffer; + // @ts-expect-error Buffer exists + window.Buffer = (window as any).Buffer || Buffer; } @@ -89,6 +89,7 @@ export interface Groth16Proof { c: Buffer; } +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export interface Client { /** * Construct and simulate a init transaction. Returns an `AssembledTransaction` object which will have a `result` field containing the result of the simulation. If this transaction changes contract state, you will need to call `signAndSend()` on the returned object. @@ -138,6 +139,7 @@ export interface Client { verify_and_register: ({proof, public_inputs}: {proof: Groth16Proof, public_inputs: Array}, options?: MethodOptions) => Promise>> } +// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging export class Client extends ContractClient { static async deploy( /** Options for initializing a Client as well as for calling a method, with extras specific to deploying. */ diff --git a/lib/protocols/x402.test.ts b/lib/protocols/x402.test.ts index 97747dfb..57265ed6 100644 --- a/lib/protocols/x402.test.ts +++ b/lib/protocols/x402.test.ts @@ -1,8 +1,19 @@ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { describe, expect, it } from 'vitest' import { createLocalReputationAttestation } from '@/lib/reputation/attestation' import type { ReputationSnapshot } from '@/lib/reputation/reputation-store' -import { createX402Quote } from './x402' +import { + checkX402Subscription, + createX402Quote, + createX402Subscription, + listX402Subscriptions, + resetX402SubscriptionStorePathForTests, + resetX402SubscriptionsForTests, + setX402SubscriptionStorePathForTests, +} from './x402' const reputation: ReputationSnapshot = { actorId: 'bot-silver', @@ -49,3 +60,27 @@ describe('x402 reputation gate', () => { expect(quote.serviceId).toBe('silver-service') }) }) + +describe('x402 subscription persistence', () => { + it('stores subscriptions on disk and reloads them for later reads', () => { + const dir = mkdtempSync(join(tmpdir(), 'open-stellar-x402-subs-')) + try { + setX402SubscriptionStorePathForTests(join(dir, 'subscriptions.json')) + resetX402SubscriptionsForTests() + const subscription = createX402Subscription({ + serviceId: 'weather.v1', + agentId: 'agent-nexus', + plan: 'starter', + walletBalanceXlm: 10, + }) + + expect(subscription.active).toBe(true) + expect(listX402Subscriptions().subscriptions).toHaveLength(1) + expect(checkX402Subscription('agent-nexus', 'weather.v1', { consumeCall: true }).callsRemaining).toBe(99) + expect(listX402Subscriptions().subscriptions[0].callsUsed).toBe(1) + } finally { + resetX402SubscriptionStorePathForTests() + rmSync(dir, { recursive: true, force: true }) + } + }) +}) diff --git a/lib/protocols/x402.ts b/lib/protocols/x402.ts index 2695f81c..8860ca22 100644 --- a/lib/protocols/x402.ts +++ b/lib/protocols/x402.ts @@ -1,3 +1,6 @@ +import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs' +import { dirname, join } from 'node:path' +import { cwd } from 'node:process' import { StrKey } from '@stellar/stellar-sdk' import { verifyEvmPayment, type EvmSettlementChain } from '@/lib/evm-utils' @@ -103,6 +106,9 @@ const globalState = globalThis as typeof globalThis & { __x402SubscriptionRegistry__?: SubscriptionRegistry } +const DEFAULT_SUBSCRIPTIONS_DB_PATH = join(cwd(), '.data', 'x402-subscriptions.json') +let subscriptionsDbPath = process.env.X402_SUBSCRIPTION_DB_PATH || DEFAULT_SUBSCRIPTIONS_DB_PATH + const quoteRegistry: QuoteRegistry = globalState.__x402QuoteRegistry__ ?? new Map() if (!globalState.__x402QuoteRegistry__) globalState.__x402QuoteRegistry__ = quoteRegistry export interface X402SettlementResult { ok: boolean; receipt?: X402Receipt; error?: string } @@ -312,6 +318,44 @@ if (!globalState.__x402SubscriptionRegistry__) { globalState.__x402SubscriptionRegistry__ = subscriptionRegistry } +function ensureSubscriptionStore(): void { + const dir = dirname(subscriptionsDbPath) + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }) + if (!existsSync(subscriptionsDbPath)) writeFileSync(subscriptionsDbPath, '[]\n', 'utf8') +} + +function isX402Subscription(value: unknown): value is X402Subscription { + if (!value || typeof value !== 'object') return false + const item = value as Partial + return typeof item.id === 'string' && typeof item.serviceId === 'string' && typeof item.agentId === 'string' && typeof item.renewsAt === 'string' && Array.isArray(item.billingEvents) +} + +function readSubscriptionStore(): X402Subscription[] { + ensureSubscriptionStore() + const raw = readFileSync(subscriptionsDbPath, 'utf8').trim() + if (!raw) return [] + const parsed = JSON.parse(raw) as unknown + return Array.isArray(parsed) ? parsed.filter(isX402Subscription) : [] +} + +function writeSubscriptionStore(subscriptions: X402Subscription[]): void { + ensureSubscriptionStore() + const tmpPath = `${subscriptionsDbPath}.${process.pid}.tmp` + writeFileSync(tmpPath, `${JSON.stringify(subscriptions, null, 2)}\n`, 'utf8') + renameSync(tmpPath, subscriptionsDbPath) +} + +function hydrateSubscriptionRegistry(): void { + subscriptionRegistry.clear() + for (const subscription of readSubscriptionStore()) { + subscriptionRegistry.set(subscriptionKey(subscription.agentId, subscription.serviceId), subscription) + } +} + +function persistSubscriptionRegistry(): void { + writeSubscriptionStore(Array.from(subscriptionRegistry.values())) +} + function subscriptionKey(agentId: string, serviceId: string) { return `${agentId}:${serviceId}` } @@ -367,11 +411,14 @@ export function createX402Subscription(input: X402SubscriptionRequest): X402Subs }], } + hydrateSubscriptionRegistry() subscriptionRegistry.set(subscriptionKey(agentId, serviceId), subscription) + persistSubscriptionRegistry() return subscription } export function renewX402Subscriptions(now: Date = new Date(), balances: Record = {}) { + hydrateSubscriptionRegistry() const renewed: X402Subscription[] = [] const paused: X402Subscription[] = [] @@ -413,14 +460,14 @@ export function renewX402Subscriptions(now: Date = new Date(), balances: Record< renewed.push(subscription) } + if (renewed.length > 0 || paused.length > 0) persistSubscriptionRegistry() return { renewed, paused } } export function checkX402Subscription(agentId: string, serviceId: string, options: { consumeCall?: boolean } = {}): X402SubscriptionAccess { + renewX402Subscriptions() const subscription = subscriptionRegistry.get(subscriptionKey(agentId.trim(), serviceId.trim())) if (!subscription) return { active: false, callsRemaining: 0, renewsAt: '', status: 'missing' } - - renewX402Subscriptions() if (!subscription.active) { return { active: false, callsRemaining: Math.max(0, (subscription.callsPerMonth ?? 0) - subscription.callsUsed), renewsAt: subscription.renewsAt, status: subscription.status, graceEndsAt: subscription.graceEndsAt, subscription } } @@ -432,7 +479,10 @@ export function checkX402Subscription(agentId: string, serviceId: string, option return { active: false, callsRemaining: 0, renewsAt: subscription.renewsAt, status: 'exhausted', subscription } } - if (options.consumeCall && monthlyCallLimit !== null) subscription.callsUsed += 1 + if (options.consumeCall && monthlyCallLimit !== null) { + subscription.callsUsed += 1 + persistSubscriptionRegistry() + } return { active: true, callsRemaining: monthlyCallLimit === null ? null : Math.max(0, monthlyCallLimit - subscription.callsUsed), @@ -444,12 +494,14 @@ export function checkX402Subscription(agentId: string, serviceId: string, option } export function getX402SubscriptionById(subscriptionId: string): X402Subscription | undefined { + hydrateSubscriptionRegistry() const id = subscriptionId.trim() if (!id) return undefined return Array.from(subscriptionRegistry.values()).find((subscription) => subscription.id === id) } export function listX402Subscriptions() { + hydrateSubscriptionRegistry() const subscriptions = Array.from(subscriptionRegistry.values()).sort((a, b) => a.renewsAt.localeCompare(b.renewsAt)) const active = subscriptions.filter((subscription) => subscription.active) const mrrXlm = active.reduce((sum, subscription) => sum + parseXlmAmount(subscription.pricePerMonth), 0) @@ -466,4 +518,15 @@ export function listX402Subscriptions() { export function resetX402SubscriptionsForTests() { subscriptionRegistry.clear() + writeSubscriptionStore([]) +} + +export function setX402SubscriptionStorePathForTests(path: string): void { + subscriptionsDbPath = path + subscriptionRegistry.clear() +} + +export function resetX402SubscriptionStorePathForTests(): void { + subscriptionsDbPath = process.env.X402_SUBSCRIPTION_DB_PATH || DEFAULT_SUBSCRIPTIONS_DB_PATH + subscriptionRegistry.clear() } diff --git a/lib/wallet-config.ts b/lib/wallet-config.ts index 9a7a5bd8..dc95b598 100644 --- a/lib/wallet-config.ts +++ b/lib/wallet-config.ts @@ -1,5 +1,5 @@ -import { createConfig, http, injected } from 'wagmi' -import { walletConnect } from '@wagmi/connectors' +import { createConfig, http } from 'wagmi' +import { injected, walletConnect } from 'wagmi/connectors' import { bscTestnet, bsc } from 'wagmi/chains' const walletConnectProjectId = process.env.NEXT_PUBLIC_WALLETCONNECT_PROJECT_ID diff --git a/packages/x402/package.json b/packages/x402/package.json new file mode 100644 index 00000000..b02048af --- /dev/null +++ b/packages/x402/package.json @@ -0,0 +1,11 @@ +{ + "name": "@open-stellar/x402", + "version": "0.1.0", + "description": "Five-line x402 HTTP payment gate for Open Stellar services.", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "files": ["dist", "src"], + "scripts": { "build": "tsc -p tsconfig.json" }, + "dependencies": {} +} diff --git a/packages/x402/src/index.ts b/packages/x402/src/index.ts new file mode 100644 index 00000000..cb562703 --- /dev/null +++ b/packages/x402/src/index.ts @@ -0,0 +1,50 @@ +declare const process: { env?: Record } +export interface X402GateConfig { + endpoint?: string + serviceId: string + unitPriceUsd: number + chain?: 'stellar' | 'bnb' | 'base' + payer?: string +} + +export interface X402GateRequest { + headers: Headers + url?: string +} + +export interface X402GateResult { + paid: boolean + receiptId?: string + response?: Response +} + +async function verifyReceipt(endpoint: string, receiptId: string): Promise { + const response = await fetch(`${endpoint.replace(/\/$/, '')}/api/protocol/x402/receipts/${encodeURIComponent(receiptId)}`) + if (!response.ok) return false + const body = await response.json() as { receipt?: { accepted?: boolean } } + return body.receipt?.accepted === true +} + +export function x402Gate(config: X402GateConfig) { + const endpoint = config.endpoint || process.env?.OPEN_STELLAR_URL || '' + if (!endpoint) throw new Error('x402 endpoint is required') + + return async function gate(request: X402GateRequest): Promise { + const receiptId = request.headers.get('x-x402-receipt') || request.headers.get('x-open-stellar-receipt') + if (receiptId && await verifyReceipt(endpoint, receiptId)) return { paid: true, receiptId } + + const quote = await fetch(`${endpoint.replace(/\/$/, '')}/api/protocol/x402/quote`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + serviceId: config.serviceId, + units: 1, + unitPriceUsd: config.unitPriceUsd, + chain: config.chain, + payer: config.payer || 'anonymous', + }), + }).then((response) => response.json()) + + return { paid: false, response: Response.json(quote, { status: 402 }) } + } +} diff --git a/packages/x402/tsconfig.json b/packages/x402/tsconfig.json new file mode 100644 index 00000000..ba6fcc92 --- /dev/null +++ b/packages/x402/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "Bundler", + "declaration": true, + "outDir": "dist", + "strict": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/tests/lib/agents/task-drain.test.ts b/tests/lib/agents/task-drain.test.ts index 62513263..02d5f6bc 100644 --- a/tests/lib/agents/task-drain.test.ts +++ b/tests/lib/agents/task-drain.test.ts @@ -221,7 +221,6 @@ describe("task-queue drain and purge", () => { maxItems: 1, processor: async () => {}, }) - expect(result!.processed).toBe(1) const purged = purgeAgentTasks("agent-1") @@ -229,6 +228,8 @@ describe("task-queue drain and purge", () => { const tasks = listAgentTasks("agent-1") expect(tasks.filter((t) => t.status === "completed")).toHaveLength(1) + // 'b' should be gone + expect(tasks.filter((t) => t.type === "b")).toHaveLength(0) }) it("isolates purge between agents", () => {