From 78ed00f7c3c52b2a74efe65e1da15c84581e816b Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Tue, 2 Jun 2026 11:24:01 +0200 Subject: [PATCH] feat: add x402 and mpp example --- .changeset/x402-mpp-example.md | 5 ++ examples/README.md | 1 + examples/x402-mpp/README.md | 87 +++++++++++++++++++++++++++ examples/x402-mpp/package.json | 19 ++++++ examples/x402-mpp/src/app.ts | 101 ++++++++++++++++++++++++++++++++ examples/x402-mpp/src/client.ts | 77 ++++++++++++++++++++++++ examples/x402-mpp/src/server.ts | 32 ++++++++++ examples/x402-mpp/tsconfig.json | 17 ++++++ pnpm-lock.yaml | 24 ++++++++ src/client/Transport.test.ts | 6 +- src/client/Transport.ts | 8 ++- src/x402/client/Exact.test.ts | 33 ++++++++++- src/x402/client/Exact.ts | 28 ++++++++- src/x402/server/EvmCharge.ts | 11 +++- 14 files changed, 439 insertions(+), 10 deletions(-) create mode 100644 .changeset/x402-mpp-example.md create mode 100644 examples/x402-mpp/README.md create mode 100644 examples/x402-mpp/package.json create mode 100644 examples/x402-mpp/src/app.ts create mode 100644 examples/x402-mpp/src/client.ts create mode 100644 examples/x402-mpp/src/server.ts create mode 100644 examples/x402-mpp/tsconfig.json diff --git a/.changeset/x402-mpp-example.md b/.changeset/x402-mpp-example.md new file mode 100644 index 00000000..4f090301 --- /dev/null +++ b/.changeset/x402-mpp-example.md @@ -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. diff --git a/examples/README.md b/examples/README.md index 67b0c846..87a0c1a3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -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 diff --git a/examples/x402-mpp/README.md b/examples/x402-mpp/README.md new file mode 100644 index 00000000..ffc4bb48 --- /dev/null +++ b/examples/x402-mpp/README.md @@ -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`. diff --git a/examples/x402-mpp/package.json b/examples/x402-mpp/package.json new file mode 100644 index 00000000..76d2125c --- /dev/null +++ b/examples/x402-mpp/package.json @@ -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" + } +} diff --git a/examples/x402-mpp/src/app.ts b/examples/x402-mpp/src/app.ts new file mode 100644 index 00000000..bc2a3de7 --- /dev/null +++ b/examples/x402-mpp/src/app.ts @@ -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), + }) +} diff --git a/examples/x402-mpp/src/client.ts b/examples/x402-mpp/src/client.ts new file mode 100644 index 00000000..0b6a1b16 --- /dev/null +++ b/examples/x402-mpp/src/client.ts @@ -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'}`) +} diff --git a/examples/x402-mpp/src/server.ts b/examples/x402-mpp/src/server.ts new file mode 100644 index 00000000..22e0d626 --- /dev/null +++ b/examples/x402-mpp/src/server.ts @@ -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`) diff --git a/examples/x402-mpp/tsconfig.json b/examples/x402-mpp/tsconfig.json new file mode 100644 index 00000000..da5857e9 --- /dev/null +++ b/examples/x402-mpp/tsconfig.json @@ -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/**/*"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 70ac7556..530963e2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,30 @@ importers: specifier: latest version: 8.0.14(@types/node@25.6.2)(esbuild@0.28.0)(tsx@4.21.0)(yaml@2.9.0) + examples/x402-mpp: + dependencies: + '@types/node': + specifier: 25.6.0 + version: 25.6.0 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260323.1 + version: 7.0.0-dev.20260323.1 + hono: + specifier: 4.12.23 + version: 4.12.23 + mppx: + specifier: workspace:* + version: link:../.. + tsx: + specifier: 4.21.0 + version: 4.21.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + viem: + specifier: ^2.51.3 + version: 2.51.3(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + src/stripe/server/internal/html: dependencies: '@stripe/stripe-js': diff --git a/src/client/Transport.test.ts b/src/client/Transport.test.ts index b0a01fc5..c368794b 100644 --- a/src/client/Transport.test.ts +++ b/src/client/Transport.test.ts @@ -130,13 +130,13 @@ describe('http', () => { name: 'multiple x402 accepts', }, { - expectedIds: [challenge.id], - expectedMethods: ['tempo'], + expectedIds: [challenge.id, `${x402_Types.syntheticChallengeIdPrefix}0`], + expectedMethods: ['tempo', x402_Types.paymentMethod], headers: () => ({ 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired(x402PaymentRequired), 'WWW-Authenticate': Challenge.serialize(challenge), }), - name: 'Payment auth challenges when x402 is also present', + name: 'Payment auth and x402 challenges when both are present', }, ])('returns $name', ({ expectedIds, expectedMethods, headers }) => { const transport = Transport.http() diff --git a/src/client/Transport.ts b/src/client/Transport.ts index ec075064..2b384332 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -115,8 +115,12 @@ export function http() { } function paymentRequiredChallenges(response: Response): Challenge.Challenge[] { - if (response.headers.has(paymentAuthChallengeHeader)) return Challenge.fromResponseList(response) - return x402Challenges(response) + return [ + ...(response.headers.has(paymentAuthChallengeHeader) + ? Challenge.fromResponseList(response) + : []), + ...x402Challenges(response), + ] } function x402Challenges(response: Response): Challenge.Challenge[] { diff --git a/src/x402/client/Exact.test.ts b/src/x402/client/Exact.test.ts index 3a1dbfbe..36f92784 100644 --- a/src/x402/client/Exact.test.ts +++ b/src/x402/client/Exact.test.ts @@ -102,6 +102,8 @@ describe('x402 exact credential helper', () => { expect(paymentPayload.accepted.scheme).toBe('exact') expect('authorization' in paymentPayload.payload).toBe(true) if (!('authorization' in paymentPayload.payload)) throw new Error() + expect(paymentPayload.extensions?.mppx?.info.method).toBe('GET') + expect(paymentPayload.extensions?.mppx?.info.nonce).toEqual(expect.any(String)) expect(paymentPayload.payload.authorization.nonce).toBe( RouteBinding.nonce({ accepted: paymentPayload.accepted, @@ -111,6 +113,35 @@ describe('x402 exact credential helper', () => { ) expect(paymentPayload.payload.signature).toBe('0x1234') }) + + test('uses a fresh route-bound nonce for repeated payments', async () => { + const config = { + account, + currencies: [usdc], + maxAmount: '0.01', + networks: [Chains.baseSepolia], + } as const + + const first = Header.decodePaymentSignature( + await createCredential({ + challenge: challenge(), + config, + context: {}, + }), + ) + const second = Header.decodePaymentSignature( + await createCredential({ + challenge: challenge(), + config, + context: {}, + }), + ) + + expect(first.extensions?.mppx?.info.nonce).not.toBe(second.extensions?.mppx?.info.nonce) + if (!('authorization' in first.payload) || !('authorization' in second.payload)) + throw new Error() + expect(first.payload.authorization.nonce).not.toBe(second.payload.authorization.nonce) + }) }) function challenge(overrides: Partial = {}): X402Challenge { @@ -136,7 +167,7 @@ function challenge(overrides: Partial = {}): X402Chal info: { method: 'GET' }, schema: { additionalProperties: false, - properties: { method: { type: 'string' } }, + properties: { method: { type: 'string' }, nonce: { type: 'string' } }, required: ['method'], type: 'object', }, diff --git a/src/x402/client/Exact.ts b/src/x402/client/Exact.ts index 7b563932..8e854e5a 100644 --- a/src/x402/client/Exact.ts +++ b/src/x402/client/Exact.ts @@ -19,6 +19,7 @@ export async function createCredential(parameters: createCredential.Parameters): assertPolicy(parameters.config, accepted) if (!request.resource || !request.extensions?.mppx) throw new Error('x402 exact EIP-3009 requires route binding.') + const extensions = withNonceSalt(request.extensions) const transferMethod = accepted.extra?.assetTransferMethod ?? 'eip3009' if (transferMethod !== 'eip3009') throw new Error(`x402 exact ${String(transferMethod)} signing is not implemented yet.`) @@ -33,7 +34,7 @@ export async function createCredential(parameters: createCredential.Parameters): from: getAddress(account.address), nonce: RouteBinding.nonce({ accepted, - extensions: request.extensions, + extensions, resource: request.resource, }), to: getAddress(accepted.payTo), @@ -60,7 +61,7 @@ export async function createCredential(parameters: createCredential.Parameters): return Header.encodePaymentSignature({ accepted, - ...(request.extensions ? { extensions: request.extensions } : {}), + extensions, payload: { authorization, signature, @@ -173,3 +174,26 @@ function decimalsOfAcceptedCurrency( if (currency && Assets.isAsset(currency)) return currency.decimals return parameters.decimals } + +function withNonceSalt(extensions: Types.Extensions): Types.Extensions { + const mppx = extensions.mppx + if (!mppx) return extensions + return { + ...extensions, + mppx: { + ...mppx, + info: { + ...mppx.info, + nonce: randomNonceSalt(), + }, + }, + } +} + +function randomNonceSalt(): string { + const crypto = globalThis.crypto + if (crypto?.randomUUID) return crypto.randomUUID() + if (!crypto?.getRandomValues) throw new Error('x402 exact requires crypto randomness.') + const bytes = crypto.getRandomValues(new Uint8Array(16)) + return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('') +} diff --git a/src/x402/server/EvmCharge.ts b/src/x402/server/EvmCharge.ts index 95a21ea4..d027dba5 100644 --- a/src/x402/server/EvmCharge.ts +++ b/src/x402/server/EvmCharge.ts @@ -26,6 +26,7 @@ const mppxRouteBindingSchema = { [Scope.reservedMetaKey]: { type: 'string' }, digest: { type: 'string' }, method: { type: 'string' }, + nonce: { type: 'string' }, opaque: { type: 'string' }, }, required: ['method'], @@ -124,7 +125,7 @@ export function createPath(config: ResolvedOptions): Path { const payload = payloadToAuthorization(paymentPayload) const expectedNonce = x402_RouteBinding.nonce({ accepted: paymentRequirements, - extensions: expectedExtensions, + extensions: paymentPayload.extensions!, resource: expectedResource, }) if (payload.nonce !== expectedNonce) @@ -341,11 +342,17 @@ function containsExtensions( return ( actualExtension !== undefined && isDeepStrictEqual(actualExtension.schema, expectedExtension.schema) && - isDeepStrictEqual(actualExtension.info, expectedExtension.info) + isDeepStrictEqual(stripClientNonce(actualExtension.info), expectedExtension.info) ) }) } +function stripClientNonce(info: Record): Record { + const { nonce, ...rest } = info + if (nonce !== undefined && typeof nonce !== 'string') return info + return rest +} + async function assertBodyDigest(challenge: Challenge.Challenge, input: Request): Promise { if (input.body === null) return if (challenge.digest === undefined)