Skip to content
Closed
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 .changeset/x402-mpp-example.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added an x402 and mpp example server/client, fixed HTTP clients to parse x402 offers when Payment-auth challenges were also present, and fixed repeated x402 EIP-3009 payments for live facilitators.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ Standalone, runnable examples demonstrating the mppx HTTP 402 payment flow.
| [session/ws](./session/ws/) | Pay-per-token LLM streaming with WebSocket |
| [stripe](./stripe/) | Stripe SPT charge with automatic client |
| [subscription](./subscription/) | Daily news subscription using Tempo access keys |
| [x402-mpp](./x402-mpp/) | Server route supporting x402 and mpp payments |

## Running Examples

Expand Down
87 changes: 87 additions & 0 deletions examples/x402-mpp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
# x402 + mpp

A Hono server that serves mpp, x402, and composed mpp-or-x402 payment routes from one process.

```bash
npx gitpick wevm/mppx/examples/x402-mpp
pnpm i
pnpm dev
```

## Routes

| Route | Protocols |
| ------------- | ---------------- |
| `/api/mpp` | mpp |
| `/api/x402` | x402 exact |
| `/api/paid` | mpp or x402 |
| `/api/health` | free healthcheck |

The x402 route defaults to the free no-key `https://facilitator.x402.rs` testnet facilitator.
Set `X402_FACILITATOR_URL` to use another facilitator.

No-key facilitators that currently advertise Base Sepolia v2 exact support:

| Facilitator URL | Notes |
| --------------------------------- | ------------------------------------- |
| `https://facilitator.x402.rs` | Default free x402.rs test facilitator |
| `https://x402.org/facilitator` | Free x402 test facilitator |
| `https://pay.openfacilitator.io` | Hosted OpenFacilitator endpoint |
| `https://facilitator.openx402.ai` | Hosted OpenX402 endpoint |

## Test both clients

With the server running:

```bash
MPP_PRIVATE_KEY=0x... X402_PRIVATE_KEY=0x... pnpm client
```

