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
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')
})
})
81 changes: 81 additions & 0 deletions __tests__/x402-ssrf-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { describe, expect, it, beforeEach } from 'vitest'
import {
validateWebhookTargetUrl,
isPrivateOrLoopbackHost,
dispatchX402SettlementWebhook,
resetX402WebhookDeliveriesForTests,
} from '@/lib/protocols/x402-webhooks'
import { POST } from '@/app/api/protocol/x402/webhooks/route'

describe('SSRF Guard & Webhook Auth Security', () => {
beforeEach(() => {
resetX402WebhookDeliveriesForTests()
})

it('detects private, loopback, and cloud metadata hostnames', () => {
expect(isPrivateOrLoopbackHost('localhost')).toBe(true)
expect(isPrivateOrLoopbackHost('127.0.0.1')).toBe(true)
expect(isPrivateOrLoopbackHost('127.0.0.2')).toBe(true)
expect(isPrivateOrLoopbackHost('169.254.169.254')).toBe(true)
expect(isPrivateOrLoopbackHost('10.0.0.5')).toBe(true)
expect(isPrivateOrLoopbackHost('192.168.1.100')).toBe(true)
expect(isPrivateOrLoopbackHost('172.20.0.1')).toBe(true)
expect(isPrivateOrLoopbackHost('2130706433')).toBe(true)
expect(isPrivateOrLoopbackHost('::ffff:169.254.169.254')).toBe(true)
expect(isPrivateOrLoopbackHost('example.internal')).toBe(true)

expect(isPrivateOrLoopbackHost('api.example.com')).toBe(false)
})

it('rejects SSRF target URLs in validateWebhookTargetUrl', () => {
expect(() => validateWebhookTargetUrl('http://169.254.169.254/latest/meta-data')).toThrow(
/private\/loopback\/metadata/
)
expect(() => validateWebhookTargetUrl('http://localhost:8080/admin')).toThrow(
/private\/loopback\/metadata/
)
expect(() => validateWebhookTargetUrl('http://127.0.0.1/internal')).toThrow(
/private\/loopback\/metadata/
)
expect(() => validateWebhookTargetUrl('http://2130706433/internal')).toThrow(
/private\/loopback\/metadata/
)
})

it('prevents SSRF dispatches via dispatchX402SettlementWebhook', async () => {
const log = await dispatchX402SettlementWebhook(
{
accepted: true,
quoteId: 'q_123',
paymentRef: 'ref_123',
settledAt: new Date().toISOString(),
txHash: '0x1234567890123456789012345678901234567890123456789012345678901234',
chain: 'stellar',
},
'http://169.254.169.254/latest/meta-data'
)

expect(log.status).toBe('failed')
expect(log.error).toMatch(/private\/loopback\/metadata/)
})

it('rejects unauthenticated POST requests to /api/protocol/x402/webhooks in production or when secret set', async () => {
const req = new Request('https://openstellar.org/api/protocol/x402/webhooks', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
receipt: { paymentRef: 'test' },
}),
})

const oldSecret = process.env.ADMIN_SECRET
process.env.ADMIN_SECRET = 'top-secret-key'

try {
const res = await POST(req)
expect(res.status).toBe(401)
} finally {
process.env.ADMIN_SECRET = oldSecret
}
})
})
78 changes: 78 additions & 0 deletions __tests__/x402-stellar-verification.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it, vi } from 'vitest'
import { verifyStellarPayment } from '@/lib/protocols/x402'

