Skip to content
Draft
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
5 changes: 5 additions & 0 deletions example-x402/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Stripe — fresh crypto deposit addresses are minted via Stripe PaymentIntents
STRIPE_SECRET_KEY=sk_test_...

# Optional — defaults to https://www.x402.org/facilitator
FACILITATOR_URL=https://www.x402.org/facilitator
4 changes: 4 additions & 0 deletions example-x402/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
.env
.env.local
76 changes: 76 additions & 0 deletions example-x402/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
# example-x402

A spiceflow app that protects a single API route (`GET /paid`) with the
[x402](https://www.x402.org) payment protocol. Each request costs **$0.01**,
settled on **Base Sepolia** in USDC. Fresh deposit addresses are minted via
Stripe PaymentIntents, mirroring [stripe-samples/machine-payments](https://github.com/stripe-samples/machine-payments).

## Shape

```
app (root Spiceflow)
├── serveStatic('/public')
├── layout '/*'
├── page '/' ← RSC page with a <PaidData /> client comp
└── use(paidApp)
paidApp (Spiceflow sub-app)
├── use(x402({ price, network, payTo })) ← only runs for paidApp routes
└── get '/paid' → () => ({ foo: 'bar' })
```

Spiceflow scopes middleware by **sub-app ownership**, not by path. Mounting
`paidApp` via `app.use(paidApp)` means the x402 handshake only runs for routes
defined on `paidApp`. Anything on the parent app (`/`, static files, RSC
pages) is untouched.

## Why a custom middleware instead of `@x402/hono`

`@x402/hono`'s `paymentMiddleware` is ~380 lines and wraps a ~1000-line
`x402HTTPResourceServer`. It carries its own route matcher, HTML paywall
generator, Hono context adapter, bazaar extension loader, and settlement
overrides header. Spiceflow already has a trie router, so a bespoke ~180-line
middleware at [`src/x402-middleware.ts`](./src/x402-middleware.ts) is enough.
It only imports from `@x402/core/server`, `@x402/core/http`, `@x402/core/types`,
and `@x402/evm/exact/server`.

## Setup

```bash
cp .env.example .env
# fill in STRIPE_SECRET_KEY
pnpm install
pnpm dev
```

Open <http://localhost:3000> and click **Call /paid** to see the 402 challenge,
or hit the route directly with curl:

```bash
curl -i http://localhost:3000/paid
# HTTP/1.1 402 Payment Required
# www-authenticate: x402 scheme="exact"
# content-type: application/json
#
# {"x402Version":2,"error":"Payment required","accepts":[...]}
```

A real x402 client would sign an on-chain payment to the `payTo` address in
the challenge and retry with an `x-payment` header. See
[x402.org](https://www.x402.org) for client libraries.

## Environment

| Variable | Required | Default |
|---------------------|----------|-------------------------------------------|
| `STRIPE_SECRET_KEY` | yes | — |
| `FACILITATOR_URL` | no | `https://www.x402.org/facilitator` |
| `PORT` | no | `3000` |

## Tests

```bash
pnpm test
```

The middleware unit tests inject a fake `x402ResourceServer` via the
undocumented `server` option on `x402()`. No network, no mocks.
32 changes: 32 additions & 0 deletions example-x402/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
{
"name": "example-x402",
"private": true,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node dist/rsc/index.js",
"test": "vitest --run"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"@types/react": "19.2.14",
"@types/react-dom": "19.2.3",
"@x402/core": "^2.9.0",
"@x402/evm": "^2.9.0",
"dotenv": "^17.4.0",
"errore": "^0.14.0",
"node-cache": "^5.1.2",
"react": "19.2.4",
"react-dom": "19.2.4",
"spiceflow": "workspace:^",
"stripe": "^21.0.1",
"tailwindcss": "4.0.6",
"typescript": "5.7.3",
"vite": "^8.0.8"
},
"devDependencies": {
"@vitejs/plugin-react": "^6.0.1",
"vitest": "^4.1.4"
}
}
Binary file added example-x402/public/favicon.ico
Binary file not shown.
19 changes: 19 additions & 0 deletions example-x402/src/globals.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
@import 'tailwindcss';

:root {
--foreground-rgb: 0, 0, 0;
--background-rgb: 255, 255, 255;
}

@media (prefers-color-scheme: dark) {
:root {
--foreground-rgb: 255, 255, 255;
--background-rgb: 10, 10, 10;
}
}

body {
color: rgb(var(--foreground-rgb));
background: rgb(var(--background-rgb));
font-family: system-ui, -apple-system, sans-serif;
}
87 changes: 87 additions & 0 deletions example-x402/src/main.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
// Spiceflow + x402 example.
//
// Shape:
// app (root)
// ├── serveStatic('/public')
// ├── layout '/*' — RSC layout
// ├── page '/' — RSC page with a <PaidData /> client component
// └── use(paidApp)
// paidApp (sub-app)
// ├── use(x402({...})) ← only applies to routes on paidApp
// └── get '/paid' → () => ({ foo: 'bar' })
//
// Request flow:
// GET / → rootApp.page('/'), no x402 middleware in chain, renders HTML
// GET /paid → paidApp.get('/paid'), x402 middleware enforces payment
import './globals.css'
import { Spiceflow, serveStatic } from 'spiceflow'
import { Head, Link } from 'spiceflow/react'
import Stripe from 'stripe'
import { x402, stripeDepositAddress } from './x402-middleware.js'
import { PaidData } from './paid-data.js'

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY ?? 'sk_test_placeholder')

// Sub-app that owns every x402-protected route. Middleware registered on
// paidApp only runs for routes defined on paidApp (see
// getAppsInScope in spiceflow.tsx), so `/` below is untouched.
export const paidApp = new Spiceflow()
.use(
x402({
price: '$0.01',
network: 'eip155:84532', // Base Sepolia
facilitatorUrl:
process.env.FACILITATOR_URL ?? 'https://www.x402.org/facilitator',
payTo: stripeDepositAddress({ stripe, network: 'base' }),
}),
)
.get('/paid', () => ({ foo: 'bar' }))

export const app = new Spiceflow()
.use(serveStatic({ root: './public' }))
.use(paidApp)
.layout('/*', ({ children }) => (
<RootLayout>{children}</RootLayout>
))
.page('/', function Home() {
return (
<main className="flex flex-col gap-6 p-8 max-w-2xl mx-auto">
<h1 className="text-3xl font-bold">x402 + spiceflow</h1>
<p className="text-gray-700 dark:text-gray-300">
<code className="font-mono">/paid</code> is protected by the x402
payment protocol. Each call costs <strong>$0.01</strong>, settled on
Base Sepolia, with a fresh deposit address minted via Stripe.
</p>
<PaidData />
<div className="flex flex-col gap-2 text-sm text-gray-600 dark:text-gray-400">
<p>
Hit it directly to see the 402 challenge:{' '}
<Link href="/paid" className="underline">
/paid
</Link>
</p>
<p>
Or from a shell:{' '}
<code className="font-mono">curl -i http://localhost:3000/paid</code>
</p>
</div>
</main>
)
})

function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<Head>
<Head.Title>x402 + spiceflow</Head.Title>
</Head>
<body>{children}</body>
</html>
)
}

