Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 38 additions & 1 deletion demo/seller/app/api/quote/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ const handler = async (_request: NextRequest): Promise<NextResponse<Quote | Unav

// withX402 rather than paymentProxy: it settles only after the handler returns under 400, so
// the 503 above costs the caller nothing.
export const GET = withX402(
const paid = withX402(
handler,
{
'/api/quote': {
Expand All @@ -76,3 +76,40 @@ export const GET = withX402(
},
server,
);

/**
* What a browser has to be told before it will let a page read this.
*
* x402 carries everything that matters in headers, and a browser hides those from script
* unless they are named here: the terms arrive on the 402 and the receipt on the paid 200, so
* a page without `Expose-Headers` gets a response it can see and terms it cannot read. The
* request carries a custom header too, which makes it preflighted rather than simple.
*
* Any origin, because the terms are public. Anyone can already read them with curl, and an
* x402 seller exists to be found and paid.
*/
const EXPOSED = 'PAYMENT-REQUIRED, PAYMENT-RESPONSE, X-PAYMENT-RESPONSE';

function allow(request: NextRequest): Record<string, string> {
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<Response> {
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) });
}
21 changes: 17 additions & 4 deletions web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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

Expand Down
36 changes: 0 additions & 36 deletions web/app/api/demo/run/route.ts

This file was deleted.

40 changes: 22 additions & 18 deletions web/app/run.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ const VERB: Record<string, string> = {
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}`;
Expand All @@ -31,23 +31,27 @@ export function Run({ baked, at }: { baked: DemoEvent[]; at: string }) {
const [live, setLive] = useState<Live>('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 (
Expand Down Expand Up @@ -94,12 +98,12 @@ export function Run({ baked, at }: { baked: DemoEvent[]; at: string }) {
</div>

<div className="act">
<button className="cta" onClick={start} disabled={live === 'running'}>
{live === 'running' ? 'Running on testnet' : 'Run it live'}
<button className="cta" onClick={() => void start()} disabled={live === 'running'}>
{live === 'running' ? 'Running on testnet' : live === 'idle' ? 'Run it live' : 'Run it again'}
</button>
<span className="note-inline">
{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'}
</span>
</div>
Expand Down
7 changes: 1 addition & 6 deletions web/lib/dashboard/write.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
12 changes: 6 additions & 6 deletions web/lib/demo/baked.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"at": "2026-09-08T18:07:10.581Z",
"at": "2026-09-09T18:41:31.592Z",
"events": [
{
"id": "fund",
Expand All @@ -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",
Expand All @@ -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",
Expand All @@ -57,7 +57,7 @@
"amount": "0.010 USDC",
"state": "done",
"sub": "0.020 now in the window",
"hash": "cf6a7a92052cd3d3b0d29fc00f309d610349211707d14c79d12e17b54871f2d1"
"hash": "db5b753df58929ab27f4634c8207f86c3c464541be5a4438c4ad20bffdecdfed"
},
{
"id": "pay-3",
Expand Down
45 changes: 3 additions & 42 deletions web/lib/demo/live.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { createHash } from 'node:crypto';
import {
Asset,
BASE_FEE,
Expand All @@ -15,19 +14,14 @@ 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;
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);

Expand All @@ -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<string> {
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) {
Expand Down Expand Up @@ -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,
Expand Down
12 changes: 12 additions & 0 deletions web/lib/demo/params.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
2 changes: 1 addition & 1 deletion web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading