DEV Community

Cover image for I built a UI where two agents pay each other real money, so I could explain what MCP and A2A don't cover
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on AI-assisted

I built a UI where two agents pay each other real money, so I could explain what MCP and A2A don't cover

The gap nobody talks about

MCP and A2A are genuinely good at moving messages. MCP connects an agent to its tools; A2A connects an
agent to its peers. Both solve transport.

Neither one answers the three questions that stop autonomous commerce:

  1. What does this call mean? — is amount: 100 wei, ETH, cents, or a count?
  2. What does it cost, and to whom?
  3. What proves it happened?

You can have a perfectly healthy connection and still be wrong together. My favourite version of that
failure: two agents exchange bytes without a single error thrown, and one thinks it paid 100 wei while
the other thinks it received 100 ETH. Nothing "failed." They never agreed.

So the thesis of the whole project is one line: transport is solved, meaning is a contract. The
goal was to make a room feel that difference by clicking through it, not by reading a slide about it.

What the app is

A React SPA plus an Express seller, talking to the public x402 test facilitator and settling USDC on
Base Sepolia. Five chapters, each one a claim the talk makes, demonstrated rather than asserted.

Browser (React)                    Seller (Express + @x402/express)
  buyer agent  ──── HTTP ────►      ROUTES table → paymentMiddleware
      ▲    │                        /api/seller · /api/catalog
      │    ▼                        /api/balance (eth_call)
   UI stages                    Facilitator ──► Base Sepolia (USDC)
   from real events             open-meteo.com ──► real weather, only after payment
Enter fullscreen mode Exit fullscreen mode

The protocol is four HTTP messages

This is the part people expect to be complicated. It isn't:

Direction Header Body
seller → buyer PAYMENT-REQUIRED base64 JSON offer: scheme, network, amount, asset, payTo, maxTimeoutSeconds
buyer → seller PAYMENT-SIGNATURE base64 EIP-712 transferWithAuthorization
seller → buyer PAYMENT-RESPONSE settlement: success, payer, transaction, network

Request → 402 → signed replay → 200 with a receipt. The Raw exchange panel in chapter 1 prints all
four frames with their headers, because the demo is more convincing when you can see there is no magic
underneath.

One subtlety worth knowing: HTTP 402 has meant Payment Required since 1999 and never had a standard
body. 402 is the door; x402 is the paperwork behind it. I made the UI say that explicitly, because
otherwise people conflate the status code with the protocol.

One table is the whole seller

The thing I'm proudest of architecturally: the price table is the single source of truth. The middleware
config, the /api/catalog the marketplace renders, and every 402 the buyer reads are generated from the
same array. Nothing can drift.

const ROUTES = [
  { path: "/weather", name: "Weather API", description: "Live current conditions from open-meteo.com, fetched when you pay", price: "$0.001" },
  { path: "/quote",   name: "Price Feed",  description: "Live BTC/ETH quotes", price: "$0.0005" },
  // …
];

const requirements = {};
for (const r of ROUTES) {
  requirements[`GET ${r.path}`] = { accepts: offer(r.price), description: r.description, mimeType: "application/json" };
}
app.use(paymentMiddleware(requirements, new x402ResourceServer(facilitatorClient).register(BASE_SEPOLIA, new ExactEvmScheme())));
Enter fullscreen mode Exit fullscreen mode

That's the entire payment surface. Add a row and a handler: the service appears in the marketplace with
no frontend change, which is itself a talk beat — "what did you not have to write?"

The lifecycle is driven by events, not by animation

Chapter 1's centrepiece is an eight-stage stepper:

request → requirement → parse → authorize → submit → verify → release → receive
Enter fullscreen mode Exit fullscreen mode

A stepper like this is usually decoration: a spinner, then a green tick on setTimeout. That would have
destroyed the point of the demo. So each stage closes on a genuine application event, and the interesting
one is authorize — the signer object handed to the x402 client is a wrapper around the real viem account:

const signer = {
  address: account.address,
  async signTypedData(params) {
    emit("authorize", "active", { note: "signing EIP-712 transfer authorization" });
    const sig = await account.signTypedData(params);
    emit("authorize", "done", { note: "signature produced locally" });
    return sig;
  }
};
Enter fullscreen mode Exit fullscreen mode