void app.listen(Number(process.env.PORT || 3000))

declare module 'spiceflow/react' {
interface SpiceflowRegister { app: typeof app }
}
90 changes: 90 additions & 0 deletions example-x402/src/paid-data.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
'use client'

// Client component that demonstrates the x402 handshake from the browser.
// The first fetch gets a 402 with the payment challenge; the real "agent"
// flow would then sign an on-chain payment and retry with an x-payment
// header. We just show the challenge body so you can see it.
import { useState } from 'react'

interface Challenge {
x402Version: number
error: string
accepts: Array<{
scheme: string
network: string
payTo: string
amount: string
asset?: string
}>
}

export function PaidData() {
const [state, setState] = useState<
| { kind: 'idle' }
| { kind: 'loading' }
| { kind: 'challenge'; status: number; body: Challenge }
| { kind: 'paid'; body: unknown }
| { kind: 'error'; message: string }
>({ kind: 'idle' })

return (
<div className="flex flex-col gap-3 p-4 border border-gray-300 dark:border-gray-700 rounded">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
setState({ kind: 'loading' })
fetch('/paid')
.then(async (res) => {
const body = await res.json().catch(() => null)
if (res.status === 402) {
setState({ kind: 'challenge', status: res.status, body })
return
}
if (res.ok) {
setState({ kind: 'paid', body })
return
}
setState({
kind: 'error',
message: `HTTP ${res.status}: ${JSON.stringify(body)}`,
})
})
.catch((err) => {
setState({ kind: 'error', message: String(err) })
})
}}
className="px-3 py-1.5 text-sm font-medium bg-black text-white dark:bg-white dark:text-black rounded"
>
Call /paid
</button>
<span className="text-xs text-gray-500">status: {state.kind}</span>
</div>

{state.kind === 'challenge' && (
<div className="flex flex-col gap-1 text-xs">
<span className="font-semibold">
{state.status} Payment Required — {state.body.error}
</span>
<pre className="p-3 bg-gray-100 dark:bg-gray-900 overflow-x-auto rounded">
{JSON.stringify(state.body.accepts, null, 2)}
</pre>
<p className="text-gray-500">
An x402 client would sign an on-chain payment to{' '}
<code>payTo</code> and retry with an <code>x-payment</code> header.
</p>
</div>
)}

{state.kind === 'paid' && (
<pre className="p-3 text-xs bg-gray-100 dark:bg-gray-900 rounded">
{JSON.stringify(state.body, null, 2)}
</pre>
)}

{state.kind === 'error' && (
<span className="text-xs text-red-600">{state.message}</span>
)}
</div>
)
}
Loading