From d5afc0ec1314e1397616da94545671e73053a995 Mon Sep 17 00:00:00 2001 From: holistis <272782562+holistis@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:30:12 +0200 Subject: [PATCH] x402-lint reads the Payment-Required header before the body An x402 v2 seller keeps its requirements in the Payment-Required header and often answers with a body of {}. Reading only the body made this tool call a working v2 endpoint schema-invalid (measured 2026-10-24 against our own live paid route: 'x402Version: Invalid discriminator value'), and the hosted tool at x402-lint.tokenizen.nl did the same. The header is now read first (base64 JSON or plain JSON), the body is the fallback, and the report says which one was used (new optional field source). Four new tests, red on the old code, green now. README gets an erratum. Version 0.1.1; publishing it is a separate step. --- packages/x402-lint/README.md | 5 ++++ packages/x402-lint/package.json | 2 +- packages/x402-lint/src/check.test.ts | 45 +++++++++++++++++++++++++++- packages/x402-lint/src/check.ts | 43 ++++++++++++++++++++++++-- 4 files changed, 91 insertions(+), 4 deletions(-) diff --git a/packages/x402-lint/README.md b/packages/x402-lint/README.md index 534bb10..0c9da33 100644 --- a/packages/x402-lint/README.md +++ b/packages/x402-lint/README.md @@ -41,10 +41,15 @@ npm run start:http ## What it checks +- **Where it reads the requirements from** — the `Payment-Required` header first, then the body. An x402 v2 seller keeps its requirements in that header and often answers with a body of `{}`; the report says which one it used (`source`). - **Schema validity** — the real, unmodified `parsePaymentRequired()` from `@x402/core`. If this fails, no spec-compliant client will ever pay, regardless of how reasonable the response looks. - **Address shape** (warning, not a schema violation) — `accepts[].asset` and `accepts[].payTo` should look like a real `0x`-prefixed 20-byte address. - **Resource URL match** (warning) — a v2 response's `resource.url` should match the URL actually tested, or a careful client may refuse it as resource-mismatched. +## Erratum (found 2026-10-24) + +Up to and including 0.1.0 this tool read only the response body. Measured against our own live paid route, which is a v2 seller with its requirements in the header and a body of `{}`, it reported `x402Version: Invalid discriminator value`: a working endpoint called broken. The hosted tool at x402-lint.tokenizen.nl did the same until it was updated. If you were told your v2 endpoint failed the schema by an earlier version, run it again; a result that names the `body` as its `source` was not affected. Fixed in 0.1.1, with tests that fail on the old code. + ## Honest limits This only sees the HTTP/MCP-level response. It cannot see whether the seller's own signer can actually broadcast once a real payment settles — that was the *other* bug we found the same night: a signer built with no provider attached, which only throws once a real payment has already been taken. No anonymous, read-only check can see that from the outside without spending real money against someone else's live endpoint, which this tool deliberately never does. diff --git a/packages/x402-lint/package.json b/packages/x402-lint/package.json index 094145f..b10615e 100644 --- a/packages/x402-lint/package.json +++ b/packages/x402-lint/package.json @@ -1,6 +1,6 @@ { "name": "@alghanima/x402-lint", - "version": "0.1.0", + "version": "0.1.1", "description": "Checks an x402 seller's own payment-required (402) response against the REAL client-side schema from @x402/core, catching the exact class of bug where a response looks fine to its own author but silently fails every real buyer's own validation (found live, 2026-10-22: a serviceName field 53/71 characters long against the client schema's 32-character limit). Free, read-only, no wallet involved.", "keywords": [ "x402", diff --git a/packages/x402-lint/src/check.test.ts b/packages/x402-lint/src/check.test.ts index 648f710..e0474fe 100644 --- a/packages/x402-lint/src/check.test.ts +++ b/packages/x402-lint/src/check.test.ts @@ -10,7 +10,7 @@ import { checkX402Conformance } from "./check.js"; let server: Server; let baseUrl: string; -let mode: "rest" | "mcp-payment-required" | "mcp-no-payment-required" | "mcp-input-error" | "non-402" | "bad-json" = "rest"; +let mode: "rest" | "rest-header" | "rest-header-and-bad-body" | "mcp-payment-required" | "mcp-no-payment-required" | "mcp-input-error" | "non-402" | "bad-json" = "rest"; let restBody: unknown = null; let mcpPaymentRequiredBody: unknown = null; @@ -44,6 +44,13 @@ beforeAll(async () => { res.writeHead(402, { "content-type": "application/json" }).end(JSON.stringify(restBody)); return; } + // A v2 seller keeps its requirements in the Payment-Required HEADER and answers with a body of {}. + if (mode === "rest-header" || mode === "rest-header-and-bad-body") { + res + .writeHead(402, { "content-type": "application/json", "payment-required": Buffer.from(JSON.stringify(restBody)).toString("base64") }) + .end(mode === "rest-header" ? "{}" : "not json at all"); + return; + } if (mode === "non-402") { res.writeHead(200, { "content-type": "application/json" }).end(JSON.stringify({ ok: true })); return; @@ -119,6 +126,42 @@ describe("checkX402Conformance, REST transport, against a local fake server", () expect(report.issues.some((i) => i.field === "resource.url")).toBe(true); }); + // FOUND 2026-10-24 on our own live paid route: a v2 seller that keeps its requirements in the + // Payment-Required header and answers with a body of {} was reported as schema-invalid + // ("x402Version: Invalid discriminator value"), because only the body was read. These two tests + // fail on that old code and pass on the fix. + it("reads the requirements from the Payment-Required header when the body is only {}", async () => { + mode = "rest-header"; + restBody = validV2Response(); + const report = await checkX402Conformance(`${baseUrl}/rest-route`, { fetchImpl: fetch }); + expect(report.schemaValid).toBe(true); + expect(report.ok).toBe(true); + expect(report.source).toBe("header"); + }); + + it("still catches a schema problem that sits in the header, and says where it read it from", async () => { + mode = "rest-header"; + restBody = validV2Response({ serviceName: "x".repeat(53) }); + const report = await checkX402Conformance(`${baseUrl}/rest-route`, { fetchImpl: fetch }); + expect(report.schemaValid).toBe(false); + expect(report.schemaErrors.join(" ")).toContain("serviceName"); + expect(report.source).toBe("header"); + }); + + it("does not need the body at all when the header is good", async () => { + mode = "rest-header-and-bad-body"; + restBody = validV2Response(); + const report = await checkX402Conformance(`${baseUrl}/rest-route`, { fetchImpl: fetch }); + expect(report.ok).toBe(true); + }); + + it("reports the body as the source for a v1 style seller", async () => { + mode = "rest"; + restBody = validV2Response(); + const report = await checkX402Conformance(`${baseUrl}/rest-route`, { fetchImpl: fetch }); + expect(report.source).toBe("body"); + }); + it("reports a clean error, not a crash, when the body is not valid JSON", async () => { mode = "bad-json"; const report = await checkX402Conformance(`${baseUrl}/rest-route`, { fetchImpl: fetch }); diff --git a/packages/x402-lint/src/check.ts b/packages/x402-lint/src/check.ts index ff136c5..e0c7d16 100644 --- a/packages/x402-lint/src/check.ts +++ b/packages/x402-lint/src/check.ts @@ -65,6 +65,11 @@ export interface ConformanceReport { issues: ConformanceIssue[]; /** The exact payment-required JSON object this report actually validated, for a caller who wants to inspect it directly rather than trust this report alone. Null if none could be extracted. */ rawPaymentRequired: unknown | null; + /** + * Where the payment-required object was read from on the REST transport: the Payment-Required header (x402 v2 sellers, + * whose body is often just {}) or the response body (v1 style). Absent on the MCP transport and when nothing was read. + */ + source?: "header" | "body"; } export interface CheckOptions { @@ -111,6 +116,28 @@ function errorReport( }; } +/** + * The payment-required object from a Payment-Required header: base64 of JSON, or plain JSON. + * Returns null for anything else rather than throwing, so a garbage header falls back to the body. + * + * ADDED 2026-10-24 after a measurement against our own live paid route: an x402 v2 seller keeps its + * requirements in this header and answers with a body of {}. Reading only the body made this tool call + * a working v2 endpoint schema-invalid ("x402Version: Invalid discriminator value"), and the hosted + * x402-lint service told every v2 seller that for as long as it ran the older code. + */ +function decodePaymentRequiredHeader(value: string | null): unknown | null { + if (!value) return null; + for (const text of [() => Buffer.from(value, "base64").toString("utf8"), () => value]) { + try { + const parsed: unknown = JSON.parse(text()); + if (parsed !== null && typeof parsed === "object") return parsed; + } catch { + // try the next reading + } + } + return null; +} + /** Extracts the JSON payload from an MCP transport response, which may be plain JSON or SSE-framed ("event: message\ndata: {...}\n\n"). */ function extractMcpEnvelope(rawText: string): unknown { const dataLine = rawText.split("\n").find((l) => l.startsWith("data:")); @@ -149,14 +176,26 @@ async function checkRest(resourceUrl: string, fetchImpl: typeof fetch): Promise< return report; } + // The header first: it is where a v2 seller puts its requirements, and its body may be just {}. + const fromHeader = decodePaymentRequiredHeader(res.headers.get("payment-required")); + if (fromHeader !== null) { + return { ...finishReport(resourceUrl, "rest", res.status, fromHeader), source: "header" }; + } + let body: unknown; try { body = JSON.parse(rawText); } catch { - return errorReport(resourceUrl, "rest", res.status, "body", "response body is not valid JSON"); + return errorReport( + resourceUrl, + "rest", + res.status, + "body", + "no readable payment requirements: the Payment-Required header is missing or unreadable and the response body is not valid JSON", + ); } - return finishReport(resourceUrl, "rest", res.status, body); + return { ...finishReport(resourceUrl, "rest", res.status, body), source: "body" }; } async function checkMcp(