describe('Stellar On-Chain Payment Verification', () => {
it('rejects invalid txHash formats', async () => {
const res = await verifyStellarPayment({
txHash: 'invalid-hash-123',
expectedTo: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
})
expect(res.accepted).toBe(false)
expect(res.error).toMatch(/Invalid Stellar txHash format/)
})

it('accepts valid 64-hex txHash format in test environment', async () => {
const validHash = '0x1234567890123456789012345678901234567890123456789012345678901234'
const res = await verifyStellarPayment({
txHash: validHash,
expectedTo: 'GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF',
})
expect(res.accepted).toBe(true)
})

it('enforces expectedAmountXlm and sender/recipient checks on create_account ops', async () => {
vi.stubEnv('NODE_ENV', 'production')
vi.stubEnv('SKIP_ONCHAIN_VERIFICATION', 'false')

// Mock global fetch to return a Horizon create_account operation
const globalFetch = globalThis.fetch
globalThis.fetch = (async () => {
return new Response(
JSON.stringify({
_embedded: {
records: [
{
type: 'create_account',
account: 'GRECIPIENT123456789',
funder: 'GSENDER123456789',
starting_balance: '5.0000000',
},
],
},
}),
{ status: 200 }
)
}) as typeof fetch

try {
// Underpayment check: expected 10 XLM, provided 5 XLM -> rejected
const underpayRes = await verifyStellarPayment({
txHash: '1234567890123456789012345678901234567890123456789012345678901234',
expectedTo: 'GRECIPIENT123456789',
expectedFrom: 'GSENDER123456789',
expectedAmountXlm: 10,
})
expect(underpayRes.accepted).toBe(false)

// Recipient mismatch -> rejected
const wrongToRes = await verifyStellarPayment({
txHash: '1234567890123456789012345678901234567890123456789012345678901234',
expectedTo: 'GWRONGRECIPIENT',
expectedAmountXlm: 5,
})
expect(wrongToRes.accepted).toBe(false)

// Valid amount & recipient on create_account -> accepted
const validRes = await verifyStellarPayment({
txHash: '1234567890123456789012345678901234567890123456789012345678901234',
expectedTo: 'GRECIPIENT123456789',
expectedFrom: 'GSENDER123456789',
expectedAmountXlm: 5,
})
expect(validRes.accepted).toBe(true)
} finally {
globalThis.fetch = globalFetch
vi.unstubAllEnvs()
}
})
})
59 changes: 59 additions & 0 deletions __tests__/x402-subscription-concurrency.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { describe, expect, it, beforeEach } from 'vitest'
import {
saveX402SubscriptionStoreRecord,
saveX402SubscriptionStoreRecordSync,
readSubscriptions,
resetX402SubscriptionStoreForTests,
} from '@/lib/protocols/x402-subscription-store'

describe('Subscription Store Concurrency & Storage', () => {
beforeEach(() => {
resetX402SubscriptionStoreForTests()
})

it('handles concurrent writeSubscriptions calls sequentially without corruption', async () => {
const promises = Array.from({ length: 10 }).map((_, i) =>
saveX402SubscriptionStoreRecord({
id: `sub_${i}`,
serviceId: `service-${i}`,
agentId: `agent-${i}`,
plan: 'starter',
callsPerMonth: 100,
callsUsed: i,
pricePerMonth: '1 XLM',
status: 'active',
active: true,
createdAt: new Date().toISOString(),
renewsAt: new Date().toISOString(),
lastChargedAt: new Date().toISOString(),
billingEvents: [],
})
)

await Promise.all(promises)
const stored = readSubscriptions()
expect(stored).toHaveLength(10)
})

it('persists subscription updates to disk durably', () => {
saveX402SubscriptionStoreRecordSync({
id: 'sub_flush_1',
serviceId: 'oracle',
agentId: 'agent-hot',
plan: 'pro',
callsPerMonth: 1000,
callsUsed: 42,
pricePerMonth: '20 XLM',
status: 'active',
active: true,
createdAt: new Date().toISOString(),
renewsAt: new Date().toISOString(),
lastChargedAt: new Date().toISOString(),
billingEvents: [],
})

const stored = readSubscriptions()
expect(stored).toHaveLength(1)
expect(stored[0].callsUsed).toBe(42)
})
})
Loading
Loading