The stage turns green the microsecond an EIP-712 signature actually comes out of the key. Same for
submit (the signed replay is dispatched) and verify (the response carries a receipt).

The transaction ledger works the same way — status is derived from stages, never from a spinner:

const STAGE_TO_STATUS = {
  request:   "initiated",
  authorize: "awaiting_signature",
  submit:    "submitted",
  verify:    "pending"
}; // verify-done + receipt → confirmed; anything refused → failed, carrying the protocol's own code
Enter fullscreen mode Exit fullscreen mode

Three bugs that taught me more than the feature

1. The signed leg was invisible. My wire spy inspected init.headers, but the x402 client attaches
PAYMENT-SIGNATURE to a pre-built Request object. So the signed replay looked like an ordinary request:
the payment settled, the seller returned 200 with a receipt, and my UI reported "Settlement refused
(HTTP 200)". A beautiful, confusing lie.

Fix was two-part: read headers from both shapes, and — more importantly — stop trusting bookkeeping:

const evidence = Boolean(receipt) && lastResp?.status === 200;
const settled  = evidence || (stages.get("verify")?.state === "done" && stages.get("receive")?.state === "done");
Enter fullscreen mode Exit fullscreen mode

Decide from the artifact the protocol returned, not from which callbacks fired.

2. A crash hiding in the happy path. readOffer() assumed a 402 frame always exists. Any run that got
a 400 instead threw Cannot read properties of undefined (reading 'payment-required') in the browser.
Only surfaced once I drove the unhappy path in a real browser. Type guards would not have caught it;
clicking the weird button did.

3. My own test harness lied. I wrote a QA script that "verified" the nonsense-city path by editing the
city string in the script — but the script never typed into the input field, so the app kept requesting
Hyderabad and I concluded the geocoder was fuzzy-matching garbage. I caught it because a nonsense city
returning real Hyderabad data is too suspicious to accept. The fix was to drive the actual
getByRole("textbox"), and the re-run immediately produced the true behaviour.

The weather is real, and that exposed a protocol truth

Chapter 1 originally served canned JSON: {"weather":"sunny","temperature_c":24}. Fine for teaching the
protocol, terrible for the claim "this is real."

So GET /weather?city=… now calls open-meteo.com — no API key, no account —
and returns live conditions with provenance: provider, exact source URL, observation time, and whether it
came from a 60-second cache.

Then I tested the failure: pay 0.001 USDC for a city that doesn't exist, and the seller returns 404 —
after the facilitator already settled the payment. That's not a bug in my code. It's a property of
pay-first HTTP: middleware settles before the handler runs, so a seller that fails after being paid
keeps the money.

The fix is a design decision, not an error handler: validate before you charge. A guard runs ahead of
the paywall and refuses unservable requests with 400, so no 402 is ever issued:

app.use("/weather", async (req, res, next) => {
  const city = String(req.query.city ?? "Bangkok").trim().slice(0, 60) || "Bangkok";
  try {
    const place = await resolvePlace(city);
    if (!place)
      return res.status(400).json({
        error: "city_not_found",
        message: `"${city}" is not a place the weather upstream knows.`,
        note: "No payment was requested. A seller that cannot deliver should not be able to charge."
      });
  } catch { /* geocoder unreachable: fall through, the paid handler degrades honestly */ }
  next();
});
Enter fullscreen mode Exit fullscreen mode
Situation Result Charged?
Place can't be geocoded 400 before the paywall, no 402 at all no
Upstream down, cache exists report with stale: true + reason yes, labelled
Upstream down, no cache 502 stating payment already settled yes — and it says so

Now the demo's best moment is typing a nonsense city and showing the audience that the lifecycle's
"Payment required" stage goes to blocked, because payment was never requested. That's a stronger point
about protocol design than the happy path ever was.

Chapter 2: the API-key demo that settles the argument

The claim: an API key can't close a paid request, because it's a delegated permission inside one company's
walls, and the party you're paying has never heard of that company.

Rather than assert it, the app proves it. There's a real endpoint that reports what it received:

{ "sawAuthorization": true, "authorizationPreview": "Bearer sk-demo…", "acceptedApiKeys": 0,
  "note": "This seller has no API-key path." }
