diff --git a/apps/api/apps/api/data/db.json b/apps/api/apps/api/data/db.json
deleted file mode 100644
index 83e849d..0000000
--- a/apps/api/apps/api/data/db.json
+++ /dev/null
@@ -1,362 +0,0 @@
-{
- "usage": [
- {
- "id": "use_4Lobxvue84",
- "mode": "scrape",
- "endpoint": "/x402/scrape",
- "providerId": "scrape.page",
- "queryOrUrl": "https://ethereum.org/",
- "priceUsd": 0.02,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_H-iaFvtF50Cl",
- "createdAt": "2026-04-13T14:12:18.604Z",
- "latencyMs": 1134
- },
- {
- "id": "use_0HeP4FcgB1",
- "mode": "scrape",
- "endpoint": "/x402/scrape",
- "providerId": "scrape.page",
- "queryOrUrl": "https://ethereum.org/",
- "priceUsd": 0.02,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_LVVtKvxldwDp",
- "createdAt": "2026-04-13T14:03:23.561Z",
- "latencyMs": 1310
- },
- {
- "id": "use_WpIXOuyb1s",
- "mode": "search",
- "endpoint": "/x402/search",
- "providerId": "search.pro",
- "queryOrUrl": "ethereum news",
- "priceUsd": 0.02,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_L_ZhlLrnLiiK",
- "createdAt": "2026-04-13T14:02:43.619Z",
- "latencyMs": 1015
- },
- {
- "id": "use_rzJKhozxL6",
- "mode": "search",
- "endpoint": "/x402/search",
- "providerId": "search.pro",
- "queryOrUrl": "latest stellar x402 updates",
- "priceUsd": 0.02,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_YLemAiF1Jt7J",
- "createdAt": "2026-04-13T12:35:10.845Z",
- "latencyMs": 1201
- },
- {
- "id": "use_xwViQT1Z1Y",
- "mode": "scrape",
- "endpoint": "/x402/scrape",
- "providerId": "scrape.extract",
- "queryOrUrl": "https://developers.stellar.org",
- "priceUsd": 0.04,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_4yEpOBKooFMO",
- "createdAt": "2026-04-12T22:26:59.707Z",
- "latencyMs": 1674
- },
- {
- "id": "use_P9f5qDsVHQ",
- "mode": "search",
- "endpoint": "/x402/search",
- "providerId": "search.basic",
- "queryOrUrl": "latest stellar x402 updates",
- "priceUsd": 0.01,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_qdHv5ztwxJoJ",
- "createdAt": "2026-04-12T22:26:16.493Z",
- "latencyMs": 749
- },
- {
- "id": "use_X4wz7SSFCW",
- "mode": "search",
- "endpoint": "/x402/search",
- "providerId": "search.pro",
- "queryOrUrl": "latest stellar x402 updates",
- "priceUsd": 0.02,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_J_0DflTVTcB2",
- "createdAt": "2026-04-12T21:50:20.895Z",
- "latencyMs": 1151
- },
- {
- "id": "use_ZVpN6uENcZ",
- "mode": "search",
- "endpoint": "/x402/search",
- "providerId": "search.basic",
- "queryOrUrl": "latest stellar x402 updates",
- "priceUsd": 0.01,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_hokNqu0VvyLH",
- "createdAt": "2026-04-12T21:48:34.398Z",
- "latencyMs": 658
- },
- {
- "id": "use_FE8gE2Z7mD",
- "mode": "search",
- "endpoint": "/x402/search",
- "providerId": "search.basic",
- "queryOrUrl": "latest stellar x402 updates",
- "priceUsd": 0.01,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_xO4Xtq0oy_jX",
- "createdAt": "2026-04-12T21:14:52.981Z",
- "latencyMs": 821
- },
- {
- "id": "use_cjhQbYbeNV",
- "mode": "search",
- "endpoint": "/x402/search",
- "providerId": "search.basic",
- "queryOrUrl": "latest stellar x402 updates",
- "priceUsd": 0.01,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_pWh4Ef76QAjp",
- "createdAt": "2026-04-12T21:08:44.130Z",
- "latencyMs": 750
- },
- {
- "id": "use_24CtIczMZv",
- "mode": "search",
- "endpoint": "/x402/search",
- "providerId": "search.basic",
- "queryOrUrl": "latest stellar x402 updates",
- "priceUsd": 0.01,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "traceId": "trace_UCyX-SJnHlEh",
- "createdAt": "2026-04-12T18:11:28.924Z",
- "latencyMs": 616
- },
- {
- "id": "use_fVzoPFCU0N",
- "mode": "scrape",
- "endpoint": "/x402/scrape",
- "providerId": "scrape.extract",
- "queryOrUrl": "https://developers.stellar.org",
- "priceUsd": 0.04,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "paymentTxHash": "demo_tx_L1l4IAO_yL",
- "facilitatorUrl": "https://facilitator.x402.org",
- "traceId": "trace_SBmVHmUX9kp6",
- "createdAt": "2026-04-12T15:35:40.177Z",
- "latencyMs": 1783
- },
- {
- "id": "use_hQIfdnmvYY",
- "mode": "news",
- "endpoint": "/x402/news",
- "providerId": "news.deep",
- "queryOrUrl": "stablecoin micropayments",
- "priceUsd": 0.03,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "paymentTxHash": "demo_tx_4ofN2v3X37",
- "facilitatorUrl": "https://facilitator.x402.org",
- "traceId": "trace_aJl_JZP5cfjt",
- "createdAt": "2026-04-12T15:35:38.387Z",
- "latencyMs": 1490
- },
- {
- "id": "use_KVvzTfDgb-",
- "mode": "search",
- "endpoint": "/x402/search",
- "providerId": "search.pro",
- "queryOrUrl": "latest stellar x402 updates",
- "priceUsd": 0.02,
- "network": "stellar:testnet",
- "paymentStatus": "paid",
- "paymentTxHash": "demo_tx_MS1JuypwLu",
- "facilitatorUrl": "https://facilitator.x402.org",
- "traceId": "trace_g2VMaEhrfy2u",
- "createdAt": "2026-04-12T15:35:36.888Z",
- "latencyMs": 1070
- }
- ],
- "payments": [
- {
- "id": "pay_JmqVA-Chnq",
- "endpoint": "/x402/scrape",
- "providerId": "scrape.page",
- "amountUsd": 0.02,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-13T14:12:18.604Z"
- },
- {
- "id": "pay_Lzam9hIlBB",
- "endpoint": "/x402/scrape",
- "providerId": "scrape.page",
- "amountUsd": 0.02,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-13T14:03:23.561Z"
- },
- {
- "id": "pay_SrtUSToCX6",
- "endpoint": "/x402/search",
- "providerId": "search.pro",
- "amountUsd": 0.02,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-13T14:02:43.619Z"
- },
- {
- "id": "pay_PqGl4AsNY3",
- "endpoint": "/x402/search",
- "providerId": "search.pro",
- "amountUsd": 0.02,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-13T12:35:10.845Z"
- },
- {
- "id": "pay_5dGgiHgllc",
- "endpoint": "/x402/scrape",
- "providerId": "scrape.extract",
- "amountUsd": 0.04,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-12T22:26:59.707Z"
- },
- {
- "id": "pay_INjnj7_1ya",
- "endpoint": "/x402/search",
- "providerId": "search.basic",
- "amountUsd": 0.01,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-12T22:26:16.493Z"
- },
- {
- "id": "pay_4XPmT_PffZ",
- "endpoint": "/x402/search",
- "providerId": "search.pro",
- "amountUsd": 0.02,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-12T21:50:20.895Z"
- },
- {
- "id": "pay_hfouY1xCGe",
- "endpoint": "/x402/search",
- "providerId": "search.basic",
- "amountUsd": 0.01,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-12T21:48:34.398Z"
- },
- {
- "id": "pay_SSTBxrAgI7",
- "endpoint": "/x402/search",
- "providerId": "search.basic",
- "amountUsd": 0.01,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-12T21:14:52.981Z"
- },
- {
- "id": "pay_V6s-Cer7Bh",
- "endpoint": "/x402/search",
- "providerId": "search.basic",
- "amountUsd": 0.01,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-12T21:08:44.130Z"
- },
- {
- "id": "pay_ZNS-38N5TC",
- "endpoint": "/x402/search",
- "providerId": "search.basic",
- "amountUsd": 0.01,
- "network": "stellar:testnet",
- "payToAddress": "GC5TXYP4F7ZEDLDFYBIFG67KU4UUPOS2IESICQYMSZYOY6GN5MIOUW3P",
- "facilitatorUrl": "https://channels.openzeppelin.com/x402/testnet",
- "status": "settled",
- "createdAt": "2026-04-12T18:11:28.924Z"
- },
- {
- "id": "pay_JZvxvv3ycP",
- "endpoint": "/x402/scrape",
- "providerId": "scrape.extract",
- "amountUsd": 0.04,
- "network": "stellar:testnet",
- "payToAddress": "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
- "facilitatorUrl": "https://facilitator.x402.org",
- "status": "settled",
- "transactionHash": "demo_tx_L1l4IAO_yL",
- "createdAt": "2026-04-12T15:35:40.177Z"
- },
- {
- "id": "pay_-iyfWPFukZ",
- "endpoint": "/x402/news",
- "providerId": "news.deep",
- "amountUsd": 0.03,
- "network": "stellar:testnet",
- "payToAddress": "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
- "facilitatorUrl": "https://facilitator.x402.org",
- "status": "settled",
- "transactionHash": "demo_tx_4ofN2v3X37",
- "createdAt": "2026-04-12T15:35:38.387Z"
- },
- {
- "id": "pay_Lyd0pssE6d",
- "endpoint": "/x402/search",
- "providerId": "search.pro",
- "amountUsd": 0.02,
- "network": "stellar:testnet",
- "payToAddress": "GXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
- "facilitatorUrl": "https://facilitator.x402.org",
- "status": "settled",
- "transactionHash": "demo_tx_MS1JuypwLu",
- "createdAt": "2026-04-12T15:35:36.888Z"
- }
- ]
-}
\ No newline at end of file
diff --git a/apps/api/package.json b/apps/api/package.json
index 73ef387..832f722 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -10,7 +10,7 @@
"build": "tsc -p tsconfig.json",
"start": "node dist/index.js",
"typecheck": "tsc -p tsconfig.json --noEmit",
- "test": "vitest run && node --import tsx --test src/providers/registry.test.ts src/lib/scrape-url-safety.test.ts src/services/query-service.test.ts",
+ "test": "vitest run && node --import tsx --test tests/**/*.test.ts src/providers/registry.test.ts src/lib/scrape-url-safety.test.ts src/services/query-service.test.ts",
"test:watch": "vitest",
"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 069414f..6f95d29 100644
--- a/apps/api/src/lib/persistence.ts
+++ b/apps/api/src/lib/persistence.ts
@@ -1,7 +1,7 @@
import fs from "node:fs";
import path from "node:path";
import { nanoid } from "nanoid";
-import type { AnalyticsSummary, PaymentAttempt, PaymentSource, QueryMode, UsageEvent } from "@query402/shared";
+import type { AnalyticsSummary, PaymentAttempt, PaymentSource, QueryMode, UsageEvent, PaymentEvidence } from "@query402/shared";
import { config } from "./config.js";
interface PersistedDb {
@@ -47,6 +47,38 @@ 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 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;
}
@@ -96,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,
@@ -121,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/lib/x402.ts b/apps/api/src/lib/x402.ts
index 92ea079..f3e0a9e 100644
--- a/apps/api/src/lib/x402.ts
+++ b/apps/api/src/lib/x402.ts
@@ -5,6 +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 { savePaymentAttempt, updatePaymentAttemptEvidence, updateUsageEventsByPaymentId } from "./persistence.js";
+import { nanoid } from "nanoid";
type RouteMode = "search" | "news" | "scrape";
@@ -53,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();
}
@@ -74,6 +100,76 @@ function demoMode402Middleware(req: Request, res: Response, next: NextFunction)
});
}
+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);
+
+ 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()
+ });
+ }
+ },
+
+ 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) {
+ 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);
+ }
+ },
+
+ 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;
@@ -103,6 +199,11 @@ export function createX402Middleware() {
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 9250212..82a2ca3 100644
--- a/apps/api/src/routes/protected.ts
+++ b/apps/api/src/routes/protected.ts
@@ -3,11 +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 { 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;
@@ -15,25 +17,14 @@ function persistPaidRequest(input: {
priceUsd: number;
latencyMs: number;
traceId: string;
- paymentResponseHeader: string | null;
- payerPublicKey?: string;
}) {
- 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");
- 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,
- createdAt: now
- });
+ const paymentAttempt = getPaymentAttempts().find(p => p.id === paymentId);
+ if (!paymentAttempt) throw new Error("Payment attempt not found");
+
+ const now = new Date().toISOString();
saveUsageEvent({
id: `use_${nanoid(10)}`,
@@ -42,15 +33,14 @@ 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: paymentAttempt.evidence,
traceId: input.traceId,
+ paymentId: paymentId,
createdAt: now,
latencyMs: input.latencyMs
});
+
+ return { paymentId, evidence: paymentAttempt.evidence };
}
protectedRouter.get("/x402/search", async (req, res, next) => {
@@ -66,24 +56,26 @@ protectedRouter.get("/x402/search", async (req, res, next) => {
q: parsed.data.q
});
- const paymentHeader = req.header("payment-response") ?? null;
- persistPaidRequest({
+ const { paymentId, evidence } = 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
+ traceId: result.traceId
});
+ res.setHeader("x-payment-attempt-id", paymentId);
+ res.setHeader("x-payment-trace-id", result.traceId);
+
return res.json({
payment: {
network: config.STELLAR_NETWORK,
facilitatorUrl: config.X402_FACILITATOR_URL,
- paymentResponseHeader: paymentHeader
+ paymentResponseHeader: req.header("payment-response") ?? null,
+ evidence
},
result
});
@@ -105,24 +97,26 @@ protectedRouter.get("/x402/news", async (req, res, next) => {
q: parsed.data.q
});
- const paymentHeader = req.header("payment-response") ?? null;
- persistPaidRequest({
+ const { paymentId, evidence } = 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
+ traceId: result.traceId
});
+ res.setHeader("x-payment-attempt-id", paymentId);
+ res.setHeader("x-payment-trace-id", result.traceId);
+
return res.json({
payment: {
network: config.STELLAR_NETWORK,
facilitatorUrl: config.X402_FACILITATOR_URL,
- paymentResponseHeader: paymentHeader
+ paymentResponseHeader: req.header("payment-response") ?? null,
+ evidence
},
result
});
@@ -144,24 +138,26 @@ protectedRouter.get("/x402/scrape", async (req, res, next) => {
url: parsed.data.url
});
- const paymentHeader = req.header("payment-response") ?? null;
- persistPaidRequest({
+ const { paymentId, evidence } = 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
+ traceId: result.traceId
});
+ res.setHeader("x-payment-attempt-id", paymentId);
+ res.setHeader("x-payment-trace-id", result.traceId);
+
return res.json({
payment: {
network: config.STELLAR_NETWORK,
facilitatorUrl: config.X402_FACILITATOR_URL,
- paymentResponseHeader: paymentHeader
+ 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
new file mode 100644
index 0000000..b9db49f
--- /dev/null
+++ b/apps/api/tests/x402.test.ts
@@ -0,0 +1,233 @@
+import "./setup.js";
+import test from "node:test";
+import assert from "node:assert";
+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 req = {
+ path: "/x402/search",
+ headers: {} as Record payment-response: {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}