-
Notifications
You must be signed in to change notification settings - Fork 27
feat(x402): production-grade HTTP payment rails, persistent stores, w… #480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
2cae6e2
fe00889
1a041c4
61bf21c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| { | ||
| "sonarlint.connectedMode.project": { | ||
| "connectionId": "bitcoindefi", | ||
| "projectKey": "Bitcoindefi_Open-Stellar" | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| import { describe, expect, it } from 'vitest' | ||
| import { GET } from '@/app/api/protocol/x402/services/route' | ||
|
|
||
| describe('/api/protocol/x402/services API Route', () => { | ||
| it('lists registered x402 marketplace services', async () => { | ||
| const req = new Request('https://openstellar.org/api/protocol/x402/services') | ||
| const res = await GET(req) | ||
|
|
||
| expect(res.status).toBe(200) | ||
| const json = await res.json() | ||
|
|
||
| expect(json.ok).toBe(true) | ||
| expect(json.count).toBeGreaterThan(0) | ||
| expect(json.services.length).toBeGreaterThan(0) | ||
| expect(json.services[0].id).toBeTruthy() | ||
| expect(json.services[0].priceXlm).toBeGreaterThan(0) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| import { describe, expect, it, beforeEach } from 'vitest' | ||
| import { POST } from '@/app/api/protocol/x402/subscriptions/renew/route' | ||
| import { createX402Subscription, resetX402SubscriptionsForTests } from '@/lib/protocols/x402' | ||
| import { resetX402SubscriptionStoreForTests } from '@/lib/protocols/x402-subscription-store' | ||
|
|
||
| describe('/api/protocol/x402/subscriptions/renew API Route', () => { | ||
| beforeEach(() => { | ||
| resetX402SubscriptionsForTests() | ||
| resetX402SubscriptionStoreForTests() | ||
| }) | ||
|
|
||
| it('triggers recurring renewals and returns summary', async () => { | ||
| const pastDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) | ||
| createX402Subscription({ | ||
| serviceId: 'data-oracle', | ||
| agentId: 'agent-bot-77', | ||
| plan: 'starter', | ||
| now: pastDate, | ||
| }) | ||
|
|
||
| const req = new Request('https://openstellar.org/api/protocol/x402/subscriptions/renew', { | ||
| method: 'POST', | ||
| headers: { 'Content-Type': 'application/json' }, | ||
| body: JSON.stringify({ | ||
| balances: { | ||
| 'agent-bot-77': 50, | ||
| }, | ||
| }), | ||
| }) | ||
|
|
||
| const res = await POST(req) | ||
| expect(res.status).toBe(200) | ||
| const json = await res.json() | ||
| expect(json.ok).toBe(true) | ||
| expect(json.renewedCount).toBe(1) | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { describe, expect, it, beforeEach } from 'vitest' | ||
| import { withX402, gateX402Request } from '@/lib/sdk/x402-sdk' | ||
| import { | ||
| createX402Quote, | ||
| createX402Subscription, | ||
| resetX402SubscriptionsForTests, | ||
| } from '@/lib/protocols/x402' | ||
| import { resetX402ReceiptStoreForTests } from '@/lib/protocols/x402-receipt-store' | ||
|
|
||
| describe('x402 SDK (withX402)', () => { | ||
| beforeEach(() => { | ||
| resetX402SubscriptionsForTests() | ||
| resetX402ReceiptStoreForTests() | ||
| }) | ||
|
|
||
| it('returns HTTP 402 with quote payload when request is unpaid', async () => { | ||
| const handler = withX402( | ||
| { serviceId: 'stellar-oracle', unitPriceUsd: 0.05 }, | ||
| async () => Response.json({ data: 'oracle payload' }), | ||
| ) | ||
|
|
||
| const req = new Request('https://openstellar.org/api/oracle') | ||
| const res = await handler(req) | ||
|
|
||
| expect(res.status).toBe(402) | ||
| expect(res.headers.get('X-402-Quote-ID')).toBeTruthy() | ||
|
|
||
| const body = await res.json() | ||
| expect(body.code).toBe(402) | ||
| expect(body.serviceId).toBe('stellar-oracle') | ||
| expect(body.amountUsd).toBe(0.05) | ||
| expect(body.options.length).toBeGreaterThanOrEqual(3) | ||
| }) | ||
|
|
||
| it('allows access when request has an active subscription', async () => { | ||
| createX402Subscription({ | ||
| serviceId: 'stellar-oracle', | ||
| agentId: 'agent-007', | ||
| plan: 'starter', | ||
| callsPerMonth: 100, | ||
| }) | ||
|
|
||
| const handler = withX402( | ||
| { serviceId: 'stellar-oracle', unitPriceUsd: 0.05 }, | ||
| async () => Response.json({ data: 'oracle payload' }), | ||
| ) | ||
|
|
||
| const req = new Request('https://openstellar.org/api/oracle', { | ||
| headers: { | ||
| 'X-402-Agent-Id': 'agent-007', | ||
| }, | ||
| }) | ||
| const res = await handler(req) | ||
|
|
||
| expect(res.status).toBe(200) | ||
| const body = await res.json() | ||
| expect(body.data).toBe('oracle payload') | ||
| }) | ||
|
|
||
| it('allows access when valid payment settlement header is supplied', async () => { | ||
| const quote = createX402Quote({ | ||
| serviceId: 'stellar-oracle', | ||
| unitPriceUsd: 0.05, | ||
| units: 1, | ||
| payer: 'agent-999', | ||
| chain: 'stellar', | ||
| }) | ||
|
|
||
| const handler = withX402( | ||
| { serviceId: 'stellar-oracle', unitPriceUsd: 0.05 }, | ||
| async () => Response.json({ data: 'paid content' }), | ||
| ) | ||
|
|
||
| const req = new Request('https://openstellar.org/api/oracle', { | ||
| headers: { | ||
| 'X-402-Payment-Ref': quote.paymentRef, | ||
| 'X-402-Tx-Hash': '0x1234567890123456789012345678901234567890123456789012345678901234', | ||
| 'X-402-Chain': 'stellar', | ||
| 'X-402-Agent-Id': 'agent-999', | ||
| }, | ||
| }) | ||
|
|
||
| const res = await handler(req) | ||
| expect(res.status).toBe(200) | ||
| const body = await res.json() | ||
| expect(body.data).toBe('paid content') | ||
| }) | ||
| }) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { describe, expect, it, beforeEach } from 'vitest' | ||
| import { | ||
| createX402Subscription, | ||
| checkX402Subscription, | ||
| renewX402Subscriptions, | ||
| resetX402SubscriptionsForTests, | ||
| } from '@/lib/protocols/x402' | ||
| import { | ||
| readSubscriptions, | ||
| resetX402SubscriptionStoreForTests, | ||
| } from '@/lib/protocols/x402-subscription-store' | ||
|
|
||
| describe('x402 Subscriptions Persistence', () => { | ||
| beforeEach(() => { | ||
| resetX402SubscriptionsForTests() | ||
| resetX402SubscriptionStoreForTests() | ||
| }) | ||
|
|
||
| it('persists subscriptions to disk and restores them on reload', () => { | ||
| createX402Subscription({ | ||
| serviceId: 'oracle-service', | ||
| agentId: 'agent-bot-42', | ||
| plan: 'starter', | ||
| callsPerMonth: 50, | ||
| walletBalanceXlm: 10, | ||
| }) | ||
|
|
||
| const onDisk = readSubscriptions() | ||
| expect(onDisk.length).toBe(1) | ||
|
Check warning on line 29 in __tests__/x402-subscriptions-persistence.test.ts
|
||
| expect(onDisk[0].agentId).toBe('agent-bot-42') | ||
| expect(onDisk[0].serviceId).toBe('oracle-service') | ||
| expect(onDisk[0].callsPerMonth).toBe(50) | ||
|
|
||
| // Consume a call | ||
| const consumed = checkX402Subscription('agent-bot-42', 'oracle-service', { consumeCall: true }) | ||
| expect(consumed.callsRemaining).toBe(49) | ||
|
|
||
| const updatedDisk = readSubscriptions() | ||
| expect(updatedDisk[0].callsUsed).toBe(1) | ||
| }) | ||
|
|
||
| it('handles recurring renewal billing cycle deductions', () => { | ||
| const pastDate = new Date(Date.now() - 31 * 24 * 60 * 60 * 1000) | ||
| createX402Subscription({ | ||
| serviceId: 'oracle-service', | ||
| agentId: 'agent-bot-42', | ||
| plan: 'starter', | ||
| callsPerMonth: 50, | ||
| now: pastDate, | ||
| }) | ||
|
|
||
| // Trigger recurring renewal | ||
| const result = renewX402Subscriptions(new Date(), { 'agent-bot-42': 100 }) | ||
| expect(result.renewed.length).toBe(1) | ||
|
Check warning on line 54 in __tests__/x402-subscriptions-persistence.test.ts
|
||
| expect(result.renewed[0].billingEvents.length).toBeGreaterThan(1) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| import { describe, expect, it, beforeEach } from 'vitest' | ||
| import { | ||
| dispatchX402SettlementWebhook, | ||
| listX402WebhookDeliveries, | ||
| resetX402WebhookDeliveriesForTests, | ||
| } from '@/lib/protocols/x402-webhooks' | ||
| import { createX402Quote, settleX402 } from '@/lib/protocols/x402' | ||
| import { resetX402ReceiptStoreForTests } from '@/lib/protocols/x402-receipt-store' | ||
|
|
||
| describe('x402 Settlement Webhooks', () => { | ||
| beforeEach(() => { | ||
| resetX402WebhookDeliveriesForTests() | ||
| resetX402ReceiptStoreForTests() | ||
| }) | ||
|
|
||
| it('dispatches webhook payload on settlement', async () => { | ||
| const log = await dispatchX402SettlementWebhook({ | ||
| accepted: true, | ||
| quoteId: 'q_test_123', | ||
| paymentRef: 'oracle:stellar:123', | ||
| settledAt: new Date().toISOString(), | ||
| txHash: '0x1234567890123456789012345678901234567890123456789012345678901234', | ||
| chain: 'stellar', | ||
| amountUsd: 0.1, | ||
| }) | ||
|
|
||
| expect(log.status).toBe('delivered') | ||
| expect(log.payload.event).toBe('x402.settlement') | ||
| expect(log.payload.receipt.quoteId).toBe('q_test_123') | ||
|
|
||
| const deliveries = listX402WebhookDeliveries() | ||
| expect(deliveries.length).toBe(1) | ||
|
Check warning on line 32 in __tests__/x402-webhooks.test.ts
|
||
| }) | ||
|
|
||
| it('automatically triggers webhook dispatch during settleX402', () => { | ||
| const quote = createX402Quote({ | ||
| serviceId: 'packet-relay-mesh', | ||
| unitPriceUsd: 0.03, | ||
| units: 1, | ||
| payer: 'agent-bot-1', | ||
| chain: 'stellar', | ||
| }) | ||
|
|
||
| const result = settleX402({ | ||
| paymentRef: quote.paymentRef, | ||
| chain: 'stellar', | ||
| txHash: '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890', | ||
| }) | ||
|
|
||
| expect(result.ok).toBe(true) | ||
| const deliveries = listX402WebhookDeliveries() | ||
| expect(deliveries.length).toBeGreaterThan(0) | ||
| expect(deliveries[0].payload.receipt.txHash).toBe( | ||
| '0xabcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890' | ||
| ) | ||
| }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| import { createApiRouteLogger } from '@/lib/api-logging' | ||
| import { listMarketplaceServices } from '@/lib/marketplace/services' | ||
|
|
||
| export async function GET(req: Request) { | ||
| const api = createApiRouteLogger(req, '/api/protocol/x402/services') | ||
| const services = listMarketplaceServices() | ||
| return await api.json( | ||
| { ok: true, services, count: services.length }, | ||
| undefined, | ||
| { event: 'x402.services.listed', count: services.length }, | ||
| ) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { createApiRouteLogger } from '@/lib/api-logging' | ||
| import { renewX402Subscriptions } from '@/lib/protocols/x402' | ||
|
|
||
| export async function POST(req: Request) { | ||
| const api = createApiRouteLogger(req, '/api/protocol/x402/subscriptions/renew') | ||
|
|
||
| try { | ||
| const body = await req.json().catch(() => ({})) | ||
| const balances = (body.balances && typeof body.balances === 'object') | ||
| ? (body.balances as Record<string, number>) | ||
| : {} | ||
|
|
||
| const result = renewX402Subscriptions(new Date(), balances) | ||
|
|
||
| return await api.json( | ||
| { | ||
| ok: true, | ||
| renewedCount: result.renewed.length, | ||
| pausedCount: result.paused.length, | ||
| renewed: result.renewed, | ||
| paused: result.paused, | ||
| }, | ||
| undefined, | ||
| { | ||
| event: 'x402.subscriptions.renewed', | ||
| renewedCount: result.renewed.length, | ||
| pausedCount: result.paused.length, | ||
| }, | ||
| ) | ||
| } catch (error) { | ||
| return await api.report( | ||
| 'error', | ||
| error, | ||
| { ok: false, error: error instanceof Error ? error.message : 'Failed renewing x402 subscriptions' }, | ||
| { status: 500 }, | ||
| { event: 'x402.subscriptions.renew_failed' }, | ||
| ) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { createApiRouteLogger } from '@/lib/api-logging' | ||
| import { | ||
| dispatchX402SettlementWebhook, | ||
| listX402WebhookDeliveries, | ||
| } from '@/lib/protocols/x402-webhooks' | ||
|
|
||
| export async function GET(req: Request) { | ||
| const api = createApiRouteLogger(req, '/api/protocol/x402/webhooks') | ||
| const deliveries = listX402WebhookDeliveries() | ||
| return await api.json( | ||
| { ok: true, deliveries, total: deliveries.length }, | ||
| undefined, | ||
| { event: 'x402.webhooks.listed', count: deliveries.length }, | ||
| ) | ||
| } | ||
|
|
||
| export async function POST(req: Request) { | ||
| const api = createApiRouteLogger(req, '/api/protocol/x402/webhooks') | ||
|
|
||
| try { | ||
| const body = await req.json() | ||
| const targetUrl = body.targetUrl ? String(body.targetUrl) : undefined | ||
| const receipt = body.receipt | ||
|
|
||
| if (!receipt || typeof receipt !== 'object') { | ||
| return await api.json( | ||
| { ok: false, error: 'receipt object is required' }, | ||
| { status: 400 }, | ||
| { event: 'x402.webhooks.rejected', reason: 'missing_receipt' }, | ||
| ) | ||
| } | ||
|
|
||
| const log = await dispatchX402SettlementWebhook(receipt, targetUrl) | ||
| return await api.json( | ||
| { ok: true, delivery: log }, | ||
| { status: 201 }, | ||
| { event: 'x402.webhooks.dispatched', targetUrl: log.targetUrl, deliveryStatus: log.status }, | ||
| ) | ||
| } catch (error) { | ||
| return await api.report( | ||
| 'error', | ||
| error, | ||
| { ok: false, error: error instanceof Error ? error.message : 'Failed to dispatch x402 webhook' }, | ||
| { status: 500 }, | ||
| { event: 'x402.webhooks.failed' }, | ||
| ) | ||
| } | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.