From 9e71efd68ea0a00906e2304601ce1ec728edf57e Mon Sep 17 00:00:00 2001 From: ezekiel Date: Tue, 23 Jun 2026 15:21:36 +0100 Subject: [PATCH 1/5] feat: implement x402 payment evidence pipeline --- apps/api/package.json | 1 + apps/api/src/lib/persistence.ts | 20 ++++++- apps/api/src/lib/x402.ts | 33 +++++++++++ apps/api/src/routes/protected.ts | 65 ++++++++++++++++------ apps/api/tests/x402.test.ts | 76 ++++++++++++++++++++++++++ apps/web/src/pages/ControlDeckPage.tsx | 9 ++- apps/web/src/types.ts | 6 +- package-lock.json | 26 --------- packages/shared/src/types.ts | 55 ++++++++++++++----- 9 files changed, 227 insertions(+), 64 deletions(-) create mode 100644 apps/api/tests/x402.test.ts diff --git a/apps/api/package.json b/apps/api/package.json index b3f9375..a53e0a6 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -10,6 +10,7 @@ "build": "tsc -p tsconfig.json", "start": "node dist/index.js", "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "tsx --test tests/**/*.test.ts", "lint": "echo 'No lint configured for api'", "format": "echo 'No format task configured for api'" }, diff --git a/apps/api/src/lib/persistence.ts b/apps/api/src/lib/persistence.ts index 44c033d..c735d4e 100644 --- a/apps/api/src/lib/persistence.ts +++ b/apps/api/src/lib/persistence.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import type { AnalyticsSummary, PaymentAttempt, UsageEvent } from "@query402/shared"; +import type { AnalyticsSummary, PaymentAttempt, UsageEvent, PaymentEvidence } from "@query402/shared"; interface PersistedDb { usage: UsageEvent[]; @@ -45,6 +45,24 @@ export function savePaymentAttempt(payment: PaymentAttempt) { writeDb(db); } +export function updatePaymentAttemptEvidence(id: string, evidence: PaymentEvidence) { + const db = readDb(); + const index = db.payments.findIndex(p => p.id === id); + if (index !== -1) { + db.payments[index].evidence = evidence; + writeDb(db); + } +} + +export function updateUsageEventEvidence(traceId: string, evidence: PaymentEvidence) { + const db = readDb(); + const index = db.usage.findIndex(u => u.traceId === traceId); + if (index !== -1) { + db.usage[index].evidence = evidence; + writeDb(db); + } +} + export function getUsageEvents() { return readDb().usage; } diff --git a/apps/api/src/lib/x402.ts b/apps/api/src/lib/x402.ts index 92ea079..1e27d48 100644 --- a/apps/api/src/lib/x402.ts +++ b/apps/api/src/lib/x402.ts @@ -5,6 +5,7 @@ import type { NextFunction, Request, Response } from "express"; import type { HTTPRequestContext } from "@x402/core/server"; import { getProviderById, protectedRouteBasePrices } from "./pricing.js"; import { config } from "./config.js"; +import { updatePaymentAttemptEvidence, updateUsageEventEvidence } from "./persistence.js"; type RouteMode = "search" | "news" | "scrape"; @@ -103,6 +104,38 @@ export function createX402Middleware() { new ExactStellarScheme() ); + resourceServer.onAfterSettle(async (ctx) => { + const transport = ctx.transportContext as { responseHeaders?: Record }; + const paymentId = transport?.responseHeaders?.["x-payment-attempt-id"]; + const traceId = transport?.responseHeaders?.["x-payment-trace-id"]; + + if (paymentId && traceId) { + updatePaymentAttemptEvidence(paymentId, { + status: "settled", + network, + amountUsd: Number(ctx.requirements.amount), + payToAddress: ctx.requirements.payTo, + facilitatorUrl: config.X402_FACILITATOR_URL, + transactionHash: ctx.result.transaction, + paymentPayload: typeof ctx.paymentPayload === "string" ? ctx.paymentPayload : JSON.stringify(ctx.paymentPayload) + }); + updateUsageEventEvidence(traceId, { + status: "settled", + network, + amountUsd: Number(ctx.requirements.amount), + payToAddress: ctx.requirements.payTo, + facilitatorUrl: config.X402_FACILITATOR_URL, + transactionHash: ctx.result.transaction, + paymentPayload: typeof ctx.paymentPayload === "string" ? ctx.paymentPayload : JSON.stringify(ctx.paymentPayload) + }); + } + }); + + resourceServer.onSettleFailure(async (ctx) => { + // Cannot correlate failure to database entry because transportContext is missing from SettleFailureContext + // The entry will remain as "verified" + }); + const routeConfig = { "GET /x402/search": { accepts: { diff --git a/apps/api/src/routes/protected.ts b/apps/api/src/routes/protected.ts index 9250212..8b35176 100644 --- a/apps/api/src/routes/protected.ts +++ b/apps/api/src/routes/protected.ts @@ -4,6 +4,7 @@ import { searchQuerySchema, newsQuerySchema, scrapeQuerySchema } from "@query402 import { executeQuery } from "../services/query-service.js"; import { config } from "../lib/config.js"; import { savePaymentAttempt, saveUsageEvent } from "../lib/persistence.js"; +import type { PaymentEvidence } from "@query402/shared"; export const protectedRouter = Router(); @@ -17,21 +18,36 @@ function persistPaidRequest(input: { traceId: string; paymentResponseHeader: string | null; payerPublicKey?: string; + isDemo?: boolean; }) { const now = new Date().toISOString(); const paymentId = `pay_${nanoid(10)}`; + const evidence: PaymentEvidence = input.isDemo + ? { + status: "demo-paid", + network: config.STELLAR_NETWORK, + amountUsd: input.priceUsd, + payToAddress: config.X402_PAY_TO_ADDRESS, + facilitatorUrl: config.X402_FACILITATOR_URL, + payerPublicKey: input.payerPublicKey, + demoId: input.paymentResponseHeader ?? "", + } + : { + status: "verified", + network: config.STELLAR_NETWORK, + amountUsd: input.priceUsd, + payToAddress: config.X402_PAY_TO_ADDRESS, + facilitatorUrl: config.X402_FACILITATOR_URL, + payerPublicKey: input.payerPublicKey, + paymentPayload: input.paymentResponseHeader ?? "", + }; + savePaymentAttempt({ id: paymentId, endpoint: input.endpoint, providerId: input.provider, - amountUsd: input.priceUsd, - network: config.STELLAR_NETWORK, - payerPublicKey: input.payerPublicKey, - payToAddress: config.X402_PAY_TO_ADDRESS, - facilitatorUrl: config.X402_FACILITATOR_URL, - status: "settled", - transactionHash: input.paymentResponseHeader ?? undefined, + evidence, createdAt: now }); @@ -42,15 +58,13 @@ function persistPaidRequest(input: { providerId: input.provider, queryOrUrl: input.queryOrUrl, priceUsd: input.priceUsd, - network: config.STELLAR_NETWORK, - paymentStatus: "paid", - paymentTxHash: input.paymentResponseHeader ?? undefined, - facilitatorUrl: config.X402_FACILITATOR_URL, - payerPublicKey: input.payerPublicKey, + evidence, traceId: input.traceId, createdAt: now, latencyMs: input.latencyMs }); + + return paymentId; } protectedRouter.get("/x402/search", async (req, res, next) => { @@ -67,7 +81,8 @@ protectedRouter.get("/x402/search", async (req, res, next) => { }); const paymentHeader = req.header("payment-response") ?? null; - persistPaidRequest({ + const isDemo = req.header("x-query402-demo-paid") === "true"; + const paymentId = persistPaidRequest({ mode: "search", endpoint: "/x402/search", provider: parsed.data.provider, @@ -76,9 +91,13 @@ protectedRouter.get("/x402/search", async (req, res, next) => { latencyMs: result.latencyMs, traceId: result.traceId, paymentResponseHeader: paymentHeader, - payerPublicKey: req.header("x-demo-payer") ?? undefined + payerPublicKey: req.header("x-demo-payer") ?? undefined, + isDemo }); + res.setHeader("x-payment-attempt-id", paymentId); + res.setHeader("x-payment-trace-id", result.traceId); + return res.json({ payment: { network: config.STELLAR_NETWORK, @@ -106,7 +125,8 @@ protectedRouter.get("/x402/news", async (req, res, next) => { }); const paymentHeader = req.header("payment-response") ?? null; - persistPaidRequest({ + const isDemo = req.header("x-query402-demo-paid") === "true"; + const paymentId = persistPaidRequest({ mode: "news", endpoint: "/x402/news", provider: parsed.data.provider, @@ -115,9 +135,13 @@ protectedRouter.get("/x402/news", async (req, res, next) => { latencyMs: result.latencyMs, traceId: result.traceId, paymentResponseHeader: paymentHeader, - payerPublicKey: req.header("x-demo-payer") ?? undefined + payerPublicKey: req.header("x-demo-payer") ?? undefined, + isDemo }); + res.setHeader("x-payment-attempt-id", paymentId); + res.setHeader("x-payment-trace-id", result.traceId); + return res.json({ payment: { network: config.STELLAR_NETWORK, @@ -145,7 +169,8 @@ protectedRouter.get("/x402/scrape", async (req, res, next) => { }); const paymentHeader = req.header("payment-response") ?? null; - persistPaidRequest({ + const isDemo = req.header("x-query402-demo-paid") === "true"; + const paymentId = persistPaidRequest({ mode: "scrape", endpoint: "/x402/scrape", provider: parsed.data.provider, @@ -154,9 +179,13 @@ protectedRouter.get("/x402/scrape", async (req, res, next) => { latencyMs: result.latencyMs, traceId: result.traceId, paymentResponseHeader: paymentHeader, - payerPublicKey: req.header("x-demo-payer") ?? undefined + payerPublicKey: req.header("x-demo-payer") ?? undefined, + isDemo }); + res.setHeader("x-payment-attempt-id", paymentId); + res.setHeader("x-payment-trace-id", result.traceId); + return res.json({ payment: { network: config.STELLAR_NETWORK, diff --git a/apps/api/tests/x402.test.ts b/apps/api/tests/x402.test.ts new file mode 100644 index 0000000..e7d78a5 --- /dev/null +++ b/apps/api/tests/x402.test.ts @@ -0,0 +1,76 @@ +import test from "node:test"; +import assert from "node:assert"; +import { updatePaymentAttemptEvidence, updateUsageEventEvidence, savePaymentAttempt, saveUsageEvent, getPaymentAttempts, getUsageEvents } from "../src/lib/persistence.js"; + +test("evidence pipeline updates correctly on settlement", async (t) => { + const paymentId = "pay_test_123"; + const traceId = "trace_test_456"; + + savePaymentAttempt({ + id: paymentId, + endpoint: "/x402/search", + providerId: "search.basic", + evidence: { + status: "verified", + network: "stellar:testnet", + amountUsd: 0.01, + payToAddress: "G...", + facilitatorUrl: "http://...", + paymentPayload: "raw_header" + }, + createdAt: new Date().toISOString() + }); + + saveUsageEvent({ + id: "use_test_123", + mode: "search", + endpoint: "/x402/search", + providerId: "search.basic", + queryOrUrl: "test", + priceUsd: 0.01, + evidence: { + status: "verified", + network: "stellar:testnet", + amountUsd: 0.01, + payToAddress: "G...", + facilitatorUrl: "http://...", + paymentPayload: "raw_header" + }, + traceId, + createdAt: new Date().toISOString(), + latencyMs: 100 + }); + + updatePaymentAttemptEvidence(paymentId, { + status: "settled", + network: "stellar:testnet", + amountUsd: 0.01, + payToAddress: "G...", + facilitatorUrl: "http://...", + transactionHash: "tx_hash_123", + paymentPayload: "raw_header" + }); + + updateUsageEventEvidence(traceId, { + status: "settled", + network: "stellar:testnet", + amountUsd: 0.01, + payToAddress: "G...", + facilitatorUrl: "http://...", + transactionHash: "tx_hash_123", + paymentPayload: "raw_header" + }); + + const payment = getPaymentAttempts().find(p => p.id === paymentId); + const usage = getUsageEvents().find(u => u.traceId === traceId); + + assert.strictEqual(payment?.evidence.status, "settled"); + if (payment?.evidence.status === "settled") { + assert.strictEqual(payment.evidence.transactionHash, "tx_hash_123"); + } + + assert.strictEqual(usage?.evidence.status, "settled"); + if (usage?.evidence.status === "settled") { + assert.strictEqual(usage.evidence.transactionHash, "tx_hash_123"); + } +}); diff --git a/apps/web/src/pages/ControlDeckPage.tsx b/apps/web/src/pages/ControlDeckPage.tsx index abcf89a..8f93ac7 100644 --- a/apps/web/src/pages/ControlDeckPage.tsx +++ b/apps/web/src/pages/ControlDeckPage.tsx @@ -302,7 +302,8 @@ export default function ControlDeckPage() {
-

payment-response: {result.payment.paymentResponseHeader ?? ""}

+

evidence: {result.payment.paymentResponseHeader?.startsWith('demo_tx_') ? 'demo-paid' : 'verified'}

+

payload: {result.payment.paymentResponseHeader ?? ""}

network: {result.payment.network}

@@ -357,7 +358,9 @@ export default function ControlDeckPage() { {tx.providerId} {money(tx.amountUsd)}

- {new Date(tx.createdAt).toLocaleString()} + + {tx.evidence.status.toUpperCase()} · {new Date(tx.createdAt).toLocaleString()} + ))} @@ -373,7 +376,7 @@ export default function ControlDeckPage() { {usage.latencyMs}ms

- {money(usage.priceUsd)} · {new Date(usage.createdAt).toLocaleString()} + {money(usage.priceUsd)} · {usage.evidence.status.toUpperCase()} · {new Date(usage.createdAt).toLocaleString()} ))} diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 4aa68c2..052e626 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,4 +1,4 @@ -import type { ProviderDefinition, QueryMode } from "@query402/shared"; +import type { ProviderDefinition, QueryMode, PaymentEvidence } from "@query402/shared"; export interface PaidQueryResponse { payment: { @@ -33,7 +33,7 @@ export interface AnalyticsResponse { amountUsd: number; endpoint: string; providerId: string; - status: string; + evidence: PaymentEvidence; createdAt: string; }>; recentUsage: Array<{ @@ -43,7 +43,7 @@ export interface AnalyticsResponse { priceUsd: number; createdAt: string; latencyMs: number; - paymentStatus: string; + evidence: PaymentEvidence; traceId: string; }>; } diff --git a/package-lock.json b/package-lock.json index 3af2336..ee515ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -403,7 +403,6 @@ "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -421,7 +420,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -439,7 +437,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -457,7 +454,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -475,7 +471,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -493,7 +488,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -511,7 +505,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -529,7 +522,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -547,7 +539,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -565,7 +556,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -583,7 +573,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -601,7 +590,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -619,7 +607,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -637,7 +624,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -655,7 +641,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -673,7 +658,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -691,7 +675,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -709,7 +692,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -727,7 +709,6 @@ "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -745,7 +726,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -763,7 +743,6 @@ "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -781,7 +760,6 @@ "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -799,7 +777,6 @@ "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -817,7 +794,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -835,7 +811,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -853,7 +828,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 6c02e93..19d3647 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -33,6 +33,46 @@ export interface QueryResult { raw?: Record; } +export type PaymentEvidenceStatus = "demo-paid" | "verified" | "settled" | "failed"; + +export interface BasePaymentEvidence { + status: PaymentEvidenceStatus; + network: string; + amountUsd: number; + payToAddress: string; + facilitatorUrl: string; + payerPublicKey?: string; + error?: string; +} + +export interface DemoPaymentEvidence extends BasePaymentEvidence { + status: "demo-paid"; + demoId: string; +} + +export interface VerifiedPaymentEvidence extends BasePaymentEvidence { + status: "verified"; + paymentPayload: string; +} + +export interface SettledPaymentEvidence extends BasePaymentEvidence { + status: "settled"; + transactionHash: string; + paymentPayload: string; +} + +export interface FailedPaymentEvidence extends BasePaymentEvidence { + status: "failed"; + error: string; + paymentPayload?: string; +} + +export type PaymentEvidence = + | DemoPaymentEvidence + | VerifiedPaymentEvidence + | SettledPaymentEvidence + | FailedPaymentEvidence; + export interface UsageEvent { id: string; mode: QueryMode; @@ -40,11 +80,7 @@ export interface UsageEvent { providerId: string; queryOrUrl: string; priceUsd: number; - network: string; - paymentStatus: "paid" | "failed" | "demo-paid"; - paymentTxHash?: string; - facilitatorUrl?: string; - payerPublicKey?: string; + evidence: PaymentEvidence; traceId: string; createdAt: string; latencyMs: number; @@ -54,14 +90,7 @@ export interface PaymentAttempt { id: string; endpoint: string; providerId: string; - amountUsd: number; - network: string; - payerPublicKey?: string; - payToAddress: string; - facilitatorUrl: string; - status: "verified" | "settled" | "failed"; - transactionHash?: string; - error?: string; + evidence: PaymentEvidence; createdAt: string; } From 5809d79cd66037eb4a920414e4131708bf30617f Mon Sep 17 00:00:00 2001 From: ezekiel Date: Wed, 24 Jun 2026 11:12:06 +0100 Subject: [PATCH 2/5] fix: correlate verified attempts before settlement and prevent forged demo headers --- apps/api/src/lib/x402.ts | 72 ++++++++++++++++++++++------ apps/api/src/routes/protected.ts | 81 +++++++++----------------------- 2 files changed, 79 insertions(+), 74 deletions(-) diff --git a/apps/api/src/lib/x402.ts b/apps/api/src/lib/x402.ts index 1e27d48..596566c 100644 --- a/apps/api/src/lib/x402.ts +++ b/apps/api/src/lib/x402.ts @@ -5,7 +5,8 @@ import type { NextFunction, Request, Response } from "express"; import type { HTTPRequestContext } from "@x402/core/server"; import { getProviderById, protectedRouteBasePrices } from "./pricing.js"; import { config } from "./config.js"; -import { updatePaymentAttemptEvidence, updateUsageEventEvidence } from "./persistence.js"; +import { savePaymentAttempt, updatePaymentAttemptEvidence } from "./persistence.js"; +import { nanoid } from "nanoid"; type RouteMode = "search" | "news" | "scrape"; @@ -54,6 +55,30 @@ function demoMode402Middleware(req: Request, res: Response, next: NextFunction) const paymentResponse = req.header("payment-response"); if (paidHeader === "true" || typeof paymentResponse === "string") { + const paymentId = `pay_${nanoid(10)}`; + req.headers["x-payment-attempt-id"] = paymentId; + + const routeKey = `${req.method.toUpperCase()} ${req.path}`; + const price = protectedRouteBasePrices[routeKey] ?? "$0.01"; + + const rawProvider = req.query.provider; + const providerId = typeof rawProvider === "string" ? rawProvider : (Array.isArray(rawProvider) ? rawProvider[0] : "unknown"); + + savePaymentAttempt({ + id: paymentId, + endpoint: req.path, + providerId: providerId as string, + evidence: { + status: "demo-paid", + network: config.STELLAR_NETWORK, + amountUsd: Number(price.replace("$", "")), + payToAddress: config.X402_PAY_TO_ADDRESS, + facilitatorUrl: config.X402_FACILITATOR_URL, + demoId: paymentResponse ?? "demo-success" + }, + createdAt: new Date().toISOString() + }); + return next(); } @@ -104,12 +129,39 @@ export function createX402Middleware() { new ExactStellarScheme() ); + resourceServer.onAfterVerify(async (ctx) => { + const transport = ctx.transportContext as { request?: Request, req?: Request } | Request; + const req = ('headers' in transport) ? transport : (transport.request || transport.req); + + if (req) { + const paymentId = `pay_${nanoid(10)}`; + req.headers["x-payment-attempt-id"] = paymentId; + + const providerId = getProviderFromContext(ctx.transportContext) ?? "unknown"; + + savePaymentAttempt({ + id: paymentId, + endpoint: req.path, + providerId, + evidence: { + status: "verified", + network, + amountUsd: Number(ctx.requirements.amount), + payToAddress: ctx.requirements.payTo, + facilitatorUrl: config.X402_FACILITATOR_URL, + paymentPayload: typeof ctx.paymentPayload === "string" ? ctx.paymentPayload : JSON.stringify(ctx.paymentPayload) + }, + createdAt: new Date().toISOString() + }); + } + }); + resourceServer.onAfterSettle(async (ctx) => { - const transport = ctx.transportContext as { responseHeaders?: Record }; - const paymentId = transport?.responseHeaders?.["x-payment-attempt-id"]; - const traceId = transport?.responseHeaders?.["x-payment-trace-id"]; + const transport = ctx.transportContext as { request?: Request, req?: Request } | Request; + const req = ('headers' in transport) ? transport : (transport.request || transport.req); + const paymentId = req?.headers?.["x-payment-attempt-id"] as string | undefined; - if (paymentId && traceId) { + if (paymentId) { updatePaymentAttemptEvidence(paymentId, { status: "settled", network, @@ -119,20 +171,10 @@ export function createX402Middleware() { transactionHash: ctx.result.transaction, paymentPayload: typeof ctx.paymentPayload === "string" ? ctx.paymentPayload : JSON.stringify(ctx.paymentPayload) }); - updateUsageEventEvidence(traceId, { - status: "settled", - network, - amountUsd: Number(ctx.requirements.amount), - payToAddress: ctx.requirements.payTo, - facilitatorUrl: config.X402_FACILITATOR_URL, - transactionHash: ctx.result.transaction, - paymentPayload: typeof ctx.paymentPayload === "string" ? ctx.paymentPayload : JSON.stringify(ctx.paymentPayload) - }); } }); resourceServer.onSettleFailure(async (ctx) => { - // Cannot correlate failure to database entry because transportContext is missing from SettleFailureContext // The entry will remain as "verified" }); diff --git a/apps/api/src/routes/protected.ts b/apps/api/src/routes/protected.ts index 8b35176..80b0ea2 100644 --- a/apps/api/src/routes/protected.ts +++ b/apps/api/src/routes/protected.ts @@ -3,12 +3,13 @@ import { nanoid } from "nanoid"; import { searchQuerySchema, newsQuerySchema, scrapeQuerySchema } from "@query402/shared"; import { executeQuery } from "../services/query-service.js"; import { config } from "../lib/config.js"; -import { savePaymentAttempt, saveUsageEvent } from "../lib/persistence.js"; -import type { PaymentEvidence } from "@query402/shared"; +import { getPaymentAttempts, saveUsageEvent } from "../lib/persistence.js"; +import type { Request } from "express"; export const protectedRouter = Router(); -function persistPaidRequest(input: { +function persistUsageEvent(input: { + req: Request; mode: "search" | "news" | "scrape"; endpoint: string; provider: string; @@ -16,40 +17,14 @@ function persistPaidRequest(input: { priceUsd: number; latencyMs: number; traceId: string; - paymentResponseHeader: string | null; - payerPublicKey?: string; - isDemo?: boolean; }) { - const now = new Date().toISOString(); - const paymentId = `pay_${nanoid(10)}`; + const paymentId = input.req.header("x-payment-attempt-id"); + if (!paymentId) throw new Error("Payment attempt ID missing from request headers"); - const evidence: PaymentEvidence = input.isDemo - ? { - status: "demo-paid", - network: config.STELLAR_NETWORK, - amountUsd: input.priceUsd, - payToAddress: config.X402_PAY_TO_ADDRESS, - facilitatorUrl: config.X402_FACILITATOR_URL, - payerPublicKey: input.payerPublicKey, - demoId: input.paymentResponseHeader ?? "", - } - : { - status: "verified", - network: config.STELLAR_NETWORK, - amountUsd: input.priceUsd, - payToAddress: config.X402_PAY_TO_ADDRESS, - facilitatorUrl: config.X402_FACILITATOR_URL, - payerPublicKey: input.payerPublicKey, - paymentPayload: input.paymentResponseHeader ?? "", - }; + const paymentAttempt = getPaymentAttempts().find(p => p.id === paymentId); + if (!paymentAttempt) throw new Error("Payment attempt not found"); - savePaymentAttempt({ - id: paymentId, - endpoint: input.endpoint, - providerId: input.provider, - evidence, - createdAt: now - }); + const now = new Date().toISOString(); saveUsageEvent({ id: `use_${nanoid(10)}`, @@ -58,7 +33,7 @@ function persistPaidRequest(input: { providerId: input.provider, queryOrUrl: input.queryOrUrl, priceUsd: input.priceUsd, - evidence, + evidence: paymentAttempt.evidence, traceId: input.traceId, createdAt: now, latencyMs: input.latencyMs @@ -80,19 +55,15 @@ protectedRouter.get("/x402/search", async (req, res, next) => { q: parsed.data.q }); - const paymentHeader = req.header("payment-response") ?? null; - const isDemo = req.header("x-query402-demo-paid") === "true"; - const paymentId = persistPaidRequest({ + const paymentId = persistUsageEvent({ + req, mode: "search", endpoint: "/x402/search", provider: parsed.data.provider, queryOrUrl: parsed.data.q, priceUsd: result.priceUsd, latencyMs: result.latencyMs, - traceId: result.traceId, - paymentResponseHeader: paymentHeader, - payerPublicKey: req.header("x-demo-payer") ?? undefined, - isDemo + traceId: result.traceId }); res.setHeader("x-payment-attempt-id", paymentId); @@ -102,7 +73,7 @@ protectedRouter.get("/x402/search", async (req, res, next) => { payment: { network: config.STELLAR_NETWORK, facilitatorUrl: config.X402_FACILITATOR_URL, - paymentResponseHeader: paymentHeader + paymentResponseHeader: req.header("payment-response") ?? null }, result }); @@ -124,19 +95,15 @@ protectedRouter.get("/x402/news", async (req, res, next) => { q: parsed.data.q }); - const paymentHeader = req.header("payment-response") ?? null; - const isDemo = req.header("x-query402-demo-paid") === "true"; - const paymentId = persistPaidRequest({ + const paymentId = persistUsageEvent({ + req, mode: "news", endpoint: "/x402/news", provider: parsed.data.provider, queryOrUrl: parsed.data.q, priceUsd: result.priceUsd, latencyMs: result.latencyMs, - traceId: result.traceId, - paymentResponseHeader: paymentHeader, - payerPublicKey: req.header("x-demo-payer") ?? undefined, - isDemo + traceId: result.traceId }); res.setHeader("x-payment-attempt-id", paymentId); @@ -146,7 +113,7 @@ protectedRouter.get("/x402/news", async (req, res, next) => { payment: { network: config.STELLAR_NETWORK, facilitatorUrl: config.X402_FACILITATOR_URL, - paymentResponseHeader: paymentHeader + paymentResponseHeader: req.header("payment-response") ?? null }, result }); @@ -168,19 +135,15 @@ protectedRouter.get("/x402/scrape", async (req, res, next) => { url: parsed.data.url }); - const paymentHeader = req.header("payment-response") ?? null; - const isDemo = req.header("x-query402-demo-paid") === "true"; - const paymentId = persistPaidRequest({ + const paymentId = persistUsageEvent({ + req, mode: "scrape", endpoint: "/x402/scrape", provider: parsed.data.provider, queryOrUrl: parsed.data.url, priceUsd: result.priceUsd, latencyMs: result.latencyMs, - traceId: result.traceId, - paymentResponseHeader: paymentHeader, - payerPublicKey: req.header("x-demo-payer") ?? undefined, - isDemo + traceId: result.traceId }); res.setHeader("x-payment-attempt-id", paymentId); @@ -190,7 +153,7 @@ protectedRouter.get("/x402/scrape", async (req, res, next) => { payment: { network: config.STELLAR_NETWORK, facilitatorUrl: config.X402_FACILITATOR_URL, - paymentResponseHeader: paymentHeader + paymentResponseHeader: req.header("payment-response") ?? null }, result }); From 297bac5484e77426d3477f1c84ba160b43f42f30 Mon Sep 17 00:00:00 2001 From: ezekiel Date: Wed, 24 Jun 2026 17:28:55 +0100 Subject: [PATCH 3/5] fix: resolve transportContext and QueryResult type errors --- apps/api/src/lib/x402.ts | 2 +- apps/web/src/types.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/api/src/lib/x402.ts b/apps/api/src/lib/x402.ts index 596566c..3e3967a 100644 --- a/apps/api/src/lib/x402.ts +++ b/apps/api/src/lib/x402.ts @@ -129,7 +129,7 @@ export function createX402Middleware() { new ExactStellarScheme() ); - resourceServer.onAfterVerify(async (ctx) => { + resourceServer.onAfterVerify(async (ctx: any) => { const transport = ctx.transportContext as { request?: Request, req?: Request } | Request; const req = ('headers' in transport) ? transport : (transport.request || transport.req); diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index 21df53f..b6d3f6b 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -1,4 +1,4 @@ -import type { ProviderDefinition, QueryMode, PaymentEvidence } from "@query402/shared"; +import type { ProviderDefinition, QueryMode, PaymentEvidence, QueryResult } from "@query402/shared"; export interface PaidQueryResponse { payment: { From 5ff360f74115a8dc5d8451938f831d81e07933a8 Mon Sep 17 00:00:00 2001 From: ezekiel Date: Wed, 24 Jun 2026 19:16:32 +0100 Subject: [PATCH 4/5] fix: sync x402 settlement updates with persisted usage analytics --- apps/api/apps/api/data/db.json | 142 ++++++++++++++++++++++++++ apps/api/src/lib/persistence.ts | 14 +++ apps/api/src/lib/x402.ts | 104 ++++++++++++-------- apps/api/src/routes/protected.ts | 1 + apps/api/tests/x402.test.ts | 164 ++++++++++++++++++++++--------- packages/shared/src/types.ts | 1 + 6 files changed, 338 insertions(+), 88 deletions(-) diff --git a/apps/api/apps/api/data/db.json b/apps/api/apps/api/data/db.json index 83e849d..88aaf00 100644 --- a/apps/api/apps/api/data/db.json +++ b/apps/api/apps/api/data/db.json @@ -1,5 +1,87 @@ { "usage": [ + { + "id": "use_test_456", + "mode": "news", + "endpoint": "/x402/news", + "providerId": "news.basic", + "queryOrUrl": "test", + "priceUsd": 0.015, + "evidence": { + "status": "failed", + "network": "stellar:testnet", + "amountUsd": 0.015, + "payToAddress": "G...", + "facilitatorUrl": "http://test", + "error": "Insufficient funds", + "paymentPayload": "raw_header_2" + }, + "traceId": "trace_test_789", + "paymentId": "pay_YpZx97lna0", + "createdAt": "2026-06-24T18:05:58.837Z", + "latencyMs": 120 + }, + { + "id": "use_test_123", + "mode": "search", + "endpoint": "/x402/search", + "providerId": "search.basic", + "queryOrUrl": "test", + "priceUsd": 0.01, + "evidence": { + "status": "settled", + "network": "stellar:testnet", + "amountUsd": 0.01, + "payToAddress": "G...", + "facilitatorUrl": "http://test", + "transactionHash": "tx_hash_123", + "paymentPayload": "raw_header" + }, + "traceId": "trace_test_456", + "paymentId": "pay_bz7w2CRD31", + "createdAt": "2026-06-24T18:05:58.605Z", + "latencyMs": 100 + }, + { + "id": "use_test_123", + "mode": "search", + "endpoint": "/x402/search", + "providerId": "search.basic", + "queryOrUrl": "test", + "priceUsd": 0.01, + "evidence": { + "status": "settled", + "network": "stellar:testnet", + "amountUsd": 0.01, + "payToAddress": "G...", + "facilitatorUrl": "http://...", + "transactionHash": "tx_hash_123", + "paymentPayload": "raw_header" + }, + "traceId": "trace_test_456", + "createdAt": "2026-06-24T17:56:43.905Z", + "latencyMs": 100 + }, + { + "id": "use_test_123", + "mode": "search", + "endpoint": "/x402/search", + "providerId": "search.basic", + "queryOrUrl": "test", + "priceUsd": 0.01, + "evidence": { + "status": "settled", + "network": "stellar:testnet", + "amountUsd": 0.01, + "payToAddress": "G...", + "facilitatorUrl": "http://...", + "transactionHash": "tx_hash_123", + "paymentPayload": "raw_header" + }, + "traceId": "trace_test_456", + "createdAt": "2026-06-24T17:56:01.750Z", + "latencyMs": 100 + }, { "id": "use_4Lobxvue84", "mode": "scrape", @@ -201,6 +283,66 @@ } ], "payments": [ + { + "id": "pay_YpZx97lna0", + "endpoint": "/x402/news", + "providerId": "news.basic", + "evidence": { + "status": "failed", + "network": "stellar:testnet", + "amountUsd": 0.015, + "payToAddress": "G...", + "facilitatorUrl": "http://test", + "error": "Insufficient funds", + "paymentPayload": "raw_header_2" + }, + "createdAt": "2026-06-24T18:05:58.810Z" + }, + { + "id": "pay_bz7w2CRD31", + "endpoint": "/x402/search", + "providerId": "search.basic", + "evidence": { + "status": "settled", + "network": "stellar:testnet", + "amountUsd": 0.01, + "payToAddress": "G...", + "facilitatorUrl": "http://test", + "transactionHash": "tx_hash_123", + "paymentPayload": "raw_header" + }, + "createdAt": "2026-06-24T18:05:58.499Z" + }, + { + "id": "pay_test_123", + "endpoint": "/x402/search", + "providerId": "search.basic", + "evidence": { + "status": "settled", + "network": "stellar:testnet", + "amountUsd": 0.01, + "payToAddress": "G...", + "facilitatorUrl": "http://...", + "transactionHash": "tx_hash_123", + "paymentPayload": "raw_header" + }, + "createdAt": "2026-06-24T17:56:43.897Z" + }, + { + "id": "pay_test_123", + "endpoint": "/x402/search", + "providerId": "search.basic", + "evidence": { + "status": "settled", + "network": "stellar:testnet", + "amountUsd": 0.01, + "payToAddress": "G...", + "facilitatorUrl": "http://...", + "transactionHash": "tx_hash_123", + "paymentPayload": "raw_header" + }, + "createdAt": "2026-06-24T17:56:01.745Z" + }, { "id": "pay_JmqVA-Chnq", "endpoint": "/x402/scrape", diff --git a/apps/api/src/lib/persistence.ts b/apps/api/src/lib/persistence.ts index c735d4e..5fce716 100644 --- a/apps/api/src/lib/persistence.ts +++ b/apps/api/src/lib/persistence.ts @@ -63,6 +63,20 @@ export function updateUsageEventEvidence(traceId: string, evidence: PaymentEvide } } +export function updateUsageEventsByPaymentId(paymentId: string, evidence: PaymentEvidence) { + const db = readDb(); + let updated = false; + db.usage.forEach(u => { + if (u.paymentId === paymentId) { + u.evidence = evidence; + updated = true; + } + }); + if (updated) { + writeDb(db); + } +} + export function getUsageEvents() { return readDb().usage; } diff --git a/apps/api/src/lib/x402.ts b/apps/api/src/lib/x402.ts index 3e3967a..f3e0a9e 100644 --- a/apps/api/src/lib/x402.ts +++ b/apps/api/src/lib/x402.ts @@ -5,7 +5,7 @@ import type { NextFunction, Request, Response } from "express"; import type { HTTPRequestContext } from "@x402/core/server"; import { getProviderById, protectedRouteBasePrices } from "./pricing.js"; import { config } from "./config.js"; -import { savePaymentAttempt, updatePaymentAttemptEvidence } from "./persistence.js"; +import { savePaymentAttempt, updatePaymentAttemptEvidence, updateUsageEventsByPaymentId } from "./persistence.js"; import { nanoid } from "nanoid"; type RouteMode = "search" | "news" | "scrape"; @@ -100,36 +100,8 @@ function demoMode402Middleware(req: Request, res: Response, next: NextFunction) }); } -export function createX402Middleware() { - if (config.demoMode) { - return demoMode402Middleware; - } - - const network = config.STELLAR_NETWORK as `${string}:${string}`; - - const createAuthHeaders = - config.X402_FACILITATOR_API_KEY && config.X402_FACILITATOR_API_KEY.length > 0 - ? async () => { - const authHeaders = { Authorization: `Bearer ${config.X402_FACILITATOR_API_KEY}` }; - return { - verify: authHeaders, - settle: authHeaders, - supported: authHeaders - }; - } - : undefined; - - const facilitatorClient = new HTTPFacilitatorClient({ - url: config.X402_FACILITATOR_URL, - createAuthHeaders - }); - - const resourceServer = new x402ResourceServer(facilitatorClient).register( - network, - new ExactStellarScheme() - ); - - resourceServer.onAfterVerify(async (ctx: any) => { +export const getX402LifecycleHandlers = (network: string) => ({ + onAfterVerify: async (ctx: any) => { const transport = ctx.transportContext as { request?: Request, req?: Request } | Request; const req = ('headers' in transport) ? transport : (transport.request || transport.req); @@ -154,30 +126,84 @@ export function createX402Middleware() { createdAt: new Date().toISOString() }); } - }); + }, - resourceServer.onAfterSettle(async (ctx) => { + onAfterSettle: async (ctx: any) => { const transport = ctx.transportContext as { request?: Request, req?: Request } | Request; const req = ('headers' in transport) ? transport : (transport.request || transport.req); const paymentId = req?.headers?.["x-payment-attempt-id"] as string | undefined; if (paymentId) { - updatePaymentAttemptEvidence(paymentId, { - status: "settled", + const evidence = { + status: "settled" as const, network, amountUsd: Number(ctx.requirements.amount), payToAddress: ctx.requirements.payTo, facilitatorUrl: config.X402_FACILITATOR_URL, transactionHash: ctx.result.transaction, paymentPayload: typeof ctx.paymentPayload === "string" ? ctx.paymentPayload : JSON.stringify(ctx.paymentPayload) - }); + }; + updatePaymentAttemptEvidence(paymentId, evidence); + updateUsageEventsByPaymentId(paymentId, evidence); } - }); + }, - resourceServer.onSettleFailure(async (ctx) => { - // The entry will remain as "verified" + onSettleFailure: async (ctx: any) => { + const transport = ctx.transportContext as { request?: Request, req?: Request } | Request; + const req = ('headers' in transport) ? transport : (transport.request || transport.req); + const paymentId = req?.headers?.["x-payment-attempt-id"] as string | undefined; + + if (paymentId) { + const evidence = { + status: "failed" as const, + network, + amountUsd: Number(ctx.requirements.amount), + payToAddress: ctx.requirements.payTo, + facilitatorUrl: config.X402_FACILITATOR_URL, + error: ctx.error?.message ?? "Payment settlement failed", + paymentPayload: ctx.paymentPayload ? (typeof ctx.paymentPayload === "string" ? ctx.paymentPayload : JSON.stringify(ctx.paymentPayload)) : undefined + }; + + updatePaymentAttemptEvidence(paymentId, evidence); + updateUsageEventsByPaymentId(paymentId, evidence); + } + } +}); + +export function createX402Middleware() { + if (config.demoMode) { + return demoMode402Middleware; + } + + const network = config.STELLAR_NETWORK as `${string}:${string}`; + + const createAuthHeaders = + config.X402_FACILITATOR_API_KEY && config.X402_FACILITATOR_API_KEY.length > 0 + ? async () => { + const authHeaders = { Authorization: `Bearer ${config.X402_FACILITATOR_API_KEY}` }; + return { + verify: authHeaders, + settle: authHeaders, + supported: authHeaders + }; + } + : undefined; + + const facilitatorClient = new HTTPFacilitatorClient({ + url: config.X402_FACILITATOR_URL, + createAuthHeaders }); + const resourceServer = new x402ResourceServer(facilitatorClient).register( + network, + new ExactStellarScheme() + ); + + const handlers = getX402LifecycleHandlers(network); + resourceServer.onAfterVerify(handlers.onAfterVerify); + resourceServer.onAfterSettle(handlers.onAfterSettle); + resourceServer.onSettleFailure(handlers.onSettleFailure); + const routeConfig = { "GET /x402/search": { accepts: { diff --git a/apps/api/src/routes/protected.ts b/apps/api/src/routes/protected.ts index 80b0ea2..00e476f 100644 --- a/apps/api/src/routes/protected.ts +++ b/apps/api/src/routes/protected.ts @@ -35,6 +35,7 @@ function persistUsageEvent(input: { priceUsd: input.priceUsd, evidence: paymentAttempt.evidence, traceId: input.traceId, + paymentId: paymentId, createdAt: now, latencyMs: input.latencyMs }); diff --git a/apps/api/tests/x402.test.ts b/apps/api/tests/x402.test.ts index e7d78a5..16e1e94 100644 --- a/apps/api/tests/x402.test.ts +++ b/apps/api/tests/x402.test.ts @@ -1,26 +1,38 @@ import test from "node:test"; import assert from "node:assert"; -import { updatePaymentAttemptEvidence, updateUsageEventEvidence, savePaymentAttempt, saveUsageEvent, getPaymentAttempts, getUsageEvents } from "../src/lib/persistence.js"; +import { saveUsageEvent, getPaymentAttempts, getUsageEvents } from "../src/lib/persistence.js"; +import { getX402LifecycleHandlers } from "../src/lib/x402.js"; +import { config } from "../src/lib/config.js"; test("evidence pipeline updates correctly on settlement", async (t) => { - const paymentId = "pay_test_123"; - const traceId = "trace_test_456"; - - savePaymentAttempt({ - id: paymentId, - endpoint: "/x402/search", - providerId: "search.basic", - evidence: { - status: "verified", - network: "stellar:testnet", - amountUsd: 0.01, - payToAddress: "G...", - facilitatorUrl: "http://...", - paymentPayload: "raw_header" + const req = { + path: "/x402/search", + headers: {} as Record + }; + + const verifyCtx = { + transportContext: { + req, + request: req, + adapter: { getQueryParam: (key: string) => key === "provider" ? "search.basic" : undefined } }, - createdAt: new Date().toISOString() - }); + requirements: { amount: "0.01", payTo: "G..." }, + paymentPayload: "raw_header" + }; + + const handlers = getX402LifecycleHandlers(config.STELLAR_NETWORK as string); + + // 1. Simulate onAfterVerify which creates the payment attempt + await handlers.onAfterVerify(verifyCtx); + + const paymentId = req.headers["x-payment-attempt-id"]; + assert.ok(paymentId, "onAfterVerify should set x-payment-attempt-id"); + const paymentAfterVerify = getPaymentAttempts().find(p => p.id === paymentId); + assert.strictEqual(paymentAfterVerify?.evidence.status, "verified"); + + // 2. Simulate the endpoint handler persisting the usage event + const traceId = "trace_test_456"; saveUsageEvent({ id: "use_test_123", mode: "search", @@ -28,49 +40,103 @@ test("evidence pipeline updates correctly on settlement", async (t) => { providerId: "search.basic", queryOrUrl: "test", priceUsd: 0.01, - evidence: { - status: "verified", - network: "stellar:testnet", - amountUsd: 0.01, - payToAddress: "G...", - facilitatorUrl: "http://...", - paymentPayload: "raw_header" - }, + evidence: paymentAfterVerify.evidence, traceId, + paymentId, createdAt: new Date().toISOString(), latencyMs: 100 }); - updatePaymentAttemptEvidence(paymentId, { - status: "settled", - network: "stellar:testnet", - amountUsd: 0.01, - payToAddress: "G...", - facilitatorUrl: "http://...", - transactionHash: "tx_hash_123", - paymentPayload: "raw_header" - }); + const usageAfterVerify = getUsageEvents().find(u => u.traceId === traceId); + assert.strictEqual(usageAfterVerify?.evidence.status, "verified"); - updateUsageEventEvidence(traceId, { - status: "settled", - network: "stellar:testnet", - amountUsd: 0.01, - payToAddress: "G...", - facilitatorUrl: "http://...", - transactionHash: "tx_hash_123", + // 3. Simulate onAfterSettle updating the payment + const settleCtx = { + transportContext: { req, request: req }, + requirements: { amount: "0.01", payTo: "G..." }, + result: { transaction: "tx_hash_123" }, paymentPayload: "raw_header" + }; + + await handlers.onAfterSettle(settleCtx); + + const paymentAfterSettle = getPaymentAttempts().find(p => p.id === paymentId); + const usageAfterSettle = getUsageEvents().find(u => u.traceId === traceId); + + assert.strictEqual(paymentAfterSettle?.evidence.status, "settled"); + if (paymentAfterSettle?.evidence.status === "settled") { + assert.strictEqual(paymentAfterSettle.evidence.transactionHash, "tx_hash_123"); + } + + assert.strictEqual(usageAfterSettle?.evidence.status, "settled"); + if (usageAfterSettle?.evidence.status === "settled") { + assert.strictEqual(usageAfterSettle.evidence.transactionHash, "tx_hash_123"); + } +}); + +test("evidence pipeline updates correctly on failure", async (t) => { + const req = { + path: "/x402/news", + headers: {} as Record + }; + + const verifyCtx = { + transportContext: { + req, + request: req, + adapter: { getQueryParam: (key: string) => key === "provider" ? "news.basic" : undefined } + }, + requirements: { amount: "0.015", payTo: "G..." }, + paymentPayload: "raw_header_2" + }; + + const handlers = getX402LifecycleHandlers(config.STELLAR_NETWORK as string); + + // 1. Simulate onAfterVerify which creates the payment attempt + await handlers.onAfterVerify(verifyCtx); + + const paymentId = req.headers["x-payment-attempt-id"]; + assert.ok(paymentId, "onAfterVerify should set x-payment-attempt-id"); + + const paymentAfterVerify = getPaymentAttempts().find(p => p.id === paymentId); + assert.strictEqual(paymentAfterVerify?.evidence.status, "verified"); + + // 2. Simulate the endpoint handler persisting the usage event + const traceId = "trace_test_789"; + saveUsageEvent({ + id: "use_test_456", + mode: "news", + endpoint: "/x402/news", + providerId: "news.basic", + queryOrUrl: "test", + priceUsd: 0.015, + evidence: paymentAfterVerify.evidence, + traceId, + paymentId, + createdAt: new Date().toISOString(), + latencyMs: 120 }); - const payment = getPaymentAttempts().find(p => p.id === paymentId); - const usage = getUsageEvents().find(u => u.traceId === traceId); + // 3. Simulate onSettleFailure updating the payment and usage + const failCtx = { + transportContext: { req, request: req }, + requirements: { amount: "0.015", payTo: "G..." }, + error: new Error("Insufficient funds"), + paymentPayload: "raw_header_2" + }; + + await handlers.onSettleFailure(failCtx); + + const paymentAfterFail = getPaymentAttempts().find(p => p.id === paymentId); + const usageAfterFail = getUsageEvents().find(u => u.traceId === traceId); - assert.strictEqual(payment?.evidence.status, "settled"); - if (payment?.evidence.status === "settled") { - assert.strictEqual(payment.evidence.transactionHash, "tx_hash_123"); + assert.strictEqual(paymentAfterFail?.evidence.status, "failed"); + if (paymentAfterFail?.evidence.status === "failed") { + assert.strictEqual(paymentAfterFail.evidence.error, "Insufficient funds"); } - assert.strictEqual(usage?.evidence.status, "settled"); - if (usage?.evidence.status === "settled") { - assert.strictEqual(usage.evidence.transactionHash, "tx_hash_123"); + assert.strictEqual(usageAfterFail?.evidence.status, "failed"); + if (usageAfterFail?.evidence.status === "failed") { + assert.strictEqual(usageAfterFail.evidence.error, "Insufficient funds"); } }); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 16c58d8..8ac2f1d 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -83,6 +83,7 @@ export interface UsageEvent { priceUsd: number; evidence: PaymentEvidence; traceId: string; + paymentId: string; createdAt: string; latencyMs: number; } From 100ad76562dc43709f7236751666bba3746b2dc0 Mon Sep 17 00:00:00 2001 From: ezekiel Date: Fri, 26 Jun 2026 10:19:44 +0100 Subject: [PATCH 5/5] Address PR feedback: typed evidence, improved testing, fixed persistence --- apps/api/src/lib/persistence.ts | 26 ++++---- apps/api/src/routes/protected.ts | 17 +++-- apps/api/tests/setup.ts | 2 + apps/api/tests/x402.test.ts | 91 ++++++++++++++++++++++++++ apps/web/src/pages/ControlDeckPage.tsx | 13 +++- apps/web/src/types.ts | 1 + 6 files changed, 129 insertions(+), 21 deletions(-) create mode 100644 apps/api/tests/setup.ts diff --git a/apps/api/src/lib/persistence.ts b/apps/api/src/lib/persistence.ts index 68d535e..6f95d29 100644 --- a/apps/api/src/lib/persistence.ts +++ b/apps/api/src/lib/persistence.ts @@ -128,17 +128,22 @@ export function persistSponsoredPayment(input: { const paymentSource = input.paymentSource ?? "sponsored"; const sponsorPublicKey = input.sponsorPublicKey ?? config.DEMO_CLIENT_PUBLIC_KEY; + const evidence: PaymentEvidence = { + status: "settled", + network: config.STELLAR_NETWORK, + amountUsd: input.priceUsd, + payToAddress: config.X402_PAY_TO_ADDRESS, + facilitatorUrl: config.X402_FACILITATOR_URL, + payerPublicKey: input.walletPublicKey, + transactionHash: input.paymentResponseHeader ?? "sponsored_tx", + paymentPayload: "sponsored" + }; + savePaymentAttempt({ id: paymentId, endpoint: input.endpoint, providerId: input.provider, - amountUsd: input.priceUsd, - network: config.STELLAR_NETWORK, - payerPublicKey: input.walletPublicKey, - payToAddress: config.X402_PAY_TO_ADDRESS, - facilitatorUrl: config.X402_FACILITATOR_URL, - status: "settled", - transactionHash: input.paymentResponseHeader ?? undefined, + evidence, createdAt: now, sponsorshipGrantId: input.sponsorshipGrantId, policyDecision: input.policyDecision, @@ -153,12 +158,9 @@ export function persistSponsoredPayment(input: { providerId: input.provider, queryOrUrl: input.queryOrUrl, priceUsd: input.priceUsd, - network: config.STELLAR_NETWORK, - paymentStatus: "paid", - paymentTxHash: input.paymentResponseHeader ?? undefined, - facilitatorUrl: config.X402_FACILITATOR_URL, - payerPublicKey: input.walletPublicKey, + evidence, traceId: input.traceId, + paymentId, createdAt: now, latencyMs: input.latencyMs, sponsorshipGrantId: input.sponsorshipGrantId, diff --git a/apps/api/src/routes/protected.ts b/apps/api/src/routes/protected.ts index 00e476f..82a2ca3 100644 --- a/apps/api/src/routes/protected.ts +++ b/apps/api/src/routes/protected.ts @@ -40,7 +40,7 @@ function persistUsageEvent(input: { latencyMs: input.latencyMs }); - return paymentId; + return { paymentId, evidence: paymentAttempt.evidence }; } protectedRouter.get("/x402/search", async (req, res, next) => { @@ -56,7 +56,7 @@ protectedRouter.get("/x402/search", async (req, res, next) => { q: parsed.data.q }); - const paymentId = persistUsageEvent({ + const { paymentId, evidence } = persistUsageEvent({ req, mode: "search", endpoint: "/x402/search", @@ -74,7 +74,8 @@ protectedRouter.get("/x402/search", async (req, res, next) => { payment: { network: config.STELLAR_NETWORK, facilitatorUrl: config.X402_FACILITATOR_URL, - paymentResponseHeader: req.header("payment-response") ?? null + paymentResponseHeader: req.header("payment-response") ?? null, + evidence }, result }); @@ -96,7 +97,7 @@ protectedRouter.get("/x402/news", async (req, res, next) => { q: parsed.data.q }); - const paymentId = persistUsageEvent({ + const { paymentId, evidence } = persistUsageEvent({ req, mode: "news", endpoint: "/x402/news", @@ -114,7 +115,8 @@ protectedRouter.get("/x402/news", async (req, res, next) => { payment: { network: config.STELLAR_NETWORK, facilitatorUrl: config.X402_FACILITATOR_URL, - paymentResponseHeader: req.header("payment-response") ?? null + paymentResponseHeader: req.header("payment-response") ?? null, + evidence }, result }); @@ -136,7 +138,7 @@ protectedRouter.get("/x402/scrape", async (req, res, next) => { url: parsed.data.url }); - const paymentId = persistUsageEvent({ + const { paymentId, evidence } = persistUsageEvent({ req, mode: "scrape", endpoint: "/x402/scrape", @@ -154,7 +156,8 @@ protectedRouter.get("/x402/scrape", async (req, res, next) => { payment: { network: config.STELLAR_NETWORK, facilitatorUrl: config.X402_FACILITATOR_URL, - paymentResponseHeader: req.header("payment-response") ?? null + paymentResponseHeader: req.header("payment-response") ?? null, + evidence }, result }); diff --git a/apps/api/tests/setup.ts b/apps/api/tests/setup.ts new file mode 100644 index 0000000..ddcd0b4 --- /dev/null +++ b/apps/api/tests/setup.ts @@ -0,0 +1,2 @@ +process.env.NODE_ENV = "test"; +process.env.X402_PAY_TO_ADDRESS = "GDUMMY12345678901234567890123456789012345678901234567890"; diff --git a/apps/api/tests/x402.test.ts b/apps/api/tests/x402.test.ts index 16e1e94..b9db49f 100644 --- a/apps/api/tests/x402.test.ts +++ b/apps/api/tests/x402.test.ts @@ -1,3 +1,4 @@ +import "./setup.js"; import test from "node:test"; import assert from "node:assert"; import { saveUsageEvent, getPaymentAttempts, getUsageEvents } from "../src/lib/persistence.js"; @@ -140,3 +141,93 @@ test("evidence pipeline updates correctly on failure", async (t) => { assert.strictEqual(usageAfterFail.evidence.error, "Insufficient funds"); } }); + +test("proof that forged demo headers cannot bypass real verification when demoMode is false", async (t) => { + const originalDemoMode = config.demoMode; + config.demoMode = false; + + // create middleware + const { createX402Middleware } = await import("../src/lib/x402.js"); + const middleware = createX402Middleware(); + + const req = { + method: "GET", + path: "/x402/search", + header: (k: string) => { + if (k === "x-query402-demo-paid") return "true"; + if (k === "payment-response") return "demo_tx_forged"; + return undefined; + }, + headers: {}, + query: { provider: "search.basic" } + }; + + let statusCode = 200; + let jsonResponse: any = null; + const res = { + status: (code: number) => { + statusCode = code; + return { json: (data: any) => { jsonResponse = data; } }; + } + }; + + let nextCalled = false; + const next = () => { nextCalled = true; }; + + await new Promise((resolve) => { + // Cast middleware to handle our mock req/res + (middleware as any)(req, res, () => { + nextCalled = true; + resolve(); + }); + // the real middleware will likely call res.status(402).json(...) synchronously or async + setTimeout(resolve, 50); + }); + + assert.strictEqual(nextCalled, false, "Should not call next() for real verification with forged headers"); + assert.strictEqual(statusCode, 402, "Should return 402 Payment Required"); + assert.ok(jsonResponse?.error === "Payment Required", "Should require payment"); + + config.demoMode = originalDemoMode; +}); + +test("demo flow creates demo-paid attempt when demoMode is true", async (t) => { + const originalDemoMode = config.demoMode; + config.demoMode = true; + + const { createX402Middleware } = await import("../src/lib/x402.js"); + const middleware = createX402Middleware(); + + const req = { + method: "GET", + path: "/x402/news", + header: (k: string) => { + if (k === "x-query402-demo-paid") return "true"; + if (k === "payment-response") return "demo_tx_123"; + return undefined; + }, + headers: {}, + query: { provider: "news.basic" } + }; + + const res = {}; + let nextCalled = false; + const next = () => { nextCalled = true; }; + + (middleware as any)(req, res, next); + + assert.strictEqual(nextCalled, true, "Should call next() in demo mode with headers"); + + const paymentId = (req.headers as any)["x-payment-attempt-id"]; + assert.ok(paymentId, "Should generate a payment attempt ID"); + + const payment = getPaymentAttempts().find(p => p.id === paymentId); + assert.ok(payment, "Payment attempt should be saved"); + assert.strictEqual(payment?.evidence.status, "demo-paid"); + if (payment?.evidence.status === "demo-paid") { + assert.strictEqual(payment.evidence.demoId, "demo_tx_123"); + } + + config.demoMode = originalDemoMode; +}); + diff --git a/apps/web/src/pages/ControlDeckPage.tsx b/apps/web/src/pages/ControlDeckPage.tsx index f6d3a65..4394f21 100644 --- a/apps/web/src/pages/ControlDeckPage.tsx +++ b/apps/web/src/pages/ControlDeckPage.tsx @@ -332,8 +332,17 @@ export default function ControlDeckPage() {
-

evidence: {result.payment.paymentResponseHeader?.startsWith('demo_tx_') ? 'demo-paid' : 'verified'}

-

payload: {result.payment.paymentResponseHeader ?? ""}

+

evidence: {result.payment.evidence.status}

+

+ payload:{" "} + {result.payment.evidence.status === "demo-paid" + ? result.payment.evidence.demoId + : result.payment.evidence.status === "settled" + ? result.payment.evidence.transactionHash + : result.payment.evidence.status === "failed" + ? result.payment.evidence.error + : result.payment.paymentResponseHeader ?? ""} +

network: {result.payment.network}

diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index b6d3f6b..f6fb63b 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -5,6 +5,7 @@ export interface PaidQueryResponse { network: string; facilitatorUrl: string; paymentResponseHeader: string | null; + evidence: PaymentEvidence; }; result: QueryResult; }