From a9eb732819b4360e50ef90dbe77259e25b55e06e Mon Sep 17 00:00:00 2001 From: "synacktra.work@gmail.com" Date: Thu, 10 Sep 2026 00:07:08 +0530 Subject: [PATCH 1/2] feat(seller): let a browser read the terms 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 --- demo/seller/app/api/quote/route.ts | 39 +++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/demo/seller/app/api/quote/route.ts b/demo/seller/app/api/quote/route.ts index f853254..01b8d10 100644 --- a/demo/seller/app/api/quote/route.ts +++ b/demo/seller/app/api/quote/route.ts @@ -66,7 +66,7 @@ const handler = async (_request: NextRequest): Promise { + return { + 'Access-Control-Allow-Origin': '*', + 'Access-Control-Allow-Methods': 'GET, OPTIONS', + // Echoed rather than listed: the payment header names belong to x402 and change with it, + // and a preflight that names them is a preflight that goes stale. + 'Access-Control-Allow-Headers': + request.headers.get('access-control-request-headers') ?? + 'content-type, PAYMENT-SIGNATURE, X-PAYMENT', + 'Access-Control-Expose-Headers': EXPOSED, + 'Access-Control-Max-Age': '86400', + }; +} + +export async function GET(request: NextRequest): Promise { + const response = await paid(request); + for (const [name, value] of Object.entries(allow(request))) response.headers.set(name, value); + return response; +} + +export function OPTIONS(request: NextRequest): Response { + return new Response(null, { status: 204, headers: allow(request) }); +} From 55eea3b487e29c66d6dc568120c7dc54548153c5 Mon Sep 17 00:00:00 2001 From: "synacktra.work@gmail.com" Date: Thu, 10 Sep 2026 00:18:34 +0530 Subject: [PATCH 2/2] feat(web): the demo runs in the visitor's browser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- web/README.md | 21 ++++++++++++---- web/app/api/demo/run/route.ts | 36 ---------------------------- web/app/run.tsx | 40 +++++++++++++++++-------------- web/lib/dashboard/write.ts | 7 +----- web/lib/demo/baked.json | 12 +++++----- web/lib/demo/live.ts | 45 +++-------------------------------- web/lib/demo/params.ts | 12 ++++++++++ web/package.json | 2 +- web/scripts/bake.mjs | 23 ++++++++---------- 9 files changed, 72 insertions(+), 126 deletions(-) delete mode 100644 web/app/api/demo/run/route.ts diff --git a/web/README.md b/web/README.md index 0ae2d82..493c22b 100644 --- a/web/README.md +++ b/web/README.md @@ -25,6 +25,20 @@ Nobody is asked which index to use. Sixty addresses are derived from the owner, instance entries are fetched in one `getLedgerEntries` call, and the first address that answers with nothing is where the next allowance goes. +## The demo runs in the visitor's browser + +Pressing the button funds an owner through friendbot, swaps XLM for USDC on the testnet DEX, +deploys an allowance, pays a live x402 API and shows two payments refused on chain. All of it +from the page: there is no route behind the button and nothing of ours in the path. + +That matters under load. A serverless function would serialise on a module-level flag within one +instance and cap nothing across instances, and every run would reach friendbot from one shared +address, which is what friendbot rate limits. Runs from a hundred browsers are a hundred +addresses. + +`npm run bake` drives the same generator from Node and records the result into +`lib/demo/baked.json`, which is what the page shows before anyone presses anything. + ## Running it The SDK is linked from `../sdk`, so build it once before the first run. @@ -37,14 +51,13 @@ npm run dev ## Environment -Nothing here holds a key. The demo generates every keypair it uses per run and discards it. +Two values, both public, and no secret. The demo generates every keypair it uses inside the run +and discards it, in the visitor's own browser. | | | |---|---| | `NEXT_PUBLIC_SITE_URL` | absolute base for the link preview. Defaults to `http://localhost:3000` | -| `SELLER_URL` | the x402 API the demo pays | -| `WASM_URL` | the contract release the demo deploys from | -| `BAKE_URL` | which host `npm run bake` records a run against | +| `NEXT_PUBLIC_SELLER_URL` | the x402 API the demo pays. Point it at a local seller to develop against one | ## Tests diff --git a/web/app/api/demo/run/route.ts b/web/app/api/demo/run/route.ts deleted file mode 100644 index 578fe53..0000000 --- a/web/app/api/demo/run/route.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { runDemo } from '@/lib/demo/run'; -import { liveDeps } from '@/lib/demo/live'; - -export const runtime = 'nodejs'; -export const dynamic = 'force-dynamic'; - -let running = false; - -// One run at a time per instance. Every key is generated inside the run and discarded with it. -export async function GET(): Promise { - if (running) return new Response('a run is already in progress', { status: 429 }); - running = true; - - const encoder = new TextEncoder(); - const stream = new ReadableStream({ - async start(controller) { - try { - for await (const event of runDemo(liveDeps())) { - controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); - } - controller.enqueue(encoder.encode('event: end\ndata: {}\n\n')); - } finally { - running = false; - controller.close(); - } - }, - }); - - return new Response(stream, { - headers: { - 'content-type': 'text/event-stream', - 'cache-control': 'no-cache, no-transform', - connection: 'keep-alive', - }, - }); -} diff --git a/web/app/run.tsx b/web/app/run.tsx index 71ee647..9381ecc 100644 --- a/web/app/run.tsx +++ b/web/app/run.tsx @@ -21,7 +21,7 @@ const VERB: Record = { deploy: 'deployed', }; -type Live = 'idle' | 'running' | 'busy' | 'done'; +type Live = 'idle' | 'running' | 'done' | 'stopped'; const explorer = (event: DemoEvent) => `https://stellar.expert/explorer/testnet/${event.id === 'deploy' ? 'contract' : 'tx'}/${event.hash}`; @@ -31,23 +31,27 @@ export function Run({ baked, at }: { baked: DemoEvent[]; at: string }) { const [live, setLive] = useState('idle'); const [startedAt, setStartedAt] = useState(at); - function start() { - const source = new EventSource('/api/demo/run'); + // The run happens here, in this browser, against the public network. There is nothing of ours + // between the button and the chain: no route, no queue, and no shared address for friendbot to + // rate limit, because the requests come from whoever pressed it. + async function start() { setRows(new Map()); setLive('running'); setStartedAt(new Date().toISOString()); - source.onmessage = (message) => { - const event = JSON.parse(message.data) as DemoEvent; - setRows((current) => new Map(current).set(event.id, event)); - }; - source.addEventListener('end', () => { - source.close(); + try { + // Loaded on the press rather than with the page. Most of this weight is stellar-sdk, and + // a reader who never runs it should not pay for it. + const [{ runDemo }, { liveDeps }] = await Promise.all([ + import('@/lib/demo/run'), + import('@/lib/demo/live'), + ]); + for await (const event of runDemo(liveDeps())) { + setRows((current) => new Map(current).set(event.id, event)); + } setLive('done'); - }); - source.onerror = () => { - source.close(); - setLive((state) => (state === 'running' ? 'busy' : state)); - }; + } catch { + setLive('stopped'); + } } return ( @@ -94,12 +98,12 @@ export function Run({ baked, at }: { baked: DemoEvent[]; at: string }) {
- - {live === 'busy' - ? 'another run is in progress, try again in a minute' + {live === 'stopped' + ? 'the run stopped early. the rows above say where' : 'testnet · about a minute · no wallet'}
diff --git a/web/lib/dashboard/write.ts b/web/lib/dashboard/write.ts index 248bd88..816e551 100644 --- a/web/lib/dashboard/write.ts +++ b/web/lib/dashboard/write.ts @@ -1,13 +1,8 @@ import { Keypair, Networks, contract } from '@stellar/stellar-sdk'; import { generateAllowanceSalt } from '@stellar-allowance/sdk'; -import { USDC_SAC } from '../demo/params'; +import { USDC_SAC, WASM_HASH } from '../demo/params'; import { RPC_URL } from './read'; -// The contract code every allowance is deployed from, already installed on testnet. Checked by -// hash rather than fetched: the release asset carries no cross-origin headers, and the hash is -// what a deployment needs anyway. -export const WASM_HASH = '6077823e41bb7de03da15497b5996894609a3caa26fab363efdf0a0499eeb7c2'; - /** Freighter's signer satisfies this, and so does a Keypair, which is how tests drive it. */ export interface Signing { owner: string; diff --git a/web/lib/demo/baked.json b/web/lib/demo/baked.json index 2c79d9a..7f5ffb0 100644 --- a/web/lib/demo/baked.json +++ b/web/lib/demo/baked.json @@ -1,5 +1,5 @@ { - "at": "2026-09-08T18:07:10.581Z", + "at": "2026-09-09T18:41:31.592Z", "events": [ { "id": "fund", @@ -21,16 +21,16 @@ "title": "Swap 10 XLM for USDC", "sub": "testnet DEX, strict send", "state": "done", - "amount": "17.2310 USDC" + "amount": "17.0899 USDC" }, { "id": "deploy", "party": "owner", "title": "Deploy the allowance", - "sub": "allowlist: the seller · cap 0.025 USDC per 24h · the agent's key GDZK…325L holds 0.000", + "sub": "allowlist: the seller · cap 0.025 USDC per 24h · the agent's key GD4E…S2P6 holds 0.000", "state": "done", "amount": "0.050 USDC in", - "hash": "CD5DYH7TXU6PXD7EOLKRNOX2J3SQT3H5BKJG7QGUBW3LXWEX7IEPKXGZ" + "hash": "CAUKREBY6F2CAPFSSNAIJWYN2XSOGQ6J5GXZ4BLQ3HIV4NTK7L6FTYNZ" }, { "id": "pay-1", @@ -39,7 +39,7 @@ "amount": "0.010 USDC", "state": "done", "sub": "settled by OpenZeppelin · the payer is the contract", - "hash": "bfec7bdd37c8b111e0d88afe0133e4bb2bbeba0e18500ada8605e886f5d63ecf" + "hash": "bc9edba68669f8df8ebcbe6d77b3506c39202e7702db9b8e8d415714a03f7840" }, { "id": "stranger", @@ -57,7 +57,7 @@ "amount": "0.010 USDC", "state": "done", "sub": "0.020 now in the window", - "hash": "cf6a7a92052cd3d3b0d29fc00f309d610349211707d14c79d12e17b54871f2d1" + "hash": "db5b753df58929ab27f4634c8207f86c3c464541be5a4438c4ad20bffdecdfed" }, { "id": "pay-3", diff --git a/web/lib/demo/live.ts b/web/lib/demo/live.ts index a3257bc..bf65577 100644 --- a/web/lib/demo/live.ts +++ b/web/lib/demo/live.ts @@ -1,4 +1,3 @@ -import { createHash } from 'node:crypto'; import { Asset, BASE_FEE, @@ -15,7 +14,7 @@ import { decodePaymentRequiredHeader, decodePaymentResponseHeader } from '@x402/ import type { PaymentRequirements } from '@x402/core/types'; import { Allowance, AllowanceRefused, generateAllowanceSalt } from '@stellar-allowance/sdk'; import { firstFreeIndex } from '../allowance/index'; -import { DEPOSIT, WINDOW_CAP, WINDOW_LEDGERS } from './params'; +import { DEPOSIT, SELLER_URL, WASM_HASH, WINDOW_CAP, WINDOW_LEDGERS } from './params'; import type { Deps, Wants } from './run'; const NETWORK = Networks.TESTNET; @@ -23,11 +22,6 @@ const RPC_URL = 'https://soroban-testnet.stellar.org'; const HORIZON_URL = 'https://horizon-testnet.stellar.org'; const FRIENDBOT = 'https://friendbot.stellar.org'; const USDC = new Asset('USDC', 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5'); -const SELLER_URL = process.env.SELLER_URL ?? 'https://xlm-quote-api.vercel.app/api/quote'; -const WASM_URL = - process.env.WASM_URL ?? - 'https://github.com/synacktraa/stellar-allowance/releases/download/allowance-contract-v0.2.0/allowance.wasm'; - const server = new rpc.Server(RPC_URL); const horizon = new Horizon.Server(HORIZON_URL); @@ -44,38 +38,6 @@ async function submitClassic(keypair: Keypair, ...operations: xdr.Operation[]) { return horizon.submitTransaction(tx); } -async function pollUntilDone(hash: string) { - for (let attempt = 0; attempt < 30; attempt += 1) { - const result = await server.getTransaction(hash); - if (result.status === 'SUCCESS') return result; - if (result.status === 'FAILED') throw new Error(`transaction ${hash} failed`); - await new Promise((resolve) => setTimeout(resolve, 1000)); - } - throw new Error(`transaction ${hash} did not settle`); -} - -// Uploads the release wasm unless the network already holds it, and returns its hash either way. -async function ensureWasm(owner: Keypair): Promise { - const wasm = Buffer.from(await (await fetch(WASM_URL)).arrayBuffer()); - const hash = createHash('sha256').update(wasm).digest('hex'); - const key = xdr.LedgerKey.contractCode( - new xdr.LedgerKeyContractCode({ hash: Buffer.from(hash, 'hex') }), - ); - if ((await server.getLedgerEntries(key)).entries.length > 0) return hash; - - const account = await server.getAccount(owner.publicKey()); - const tx = new TransactionBuilder(account, { fee: BASE_FEE, networkPassphrase: NETWORK }) - .addOperation(Operation.uploadContractWasm({ wasm })) - .setTimeout(60) - .build(); - const prepared = await server.prepareTransaction(tx); - prepared.sign(owner); - const sent = await server.sendTransaction(prepared); - if (sent.status === 'ERROR') throw new Error('the wasm upload was rejected'); - await pollUntilDone(sent.hash); - return hash; -} - export function liveDeps(): Deps { return { async fund(address) { @@ -116,19 +78,18 @@ export function liveDeps(): Deps { async deploy(owner, agent, wants) { const { index } = await firstFreeIndex(server, owner.publicKey()); - const wasmHash = await ensureWasm(owner); const deployment = await contract.Client.deploy( { setup: { owner: owner.publicKey(), - agent_key: Buffer.from(agent.rawPublicKey()), + agent_key: agent.rawPublicKey(), name: 'Demo agent', spending: { token: wants.asset, initial_deposit: DEPOSIT }, rules: { window_ledgers: WINDOW_LEDGERS, window_cap: WINDOW_CAP, allowlist: [wants.payTo] }, }, }, { - wasmHash, + wasmHash: WASM_HASH, salt: generateAllowanceSalt(owner.publicKey(), index), networkPassphrase: NETWORK, rpcUrl: RPC_URL, diff --git a/web/lib/demo/params.ts b/web/lib/demo/params.ts index 80c45ce..cfb71ab 100644 --- a/web/lib/demo/params.ts +++ b/web/lib/demo/params.ts @@ -20,6 +20,18 @@ export const USDC_ISSUER = 'GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLL // so the ceiling is a product decision rather than a technical one. export const MAX_ALLOWANCES = 60; +/** + * The contract code every allowance is deployed from, already installed on testnet. + * + * Checked by hash rather than fetched. The release asset carries no cross-origin headers, so a + * browser cannot read it, and the hash is what a deployment needs anyway. + */ +export const WASM_HASH = '6077823e41bb7de03da15497b5996894609a3caa26fab363efdf0a0499eeb7c2'; + +/** The x402 API the demo pays. Public, and pointable at a local seller during development. */ +export const SELLER_URL = + process.env.NEXT_PUBLIC_SELLER_URL ?? 'https://xlm-quote-api.vercel.app/api/quote'; + export const usdc = (base: bigint | string | number): string => (Number(base) / 10 ** DECIMALS).toFixed(3); diff --git a/web/package.json b/web/package.json index f89a4c3..98a617d 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,7 @@ "start": "next start", "typecheck": "tsc --noEmit", "test": "tsc -p tsconfig.test.json && node --test test-dist/test/*.test.js", - "bake": "node scripts/bake.mjs" + "bake": "tsc -p tsconfig.test.json && node scripts/bake.mjs" }, "dependencies": { "@stellar-allowance/sdk": "file:../sdk", diff --git a/web/scripts/bake.mjs b/web/scripts/bake.mjs index 64428bf..71cb7bd 100644 --- a/web/scripts/bake.mjs +++ b/web/scripts/bake.mjs @@ -1,25 +1,22 @@ // Records one live run so the page has proof on screen before it has a wait. // -// npm run dev // npm run bake // -// Reads the stream from a running dev server, keeps the final state of every step, and writes +// Drives the same generator the page drives, through the same dependencies, and writes // lib/demo/baked.json. Run it again to replace the run the page opens with. +// +// No server is involved. The demo stopped being a route when it moved into the browser, and +// this is the other caller of the same code. import { writeFileSync } from 'node:fs'; - -const url = process.env.BAKE_URL ?? 'http://localhost:3000/api/demo/run'; -const response = await fetch(url); -if (!response.ok) throw new Error(`${url} answered ${response.status}`); - -const events = (await response.text()) - .split('\n\n') - .filter((block) => block.startsWith('data: ')) - .map((block) => JSON.parse(block.slice('data: '.length))) - .filter((event) => event.id); +import { runDemo } from '../test-dist/lib/demo/run.js'; +import { liveDeps } from '../test-dist/lib/demo/live.js'; const final = new Map(); -for (const event of events) final.set(event.id, event); +for await (const event of runDemo(liveDeps())) { + final.set(event.id, event); + process.stdout.write(`${event.id.padEnd(10)} ${event.state}\n`); +} const run = { at: new Date().toISOString(), events: [...final.values()] }; const out = new URL('../lib/demo/baked.json', import.meta.url);