`MPP_PRIVATE_KEY` is optional for mpp-only runs. When it is omitted, the client creates a Tempo
testnet account and funds it from the public faucet. For x402 runs, set `MPP_PRIVATE_KEY` or
`X402_PRIVATE_KEY`, then fund the derived address with Base Sepolia USDC from
[Circle's public testnet faucet](https://faucet.circle.com/). `X402_PRIVATE_KEY` overrides
`MPP_PRIVATE_KEY` when both are set.

The client calls `/api/mpp`, `/api/x402`, then calls `/api/paid` once through mpp and once through x402.
Set `FLOW=mpp` or `FLOW=x402` to run one protocol path at a time:

```bash
FLOW=mpp pnpm client
FLOW=x402 X402_PRIVATE_KEY=0x... pnpm client
```

## Inspect x402

Inspect x402 requirements without paying:

```bash
curl -i http://localhost:5173/api/x402
curl -i http://localhost:5173/api/paid
```

## Test with purl

Install the current purl CLI:

```bash
brew install stripe/purl/purl
```

`purl v0.2.7` can inspect both Payment-auth and x402 headers:

```bash
purl inspect http://localhost:5173/api/mpp
purl inspect http://localhost:5173/api/x402
purl inspect http://localhost:5173/api/paid
```

The composed route can be exercised with an EVM key:

```bash
purl --private-key 0x... http://localhost:5173/api/paid
```

purl releases before [stripe/purl#102](https://github.com/stripe/purl/pull/102) may select the
Payment-auth EVM challenge before the x402 challenge on `/api/x402` or `/api/paid`, then exit
with `EVM provider expects x402 PaymentRequirements`.
19 changes: 19 additions & 0 deletions examples/x402-mpp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"name": "x402-mpp",
"private": true,
"type": "module",
"scripts": {
"check:types": "tsgo -p tsconfig.json",
"client": "tsx src/client.ts",
"dev": "tsx src/server.ts"
},
"dependencies": {
"@types/node": "25.6.0",
"@typescript/native-preview": "7.0.0-dev.20260323.1",
"hono": "4.12.23",
"mppx": "latest",
"tsx": "4.21.0",
"typescript": "~6.0.3",
"viem": "2.51.3"
}
}
101 changes: 101 additions & 0 deletions examples/x402-mpp/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { Hono } from 'hono'
import { Mppx, evm, tempo } from 'mppx/server'
import type { Facilitator } from 'mppx/x402'
import type { Account, Client } from 'viem'
import { createClient, http } from 'viem'
import { Chain } from 'viem/tempo'

const currency = '0x20c0000000000000000000000000000000000000' as const // pathUSD

export type AppOptions = {
account: Account
facilitator?: string | Facilitator | undefined
getTempoClient?: (() => Client) | undefined
recipient?: `0x${string}` | undefined
secretKey?: string | undefined
}

/** Creates the example server with mpp, x402, and composed payment routes. */
export function createApp(options: AppOptions) {
const recipient = options.recipient ?? options.account.address
const facilitator =
options.facilitator ?? process.env.X402_FACILITATOR_URL ?? 'https://facilitator.x402.rs'
const getTempoClient = options.getTempoClient ?? (() => createTempoClient())

const payments = Mppx.create({
methods: [
tempo.charge({
account: options.account,
currency,
feePayer: true,
getClient: getTempoClient,
recipient,
testnet: true,
}),
evm.charge({
currency: evm.assets.baseSepolia.USDC,
recipient,
x402: { facilitator },
}),
],
secretKey: options.secretKey ?? 'x402-mpp-example',
})

const paid = payments.compose(
[
payments.tempo.charge,
{
amount: '0.01',
chainId: Chain.testnet.id,
description: 'Composed mpp payment',
},
],
[
payments.evm.charge,
{
amount: '0.01',
description: 'Composed x402 payment',
},
],
)

const app = new Hono()

app.get('/api/health', (c) => c.json({ status: 'ok' }))

app.get('/api/mpp', async (c) => {
const result = await payments.tempo.charge({
amount: '0.01',
chainId: Chain.testnet.id,
description: 'MPP-only payment',
})(c.req.raw)
if (result.status === 402) return result.challenge
return result.withReceipt(c.json({ data: 'paid with mpp' }))
})

app.get('/api/x402', async (c) => {
const result = await payments.evm.charge({
amount: '0.01',
description: 'x402-only payment',
})(c.req.raw)
if (result.status === 402) return result.challenge
return result.withReceipt(c.json({ data: 'paid with x402' }))
})

app.get('/api/paid', async (c) => {
const result = await paid(c.req.raw)
if (result.status === 402) return result.challenge
return result.withReceipt(c.json({ data: 'paid with mpp or x402' }))
})

return app
}

/** Creates the Tempo testnet client used by the example server. */
export function createTempoClient(): Client {
return createClient({
chain: Chain.testnet,
pollingInterval: 1_000,
transport: http(process.env.MPPX_RPC_URL),
})
}
77 changes: 77 additions & 0 deletions examples/x402-mpp/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { Mppx, evm, tempo } from 'mppx/client'
import { createClient, http } from 'viem'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { Actions, Chain } from 'viem/tempo'

const baseUrl = process.env.BASE_URL ?? 'http://localhost:5173'
const flow = process.env.FLOW ?? 'all'
const mppPrivateKey = (process.env.MPP_PRIVATE_KEY ?? generatePrivateKey()) as `0x${string}`
const configuredX402PrivateKey = (process.env.X402_PRIVATE_KEY ?? process.env.MPP_PRIVATE_KEY) as
| `0x${string}`
| undefined

if (!['all', 'mpp', 'x402'].includes(flow)) throw new Error('FLOW must be all, mpp, or x402.')
if (flow !== 'mpp' && !configuredX402PrivateKey) {
throw new Error('Set MPP_PRIVATE_KEY or X402_PRIVATE_KEY, then fund it with Base Sepolia USDC.')
}

const tempoClient = createClient({
chain: Chain.testnet,
pollingInterval: 1_000,
transport: http(process.env.MPPX_RPC_URL),
})
const mppAccount = privateKeyToAccount(mppPrivateKey)
const x402Account = privateKeyToAccount(configuredX402PrivateKey ?? mppPrivateKey)

if (flow !== 'x402') {
console.log('Funding mpp account from Tempo testnet faucet...')
await Actions.faucet.fundSync(tempoClient, { account: mppAccount, timeout: 30_000 })
}

if (flow !== 'mpp') {
console.log(`x402 account: ${x402Account.address}`)
console.log('Fund this address with Base Sepolia USDC at https://faucet.circle.com/')
}

const mpp = Mppx.create({
methods: [
tempo.charge({
account: mppAccount,
getClient: () => tempoClient,
}),
],
polyfill: false,
})

const x402 = Mppx.create({
methods: [
evm.charge({
account: x402Account,
currencies: [evm.assets.baseSepolia.USDC],
maxAmount: '0.01',
networks: [84532],
}),
],
orderChallenges: (candidates) =>
candidates.filter(({ challenge }) => challenge.request.scheme === 'exact'),
polyfill: false,
})

if (flow !== 'x402') {
await fetchPaid('mpp route', mpp, '/api/mpp')
await fetchPaid('composed route via mpp', mpp, '/api/paid')
}

if (flow !== 'mpp') {
await fetchPaid('x402 route', x402, '/api/x402')
await fetchPaid('composed route via x402', x402, '/api/paid')
}

async function fetchPaid(label: string, payments: typeof mpp | typeof x402, path: string) {
const response = await payments.fetch(`${baseUrl}${path}`)
const receipt =
response.headers.get('Payment-Receipt') ?? response.headers.get('PAYMENT-RESPONSE')
if (!response.ok) throw new Error(`${label} failed: ${response.status} ${await response.text()}`)
console.log(`${label}: ${await response.text()}`)
console.log(`${label} receipt: ${receipt ? 'yes' : 'no'}`)
}
32 changes: 32 additions & 0 deletions examples/x402-mpp/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { createServer } from 'node:http'

import { NodeListener, Request as ServerRequest } from 'mppx/server'
import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts'
import { Actions } from 'viem/tempo'

import { createApp, createTempoClient } from './app.js'

const port = Number(process.env.PORT ?? 5173)
const privateKey = process.env.MPPX_PRIVATE_KEY as `0x${string}` | undefined
const account = privateKeyToAccount(privateKey ?? generatePrivateKey())
const tempoClient = createTempoClient()

if (process.env.MPPX_SKIP_FAUCET !== 'true') await Actions.faucet.fundSync(tempoClient, { account })

const app = createApp({
account,
getTempoClient: () => tempoClient,
})

const server = createServer(async (req, res) => {
const request = ServerRequest.fromNodeListener(req, res)
const response = await app.fetch(request)
return NodeListener.sendResponse(res, response)
})

server.listen(port)

console.log(`x402 + mpp example listening on http://localhost:${port}`)
console.log(`mpp route: pnpm mppx http://localhost:${port}/api/mpp`)
console.log(`x402 route: curl -i http://localhost:${port}/api/x402`)
console.log(`composed route: http://localhost:${port}/api/paid`)
17 changes: 17 additions & 0 deletions examples/x402-mpp/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"types": ["node"],
"noEmit": true,
"paths": {
"mppx": ["../../src/index.ts"],
"mppx/*": ["../../src/*/index.ts"]
}
},
"include": ["src/**/*"]
}
24 changes: 24 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading