This content reflects minia2a as of its publication date and is kept for the record. Current model: x402 pay-per-call in USDC on Base only, 5 free trial calls per signed wallet, no credits and no top-up rail.
2026-11-05 · postmortem + measurement
An x402 challenge document is a contract written twice. The input block tells a
client what to send. The output block tells it what it will get back. Both are
machine-readable, and a client that copies one and compares against the other is doing exactly
what we designed it to do.
On 2026-11-05, one of those pairs contradicted itself.
What a copy-first client saw
For /x402/prettier-config the unpaid challenge advertises, in the same document:
input.queryParams: {"semi": true, "singleQuote": true, "tabWidth": 2, ...}
output.example: {"prettier": {"semi": true, "singleQuote": true, ...}}
Send the input example as a GET, which is what input.method says to do:
$ curl -s "https://minia2a.uk/x402/prettier-config?singleQuote=true&semi=false"
{"ok":true,"prettier":{"semi":false,"singleQuote":false,...}}
semi came back correct — asked false, got false.
singleQuote was asked for as true and came back false. HTTP
-
ok:true. No error, no warning: a delivered answer that is the opposite of the request, carrying a payload that looks entirely reasonable.
Why: the type never survives the transport
A query string has one type: text. The gateway does not guess. Its GET path merges query values
into the handler input as strings, verbatim:
// handler_x402.go, GET path
input[k] = v[0] // "true", never true
The handler was written expecting the JSON type from the documentation, and it tested for two
encodings it had actually seen in testing:
// before
const singleQuote = input?.singleQuote === '1' || input?.singleQuote === true;
"true" is not '1', and the string "true" is not the
boolean true. Both comparisons are false, so the field fell through to its default.
The JSON body path — where a real boolean does arrive — worked fine, which is why every test we had
written passed. Every one of those tests called the function with an object, not with a request.
The measurement
This is not a one-line bug, it is a class, so we counted the class. On the site host, loopback,
anonymously, we fetched all 1,841 first-party challenge documents and read the JSON type of every
value inside extensions.bazaar.info.input.queryParams:
| advertised type | count (GET surface) |
|---|---|
| int | 1332 |
| str | 1183 |
| list | 271 |
| none (no queryParams) | 206 |
| float | 49 |
| dict | 11 |
| bool | 3 |
Three booleans, all on GET. Two of them were broken; the third (semi) happened to be
written as !== 'false', which accepts the string form by accident and rejects nothing.
Grepping the deployed handlers for the same predicate shape
(=== '1' || === true) found five files. Three of them advertise the string
"1", so their published example works — but a caller who writes the natural
true instead gets the default. All five are in the table below:
| endpoint | field | sent | before | after |
|---|---|---|---|---|
| x402-prettier-config | singleQuote | true | false | true |
| x402-editorconfig | lf | true / 0 | no effect | lf / crlf |
| x402-url-canonical | sortQuery | true | unsorted | sorted |
| x402-variance | sample | true | population variance | sample variance |
| x402-remove-punct | keep | true | punctuation kept | removed |
x402-editorconfig had a second, independent defect stacked on the first: the
lf value was read into a variable and then never used. The output line
end_of_line = lf was a string literal, so the advertised field could not have changed
anything even with perfect coercion. A guard that asks "does the handler read this declared field?"
answers yes. Reading is not using.
The fix, and why it throws
Every other coercion on this platform already lives in one module
(compat/lib/input-coerce.js) because the GET surface erases array and number types
too. Booleans joined it:
function asBool(v, dflt, name) {
if (v === undefined || v === null || v === "") return dflt;
if (typeof v === "boolean") return v;
if (typeof v === "number") return v !== 0;
const t = String(v).trim().toLowerCase();
if (t === "true" || t === "1" || t === "yes" || t === "on") return true;
if (t === "false" || t === "0" || t === "no" || t === "off") return false;
throw new BadRequestError(name + " must be true or false (got " + JSON.stringify(v) + ")");
}
The last line is the point. Returning the default for an unrecognised string is how a wrong
answer becomes a delivered answer: the caller gets 200, pays, and never learns that its input was
ignored. An unrecognised boolean is a caller error, and a caller error is a 400 that does not count
as a delivery failure.
Defaults were chosen to match the pre-fix output for calls that omit the field, so nothing that
worked before changed shape. Verified by running the deployed bytes in-process before and after,
and then end-to-end through the real edge with a signed trial call:
GET /x402/prettier-config?singleQuote=true&semi=false
-> {"ok":true,"prettier":{"semi":false,"singleQuote":true,...}}
GET /x402/editorconfig?lf=0
-> {"ok":true,"editorconfig":"...\nend_of_line = crlf\n..."}
What we did not find
Having been burned by the boolean case, we swept the adjacent one: a numeric query parameter used
directly in a + expression, where "5" + 1 is "51". Across 792
endpoints with int or float parameters on GET — 1,381 field positions, 274 direct references to the
input field — 195 were explicitly wrapped in a numeric coercion, 79 used the value in other
contexts, and zero performed uncoerced string concatenation. That is a negative result, and it is
worth writing down: it says the discipline exists on this codebase, not that the bug is impossible
elsewhere.
The rule
If you publish an example, the example is part of the contract, and it must be exercised through
the transport you advertise. Calling your handler with an object in a unit test does not test the
query string. Two specific consequences for anyone shipping an x402 resource:
- Advertise the type you will actually receive. On a GET surface, a boolean example is a string by the time your code sees it. Either accept the string forms or advertise a string.
- Compare the two halves of your own document. Take the input example, send it through the real URL, and diff the response against the output example. If they disagree, the document is lying to every client that trusts it, and the client has no way to tell.
We found this on ourselves with a script that did nothing but read our own challenges. The
cheapest audit of a machine-readable API is to become its client.
Originally published at minia2a.uk.
Top comments (0)