Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
6 changes: 6 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"sonarlint.connectedMode.project": {
"connectionId": "bitcoindefi",
"projectKey": "Bitcoindefi_Open-Stellar"
}
}
6 changes: 5 additions & 1 deletion __tests__/api/explorer/receipts.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
import { describe, expect, it } from "vitest"
import { describe, expect, it, beforeEach } from "vitest"
import { createX402Quote, listX402ExplorerReceipts, settleX402 } from "@/lib/protocols/x402"
import { resetX402ReceiptStoreForTests } from "@/lib/protocols/x402-receipt-store"

describe("x402 explorer receipts", () => {
beforeEach(() => {
resetX402ReceiptStoreForTests()
})
it("records accepted settlements for explorer queries", () => {
const quote = createX402Quote({
serviceId: "data-api",
Expand Down
18 changes: 18 additions & 0 deletions __tests__/api/protocol/x402-services.test.ts
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)
})
})
37 changes: 37 additions & 0 deletions __tests__/api/protocol/x402-subscriptions-renew.test.ts
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)
})
})
88 changes: 88 additions & 0 deletions __tests__/x402-sdk.test.ts
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')
})
})
57 changes: 57 additions & 0 deletions __tests__/x402-subscriptions-persistence.test.ts
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer "expect(onDisk).toHaveLength(1)" over this generic assertion for better reporting; it works on any object with a numeric length property.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AaAbCu5mqAnnN-dvfNy_&open=AaAbCu5mqAnnN-dvfNy_&pullRequest=480
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer "expect(result.renewed).toHaveLength(1)" over this generic assertion for better reporting; it works on any object with a numeric length property.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AaAbCu5mqAnnN-dvfNzA&open=AaAbCu5mqAnnN-dvfNzA&pullRequest=480
expect(result.renewed[0].billingEvents.length).toBeGreaterThan(1)
})
})
57 changes: 57 additions & 0 deletions __tests__/x402-webhooks.test.ts
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

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Prefer "expect(deliveries).toHaveLength(1)" over this generic assertion for better reporting; it works on any object with a numeric length property.

See more on https://sonarcloud.io/project/issues?id=Bitcoindefi_Open-Stellar&issues=AaAbCuZ3qAnnN-dvfNy-&open=AaAbCuZ3qAnnN-dvfNy-&pullRequest=480
})

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'
)
})
})
12 changes: 12 additions & 0 deletions app/api/protocol/x402/services/route.ts
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 },
)
}
39 changes: 39 additions & 0 deletions app/api/protocol/x402/subscriptions/renew/route.ts
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' },
)
}
}
48 changes: 48 additions & 0 deletions app/api/protocol/x402/webhooks/route.ts
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' },
)
}
Comment thread
gitar-bot[bot] marked this conversation as resolved.

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' },
)
}
}
Loading
Loading