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(