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;
}