@rail402.dev/sdk/browser), with the same spending policy, signing guard and budget ledger as the
Node SDK. The server (apps/playground/src) serves the page, relays seller requests the browser cannot
make itself, proxies the Bazaar and the facilitator’s /supported, and runs a few lab sellers. The server
holds no secret and stores nothing.
The wallet
- Storage. The seed is encrypted with AES-GCM under a 256-bit key generated with
extractable: false, and both are kept in the browser’s IndexedDB (databaserail402-playground). The record is bound to the wallet’s address and network, so a tampered or swapped record is refused instead of loaded. The seed is decrypted once per page load into the signer and never reaches the page’s state, events, storage, logs or text. A browser without IndexedDB or WebCrypto keeps the wallet in the tab’s memory only and says so (playground_wallet_storage_unavailable). - One tab. The wallet is held through a Web Lock; another tab of the same origin is read-only
(
playground_wallet_locked) until the first lets go, so two tabs cannot spend one budget twice. - Export and forget. The key leaves the page only as a downloaded key file, after a confirmation that names
the testnet; it is never shown on screen or copied to the clipboard. A browser saves the file readable by
others, which the CLI, the SDK and the MCP server refuse (
key_file_insecure):rail402 keys import < <file>makes it the default testnet key (mode 0600, at the path the page’s code uses), after which the download can be deleted; orchmod 600 <file>before pointingRAIL402_SECRET_KEY_FILEat it. Forgetting deletes the wallet and its budget ledger.
What the key can sign
The setup signer is bound to the configured asset: it signs a transaction from the wallet’s own account with
exactly one of these two operations and a fee of at most 100,000 stroops, and refuses any other (removing or
capping a trustline, another asset, several operations). There is no other signing path.
Caps and the budget
- A per-call cap is required before a run starts: 0.01 USDC by default, and at most the operator’s ceiling of 0.10 USDC (a higher value is shown clamped). Exceeding it is refused at option selection, before anything is signed, and again by the guard on the exact amount.
- The session budget is 1.00 USDC per 24-hour window. The page can only lower it.
- The budget ledger is kept in the browser’s localStorage, so a reload keeps what was spent and what is held.
A corrupt ledger is refused (
payment_ledger_unavailable) until it is reset, never silently emptied. - A payment whose outcome is still open (a held
uptocap, a payment whose answer was lost) is checked against the chain every 20 seconds, when the tab regains focus and on load, and released or settled from on-chain proof. The wallet panel can ask for that check at once. - One run at a time: a second one is refused with
playground_run_in_progress. Each run’s payments use the idempotency keypg_<run id>.
The relay
Rail402 and most sellers send no CORS headers, so the page cannot call them itself. Seller requests go through the server’s relay,POST /api/relay, which sends exactly what a stock client would and returns the seller’s
answer:
- Other sites cannot use it through their visitors’ browsers: the request needs the relay’s own headers, and a
browser’s
Sec-Fetch-Sitemust besame-originand itsOriginthe Playground’s own (playground_origin_not_allowed). The relay answers no CORS headers. A client that is not a browser sends neither header and can use the relay as anhttpsegress to public hosts, bounded only by the limits below. - It sends with
@rail402.dev/safe-fetch:httpson port 443 to public addresses only, DNS answers checked and pinned, redirects refused except same-origin ones (at most 3) for unpaid probes, a request body of at most 64 KiB and an answer of at most 1 MiB, and the SDK’s timeouts. A request without a payment waits no longer than the SDK’s probe timeouts (15 s for the headers, 60 s in total) unless it is an event stream. A body is forwarded only asapplication/json,text/plainorapplication/octet-stream, the types the SDK and MCP send. A connection to a host other than the demo seller is closed after its answer. - It does not call, by host name, the Playground itself (its public host and the platform’s generated domain),
the facilitator or the Bazaar (those are read through the server’s fixed proxies), or a host the operator
denies. Another name for one of these hosts is not recognized unless the operator lists it in
RELAY_DENY_HOSTS. - Only allowlisted headers pass, both ways:
PAYMENT-SIGNATURE, the MCP session headers,acceptandcontent-typego out;PAYMENT-REQUIRED,PAYMENT-RESPONSE,EXTENSION-RESPONSES,content-typeand a few others come back. Cookies,authorizationandlocationnever pass. - A request is paid when it carries
PAYMENT-SIGNATURE, or when its JSON-RPC body is an MCP tool call with a payment inparams._meta["x402/payment"]. It holds a payment in memory for one request only. Its logs record the host, the status and the outcome code, never the path, query, headers or body.
/supported proxies are on the page’s origin, so they need
no relay. RPC, Horizon and Friendbot are called by the page directly; the page’s content security policy
allows exactly those origins.
Limits
Every limit is per client address (IPv6 by its /56) unless it says otherwise, and every value is a setting. A limit “for all clients” is fair-shared: one network (an IPv4 address or an IPv6 /48) gets at most an eighth of it, or its per-client limit when that is larger. A refusal carries a code andRetry-After. A payment counts against the paid limits only when it decodes as an x402
payment; anything else is refused with playground_request_invalid before any limit counts it. That check stops
only malformed payments: anyone can write a payment that merely has the right shape, and it is then counted as
paid. So the demo seller has a paid pool of its own, a payment refused before it reached a seller (or, at the
lab sellers, before the facilitator was asked) gives the pool every visitor shares its token back, and a client
spends its own paid limit either way. The Bazaar and /supported proxies refuse another site’s requests the
same way the relay does.
RELAY_ENABLED, LAB_ENABLED and SELLER_CHECK_ENABLED switch each feature off
(playground_feature_disabled). The page then shows the scenarios, experiments and checks that need it as
turned off, with that code, instead of offering them.
The lab sellers
The server runs three kinds of sellers of its own, with the stock@x402/hono middleware and the configured
facilitator. They are never part of the public demo seller.
- The sandbox route,
GET /lab/sell/forecast/:city:exact, 0.01 USDC, with Bazaar discovery metadata, paid to the operator’s account (PLAYGROUND_PAY_TO). Its 402 is the same for every city and every visitor, so it is one listing, and Rail402’s origin check always agrees with it; the example in its discovery metadata is always the declared city, never the one a request names. A path that is not a city name (letters, spaces, dots, apostrophes and hyphens, up to 40) gets a coded 404 instead of a 402. The server’s/.well-known/stellar.tomlnames that account. - The greedy route,
GET /lab/greedy/history:uptowith a 0.01 USDC cap and a one-minute authorization, which asks the facilitator to settle 0.02. Rail402 refuses it (invalid_upto_stellar_payload_settlement_exceeds_amount) and nothing moves; the held cap returns to the budget once the authorization expires. - Offers,
/lab/offers/{pubnet,asset,zero}: 402 answers only, asking for payment onstellar:pubnet, in a token outside the policy, or for 0. They never verify or settle.
Rail402-Trace header with what the facilitator answered (verify and settle
outcomes and codes, the settled and requested amounts, the catalog’s outcome) and a Server-Timing header.
The trace never holds a payment payload.
What a row is based on
Each swimlane row and inspector field is tagged with its source, and claims no more than that source shows:
A seller that sends no trace (the demo seller) does not report its calls to Rail402, so that row says what the
PAYMENT-RESPONSE and the chain show instead. The inspector never shows XDR, signatures, preimages or payment
headers.
What the server sees
- Its configuration has no field for a secret, and it refuses to start when any environment value is a Stellar
secret seed (
playground_config_invalid). - It sees the payment headers it relays, for the length of one request, as any seller would. It sees no key.
- It sends no telemetry. Its logs are JSON lines with the route, status, duration, outcome code and a daily salted hash of the client address.
- The page is served with
default-src 'self'andscript-src 'self', no inline script, no third-party code,frame-ancestors 'none', andconnect-srclimited to the server and the configured RPC, Horizon and Friendbot. Seller text is rendered as text, never as markup.