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) }); +} 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);