Skip to content

feat(web): the demo runs in the visitor's browser - #21

Merged
synacktraa merged 2 commits into
devfrom
feat/client-demo
Sep 9, 2026
Merged

synacktraa merged 2 commits into
devfrom
feat/client-demo

Conversation

@synacktraa

Copy link
Copy Markdown
Owner

The live demo ran in a serverless function behind /api/demo/run. It runs in the visitor's browser now, and the function, its concurrency flag and the EventSource are gone.

What the guard was actually doing

let running = false;   // module scope

On an always-on server there is one process, one running, and that line means what it says. On Vercel the route is a serverless function: each instance loads the module fresh and gets its own copy, and Vercel answers a queue by starting more instances. So it failed in both directions at once. Within one instance it turned the second visitor away from a run that is mostly idle on the network. Across instances it capped nothing, which is the only place a cap would have mattered.

What broke under load was friendbot. Every run funds a fresh owner through it, it rate limits by source address, and every function shared one Vercel egress. A hundred browsers are a hundred addresses, so moving the run removes the pressure rather than managing it. The 60 second function ceiling on this plan goes with it.

What had to be true first

the seller must let a page read its headers the commit before this one
the SDK must not import node:crypto #20, 0.1.2
the wasm must not need fetching it does not: the hash is a constant the dashboard already deploys from

ensureWasm fetched the release asset only to hash it. GitHub sends no cross-origin headers on release assets, and the hash is what a deployment needs anyway, so WASM_HASH moved into params.ts and write.ts imports it from there instead of declaring its own. Buffer.from(agent.rawPublicKey()) became the raw key, which is what write.ts has always passed.

npm run bake was the route's other caller. It drives the generator directly now, through the same liveDeps the page uses, so there is one implementation instead of a server one and a browser one.

The import is dynamic. Most of the page's weight is stellar-sdk, and a reader who never presses the button should not carry it.

Verification

npm run bake completed eight rows in Node, both refusals, no server.

In the browser, the first attempt looked like a success and was not. The click landed before hydration, so the page simply rendered the baked run back, and the rows were right because they were a real run - the identical swap figure and transaction hash to baked.json are what gave it away. Worth recording, because the screenshot would have passed.

The real run produced a new contract and new hashes, and Horizon was asked directly rather than the page believed:

tx      3982e1d0b5268f40bf4272f73395a38a40a20b448a72c2da9fb7e0c0c5ae6385
        successful: true
source  GCS6COEVWTJ7MMZJL7XKIPKLDIONZLI6CTELQ6GW5447XIKNM4RQLXKB   the facilitator
move    0.0100000 USDC  from CBRRQC6ADHF7...  to GA4ND56VAGBN...   the contract, not an account

Both refusals appeared, the button returned to an enabled state, and no request touched an origin of ours.

32 unit tests, 27 Playwright tests, tsc --noEmit clean.

Deploying this

The seller has to ship before or with the web. Until it sends the headers, a browser can fetch it and still not read the terms, and the demo stops at the row that asks what the seller wants. They are separate Vercel projects and separate manual deploys.

🤖 Generated with Claude Code

synacktraa and others added 2 commits September 10, 2026 00:07
x402 carries everything that matters in headers, and a browser hides those from script
unless the server names them. So a page could fetch this seller, receive the 402, and
still not read what it wanted paying: the response was visible and the terms were not.
That is why the interface asks through a route of its own instead.

Three parts, and the third is the one that gets forgotten. An origin, the methods, and
`Access-Control-Expose-Headers` naming `PAYMENT-REQUIRED` and the two response names.
Plus an `OPTIONS` handler, because a paid request carries `PAYMENT-SIGNATURE` and
`X-PAYMENT` and a custom header makes the request preflighted rather than simple.

The preflight echoes what was asked for rather than listing names. Those names belong to
x402 and move with it, and a preflight that hardcodes them is one that goes stale
quietly.

Any origin. The terms are public, anyone can already read them with curl, and a seller
that speaks x402 exists to be found and paid.

Checked against a running copy: the 402 carries the headers, OPTIONS answers 204 with
the requested set echoed, and a page on localhost:3000 fetched localhost:3002 and
decoded payTo and the amount out of the header it could not previously see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
It ran in a serverless function behind `/api/demo/run`, guarded by a module-level `let
running`. On one instance that turned the second visitor away; across instances it
capped nothing, because each instance gets its own copy of the module and none of them
can see the others. Vercel answers a queue by opening more doors, each with its own
sign that has never heard of the others.

What actually broke under load was friendbot. Every run funds a fresh owner through it,
it rate limits by source address, and every function shared one egress. A hundred
browsers are a hundred addresses, so the thing that made concurrency dangerous is the
thing that moving it removes. The 60 second ceiling on this plan goes with it, and so
does the flag, the route and the EventSource.

Three things had to change for a browser to do the work. The seller now says a page may
read its headers, in the commit before this one. `ensureWasm` fetched the release asset
to hash it, which a browser cannot read and which the network does not need: the hash is
a constant the dashboard already deploys from, so both now import it from one place. And
`Buffer.from(agent.rawPublicKey())` became the raw key, which is what the dashboard has
always passed.

`bake` was the other caller of that route. It drives the generator directly now, through
the same dependencies the page uses, so there is one implementation rather than a server
one and a browser one.

The import is dynamic. Most of the page's weight is stellar-sdk and a reader who never
presses the button should not carry it.

Verified as a run rather than as code. `npm run bake` completed eight rows in Node with
both refusals. In the browser, the first click landed before hydration and rendered the
baked run back at me, which is worth recording because the numbers looked right: it was
the identical swap figure and transaction hash that gave it away. The real run produced
contract `CBRRQC6A…XQFT` and transaction `3982e1d0…6385`, and Horizon says that
transaction succeeded with source `GCS6COEV…`, the facilitator, moving USDC out of
`CBRRQC6A…` and not out of any account the agent holds.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@synacktraa
synacktraa merged commit 5cdaa8f into dev Sep 9, 2026
4 checks passed
@synacktraa
synacktraa deleted the feat/client-demo branch September 9, 2026 18:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant