Skip to content
Closed
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
2 changes: 1 addition & 1 deletion app/offline/page.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
export default function OfflinePage() {
return (
<main className="min-h-screen bg-[#030712] flex flex-col items-center justify-center text-slate-100 gap-4">
<h1 className="font-mono text-2xl text-cyan-300">You're offline</h1>
<h1 className="font-mono text-2xl text-cyan-300">You&apos;re offline</h1>
<p className="text-slate-400 text-sm">Open Stellar will reconnect when your network is back.</p>
</main>
)
Expand Down
2 changes: 1 addition & 1 deletion lib/agents/task-queue.ts
Original file line number Diff line number Diff line change
@@ -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"]

Expand Down
6 changes: 4 additions & 2 deletions lib/passport/validator-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@
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

Check failure on line 29 in lib/passport/validator-client.ts

View workflow job for this annotation

GitHub Actions / Typecheck, tests, build, and guards

Unused '@ts-expect-error' directive.
window.Buffer = (window as any).Buffer || Buffer;
}


Expand Down Expand Up @@ -89,6 +89,7 @@
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.
Expand Down Expand Up @@ -138,6 +139,7 @@
verify_and_register: ({proof, public_inputs}: {proof: Groth16Proof, public_inputs: Array<u256>}, options?: MethodOptions) => Promise<AssembledTransaction<Result<Attestation>>>

}
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class Client extends ContractClient {
static async deploy<T = Client>(
/** Options for initializing a Client as well as for calling a method, with extras specific to deploying. */
Expand Down
37 changes: 36 additions & 1 deletion lib/protocols/x402.test.ts
Original file line number Diff line number Diff line change
@@ -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',
Expand Down Expand Up @@ -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 })
}
})
})
69 changes: 66 additions & 3 deletions lib/protocols/x402.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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<X402Subscription>
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}`
}
Expand Down Expand Up @@ -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<string, number> = {}) {
hydrateSubscriptionRegistry()
const renewed: X402Subscription[] = []
const paused: X402Subscription[] = []

Expand Down Expand Up @@ -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 }
}
Expand All @@ -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),
Expand All @@ -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)
Expand All @@ -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()
}
4 changes: 2 additions & 2 deletions lib/wallet-config.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down
11 changes: 11 additions & 0 deletions packages/x402/package.json
Original file line number Diff line number Diff line change
@@ -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": {}
}
50 changes: 50 additions & 0 deletions packages/x402/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
declare const process: { env?: Record<string, string | undefined> }
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<boolean> {
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<X402GateResult> {
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 }) }
}
}
12 changes: 12 additions & 0 deletions packages/x402/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ES2022",
"moduleResolution": "Bundler",
"declaration": true,
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*.ts"]
}
3 changes: 2 additions & 1 deletion tests/lib/agents/task-drain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,14 +221,15 @@ describe("task-queue drain and purge", () => {
maxItems: 1,
processor: async () => {},
})
expect(result!.processed).toBe(1)

const purged = purgeAgentTasks("agent-1")

expect(purged).toBe(1)

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", () => {
Expand Down
Loading