Most agent-payment demos stop at the happy path: an agent hits an endpoint, gets a 402, signs a payment, gets a 200, the room claps. That demo teaches that a protocol works. It does not teach what breaks, and the thing that breaks is not the payment. It is everything around it.
So this repo does three things:
- A till that sells something small for one cent over x402, with receipts that rebuild from the file on disk.
- A chaos buyer that runs six refusals over real HTTP, each with a typed code and a fix.
- An experiment: twelve agents, four doors, one sentence each. What did they read before they paid?
The framing: four questions, one protocol
An agent that wants to buy from your service is not asking "do you support x402". It is asking four questions, in order:
- Find. Is this the thing I think it is?
- Check. Who with skin in the game vouches for this?
- Pay. What am I buying, and what is the worst case?
- Result. Does this payload mean what I paid for?
x402 answers the third one well. MCP hands an agent a tool with a name. Neither of them tells the agent what the tool actually promises, what version it is, or how it fails.
A handshake is not an agreement. MCP moves bytes, x402 moves money, nothing in the stack moves meaning. The repo is an argument for putting meaning in the interface, and the demo is how you show it in twenty minutes.
Architecture
One process. That is the first design decision and it matters more than it sounds.
agent / browser / curl │ GET /llms.txt │ POST /api/vend/:item │ GET /api/receipts ▼ ▼ ▼ ┌───────────────────────────────────────────────────────────────┐ │ Express │ │ │ │ discovery: llms.txt, .well-known/agent.json, schema.json, │ │ registration.json, /mcp (generated) │ │ │ │ funnel: price → input → version → money → serve │ │ 402 422 409 402 200 │ │ │ │ x402: EIP-3009 TransferWithAuthorization, verified │ │ with viem, nonce-checked against the ledger │ │ │ │ receipts: append-only JSONL, sha256 chain, re-verifiable │ │ │ │ demo: chaos runs, tamper drill, three-doors results │ └───────────────────────────────────────────────────────────────┘ │ ▼ Base Sepolia (eip155:84532) or Base (eip155:8453), USDC verify locally, then ask the facilitator to settle Why one process:
- A demo that needs four services fails on venue Wi-Fi.
- The discovery documents are generated from the catalog, so they cannot drift from the thing they describe. If
llms.txtsays a penny and the endpoint wants two, that is not a documentation bug, it is impossible. - The UI is a client of the API, not a mock of it. A button on screen and an agent on the wire take the same path through the same handler.
Part 1: the till, and why order beats status codes
src/server/vend.ts is the whole product. It answers the four questions in a fixed order:
/** * One call, four questions answered in order: * what does this cost and where do I pay (402) -> is the input even valid (422) * -> is the money real and unused (402/409) -> here is what you bought (200). */ export async function vend(itemId: string, input: Record<string, unknown>, opts: VendOptions) { const item = getItem(itemId); // 1. price and payee, or a 402 that names both // 2. schema check, pointer by pointer, or a 422 // 3. contract version pin, or a 409 // 4. payment verification: payee, amount, network, window, nonce // 5. run the item, append the receipt, return it } The ordering is the interesting part.
- Price before validation. An agent that does not know the cost cannot decide to spend. Returning a validation error first teaches the agent it can probe your service for free, which is not the deal you offered.
- Validation before settlement. Charging for a payload you are about to reject is theft with extra steps, and it produces the worst possible support ticket: money moved, nothing arrived.
- Settlement before execution. A receipt for work that was never paid is a lie that survives in an audit trail.
Every failure is a typed object, not a sentence:
throw fail(409, "contract_version_mismatch", `This tool speaks contract ${item.version}; the request pinned ${opts.contractHeader}.`, "Read /schema.json, pin that version, retry. Or ask for the current contract.", false, { expected: item.version }); A code for the agent, a hint for the retry policy, a fix for the human reading the trace, and a retryable flag so a client can distinguish "adjust and retry" from "stop". This is the part most payment demos skip, and it is the part an agent needs most.
The payment, briefly
The 402 carries PAYMENT-REQUIRED: base64 JSON with the amount, asset, payee, network, resource and timeout. The buyer signs an EIP-3009 TransferWithAuthorization for exactly that amount and retries the same POST with PAYMENT-SIGNATURE. The server:
const valid = await verifyTypedData({ address: authorization.from, domain: { name: "USD Coin", version: "2", chainId, verifyingContract: asset }, types: TRANSFER_TYPES, primaryType: "TransferWithAuthorization", message: authorization, signature, }); Then checks payee, amount, network, window, and a nonce that has never been used in this ledger. The 200 carries PAYMENT-RESPONSE with the settlement. Then the facilitator submits the EIP-3009 transfer and returns the transaction hash, which is what the wall links to. When settlement fails the receipt says failed with the facilitator's reason and no money moved; the room sees the real outcome instead of a green tick that means nothing.
The buyer side is one function in src/server/client.ts: request, read the 402, sign, retry, return. If you only take one file from this repo, take that one.
Part 2: receipts that rebuild from disk
The receipt wall is the demo that makes people lean forward, because it turns "trust me" into a file you can hash.
export function append(input: ReceiptInput): Receipt { const seq = all().length + 1; const prevHash = headHash(); const base = { ...input, seq, prevHash }; const hash = sha256(stableStringify(base)); const receipt: Receipt = { ...base, id: `rcpt_${hash.slice(0, 12)}`, hash }; fs.appendFileSync(FILE, `${JSON.stringify(receipt)}\n`); all().push(receipt); return receipt; } Each line commits to the previous line's hash. verify() walks the file, recomputes each hash and checks the link. Two details that cost me time:
-
idis derived from the hash, so it must not be inside the hashed body. Otherwise verification is self-referential and always fails. - Keys must be stringified deterministically.
stableStringifysorts them, so a receipt written on my laptop verifies on yours.
The tamper drill is a route, not a slide show: edit line one, reload, break, restore, reload, green. Six seconds, and the room now understands what a receipt is for.
Part 3: the six refusals
The chaos buyer is the part I would keep if I could keep only one. Each row runs over the actual HTTP path against the running server.
- Baseline buy, must return
200. Without it, the suite proves nothing. - Version mismatch: request pinned to
0.9.0, tool speaks1.0.0,409 contract_version_mismatch. - Schema violation: a field outside the declared output schema,
422, with a JSON pointer to the offending path. - Expired authorization:
validBeforein the past,402 authorization_expired. - Wrong payee: signed to the wrong address,
402 wrong_payee. - Replayed nonce: reuse a signature,
409 nonce_replayed. - Budget cap: ask for more units than the till sells,
422 budget_exceeded.
Two things this buys you:
- A taxonomy you can defend. When someone asks "what happens if the agent pays twice", you do not gesture. You point at a row with a status code.
- A regression net. The tests assert on codes, so a refactor that changes a
409into a400fails CI, which is exactly the kind of change that breaks agent clients in production.
The schema validator is deliberately small, about eighty lines, covering only the keywords this contract uses. A real deployment should use a real validator. The point here is the pointer-level error, not the parser.
Part 4: twelve cold agents and four doors
This is the experiment behind the talk's title. Same service, published four ways:
-
cold: the domain and nothing else. -
llms: the domain plus a pointer to/llms.txt. -
mcp: the domain plus "there is an MCP tool at/mcp". -
registry: the domain plus "it is registered under ERC-8004".
Each agent gets one HTTP tool, a wallet flag, and one sentence: buy something from this site. It decides what to read, whether to check the contract or the identity, and what to pay.
The run in this repo: all twelve bought. Median five steps. Ten read a contract or an identity before paying. Two of the three that came in through the MCP door never opened either. A tool name looks like an answer, and agents behave accordingly.
Caveats, because this is a talk prop and not a benchmark: one model, one afternoon, n=12, and ERC-8004 validation is not production-deployed, so the registry arm reads a registration document rather than a live validation registry. The result is a shape worth arguing about, not a decimal place worth quoting.
/api/three-doors serves the result from .data/three-doors.json. The numbers you quote are the numbers on disk. If the file is missing, the endpoint says ok: false, which is the honest failure mode and also a better story than a fabricated chart.
Part 5: discovering services that actually exist
The first version of this talk had a discovery demo with a JSON file full of fake services. It looked fine
and taught nothing, because the hard part of discovery is not the search box. It is that the world's
inventory is messy, unreachable, and mostly not payable.
ERC-8004 gives you an identity registry. On Base Sepolia that is
0x8004A818BFB912233c491871b3d84c89A494BD9e, and at the time of writing it holds roughly 9,400 agent
identities. There is no enumeration function, so the only way to know what exists is to walk the
Registered logs. Public RPCs reject a two-million block getLogs call, so I chunk it:
const CHUNK = 400_000n; for (let i = 0; i < MAX_CHUNKS; i++) { const from = to > CHUNK ? to - CHUNK : 0n; const logs = await client.getLogs({ address: REGISTRY.identity, event: registeredEvent, fromBlock: from, toBlock: to }); // decode agentId, agentURI, owner; stop after a few empty windows to = from - 1n; } The registration document itself is behind tokenURI(agentId), and it is either inline base64 or a URL on
somebody's server. Of the newest 240 registrations I warmed, 171 answered with parseable JSON and 69 did
not. That ratio is the lesson, not a bug.
Ranking stays deliberately explainable, because an audience has to be able to argue with it:
if (name.includes(term)) value += 6; if (services.includes(term)) value += 3; if (desc.includes(term)) value += 2; return { score: value, why: ["name: sql", "description: query"] }; Then the agent knocks. One GET to the endpoint the winner advertises, no money, and it reports what came
back: 402 payment required, a 200, or nothing at all. On the live registry, most entries advertising
x402 point at localhost:8001 or a machine that has since gone dark. So the verdicts are the honest ones:
- payable and reachable: "buy it, then verify what came back"
- claims x402 but only lists a loopback address: "the claim is not yet a service"
- registered but no x402: "reading it is free; paying needs a human"
The one service this build can genuinely settle with is its own till, because it holds those keys. That is
why the registry view ends by handing the room back to the till: discovery is somebody else's contract to
honour, settlement is yours.
Part 6: an index is not a source of truth
Walking nine thousand log-derived identities to answer one shout-out is honest and too slow for a stage. So
discovery asks an index first. 8004scan exposes semantic search over the same
registry:
GET https://api.8004scan.io/api/v1/agents/search/semantic?q=<topic>&chain_id=84532&limit=6 It answers in 0.8 to 1.6 seconds with items[] carrying token_id, name, x402_supported and services.
That is a much better search than mine, and it is still somebody else's database. Two things follow, and they
are the whole reason this part exists:
// every row the index hands back is re-read from the contract const owner = await client.readContract({ address: REGISTRY.identity, abi: ownerOfAbi, functionName: "ownerOf", args: [BigInt(raw.token_id)], }); const verified = owner.toLowerCase() === (raw.owner_address ?? "").toLowerCase(); If the contract disagrees, the card is marked unverified rather than presented. And if the index is down, slow, or rate-limited, the run does not fail: it falls back to the log walk and labels itself honestly.
via: "8004scan" | "on-chain" // the screen says which one answered The screen also refuses to overstate. matched is how many rows the index returned; chainTotal is how many registrations the chain holds. Telling a room "6 agents" when the registry has 9,388 would be a lie told with a true number.
One more filter earns its place. ERC-8004 service maps include oasf, which is a taxonomy path, not an endpoint:
if (name === "oasf") continue; // an ontology is not a door if (!/^https?:\/\//.test(endpoint)) continue; That leaves only things an agent could actually knock on, which is what makes the next step meaningful: 402 means sellable, 404 or silence means the badge has no door.
Measured over the ten audience chips: 0.9 to 2.1 seconds warm, 0.9 to 3.6 cold, and 0.21 seconds to fail over to the on-chain path when I pointed the index at a dead host on purpose.
What I would tell someone building this for real
- Generate your discovery documents from your catalog. Hand-written
llms.txtis a lie waiting for a deploy. - Put the version in the contract, not the URL. Agents pin versions; humans forget to.
- Return typed refusals with a retryable flag. The agent's retry policy is the interface.
- Charge before you compute, but validate before you charge. Anything else is either free probing or paid rejection.
- Write receipts to disk, append-only, hash-chained. A database row you can update is not evidence.
- Rehearse without a chain. A demo that only works with live settlement is a demo that dies on conference Wi-Fi.
- Test the refusals, not just the sale. The failures are the product.
- Use an index, verify on chain, and label which one answered. Speed is somebody else's; truth has to be yours.
Run it
npm install npm run build:ui npm start # http://localhost:8787, till; registry at /#registry Live by default: every paid call settles on Base Sepolia, and npm run live-doctor tells you what is missing before you go on stage. AGENTVEND_MODE=rehearsal is the opt-in offline fallback.
Twenty-two tests, no mocks of the HTTP layer, strict TypeScript, and no CDN anywhere in the UI.
Code & more: https://www.dailybuild.xyz/project/304-agent-vend