Enter fullscreen mode Exit fullscreen mode

Send a bearer token: 402, same as everyone. Send a wallet signature: 200. Same endpoint, same offer, one
difference in capability. The point lands without a single definition of "agent."

Chapter 3: don't trust the seller, ask the chain

The receipt header contains a transaction hash. The UI takes that hash and calls
eth_getTransactionReceipt itself:

status 0x1 · block 54507174 · head 54507181 · 7 confirmations · gas 78120
paid by 0xd407e409e34e0b9afb99eccbb609bdbcd5e7f1bf   ← the facilitator's hot key, not the buyer
Enter fullscreen mode Exit fullscreen mode

That last field is the one people miss: the buyer never touched a nonce, never held gas, never broadcast
anything. It signed. Everything else was somebody else's job — which is exactly the abstraction x402 is
selling.

Pre-flight warning for anyone presenting this: if your buyer wallet and your seller's payTo are the
same address, you are paying yourself and the before/after deltas read 0.000000. That's how I found it,
and it kills the beat.

Chapter 4: labelling the layers, including the one I skipped

The message thread tags every line: violet A2A for discovery and task lifecycle, blue x402 for
settlement, teal MCP for the tool surface.

And the honest part — the app states in plain text that MCP is not wired in this build. An MCP server
would expose the same endpoints as named tools with input schemas; payment behaviour would not change,
because x402 sits underneath either transport.

Reserving the teal tag for something I didn't build was deliberate. The distinction I want the room to
leave with is: transport carries bytes, tooling describes capabilities, settlement defines what "paid"
and "delivered" mean.
Three jobs. Conflating them is why agent payments end up with an API key taped to
a spreadsheet.

Chapter 5: the catalog is read, not written

The marketplace list comes from /api/catalog, which is generated from the same ROUTES array as the
middleware. Change a price in the server and the 402 changes before anyone pays. Six services, one table.

There's also a headless version, because the UI is not the point — the agreement is:

npm run deal
# buyer 0x411a…
# quote terms        HTTP 200  4.08s  tx 0x031e9c871928d0c6…
# execute deal       HTTP 200  1.23s  tx 0xd17bc807069a491f…
Enter fullscreen mode Exit fullscreen mode

Two paid calls, two receipts, no browser. That's the whole talk in a script, and it's the last hack-room
challenge.

Design constraints that were actually load-bearing

  • Colour encodes protocol, nothing else. One accent (#4D7CFE) reserved for the primary action; emerald/amber/red only for state. When a colour means a layer, you can read chapter 4 at a glance.
  • Monospace only for technical content — hashes, addresses, endpoints, status codes, payloads. Everything a human reads is a proportional face. Mixed use of mono is how developer tools turn unreadable.
  • Motion answers state. The wire between the two agents animates only while a request is genuinely in flight. Nothing pulses at idle. prefers-reduced-motion kills it all.
  • A field the protocol didn't return is absent from the UI. No placeholder values, no "N/A" theatre.
  • The ledger is the closing slide. Every settlement accumulates at the bottom of the screen with a clickable hash, so the evidence builds itself while you talk.

Run it

npm install
npm run seller && npm run dev     # :4021 and :5173
npm run qa                        # browser walkthrough (inspection mode)
BUYER_PRIVATE_KEY=0x… npm run qa  # with real settlement + mobile overflow assertion
Enter fullscreen mode Exit fullscreen mode

Fund a wallet at faucet.circle.com → USDC on Base Sepolia. A full five-chapter run costs about 0.01 test USDC.

What I'd build next

  1. MCP server wrapper over ROUTES, so the teal tag stops being an apology.
  2. Real A2A Agent Card at /.well-known/agent.json, fetched instead of assembled client-side.
  3. Escrow-backed delivery — hold payment until the buyer accepts, which finally fixes the 502-after-settlement problem instead of just labelling it.
  4. upto scheme for metered budgets across many calls.
  5. Injected browser wallet signing, keeping the generated-key path as the demo fallback.

Exchange Lab 1

Exchange Lab 2

Code & more: https://www.dailybuild.xyz/project/305-agent-exchange-lab

Top comments (0)