From 3ef950111d6560b34a36230547133620fb64c336 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 11:38:25 -0700 Subject: [PATCH 01/14] feat: add x402 exact support --- .changeset/x402-exact-types.md | 5 + README.md | 69 +++++++++ package.json | 15 ++ src/client/Methods.ts | 1 + src/client/Mppx.ts | 1 + src/client/index.ts | 1 + src/client/internal/Fetch.ts | 37 +++-- src/index.ts | 1 + src/middlewares/elysia.test.ts | 25 +++ src/middlewares/elysia.ts | 3 +- src/middlewares/express.test.ts | 28 ++++ src/middlewares/express.ts | 2 +- src/middlewares/hono.test.ts | 24 +++ src/middlewares/nextjs.test.ts | 22 +++ src/server/Methods.ts | 1 + src/server/Mppx.ts | 13 +- src/server/Transport.ts | 12 ++ src/server/index.ts | 1 + src/x402/Assets.ts | 63 ++++++++ src/x402/Exact.e2e.test.ts | 137 +++++++++++++++++ src/x402/Header.test.ts | 91 +++++++++++ src/x402/Header.ts | 61 ++++++++ src/x402/Methods.ts | 27 ++++ src/x402/PublicInterface.test-d.ts | 46 ++++++ src/x402/Types.ts | 236 +++++++++++++++++++++++++++++ src/x402/client/Exact.test.ts | 96 ++++++++++++ src/x402/client/Exact.ts | 122 +++++++++++++++ src/x402/client/Methods.ts | 19 +++ src/x402/client/Transport.ts | 52 +++++++ src/x402/client/index.ts | 4 + src/x402/index.ts | 6 + src/x402/server/Exact.ts | 157 +++++++++++++++++++ src/x402/server/Methods.ts | 19 +++ src/x402/server/Transport.ts | 91 +++++++++++ src/x402/server/index.ts | 4 + test/tsconfig.json | 3 + 36 files changed, 1472 insertions(+), 23 deletions(-) create mode 100644 .changeset/x402-exact-types.md create mode 100644 src/x402/Assets.ts create mode 100644 src/x402/Exact.e2e.test.ts create mode 100644 src/x402/Header.test.ts create mode 100644 src/x402/Header.ts create mode 100644 src/x402/Methods.ts create mode 100644 src/x402/PublicInterface.test-d.ts create mode 100644 src/x402/Types.ts create mode 100644 src/x402/client/Exact.test.ts create mode 100644 src/x402/client/Exact.ts create mode 100644 src/x402/client/Methods.ts create mode 100644 src/x402/client/Transport.ts create mode 100644 src/x402/client/index.ts create mode 100644 src/x402/index.ts create mode 100644 src/x402/server/Exact.ts create mode 100644 src/x402/server/Methods.ts create mode 100644 src/x402/server/Transport.ts create mode 100644 src/x402/server/index.ts diff --git a/.changeset/x402-exact-types.md b/.changeset/x402-exact-types.md new file mode 100644 index 00000000..39304512 --- /dev/null +++ b/.changeset/x402-exact-types.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added x402 exact core types, header codecs, known asset metadata, client/server transports, and public client/server interfaces. diff --git a/README.md b/README.md index a625c045..a9d36e3b 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,75 @@ Mppx.create({ const res = await fetch('https://mpp.dev/api/ping/paid') ``` +### x402 Exact + +```ts +import { Mppx, x402 } from 'mppx/server' + +const mppx = Mppx.create({ + methods: [ + x402.exact({ + config: { + asset: x402.assets.baseSepolia.USDC, + facilitator: 'https://x402.org/facilitator', + payTo: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }, + }), + ], +}) + +export async function GET(request: Request) { + const result = await mppx.x402.exact({ + amount: '10000', + resource: { url: request.url }, + })(request) + + if (result.status === 402) return result.challenge + return result.withReceipt(Response.json({ data: 'paid content' })) +} +``` + +Existing server adapters expose the same method. For Hono: + +```ts +import { Hono } from 'hono' +import { Mppx, x402 } from 'mppx/hono' + +const app = new Hono() +const mppx = Mppx.create({ + methods: [ + x402.exact({ + config: { + asset: x402.assets.baseSepolia.USDC, + facilitator: 'https://x402.org/facilitator', + payTo: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }, + }), + ], +}) + +app.get('/paid', mppx.x402.exact({ amount: '10000' }), (c) => c.json({ ok: true })) +``` + +```ts +import { privateKeyToAccount } from 'viem/accounts' +import { Mppx, x402 } from 'mppx/client' + +const mppx = Mppx.create({ + methods: [ + x402.exact({ + account: privateKeyToAccount('0x...'), + assets: [x402.assets.baseSepolia.USDC.address], + maxAmount: '10000', + networks: ['eip155:84532'], + }), + ], + transport: x402.Transport.http(), +}) + +const res = await mppx.fetch('https://api.example.com/paid') +``` + ## Examples | Example | Description | diff --git a/package.json b/package.json index 7c182c08..df9f1a0f 100644 --- a/package.json +++ b/package.json @@ -130,6 +130,21 @@ "src": "./src/stripe/server/index.ts", "default": "./dist/stripe/server/index.js" }, + "./x402": { + "types": "./dist/x402/index.d.ts", + "src": "./src/x402/index.ts", + "default": "./dist/x402/index.js" + }, + "./x402/client": { + "types": "./dist/x402/client/index.d.ts", + "src": "./src/x402/client/index.ts", + "default": "./dist/x402/client/index.js" + }, + "./x402/server": { + "types": "./dist/x402/server/index.d.ts", + "src": "./src/x402/server/index.ts", + "default": "./dist/x402/server/index.js" + }, "./tempo": { "types": "./dist/tempo/index.d.ts", "src": "./src/tempo/index.ts", diff --git a/src/client/Methods.ts b/src/client/Methods.ts index 72ee6281..a8909f40 100644 --- a/src/client/Methods.ts +++ b/src/client/Methods.ts @@ -2,3 +2,4 @@ export { stripe } from '../stripe/client/index.js' export { subscription } from '../tempo/client/Subscription.js' export { tempo } from '../tempo/client/index.js' export { session } from '../tempo/client/Session.js' +export { x402 } from '../x402/client/index.js' diff --git a/src/client/Mppx.ts b/src/client/Mppx.ts index ef15c919..b6788327 100644 --- a/src/client/Mppx.ts +++ b/src/client/Mppx.ts @@ -125,6 +125,7 @@ export function create< ...(resolvedOnChallenge && { onChallenge: resolvedOnChallenge }), ...(orderChallenges && { orderChallenges }), methods, + transport: transport as never, } satisfies Fetch.from.Config> const fetch = Fetch.from>(config_fetch) diff --git a/src/client/index.ts b/src/client/index.ts index d0616346..36d50199 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -1,5 +1,6 @@ export * as Expires from '../Expires.js' export * as Fetch from './internal/Fetch.js' export { session, stripe, tempo } from './Methods.js' +export { x402 } from './Methods.js' export * as Mppx from './Mppx.js' export * as Transport from './Transport.js' diff --git a/src/client/internal/Fetch.ts b/src/client/internal/Fetch.ts index 0d02ebac..0874b6a2 100644 --- a/src/client/internal/Fetch.ts +++ b/src/client/internal/Fetch.ts @@ -4,6 +4,7 @@ import * as AcceptPayment from '../../internal/AcceptPayment.js' import type { MaybePromise } from '../../internal/types.js' import type * as Method from '../../Method.js' import type * as z from '../../zod.js' +import * as Transport from '../Transport.js' // We tag wrappers with a global symbol so we can recognize wrappers created by mppx, // even across multiple module instances/bundles. This lets restore() avoid clobbering @@ -162,6 +163,7 @@ export function from( methods, onChallenge, orderChallenges, + transport = Transport.http(), } = config const events = config.eventDispatcher ?? createEventDispatcher() const resolvedAcceptPayment = acceptPayment ?? AcceptPayment.resolve(methods) @@ -183,7 +185,7 @@ export function from( ) const response = await baseFetch(initialRequest.input, initialRequest.init) - if (response.status !== 402) return response + if (!transport.isPaymentRequired(response)) return response // Only extract context for payment handling after confirming 402. const context = (init as Record | undefined)?.context @@ -198,8 +200,9 @@ export function from( let mi: methods[number] | undefined try { - // Parse all challenges from the response (supports merged WWW-Authenticate headers). - challenges = Challenge.fromResponseList(response) + challenges = transport.getChallenges + ? transport.getChallenges(response) + : [transport.getChallenge(response)] const candidates = AcceptPayment.selectChallengeCandidates( challenges, @@ -258,10 +261,16 @@ export function from( }), ) - const paymentResponse = await baseFetch(initialRequest.input, { - ...fetchInit, - headers: withAuthorizationHeader(initialRequest.headers, credential), - }) + const paymentResponse = await baseFetch( + initialRequest.input, + transport.setCredential( + { + ...fetchInit, + headers: initialRequest.headers, + }, + credential, + ), + ) if (paymentResponse.ok) await events.emit( 'payment.response', @@ -335,6 +344,8 @@ export declare namespace from { | undefined /** Filters and sorts supported challenges before credential creation. */ orderChallenges?: AcceptPayment.OrderChallenges | undefined + /** Transport to use for challenge extraction and credential attachment. */ + transport?: Transport.Transport | undefined } type Fetch = ( @@ -688,18 +699,6 @@ function freezeSnapshot(value: value): value { return value } -/** @internal */ -function withAuthorizationHeader(headers: unknown, credential: string): Record { - const normalized = normalizeHeaders(headers) - // Remove any existing Authorization header regardless of casing to avoid - // duplicate/conflicting credentials on retry. - for (const key of Object.keys(normalized)) { - if (key.toLowerCase() === 'authorization') delete normalized[key] - } - normalized.Authorization = credential - return normalized -} - /** @internal */ function prepareInitialRequest( input: RequestInfo | URL, diff --git a/src/index.ts b/src/index.ts index e7c609a2..71783f9b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,4 +8,5 @@ export * as Method from './Method.js' export * as PaymentRequest from './PaymentRequest.js' export * as Receipt from './Receipt.js' export * as Store from './Store.js' +export * as x402 from './x402/index.js' export * as z from './zod.js' diff --git a/src/middlewares/elysia.test.ts b/src/middlewares/elysia.test.ts index 5b2ccdec..89aa5758 100644 --- a/src/middlewares/elysia.test.ts +++ b/src/middlewares/elysia.test.ts @@ -63,6 +63,31 @@ describe('payment', () => { server.close() }) + + test('copies transport-specific success headers', async () => { + const intent = () => async () => ({ + status: 200 as const, + withReceipt: (response?: Response) => + new Response(response?.body ?? null, { + headers: { + ...(response ? Object.fromEntries(response.headers) : {}), + 'PAYMENT-RESPONSE': 'x402-response', + }, + status: response?.status ?? 200, + }), + }) + + const app = new Elysia().guard({ beforeHandle: payment(intent as any, {} as any) }, (app) => + app.get('/', () => ({ data: 'content' })), + ) + + const server = await createServer(app) + const response = await globalThis.fetch(server.url) + expect(response.status).toBe(200) + expect(response.headers.get('PAYMENT-RESPONSE')).toBe('x402-response') + + server.close() + }) }) function createChargeHarness(feePayer: boolean) { diff --git a/src/middlewares/elysia.ts b/src/middlewares/elysia.ts index 0921d744..b4f18aa4 100644 --- a/src/middlewares/elysia.ts +++ b/src/middlewares/elysia.ts @@ -67,8 +67,7 @@ export function payment( const managementResponse = getManagementResponse(result) if (managementResponse) return managementResponse const receipt = result.withReceipt(new Response()) - const header = receipt.headers.get('Payment-Receipt') - if (header) set.headers['Payment-Receipt'] = header + for (const [key, value] of receipt.headers) set.headers[key] = value } } diff --git a/src/middlewares/express.test.ts b/src/middlewares/express.test.ts index 551fb078..42f4e9d0 100644 --- a/src/middlewares/express.test.ts +++ b/src/middlewares/express.test.ts @@ -162,6 +162,34 @@ describe('charge', () => { }) }) +describe('payment', () => { + test('copies transport-specific success headers', async () => { + const intent = () => async () => ({ + status: 200 as const, + withReceipt: (response?: Response) => + new Response(response?.body ?? null, { + headers: { + ...(response ? Object.fromEntries(response.headers) : {}), + 'PAYMENT-RESPONSE': 'x402-response', + }, + status: response?.status ?? 200, + }), + }) + + const app = express() + app.get('/', payment(intent as any, {} as any), (_req, res) => { + res.json({ data: 'content' }) + }) + + const server = await createServer(app) + const response = await globalThis.fetch(server.url) + expect(response.status).toBe(200) + expect(response.headers.get('PAYMENT-RESPONSE')).toBe('x402-response') + + server.close() + }) +}) + describe('session', () => { let escrowContract: Address diff --git a/src/middlewares/express.ts b/src/middlewares/express.ts index 3be41c27..278c7500 100644 --- a/src/middlewares/express.ts +++ b/src/middlewares/express.ts @@ -99,7 +99,7 @@ export function payment( const originalJson = res.json.bind(res) res.json = (body: any) => { const wrapped = result.withReceipt(Response.json(body)) - res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt')!) + for (const [key, value] of wrapped.headers) res.setHeader(key, value) return originalJson(body) } diff --git a/src/middlewares/hono.test.ts b/src/middlewares/hono.test.ts index 8983c56c..13657db1 100644 --- a/src/middlewares/hono.test.ts +++ b/src/middlewares/hono.test.ts @@ -53,6 +53,30 @@ describe('payment', () => { server.close() }) + + test('copies transport-specific success headers', async () => { + const intent = () => async () => ({ + status: 200 as const, + withReceipt: (response?: Response) => + new Response(response?.body ?? null, { + headers: { + ...(response ? Object.fromEntries(response.headers) : {}), + 'PAYMENT-RESPONSE': 'x402-response', + }, + status: response?.status ?? 200, + }), + }) + + const app = new Hono() + app.get('/', payment(intent as any, {} as any), (c) => c.json({ data: 'content' })) + + const server = await createServer(app) + const response = await globalThis.fetch(server.url) + expect(response.status).toBe(200) + expect(response.headers.get('PAYMENT-RESPONSE')).toBe('x402-response') + + server.close() + }) }) const scopeMethod = Method.toServer( diff --git a/src/middlewares/nextjs.test.ts b/src/middlewares/nextjs.test.ts index d93eaa86..f0570142 100644 --- a/src/middlewares/nextjs.test.ts +++ b/src/middlewares/nextjs.test.ts @@ -59,6 +59,28 @@ describe('payment', () => { server.close() }) + + test('copies transport-specific success headers', async () => { + const intent = () => async () => ({ + status: 200 as const, + withReceipt: (response?: Response) => + new Response(response?.body ?? null, { + headers: { + ...(response ? Object.fromEntries(response.headers) : {}), + 'PAYMENT-RESPONSE': 'x402-response', + }, + status: response?.status ?? 200, + }), + }) + const handler = payment(intent as any, {} as any, () => Response.json({ data: 'content' })) + + const server = await createServer(handler) + const response = await globalThis.fetch(server.url) + expect(response.status).toBe(200) + expect(response.headers.get('PAYMENT-RESPONSE')).toBe('x402-response') + + server.close() + }) }) function createChargeHarness(feePayer: boolean) { diff --git a/src/server/Methods.ts b/src/server/Methods.ts index 4b8e2ca2..00d54d6d 100644 --- a/src/server/Methods.ts +++ b/src/server/Methods.ts @@ -1,2 +1,3 @@ export { stripe } from '../stripe/server/index.js' export { tempo } from '../tempo/server/index.js' +export { x402 } from '../x402/server/index.js' diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 061934fc..285098d3 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -790,7 +790,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R : staticMeta // Extract credential once — getCredential may have side effects (e.g. SSE transports). - const [credential, credentialError] = (() => { + let [credential, credentialError] = (() => { try { const credential = transport.getCredential(input) as Credential.Credential | null return [credential ? hydrateCredentialMeta(credential) : null, undefined] as const @@ -898,6 +898,17 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R if ('response' in routeChallenge) return { challenge: routeChallenge.response, status: 402 } const { challenge, parsedRequest, request } = routeChallenge + if (credential && transport.bindCredential) { + try { + credential = hydrateCredentialMeta( + transport.bindCredential({ challenge, credential, input }) as Credential.Credential, + ) + } catch (e) { + credential = null + credentialError = e as Error + } + } + // Credential was provided but malformed if (credentialError) { const reason = getSafeCredentialReason(credentialError) diff --git a/src/server/Transport.ts b/src/server/Transport.ts index 1d1cd3db..93d0c780 100644 --- a/src/server/Transport.ts +++ b/src/server/Transport.ts @@ -26,6 +26,18 @@ export type Transport< name: string /** Captures the transport request into an immutable verification snapshot. */ captureRequest?: ((input: input) => MaybePromise) | undefined + /** + * Rebinds a transport-native credential to the route challenge after request + * normalization. Transports with non-Payment-auth wire formats can parse their + * payload early, then attach the canonical mppx challenge here. + */ + bindCredential?: + | ((options: { + challenge: Challenge.Challenge + credential: Credential.Credential + input: input + }) => Credential.Credential) + | undefined /** * Extracts credential from the transport input. * Returns `null` if no credential was provided, or throws if malformed. diff --git a/src/server/index.ts b/src/server/index.ts index f87db1c4..fbff05b0 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,7 @@ export * as Expires from '../Expires.js' export * as Store from '../Store.js' export { stripe, tempo } from './Methods.js' +export { x402 } from './Methods.js' export * as Mppx from './Mppx.js' export * as NodeListener from './NodeListener.js' export * as Request from './Request.js' diff --git a/src/x402/Assets.ts b/src/x402/Assets.ts new file mode 100644 index 00000000..1fa57002 --- /dev/null +++ b/src/x402/Assets.ts @@ -0,0 +1,63 @@ +import type { Asset, EvmNetwork, ExactTransfer } from './Types.js' + +const knownAsset = Symbol('mppx.x402.asset') + +/** Known x402 asset metadata. */ +export type KnownAsset = Asset & { + readonly [knownAsset]: true + network: EvmNetwork +} + +/** Creates typed x402 asset metadata for custom tokens. */ +export function define(parameters: define.Parameters): KnownAsset { + return { + [knownAsset]: true, + address: parameters.address, + decimals: parameters.decimals, + network: parameters.network, + transfer: parameters.transfer, + } +} + +export declare namespace define { + type Parameters = { + address: `0x${string}` + decimals: number + network: EvmNetwork + transfer: ExactTransfer + } +} + +/** Base network known assets. */ +export const base = { + USDC: define({ + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + decimals: 6, + network: 'eip155:8453', + transfer: { + name: 'USD Coin', + type: 'eip3009', + version: '2', + }, + }), +} as const + +/** Base Sepolia known assets. */ +export const baseSepolia = { + USDC: define({ + address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + decimals: 6, + network: 'eip155:84532', + transfer: { + name: 'USDC', + type: 'eip3009', + version: '2', + }, + }), +} as const + +/** Returns true when a value is known x402 asset metadata. */ +export function isAsset(value: unknown): value is KnownAsset { + if (typeof value !== 'object' || value === null) return false + return (value as Partial)[knownAsset] === true +} diff --git a/src/x402/Exact.e2e.test.ts b/src/x402/Exact.e2e.test.ts new file mode 100644 index 00000000..325344ab --- /dev/null +++ b/src/x402/Exact.e2e.test.ts @@ -0,0 +1,137 @@ +import { Mppx as ClientMppx, tempo as tempoClient, x402 as x402Client } from 'mppx/client' +import { + Mppx as ServerMppx, + NodeListener, + Request as ServerRequest, + tempo, + x402 as x402Server, +} from 'mppx/server' +import { describe, expect, test } from 'vp/test' +import * as Http from '~test/Http.js' +import { accounts, asset, client } from '~test/tempo/viem.js' + +import * as Header from './Header.js' +import * as Types from './Types.js' + +const secretKey = 'test-secret' +const transaction = `0x${'1'.repeat(64)}` + +describe('x402 exact e2e', () => { + test('serves tempo and x402 paid routes from a live server', async () => { + const tempoPayment = ServerMppx.create({ + methods: [ + tempo.charge({ + account: accounts[0], + currency: asset, + getClient: () => client, + recipient: accounts[0].address, + }), + ], + secretKey, + }) + + const facilitator: Types.Facilitator = { + async verify(paymentPayload) { + return { + isValid: true, + payer: payerOf(paymentPayload), + } + }, + async settle(paymentPayload) { + return { + network: paymentPayload.accepted.network, + payer: payerOf(paymentPayload), + success: true, + transaction, + } + }, + } + const x402Payment = ServerMppx.create({ + methods: [ + x402Server.exact({ + config: { + asset: x402Server.assets.baseSepolia.USDC, + facilitator, + payTo: accounts[0].address, + }, + }), + ], + secretKey, + }) + + const server = await Http.createServer(async (req, res) => { + const request = ServerRequest.fromNodeListener(req, res) + + if (req.url === '/tempo') { + const result = await tempoPayment.tempo.charge({ + amount: '0', + chainId: client.chain!.id, + })(request) + if (result.status === 402) return NodeListener.sendResponse(res, result.challenge) + return NodeListener.sendResponse(res, result.withReceipt(new Response('tempo ok'))) + } + + if (req.url === '/x402') { + const result = await x402Payment.x402.exact({ + amount: '10000', + resource: { + mimeType: 'text/plain', + url: new URL('/x402', request.url).toString(), + }, + })(request) + if (result.status === 402) return NodeListener.sendResponse(res, result.challenge) + return NodeListener.sendResponse(res, result.withReceipt(new Response('x402 ok'))) + } + + return NodeListener.sendResponse(res, new Response('not found', { status: 404 })) + }) + + try { + const tempoClientPayment = ClientMppx.create({ + methods: [ + tempoClient.charge({ + account: accounts[0], + getClient: () => client, + }), + ], + polyfill: false, + }) + const tempoResponse = await tempoClientPayment.fetch(`${server.url}/tempo`) + expect(tempoResponse.status).toBe(200) + expect(await tempoResponse.text()).toBe('tempo ok') + expect(tempoResponse.headers.has('Payment-Receipt')).toBe(true) + + const x402ClientPayment = ClientMppx.create({ + methods: [ + x402Client.exact({ + account: accounts[0], + }), + ], + polyfill: false, + transport: x402Client.Transport.http(), + }) + const x402Required = await x402ClientPayment.rawFetch(`${server.url}/x402`) + expect(x402Required.status).toBe(402) + expect(x402Required.headers.has(Types.paymentRequiredHeader)).toBe(true) + + const paymentSignature = await x402ClientPayment.createCredential(x402Required) + const paymentPayload = Header.decodePaymentSignature(paymentSignature) + expect(paymentPayload.accepted.scheme).toBe('exact') + + const x402Response = await x402ClientPayment.fetch(`${server.url}/x402`) + expect(x402Response.status).toBe(200) + expect(await x402Response.text()).toBe('x402 ok') + + const paymentResponseHeader = x402Response.headers.get(Types.paymentResponseHeader) + expect(paymentResponseHeader).toBeTruthy() + expect(Header.decodePaymentResponse(paymentResponseHeader!).transaction).toBe(transaction) + } finally { + server.close() + } + }) +}) + +function payerOf(paymentPayload: Types.PaymentPayload): string { + if ('authorization' in paymentPayload.payload) return paymentPayload.payload.authorization.from + return paymentPayload.payload.permit2Authorization.from +} diff --git a/src/x402/Header.test.ts b/src/x402/Header.test.ts new file mode 100644 index 00000000..e78a9314 --- /dev/null +++ b/src/x402/Header.test.ts @@ -0,0 +1,91 @@ +import * as Header from './Header.js' +import * as Methods from './Methods.js' +import type * as Types from './Types.js' + +describe('x402 headers', () => { + test('round trips PAYMENT-REQUIRED header values', () => { + const paymentRequired: Types.PaymentRequired = { + accepts: [ + { + amount: '10000', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + extra: { + assetTransferMethod: 'eip3009', + name: 'USDC', + version: '2', + }, + maxTimeoutSeconds: 60, + network: 'eip155:84532', + payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + scheme: 'exact', + }, + ], + resource: { + mimeType: 'application/json', + url: 'https://api.example.com/premium-data', + }, + x402Version: 2, + } + + const header = Header.encodePaymentRequired(paymentRequired) + + expect(Header.decodePaymentRequired(header)).toEqual(paymentRequired) + }) + + test('round trips PAYMENT-SIGNATURE header values', () => { + const paymentPayload: Types.PaymentPayload = { + accepted: { + amount: '10000', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + maxTimeoutSeconds: 60, + network: 'eip155:84532', + payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + scheme: 'exact', + }, + payload: { + authorization: { + from: '0x857b06519E91e3A54538791bDbb0E22373e36b66', + nonce: '0xf3746613c2d920b5fdabc0856f2aeb2d4f88ee6037b8cc5d04a71a4462f13480', + to: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + validAfter: '1740672089', + validBefore: '1740672154', + value: '10000', + }, + signature: + '0x2d6a7588d6acca505cbf0d9a4a227e0c52c6c34008c8e8986a1283259764173608a2ce6496642e377d6da8dbbf5836e9bd15092f9ecab05ded3d6293af148b571c', + }, + x402Version: 2, + } + + const header = Header.encodePaymentSignature(paymentPayload) + + expect(Header.decodePaymentSignature(header)).toEqual(paymentPayload) + }) +}) + +describe('x402 exact method', () => { + test('maps public transfer config to wire extra', () => { + const request = Methods.exact.schema.request.parse({ + amount: '10000', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + maxTimeoutSeconds: 60, + network: 'eip155:84532', + payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + transfer: { + name: 'USDC', + type: 'eip3009', + version: '2', + }, + }) + + expect(request).toMatchObject({ + extra: { + assetTransferMethod: 'eip3009', + name: 'USDC', + version: '2', + }, + scheme: 'exact', + }) + expect('transfer' in request).toBe(false) + }) +}) diff --git a/src/x402/Header.ts b/src/x402/Header.ts new file mode 100644 index 00000000..1307daf7 --- /dev/null +++ b/src/x402/Header.ts @@ -0,0 +1,61 @@ +import { Base64 } from 'ox' + +import type * as z from '../zod.js' +import { + PaymentPayloadSchema, + PaymentRequiredSchema, + SettleResponseSchema, + type PaymentPayload, + type PaymentRequired, + type SettleResponse, +} from './Types.js' + +const paymentRequired = createHeaderCodec(PaymentRequiredSchema) +const paymentSignature = createHeaderCodec(PaymentPayloadSchema) +const paymentResponse = createHeaderCodec(SettleResponseSchema) + +/** Encodes an x402 payment-required object for the `PAYMENT-REQUIRED` header. */ +export const encodePaymentRequired: (paymentRequired: PaymentRequired) => string = + paymentRequired.encode + +/** Decodes an x402 `PAYMENT-REQUIRED` header value. */ +export const decodePaymentRequired: (value: string) => PaymentRequired = paymentRequired.decode + +/** Encodes an x402 payment payload for the `PAYMENT-SIGNATURE` header. */ +export const encodePaymentSignature: (paymentPayload: PaymentPayload) => string = + paymentSignature.encode + +/** Decodes an x402 `PAYMENT-SIGNATURE` header value. */ +export const decodePaymentSignature: (value: string) => PaymentPayload = paymentSignature.decode + +/** Encodes an x402 settlement response for the `PAYMENT-RESPONSE` header. */ +export const encodePaymentResponse: (paymentResponse: SettleResponse) => string = + paymentResponse.encode + +/** Decodes an x402 `PAYMENT-RESPONSE` header value. */ +export const decodePaymentResponse: (value: string) => SettleResponse = paymentResponse.decode + +function createHeaderCodec(schema: schema) { + type value = z.output + + return { + encode(value: value): string { + return encodeJson(schema.parse(value)) + }, + decode(value: string): value { + return schema.parse(decodeJson(value)) as value + }, + } +} + +function encodeJson(value: unknown): string { + return Base64.fromString(JSON.stringify(value)) +} + +function decodeJson(value: string): unknown { + try { + return JSON.parse(Base64.toString(value)) + } catch { + throw new Error('Invalid x402 base64 JSON header.') + } +} diff --git a/src/x402/Methods.ts b/src/x402/Methods.ts new file mode 100644 index 00000000..38dea640 --- /dev/null +++ b/src/x402/Methods.ts @@ -0,0 +1,27 @@ +import * as Method from '../Method.js' +import * as z from '../zod.js' +import * as Types from './Types.js' + +/** + * x402 exact payment method. + * + * Public route input accepts typed `transfer` config; the method request output + * converts it into the x402 wire `extra` object. + */ +export const exact = Method.from({ + name: 'x402', + intent: 'exact', + schema: { + credential: { + payload: Types.PaymentPayloadSchema, + }, + request: z.pipe( + Types.ExactRequestInputSchema, + z.transform(({ transfer, ...request }) => ({ + ...request, + extra: Types.transferToExtra(transfer), + scheme: 'exact' as const, + })), + ), + }, +}) diff --git a/src/x402/PublicInterface.test-d.ts b/src/x402/PublicInterface.test-d.ts new file mode 100644 index 00000000..2fa6b2ca --- /dev/null +++ b/src/x402/PublicInterface.test-d.ts @@ -0,0 +1,46 @@ +import { Mppx, x402 } from 'mppx/server' +import type { Account } from 'viem' +import { describe, expectTypeOf, test } from 'vp/test' + +import { x402 as clientX402 } from '../client/index.js' + +const secretKey = 'test-secret' + +describe('x402 public interface', () => { + test('server exact accepts known assets without transfer metadata', () => { + const mppx = Mppx.create({ + methods: [ + x402.exact({ + config: { + asset: x402.assets.base.USDC, + facilitator: { + settle: async () => ({ + network: 'eip155:8453', + success: true, + transaction: `0x${'1'.repeat(64)}`, + }), + verify: async () => ({ isValid: true }), + }, + payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + }, + }), + ], + secretKey, + }) + + expectTypeOf(mppx.x402.exact).toBeFunction() + expectTypeOf(mppx.x402.exact({ amount: '10000' })).toBeFunction() + }) + + test('client exact exposes account config and policies', () => { + const method = clientX402.exact({ + account: {} as Account, + assets: ['0x036CbD53842c5426634e7929541eC2318f3dCF7e'], + maxAmount: '10000', + networks: ['eip155:84532'], + }) + + expectTypeOf(method.intent).toEqualTypeOf<'exact'>() + expectTypeOf(method.name).toEqualTypeOf<'x402'>() + }) +}) diff --git a/src/x402/Types.ts b/src/x402/Types.ts new file mode 100644 index 00000000..9d0328ee --- /dev/null +++ b/src/x402/Types.ts @@ -0,0 +1,236 @@ +import * as z from '../zod.js' + +export const versions = [2] as const +export const schemes = ['exact'] as const +export const assetTransferMethods = ['eip3009', 'permit2'] as const + +/** x402 protocol version supported by this package. */ +export type Version = 2 + +/** x402 scheme supported by this package. */ +export type Scheme = (typeof schemes)[number] + +/** x402 exact EVM asset transfer method. */ +export type AssetTransferMethod = (typeof assetTransferMethods)[number] + +/** CAIP-2 EVM network identifier. */ +export type EvmNetwork = `eip155:${number}` + +/** HTTP header carrying a base64-encoded x402 payment-required response. */ +export const paymentRequiredHeader = 'PAYMENT-REQUIRED' + +/** HTTP header carrying a base64-encoded x402 payment payload. */ +export const paymentSignatureHeader = 'PAYMENT-SIGNATURE' + +/** HTTP header carrying a base64-encoded x402 settlement response. */ +export const paymentResponseHeader = 'PAYMENT-RESPONSE' + +const nonEmptyString = z.string().check(z.minLength(1)) +const positiveNumber = z.number().check(z.refine((value) => value > 0, 'Must be positive')) +const atomicAmount = z.string().check(z.regex(/^\d+$/, 'Invalid atomic amount')) +const address = z.address() +const evmNetwork = z + .string() + .check(z.regex(/^eip155:\d+$/, 'Invalid EVM CAIP-2 network')) as z.ZodMiniType + +/** Describes the protected resource in x402 v2 payment-required responses. */ +export const ResourceInfoSchema = z.object({ + description: z.optional(z.string()), + iconUrl: z.optional(z.string()), + mimeType: z.optional(z.string()), + serviceName: z.optional(z.string()), + tags: z.optional(z.array(z.string())), + url: nonEmptyString, +}) + +/** Describes the protected resource in x402 v2 payment-required responses. */ +export type ResourceInfo = z.infer + +/** Public transfer configuration for exact EVM payments. */ +export const ExactTransferSchema = z.discriminatedUnion('type', [ + z.object({ + name: nonEmptyString, + type: z.literal('eip3009'), + version: nonEmptyString, + }), + z.object({ + name: z.optional(z.string()), + type: z.literal('permit2'), + version: z.optional(z.string()), + }), +]) + +/** Public EIP-3009 transfer configuration for exact EVM payments. */ +export type ExactEip3009Transfer = Extract, { type: 'eip3009' }> + +/** Public Permit2 transfer configuration for exact EVM payments. */ +export type ExactPermit2Transfer = Extract, { type: 'permit2' }> + +/** Public transfer configuration for exact EVM payments. */ +export type ExactTransfer = z.infer + +/** Known asset metadata used to derive x402 wire `extra` fields. */ +export type Asset = { + address: `0x${string}` + decimals: number + transfer: ExactTransfer +} + +/** Public exact EVM route request accepted by `mppx` handlers. */ +export type ExactRequest = PaymentRequirements & { + resource?: ResourceInfo | undefined +} + +/** Public exact EVM route input before it is converted to x402 wire requirements. */ +export const ExactRequestInputSchema = z.object({ + amount: atomicAmount, + asset: address, + maxTimeoutSeconds: positiveNumber, + network: evmNetwork, + payTo: address, + resource: z.optional(ResourceInfoSchema), + transfer: ExactTransferSchema, +}) + +/** Public exact EVM route input before it is converted to x402 wire requirements. */ +export type ExactRequestInput = z.infer + +/** x402 v2 payment requirements for the `exact` scheme. */ +export const PaymentRequirementsSchema = z.object({ + amount: atomicAmount, + asset: nonEmptyString, + extra: z.optional(z.record(z.string(), z.unknown())), + maxTimeoutSeconds: positiveNumber, + network: evmNetwork, + payTo: nonEmptyString, + scheme: z.enum(schemes), +}) + +/** x402 v2 payment requirements for the `exact` scheme. */ +export type PaymentRequirements = z.infer + +/** x402 v2 payment-required response. */ +export const PaymentRequiredSchema = z.object({ + accepts: z.array(PaymentRequirementsSchema).check(z.minLength(1)), + error: z.optional(z.string()), + extensions: z.optional(z.record(z.string(), z.unknown())), + resource: ResourceInfoSchema, + x402Version: z.literal(2), +}) + +/** x402 v2 payment-required response. */ +export type PaymentRequired = z.infer + +/** EIP-3009 transferWithAuthorization payload for exact EVM payments. */ +export const ExactEip3009PayloadSchema = z.object({ + authorization: z.object({ + from: address, + nonce: z.hash(), + to: address, + validAfter: atomicAmount, + validBefore: atomicAmount, + value: atomicAmount, + }), + signature: z.signature(), +}) + +/** EIP-3009 transferWithAuthorization payload for exact EVM payments. */ +export type ExactEip3009Payload = z.infer + +/** Permit2 payload for exact EVM payments. */ +export const ExactPermit2PayloadSchema = z.object({ + permit2Authorization: z.object({ + deadline: atomicAmount, + from: address, + nonce: atomicAmount, + permitted: z.object({ + amount: atomicAmount, + token: address, + }), + spender: address, + witness: z.object({ + to: address, + validAfter: atomicAmount, + }), + }), + signature: z.signature(), +}) + +/** Permit2 payload for exact EVM payments. */ +export type ExactPermit2Payload = z.infer + +/** Exact EVM payment payload body. */ +export const ExactPayloadSchema = z.union([ExactEip3009PayloadSchema, ExactPermit2PayloadSchema]) + +/** Exact EVM payment payload body. */ +export type ExactPayload = z.infer + +/** x402 v2 payment payload. */ +export const PaymentPayloadSchema = z.object({ + accepted: PaymentRequirementsSchema, + extensions: z.optional(z.record(z.string(), z.unknown())), + payload: ExactPayloadSchema, + resource: z.optional(ResourceInfoSchema), + x402Version: z.literal(2), +}) + +/** x402 v2 payment payload. */ +export type PaymentPayload = z.infer + +/** Facilitator verification response. */ +export const VerifyResponseSchema = z.object({ + extensions: z.optional(z.record(z.string(), z.unknown())), + extra: z.optional(z.record(z.string(), z.unknown())), + invalidMessage: z.optional(z.string()), + invalidReason: z.optional(z.string()), + isValid: z.boolean(), + payer: z.optional(z.string()), +}) + +/** Facilitator verification response. */ +export type VerifyResponse = z.infer + +/** Facilitator settlement response and x402 `PAYMENT-RESPONSE` body. */ +export const SettleResponseSchema = z.object({ + amount: z.optional(atomicAmount), + errorMessage: z.optional(z.string()), + errorReason: z.optional(z.string()), + extensions: z.optional(z.record(z.string(), z.unknown())), + extra: z.optional(z.record(z.string(), z.unknown())), + network: nonEmptyString, + payer: z.optional(z.string()), + success: z.boolean(), + transaction: z.string(), +}) + +/** Facilitator settlement response and x402 `PAYMENT-RESPONSE` body. */ +export type SettleResponse = z.infer + +/** x402 facilitator client interface used by server exact config. */ +export type Facilitator = { + settle: ( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ) => Promise + verify: ( + paymentPayload: PaymentPayload, + paymentRequirements: PaymentRequirements, + ) => Promise +} + +/** Converts public transfer config into x402 wire `extra` fields. */ +export function transferToExtra(transfer: ExactTransfer): Record { + return { + assetTransferMethod: transfer.type, + ...('name' in transfer && transfer.name !== undefined ? { name: transfer.name } : {}), + ...('version' in transfer && transfer.version !== undefined + ? { version: transfer.version } + : {}), + } +} + +/** Extracts x402 `PaymentRequirements` from a canonical exact request. */ +export function toPaymentRequirements(request: ExactRequest): PaymentRequirements { + const { resource: _resource, ...paymentRequirements } = request + return PaymentRequirementsSchema.parse(paymentRequirements) +} diff --git a/src/x402/client/Exact.test.ts b/src/x402/client/Exact.test.ts new file mode 100644 index 00000000..cd7324ad --- /dev/null +++ b/src/x402/client/Exact.test.ts @@ -0,0 +1,96 @@ +import { Challenge } from 'mppx' +import type { Account } from 'viem' +import { describe, expect, test, vi } from 'vp/test' + +import * as Header from '../Header.js' +import * as Types from '../Types.js' +import { exact } from './Exact.js' + +type X402Challenge = Parameters['createCredential']>[0]['challenge'] + +const account = { + address: '0x1111111111111111111111111111111111111111', + signTypedData: vi.fn(async () => '0x1234'), +} as unknown as Account + +describe('x402.exact client', () => { + test('enforces max amount, network, and asset policy before signing', async () => { + const method = exact({ + account, + assets: ['0x036CbD53842c5426634e7929541eC2318f3dCF7e'], + maxAmount: '10000', + networks: ['eip155:84532'], + }) + + await expect( + method.createCredential({ + challenge: challenge({ amount: '10001' }), + context: {}, + }), + ).rejects.toThrow('x402 exact amount exceeds maxAmount.') + + await expect( + method.createCredential({ + challenge: challenge({ network: 'eip155:8453' }), + context: {}, + }), + ).rejects.toThrow('x402 exact network is not allowed: eip155:8453.') + + await expect( + method.createCredential({ + challenge: challenge({ asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913' }), + context: {}, + }), + ).rejects.toThrow( + 'x402 exact asset is not allowed: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.', + ) + + expect(account.signTypedData).not.toHaveBeenCalled() + }) + + test('signs EIP-3009 exact payment payloads', async () => { + const signTypedData = vi.fn(async () => '0x1234') + const method = exact({ + account: { + ...account, + signTypedData, + } as unknown as Account, + maxAmount: '10000', + networks: ['eip155:84532'], + }) + + const credential = await method.createCredential({ + challenge: challenge(), + context: {}, + }) + const paymentPayload = Header.decodePaymentSignature(credential) + + expect(signTypedData).toHaveBeenCalledOnce() + expect(paymentPayload.x402Version).toBe(2) + expect(paymentPayload.accepted.scheme).toBe('exact') + expect(paymentPayload.payload.signature).toBe('0x1234') + }) +}) + +function challenge(overrides: Partial = {}): X402Challenge { + return Challenge.from({ + id: 'x402-test', + intent: 'exact', + method: 'x402', + realm: 'example.com', + request: { + amount: '10000', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + extra: { + assetTransferMethod: 'eip3009', + name: 'USDC', + version: '2', + }, + maxTimeoutSeconds: 60, + network: 'eip155:84532', + payTo: '0x2222222222222222222222222222222222222222', + scheme: 'exact', + ...overrides, + }, + }) as X402Challenge +} diff --git a/src/x402/client/Exact.ts b/src/x402/client/Exact.ts new file mode 100644 index 00000000..943d5470 --- /dev/null +++ b/src/x402/client/Exact.ts @@ -0,0 +1,122 @@ +import { Hex } from 'ox' +import type { Account } from 'viem' +import { getAddress } from 'viem' + +import * as Method from '../../Method.js' +import * as z from '../../zod.js' +import * as Header from '../Header.js' +import * as Methods from '../Methods.js' +import * as Types from '../Types.js' + +/** + * Creates an x402 exact client method. + * + * This is the public interface scaffold for exact EVM payments. The signing + * implementation will use the configured account to create x402 + * `PAYMENT-SIGNATURE` payloads. + */ +export function exact(parameters: exact.Parameters) { + return Method.toClient(Methods.exact, { + context: z.object({ + account: z.optional(z.custom()), + }), + async createCredential({ challenge, context }) { + const account = (context?.account ?? parameters.account) as exact.Signer + if (!account.signTypedData) throw new Error('x402 exact requires a typed-data signer.') + + const request = challenge.request as Types.ExactRequest + const accepted = Types.toPaymentRequirements(request) + assertPolicy(parameters, accepted) + const transferMethod = accepted.extra?.assetTransferMethod ?? 'eip3009' + if (transferMethod !== 'eip3009') + throw new Error(`x402 exact ${String(transferMethod)} signing is not implemented yet.`) + + const name = accepted.extra?.name + const version = accepted.extra?.version + if (typeof name !== 'string' || typeof version !== 'string') + throw new Error('x402 exact EIP-3009 requires token name and version.') + + const now = Math.floor(Date.now() / 1000) + const authorization: Types.ExactEip3009Payload['authorization'] = { + from: getAddress(account.address), + nonce: Hex.random(32), + to: getAddress(accepted.payTo), + validAfter: (now - 600).toString(), + validBefore: (now + accepted.maxTimeoutSeconds).toString(), + value: accepted.amount, + } + const signature = await account.signTypedData({ + domain: { + chainId: chainIdOf(accepted.network), + name, + verifyingContract: getAddress(accepted.asset), + version, + }, + message: { + ...authorization, + value: BigInt(authorization.value), + validAfter: BigInt(authorization.validAfter), + validBefore: BigInt(authorization.validBefore), + }, + primaryType: 'TransferWithAuthorization', + types: authorizationTypes, + }) + + return Header.encodePaymentSignature({ + accepted, + payload: { + authorization, + signature, + }, + ...(request.resource ? { resource: request.resource } : {}), + x402Version: 2, + }) + }, + }) +} + +export declare namespace exact { + type Signer = Account & { + signTypedData?: (parameters: any) => Promise<`0x${string}`> + } + + type Parameters = { + /** Account used to sign exact EVM payment payloads. */ + account: Account + /** Optional maximum atomic amount the client is willing to pay. */ + maxAmount?: string | undefined + /** Optional allowlist of supported x402 EVM networks. */ + networks?: readonly Types.EvmNetwork[] | undefined + /** Optional allowlist of supported asset contract addresses. */ + assets?: readonly `0x${string}`[] | undefined + } +} + +const authorizationTypes = { + TransferWithAuthorization: [ + { name: 'from', type: 'address' }, + { name: 'to', type: 'address' }, + { name: 'value', type: 'uint256' }, + { name: 'validAfter', type: 'uint256' }, + { name: 'validBefore', type: 'uint256' }, + { name: 'nonce', type: 'bytes32' }, + ], +} as const + +function chainIdOf(network: Types.EvmNetwork): number { + return Number(network.slice('eip155:'.length)) +} + +function assertPolicy(parameters: exact.Parameters, accepted: Types.PaymentRequirements) { + if (parameters.maxAmount !== undefined && BigInt(accepted.amount) > BigInt(parameters.maxAmount)) + throw new Error('x402 exact amount exceeds maxAmount.') + + if (parameters.networks && !parameters.networks.includes(accepted.network)) + throw new Error(`x402 exact network is not allowed: ${accepted.network}.`) + + if (parameters.assets) { + const acceptedAsset = getAddress(accepted.asset as `0x${string}`) + const allowed = parameters.assets.some((asset) => getAddress(asset) === acceptedAsset) + if (!allowed) throw new Error(`x402 exact asset is not allowed: ${acceptedAsset}.`) + } +} diff --git a/src/x402/client/Methods.ts b/src/x402/client/Methods.ts new file mode 100644 index 00000000..0ec9d3f0 --- /dev/null +++ b/src/x402/client/Methods.ts @@ -0,0 +1,19 @@ +import * as Assets from '../Assets.js' +import { exact as exact_ } from './Exact.js' +import * as Transport_ from './Transport.js' + +/** Creates x402 client methods from shared parameters. */ +export function x402(parameters: x402.Parameters) { + return [x402.exact(parameters)] as const +} + +export namespace x402 { + export type Parameters = exact_.Parameters + + /** Creates an x402 `exact` client method. */ + export const exact = exact_ + /** Known x402 asset metadata for public config. */ + export const assets = Assets + /** x402 client transports. */ + export const Transport = Transport_ +} diff --git a/src/x402/client/Transport.ts b/src/x402/client/Transport.ts new file mode 100644 index 00000000..087c793f --- /dev/null +++ b/src/x402/client/Transport.ts @@ -0,0 +1,52 @@ +import * as Challenge from '../../Challenge.js' +import * as ClientTransport from '../../client/Transport.js' +import * as Header from '../Header.js' +import * as Types from '../Types.js' + +/** HTTP transport for x402 v2 header flow. */ +export function http() { + return ClientTransport.from({ + name: 'x402-http', + + isPaymentRequired(response) { + return response.status === 402 && response.headers.has(Types.paymentRequiredHeader) + }, + + getChallenges(response) { + return paymentRequiredToChallenges(Header.decodePaymentRequired(requireHeader(response))) + }, + + getChallenge(response) { + return paymentRequiredToChallenges(Header.decodePaymentRequired(requireHeader(response)))[0]! + }, + + setCredential(request, credential) { + const headers = new Headers(request.headers) + headers.set(Types.paymentSignatureHeader, credential) + return { ...request, headers } + }, + }) +} + +function requireHeader(response: Response): string { + const header = response.headers.get(Types.paymentRequiredHeader) + if (!header) throw new Error(`Missing ${Types.paymentRequiredHeader} header.`) + return header +} + +function paymentRequiredToChallenges( + paymentRequired: Types.PaymentRequired, +): Challenge.Challenge[] { + return paymentRequired.accepts.map((accepted, index) => + Challenge.from({ + id: `x402-${index}`, + intent: 'exact', + method: 'x402', + realm: new URL(paymentRequired.resource.url).host, + request: { + ...accepted, + resource: paymentRequired.resource, + }, + }), + ) +} diff --git a/src/x402/client/index.ts b/src/x402/client/index.ts new file mode 100644 index 00000000..8917b7fb --- /dev/null +++ b/src/x402/client/index.ts @@ -0,0 +1,4 @@ +export * as assets from '../Assets.js' +export * as Transport from './Transport.js' +export { exact } from './Exact.js' +export { x402 } from './Methods.js' diff --git a/src/x402/index.ts b/src/x402/index.ts new file mode 100644 index 00000000..8f4d1e2d --- /dev/null +++ b/src/x402/index.ts @@ -0,0 +1,6 @@ +export * as Assets from './Assets.js' +export * as Header from './Header.js' +export * as Methods from './Methods.js' +export * as Types from './Types.js' +export * as assets from './Assets.js' +export * from './Types.js' diff --git a/src/x402/server/Exact.ts b/src/x402/server/Exact.ts new file mode 100644 index 00000000..d576ee90 --- /dev/null +++ b/src/x402/server/Exact.ts @@ -0,0 +1,157 @@ +import { isDeepStrictEqual } from 'node:util' + +import { VerificationFailedError } from '../../Errors.js' +import * as Method from '../../Method.js' +import * as Receipt from '../../Receipt.js' +import * as Assets from '../Assets.js' +import * as Methods from '../Methods.js' +import * as Types from '../Types.js' +import * as Transport from './Transport.js' + +/** + * Creates an x402 exact server method. + * + * The public config hides x402 wire `extra` fields. Known assets provide the + * required EIP-712 domain metadata automatically; custom assets must provide a + * typed `transfer` config. + */ +export function exact(parameters: parameters) { + const config = resolveConfig(parameters.config) + const facilitator = resolveFacilitator(config.facilitator) + const transport = Transport.http() + + return Method.toServer(Methods.exact, { + defaults: { + asset: config.asset, + maxTimeoutSeconds: config.maxTimeoutSeconds, + network: config.network, + payTo: config.payTo, + transfer: config.transfer, + }, + transport, + async verify({ credential }) { + const paymentPayload = credential.payload as Types.PaymentPayload + const paymentRequirements = Types.toPaymentRequirements( + credential.challenge.request as Types.ExactRequest, + ) + + if (!isDeepStrictEqual(paymentPayload.accepted, paymentRequirements)) + throw new VerificationFailedError({ + reason: 'x402 payment payload does not match route requirements', + }) + + const verified = await facilitator.verify(paymentPayload, paymentRequirements) + if (!verified.isValid) + throw new VerificationFailedError({ + reason: verified.invalidMessage ?? verified.invalidReason ?? 'x402 verify failed', + }) + + const settled = await facilitator.settle(paymentPayload, paymentRequirements) + if (!settled.success) + throw new VerificationFailedError({ + reason: settled.errorMessage ?? settled.errorReason ?? 'x402 settlement failed', + }) + + return Receipt.from({ + method: 'x402', + reference: settled.transaction, + status: 'success', + timestamp: new Date().toISOString(), + }) + }, + }) +} + +export declare namespace exact { + type Parameters = { + config: Config + } + + type Config = { + /** Token contract address or known x402 asset metadata. */ + asset: `0x${string}` | Assets.KnownAsset + /** Facilitator client or base URL. */ + facilitator: string | Types.Facilitator + /** Maximum time in seconds allowed for payment completion. @default 60 */ + maxTimeoutSeconds?: number | undefined + /** CAIP-2 network. Required for custom asset addresses; inferred for known assets. */ + network?: Types.EvmNetwork | undefined + /** Recipient wallet address. */ + payTo: `0x${string}` + /** Required for custom asset addresses; inferred for known assets. */ + transfer?: Types.ExactTransfer | undefined + } + + type Defaults = { + asset: `0x${string}` + maxTimeoutSeconds: number + network: Types.EvmNetwork + payTo: `0x${string}` + transfer: Types.ExactTransfer + } + + type RouteOptions = { + /** Required atomic token amount. */ + amount: string + /** Optional x402 resource metadata for the protected route. */ + resource?: Types.ResourceInfo | undefined + } +} + +type ResolvedConfig = exact.Defaults & { + facilitator: string | Types.Facilitator +} + +function resolveConfig(config: exact.Config): ResolvedConfig { + let address: `0x${string}` + let network = config.network + let transfer = config.transfer + + if (Assets.isAsset(config.asset)) { + address = config.asset.address + network ??= config.asset.network + transfer ??= config.asset.transfer + } else { + address = config.asset + } + + if (!network) throw new Error('x402 exact custom assets require `network`.') + if (!transfer) throw new Error('x402 exact custom assets require `transfer`.') + + return { + asset: address, + facilitator: config.facilitator, + maxTimeoutSeconds: config.maxTimeoutSeconds ?? 60, + network, + payTo: config.payTo, + transfer, + } +} + +function resolveFacilitator(facilitator: string | Types.Facilitator): Types.Facilitator { + if (typeof facilitator === 'object' && facilitator !== null) return facilitator + if (typeof facilitator === 'string') return httpFacilitator(facilitator) + throw new Error('x402 exact requires `facilitator`.') +} + +function httpFacilitator(url: string): Types.Facilitator { + const base = url.replace(/\/$/, '') + return { + async verify(paymentPayload, paymentRequirements) { + const response = await fetch(`${base}/verify`, { + body: JSON.stringify({ paymentPayload, paymentRequirements }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + return Types.VerifyResponseSchema.parse(await response.json()) + }, + async settle(paymentPayload, paymentRequirements) { + const response = await fetch(`${base}/settle`, { + body: JSON.stringify({ paymentPayload, paymentRequirements }), + headers: { 'Content-Type': 'application/json' }, + method: 'POST', + }) + return Types.SettleResponseSchema.parse(await response.json()) + }, + } +} diff --git a/src/x402/server/Methods.ts b/src/x402/server/Methods.ts new file mode 100644 index 00000000..d84d5593 --- /dev/null +++ b/src/x402/server/Methods.ts @@ -0,0 +1,19 @@ +import * as Assets from '../Assets.js' +import { exact as exact_ } from './Exact.js' +import * as Transport_ from './Transport.js' + +/** Creates x402 server methods from shared parameters. */ +export function x402(parameters: parameters) { + return [x402.exact(parameters)] as const +} + +export namespace x402 { + export type Parameters = exact_.Parameters + + /** Creates an x402 `exact` server method. */ + export const exact = exact_ + /** Known x402 asset metadata for public config. */ + export const assets = Assets + /** x402 server transports. */ + export const Transport = Transport_ +} diff --git a/src/x402/server/Transport.ts b/src/x402/server/Transport.ts new file mode 100644 index 00000000..0469984e --- /dev/null +++ b/src/x402/server/Transport.ts @@ -0,0 +1,91 @@ +import * as Challenge from '../../Challenge.js' +import * as Credential from '../../Credential.js' +import * as ServerTransport from '../../server/Transport.js' +import * as Header from '../Header.js' +import * as Types from '../Types.js' + +/** HTTP transport for x402 v2 header flow. */ +export function http() { + return ServerTransport.from({ + name: 'x402-http', + + captureRequest(request) { + return { + hasBody: request.body !== null, + headers: new Headers(request.headers), + method: request.method, + url: ServerTransport.safeUrl(request.url), + } + }, + + getCredential(request) { + const header = request.headers.get(Types.paymentSignatureHeader) + if (!header) return null + const paymentPayload = Header.decodePaymentSignature(header) + + return Credential.from({ + challenge: Challenge.from({ + id: 'x402-pending', + intent: 'exact', + method: 'x402', + realm: 'x402', + request: paymentPayload.accepted, + }), + payload: paymentPayload, + }) + }, + + bindCredential({ challenge, credential }) { + return Credential.from({ + challenge, + payload: credential.payload, + }) + }, + + respondChallenge({ challenge, error, input }) { + const request = challenge.request as Types.ExactRequest + const resource = request.resource ?? { + url: input.url, + } + const paymentRequired: Types.PaymentRequired = { + accepts: [Types.toPaymentRequirements(request)], + error: error?.message ?? `${Types.paymentSignatureHeader} header is required`, + resource, + x402Version: 2, + } + + return new Response('{}', { + status: error?.status ?? 402, + headers: { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + [Types.paymentRequiredHeader]: Header.encodePaymentRequired(paymentRequired), + }, + }) + }, + + respondReceipt({ credential, receipt, response }) { + const paymentPayload = Types.PaymentPayloadSchema.parse(credential.payload) + const headers = new Headers(response.headers) + headers.set( + Types.paymentResponseHeader, + Header.encodePaymentResponse({ + network: paymentPayload.accepted.network, + payer: payerOf(paymentPayload.payload), + success: true, + transaction: receipt.reference, + }), + ) + return new Response(response.body, { + headers, + status: response.status, + statusText: response.statusText, + }) + }, + }) +} + +function payerOf(payload: Types.ExactPayload): string | undefined { + if ('authorization' in payload) return payload.authorization.from + return payload.permit2Authorization.from +} diff --git a/src/x402/server/index.ts b/src/x402/server/index.ts new file mode 100644 index 00000000..8917b7fb --- /dev/null +++ b/src/x402/server/index.ts @@ -0,0 +1,4 @@ +export * as assets from '../Assets.js' +export * as Transport from './Transport.js' +export { exact } from './Exact.js' +export { x402 } from './Methods.js' diff --git a/test/tsconfig.json b/test/tsconfig.json index 2af8613d..33790163 100644 --- a/test/tsconfig.json +++ b/test/tsconfig.json @@ -9,6 +9,9 @@ "mppx/proxy": ["../src/proxy/index.ts"], "mppx/server": ["../src/server/index.ts"], "mppx/tempo": ["../src/tempo/index.ts"], + "mppx/x402": ["../src/x402/index.ts"], + "mppx/x402/client": ["../src/x402/client/index.ts"], + "mppx/x402/server": ["../src/x402/server/index.ts"], "mppx/hono": ["../src/middlewares/hono.ts"], "mppx/express": ["../src/middlewares/express.ts"], "mppx/nextjs": ["../src/middlewares/nextjs.ts"], From 5b1efb39e0c293f6f2d90741d00f99e51b14d7e2 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 11:41:30 -0700 Subject: [PATCH 02/14] docs: clarify x402 transport usage --- README.md | 5 +++++ src/internal/HeaderCodec.ts | 36 ++++++++++++++++++++++++++++++++++++ src/x402/Header.ts | 35 ++++------------------------------- src/x402/Types.ts | 7 +++++-- src/x402/client/Exact.ts | 2 +- 5 files changed, 51 insertions(+), 34 deletions(-) create mode 100644 src/internal/HeaderCodec.ts diff --git a/README.md b/README.md index a9d36e3b..c279c939 100644 --- a/README.md +++ b/README.md @@ -147,6 +147,11 @@ const mppx = Mppx.create({ const res = await mppx.fetch('https://api.example.com/paid') ``` +x402 uses its own HTTP headers (`PAYMENT-REQUIRED`, `PAYMENT-SIGNATURE`, and +`PAYMENT-RESPONSE`), so clients must use `x402.Transport.http()` when calling +x402-protected routes. Server methods install the matching transport +automatically, including Hono, Express, Elysia, and Next.js adapters. + ## Examples | Example | Description | diff --git a/src/internal/HeaderCodec.ts b/src/internal/HeaderCodec.ts new file mode 100644 index 00000000..33c85cfd --- /dev/null +++ b/src/internal/HeaderCodec.ts @@ -0,0 +1,36 @@ +import { Base64 } from 'ox' + +import type * as z from '../zod.js' + +/** + * Creates a typed codec for JSON HTTP header values. + * + * x402 uses plain base64 JSON header bodies, while the Payment auth scheme uses + * its own base64url/JCS serializers. Keep this helper internal so transports + * can opt into the exact wire encoding their protocol expects. + */ +export function createJson(schema: schema) { + type value = z.output + + return { + encode(value: value): string { + return Base64.fromString(JSON.stringify(schema.parse(value))) + }, + decode(value: string): value { + try { + return schema.parse(JSON.parse(Base64.toString(value))) as value + } catch { + throw new InvalidJsonHeaderError() + } + }, + } +} + +/** Error thrown when a JSON header value is not valid base64-encoded JSON. */ +export class InvalidJsonHeaderError extends Error { + override readonly name = 'InvalidJsonHeaderError' + + constructor() { + super('Invalid base64 JSON header.') + } +} diff --git a/src/x402/Header.ts b/src/x402/Header.ts index 1307daf7..77bf358f 100644 --- a/src/x402/Header.ts +++ b/src/x402/Header.ts @@ -1,6 +1,4 @@ -import { Base64 } from 'ox' - -import type * as z from '../zod.js' +import * as HeaderCodec from '../internal/HeaderCodec.js' import { PaymentPayloadSchema, PaymentRequiredSchema, @@ -10,9 +8,9 @@ import { type SettleResponse, } from './Types.js' -const paymentRequired = createHeaderCodec(PaymentRequiredSchema) -const paymentSignature = createHeaderCodec(PaymentPayloadSchema) -const paymentResponse = createHeaderCodec(SettleResponseSchema) +const paymentRequired = HeaderCodec.createJson(PaymentRequiredSchema) +const paymentSignature = HeaderCodec.createJson(PaymentPayloadSchema) +const paymentResponse = HeaderCodec.createJson(SettleResponseSchema) /** Encodes an x402 payment-required object for the `PAYMENT-REQUIRED` header. */ export const encodePaymentRequired: (paymentRequired: PaymentRequired) => string = @@ -34,28 +32,3 @@ export const encodePaymentResponse: (paymentResponse: SettleResponse) => string /** Decodes an x402 `PAYMENT-RESPONSE` header value. */ export const decodePaymentResponse: (value: string) => SettleResponse = paymentResponse.decode - -function createHeaderCodec(schema: schema) { - type value = z.output - - return { - encode(value: value): string { - return encodeJson(schema.parse(value)) - }, - decode(value: string): value { - return schema.parse(decodeJson(value)) as value - }, - } -} - -function encodeJson(value: unknown): string { - return Base64.fromString(JSON.stringify(value)) -} - -function decodeJson(value: string): unknown { - try { - return JSON.parse(Base64.toString(value)) - } catch { - throw new Error('Invalid x402 base64 JSON header.') - } -} diff --git a/src/x402/Types.ts b/src/x402/Types.ts index 9d0328ee..98115e49 100644 --- a/src/x402/Types.ts +++ b/src/x402/Types.ts @@ -3,6 +3,7 @@ import * as z from '../zod.js' export const versions = [2] as const export const schemes = ['exact'] as const export const assetTransferMethods = ['eip3009', 'permit2'] as const +export const evmNetworkPrefix = 'eip155:' as const /** x402 protocol version supported by this package. */ export type Version = 2 @@ -14,7 +15,7 @@ export type Scheme = (typeof schemes)[number] export type AssetTransferMethod = (typeof assetTransferMethods)[number] /** CAIP-2 EVM network identifier. */ -export type EvmNetwork = `eip155:${number}` +export type EvmNetwork = `${typeof evmNetworkPrefix}${number}` /** HTTP header carrying a base64-encoded x402 payment-required response. */ export const paymentRequiredHeader = 'PAYMENT-REQUIRED' @@ -31,7 +32,9 @@ const atomicAmount = z.string().check(z.regex(/^\d+$/, 'Invalid atomic amount')) const address = z.address() const evmNetwork = z .string() - .check(z.regex(/^eip155:\d+$/, 'Invalid EVM CAIP-2 network')) as z.ZodMiniType + .check( + z.regex(new RegExp(`^${evmNetworkPrefix}\\d+$`), 'Invalid EVM CAIP-2 network'), + ) as z.ZodMiniType /** Describes the protected resource in x402 v2 payment-required responses. */ export const ResourceInfoSchema = z.object({ diff --git a/src/x402/client/Exact.ts b/src/x402/client/Exact.ts index 943d5470..d08b45dd 100644 --- a/src/x402/client/Exact.ts +++ b/src/x402/client/Exact.ts @@ -104,7 +104,7 @@ const authorizationTypes = { } as const function chainIdOf(network: Types.EvmNetwork): number { - return Number(network.slice('eip155:'.length)) + return Number(network.slice(Types.evmNetworkPrefix.length)) } function assertPolicy(parameters: exact.Parameters, accepted: Types.PaymentRequirements) { From 4ac59aca13a033b13ebe12a6a512da8617adf412 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 11:44:29 -0700 Subject: [PATCH 03/14] fix: multiplex x402 http transport --- README.md | 8 ++--- src/client/Transport.test.ts | 66 ++++++++++++++++++++++++++++++++++++ src/client/Transport.ts | 44 +++++++++++++++++++++--- src/x402/Exact.e2e.test.ts | 1 - 4 files changed, 108 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index c279c939..2d9db3a0 100644 --- a/README.md +++ b/README.md @@ -141,16 +141,14 @@ const mppx = Mppx.create({ networks: ['eip155:84532'], }), ], - transport: x402.Transport.http(), }) const res = await mppx.fetch('https://api.example.com/paid') ``` -x402 uses its own HTTP headers (`PAYMENT-REQUIRED`, `PAYMENT-SIGNATURE`, and -`PAYMENT-RESPONSE`), so clients must use `x402.Transport.http()` when calling -x402-protected routes. Server methods install the matching transport -automatically, including Hono, Express, Elysia, and Next.js adapters. +The default HTTP transport multiplexes Payment auth and x402 headers. It reads +`WWW-Authenticate` and `PAYMENT-REQUIRED`, then sends credentials through either +`Authorization` or `PAYMENT-SIGNATURE` based on the selected challenge. ## Examples diff --git a/src/client/Transport.test.ts b/src/client/Transport.test.ts index 4017bd97..5b4ac818 100644 --- a/src/client/Transport.test.ts +++ b/src/client/Transport.test.ts @@ -1,6 +1,7 @@ import { Challenge, Credential, Mcp } from 'mppx' import { Transport } from 'mppx/client' import { Methods } from 'mppx/tempo' +import { Header as x402_Header, type PaymentRequired } from 'mppx/x402' import { describe, expect, test } from 'vp/test' const realm = 'api.example.com' @@ -23,6 +24,23 @@ const credential = Credential.from({ payload: { signature: '0xabc123', type: 'transaction' }, }) +const x402PaymentRequired = { + accepts: [ + { + amount: '10000', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + maxTimeoutSeconds: 60, + network: 'eip155:84532', + payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + scheme: 'exact', + }, + ], + resource: { + url: 'https://api.example.com/x402', + }, + x402Version: 2, +} satisfies PaymentRequired + describe('http', () => { describe('isPaymentRequired', () => { test('returns true for 402 response', () => { @@ -95,6 +113,45 @@ describe('http', () => { 'alternate', ]) }) + + test('returns x402 challenges from PAYMENT-REQUIRED', () => { + const transport = Transport.http() + const response = new Response(null, { + status: 402, + headers: { + 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired(x402PaymentRequired), + }, + }) + + expect(transport.getChallenges?.(response)).toMatchObject([ + { + id: 'x402-0', + intent: 'exact', + method: 'x402', + realm: 'api.example.com', + request: { + amount: '10000', + scheme: 'exact', + }, + }, + ]) + }) + + test('returns Payment auth and x402 challenges from the same response', () => { + const transport = Transport.http() + const response = new Response(null, { + status: 402, + headers: { + 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired(x402PaymentRequired), + 'WWW-Authenticate': Challenge.serialize(challenge), + }, + }) + + expect(transport.getChallenges?.(response).map((entry) => entry.method)).toEqual([ + 'tempo', + 'x402', + ]) + }) }) describe('setCredential', () => { @@ -120,6 +177,15 @@ describe('http', () => { expect(headers.get('Content-Type')).toBe('application/json') }) + + test('writes raw x402 credentials to PAYMENT-SIGNATURE', () => { + const transport = Transport.http() + const result = transport.setCredential({}, 'x402-signature') + const headers = result.headers as Headers + + expect(headers.get('PAYMENT-SIGNATURE')).toBe('x402-signature') + expect(headers.has('Authorization')).toBe(false) + }) }) }) diff --git a/src/client/Transport.ts b/src/client/Transport.ts index dfc6a9c5..2e8b3ba8 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -1,6 +1,8 @@ import * as Challenge from '../Challenge.js' import * as Credential from '../Credential.js' import * as Mcp from '../Mcp.js' +import * as x402_Header from '../x402/Header.js' +import * as x402_Types from '../x402/Types.js' /** * Client-side transport adapter. @@ -55,8 +57,10 @@ export function from( * HTTP transport for client-side payment handling. * * - Detects payment required via 402 status - * - Extracts challenges from `WWW-Authenticate` header - * - Sends credentials via `Authorization` header + * - Extracts Payment auth challenges from `WWW-Authenticate` + * - Extracts x402 exact challenges from `PAYMENT-REQUIRED` + * - Sends Payment auth credentials via `Authorization` + * - Sends x402 credentials via `PAYMENT-SIGNATURE` */ export function http() { return from({ @@ -67,21 +71,51 @@ export function http() { }, getChallenges(response) { - return Challenge.fromResponseList(response) + return [...paymentAuthChallenges(response), ...x402Challenges(response)] }, getChallenge(response) { - return Challenge.fromResponse(response) + const challenge = [...paymentAuthChallenges(response), ...x402Challenges(response)][0] + if (!challenge) throw new Error('No challenge in response.') + return challenge }, setCredential(request, credential) { const headers = new Headers(request.headers) - headers.set('Authorization', credential) + if (isPaymentAuthCredential(credential)) headers.set('Authorization', credential) + else headers.set(x402_Types.paymentSignatureHeader, credential) return { ...request, headers } }, }) } +function paymentAuthChallenges(response: Response): Challenge.Challenge[] { + if (!response.headers.has('WWW-Authenticate')) return [] + return Challenge.fromResponseList(response) +} + +function x402Challenges(response: Response): Challenge.Challenge[] { + const header = response.headers.get(x402_Types.paymentRequiredHeader) + if (!header) return [] + const paymentRequired = x402_Header.decodePaymentRequired(header) + return paymentRequired.accepts.map((accepted, index) => + Challenge.from({ + id: `x402-${index}`, + intent: 'exact', + method: 'x402', + realm: new URL(paymentRequired.resource.url).host, + request: { + ...accepted, + resource: paymentRequired.resource, + }, + }), + ) +} + +function isPaymentAuthCredential(credential: string): boolean { + return /^Payment\s+/i.test(credential) +} + /** * MCP transport for client-side payment handling. * diff --git a/src/x402/Exact.e2e.test.ts b/src/x402/Exact.e2e.test.ts index 325344ab..24f345c4 100644 --- a/src/x402/Exact.e2e.test.ts +++ b/src/x402/Exact.e2e.test.ts @@ -108,7 +108,6 @@ describe('x402 exact e2e', () => { }), ], polyfill: false, - transport: x402Client.Transport.http(), }) const x402Required = await x402ClientPayment.rawFetch(`${server.url}/x402`) expect(x402Required.status).toBe(402) From b755ad6453276137a7d048d1f9d0708c97228f71 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 11:47:49 -0700 Subject: [PATCH 04/14] test: cover multiplexed http transport --- src/client/Transport.test.ts | 154 ++++++++++++++++++----------------- src/client/Transport.ts | 26 ++++-- src/client/internal/Fetch.ts | 1 + 3 files changed, 99 insertions(+), 82 deletions(-) diff --git a/src/client/Transport.test.ts b/src/client/Transport.test.ts index 5b4ac818..71f5c00a 100644 --- a/src/client/Transport.test.ts +++ b/src/client/Transport.test.ts @@ -43,25 +43,15 @@ const x402PaymentRequired = { describe('http', () => { describe('isPaymentRequired', () => { - test('returns true for 402 response', () => { - const transport = Transport.http() - const response = new Response(null, { status: 402 }) - - expect(transport.isPaymentRequired(response)).toBe(true) - }) + test.each([ + { expected: true, status: 402 }, + { expected: false, status: 200 }, + { expected: false, status: 401 }, + ])('returns $expected for $status response', ({ expected, status }) => { + const response = new Response(null, { status }) - test('returns false for 200 response', () => { const transport = Transport.http() - const response = new Response(null, { status: 200 }) - - expect(transport.isPaymentRequired(response)).toBe(false) - }) - - test('returns false for other error responses', () => { - const transport = Transport.http() - const response = new Response(null, { status: 401 }) - - expect(transport.isPaymentRequired(response)).toBe(false) + expect(transport.isPaymentRequired(response)).toBe(expected) }) }) @@ -98,71 +88,92 @@ describe('http', () => { }) describe('getChallenges', () => { - test('returns all HTTP challenges', () => { + test.each([ + { + expectedIds: [challenge.id, 'alternate'], + expectedMethods: ['tempo', 'stripe'], + headers: () => ({ + 'WWW-Authenticate': `${Challenge.serialize(challenge)}, ${Challenge.serialize({ + ...challenge, + id: 'alternate', + method: 'stripe' as const, + })}`, + }), + name: 'Payment auth challenges', + }, + { + expectedIds: ['x402-0'], + expectedMethods: ['x402'], + headers: () => ({ + 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired(x402PaymentRequired), + }), + name: 'x402 challenges', + }, + { + expectedIds: [challenge.id, 'x402-0'], + expectedMethods: ['tempo', 'x402'], + headers: () => ({ + 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired(x402PaymentRequired), + 'WWW-Authenticate': Challenge.serialize(challenge), + }), + name: 'Payment auth and x402 challenges', + }, + ])('returns $name', ({ expectedIds, expectedMethods, headers }) => { const transport = Transport.http() - const alternate = { ...challenge, id: 'alternate', method: 'stripe' as const } const response = new Response(null, { status: 402, - headers: { - 'WWW-Authenticate': `${Challenge.serialize(challenge)}, ${Challenge.serialize(alternate)}`, - }, + headers: headers(), }) + const challenges = transport.getChallenges?.(response) ?? [] - expect(transport.getChallenges?.(response).map((entry) => entry.id)).toEqual([ - challenge.id, - 'alternate', - ]) + expect(challenges.map((entry) => entry.id)).toEqual(expectedIds) + expect(challenges.map((entry) => entry.method)).toEqual(expectedMethods) }) + }) - test('returns x402 challenges from PAYMENT-REQUIRED', () => { - const transport = Transport.http() - const response = new Response(null, { - status: 402, - headers: { - 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired(x402PaymentRequired), - }, - }) - - expect(transport.getChallenges?.(response)).toMatchObject([ - { + describe('setCredential', () => { + test.each([ + { + challenge, + credential: Credential.serialize(credential), + expectedHeader: 'Authorization', + expectedValue: Credential.serialize(credential), + name: 'Payment auth credential for Payment auth challenge', + }, + { + challenge: Challenge.from({ id: 'x402-0', intent: 'exact', method: 'x402', realm: 'api.example.com', - request: { - amount: '10000', - scheme: 'exact', - }, - }, - ]) - }) - - test('returns Payment auth and x402 challenges from the same response', () => { - const transport = Transport.http() - const response = new Response(null, { - status: 402, - headers: { - 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired(x402PaymentRequired), - 'WWW-Authenticate': Challenge.serialize(challenge), - }, - }) - - expect(transport.getChallenges?.(response).map((entry) => entry.method)).toEqual([ - 'tempo', - 'x402', - ]) - }) - }) - - describe('setCredential', () => { - test('default', () => { + request: x402PaymentRequired.accepts[0]!, + }), + credential: 'x402-signature', + expectedHeader: 'PAYMENT-SIGNATURE', + expectedValue: 'x402-signature', + name: 'raw x402 credential for x402 challenge', + }, + { + challenge, + credential: 'custom-credential', + expectedHeader: 'Authorization', + expectedValue: 'custom-credential', + name: 'non-Payment credential for non-x402 challenge', + }, + { + challenge: undefined, + credential: 'custom-credential', + expectedHeader: 'Authorization', + expectedValue: 'custom-credential', + name: 'credential without selected challenge', + }, + ])('writes $name', ({ challenge, credential, expectedHeader, expectedValue }) => { const transport = Transport.http() - const serialized = Credential.serialize(credential) - const result = transport.setCredential({}, serialized) + const result = transport.setCredential({}, credential, { challenge }) const headers = result.headers as Headers - expect(headers.get('Authorization')).toBe(serialized) + expect(headers.get(expectedHeader)).toBe(expectedValue) }) test('preserves existing headers', () => { @@ -177,15 +188,6 @@ describe('http', () => { expect(headers.get('Content-Type')).toBe('application/json') }) - - test('writes raw x402 credentials to PAYMENT-SIGNATURE', () => { - const transport = Transport.http() - const result = transport.setCredential({}, 'x402-signature') - const headers = result.headers as Headers - - expect(headers.get('PAYMENT-SIGNATURE')).toBe('x402-signature') - expect(headers.has('Authorization')).toBe(false) - }) }) }) diff --git a/src/client/Transport.ts b/src/client/Transport.ts index 2e8b3ba8..e54ea700 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -20,10 +20,21 @@ export type Transport = { /** Extracts the challenge from a payment-required response. */ getChallenge: (response: response) => Challenge.Challenge /** Attaches a credential to a request. */ - setCredential: (request: request, credential: string) => request + setCredential: ( + request: request, + credential: string, + options?: setCredential.Options | undefined, + ) => request } export type AnyTransport = Transport +export declare namespace setCredential { + type Options = { + /** Challenge selected for credential creation. */ + challenge?: Challenge.Challenge | undefined + } +} + /** Extracts the response type from a transport. */ export type ResponseOf = transport extends Transport ? response : never @@ -80,10 +91,13 @@ export function http() { return challenge }, - setCredential(request, credential) { + setCredential(request, credential, options) { const headers = new Headers(request.headers) - if (isPaymentAuthCredential(credential)) headers.set('Authorization', credential) - else headers.set(x402_Types.paymentSignatureHeader, credential) + if (isX402Challenge(options?.challenge)) { + headers.set(x402_Types.paymentSignatureHeader, credential) + } else { + headers.set('Authorization', credential) + } return { ...request, headers } }, }) @@ -112,8 +126,8 @@ function x402Challenges(response: Response): Challenge.Challenge[] { ) } -function isPaymentAuthCredential(credential: string): boolean { - return /^Payment\s+/i.test(credential) +function isX402Challenge(challenge: Challenge.Challenge | undefined): boolean { + return challenge?.method === 'x402' && challenge.intent === 'exact' } /** diff --git a/src/client/internal/Fetch.ts b/src/client/internal/Fetch.ts index 0874b6a2..0e2723b0 100644 --- a/src/client/internal/Fetch.ts +++ b/src/client/internal/Fetch.ts @@ -269,6 +269,7 @@ export function from( headers: initialRequest.headers, }, credential, + { challenge: selectedChallenge }, ), ) if (paymentResponse.ok) From 23479458a594f60fb994276d672527dc7d2ba600 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 11:50:46 -0700 Subject: [PATCH 05/14] fix: strengthen x402 transport constants --- src/client/Transport.test.ts | 18 +++++++++--------- src/client/Transport.ts | 33 ++++++++++++++++++++++++--------- src/x402/Assets.ts | 2 ++ src/x402/Methods.ts | 6 +++--- src/x402/Types.ts | 18 ++++++++++++++++++ src/x402/client/Transport.ts | 6 +++--- src/x402/server/Exact.ts | 2 +- src/x402/server/Transport.ts | 4 ++-- 8 files changed, 62 insertions(+), 27 deletions(-) diff --git a/src/client/Transport.test.ts b/src/client/Transport.test.ts index 71f5c00a..468fcf23 100644 --- a/src/client/Transport.test.ts +++ b/src/client/Transport.test.ts @@ -1,7 +1,7 @@ import { Challenge, Credential, Mcp } from 'mppx' import { Transport } from 'mppx/client' import { Methods } from 'mppx/tempo' -import { Header as x402_Header, type PaymentRequired } from 'mppx/x402' +import { Header as x402_Header, Types as x402_Types, type PaymentRequired } from 'mppx/x402' import { describe, expect, test } from 'vp/test' const realm = 'api.example.com' @@ -32,7 +32,7 @@ const x402PaymentRequired = { maxTimeoutSeconds: 60, network: 'eip155:84532', payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', - scheme: 'exact', + scheme: x402_Types.schemes[0], }, ], resource: { @@ -102,16 +102,16 @@ describe('http', () => { name: 'Payment auth challenges', }, { - expectedIds: ['x402-0'], - expectedMethods: ['x402'], + expectedIds: [`${x402_Types.syntheticChallengeIdPrefix}0`], + expectedMethods: [x402_Types.paymentMethod], headers: () => ({ 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired(x402PaymentRequired), }), name: 'x402 challenges', }, { - expectedIds: [challenge.id, 'x402-0'], - expectedMethods: ['tempo', 'x402'], + expectedIds: [challenge.id, `${x402_Types.syntheticChallengeIdPrefix}0`], + expectedMethods: ['tempo', x402_Types.paymentMethod], headers: () => ({ 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired(x402PaymentRequired), 'WWW-Authenticate': Challenge.serialize(challenge), @@ -142,9 +142,9 @@ describe('http', () => { }, { challenge: Challenge.from({ - id: 'x402-0', - intent: 'exact', - method: 'x402', + id: `${x402_Types.syntheticChallengeIdPrefix}0`, + intent: x402_Types.exactIntent, + method: x402_Types.paymentMethod, realm: 'api.example.com', request: x402PaymentRequired.accepts[0]!, }), diff --git a/src/client/Transport.ts b/src/client/Transport.ts index e54ea700..db5eb4d8 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -4,6 +4,10 @@ import * as Mcp from '../Mcp.js' import * as x402_Header from '../x402/Header.js' import * as x402_Types from '../x402/Types.js' +const paymentRequiredStatus = 402 +const paymentAuthChallengeHeader = 'WWW-Authenticate' +const paymentAuthCredentialHeader = 'Authorization' + /** * Client-side transport adapter. * @@ -78,15 +82,15 @@ export function http() { name: 'http', isPaymentRequired(response) { - return response.status === 402 + return response.status === paymentRequiredStatus }, getChallenges(response) { - return [...paymentAuthChallenges(response), ...x402Challenges(response)] + return paymentRequiredChallenges(response) }, getChallenge(response) { - const challenge = [...paymentAuthChallenges(response), ...x402Challenges(response)][0] + const challenge = paymentRequiredChallenges(response)[0] if (!challenge) throw new Error('No challenge in response.') return challenge }, @@ -96,15 +100,19 @@ export function http() { if (isX402Challenge(options?.challenge)) { headers.set(x402_Types.paymentSignatureHeader, credential) } else { - headers.set('Authorization', credential) + headers.set(paymentAuthCredentialHeader, credential) } return { ...request, headers } }, }) } +function paymentRequiredChallenges(response: Response): Challenge.Challenge[] { + return [...paymentAuthChallenges(response), ...x402Challenges(response)] +} + function paymentAuthChallenges(response: Response): Challenge.Challenge[] { - if (!response.headers.has('WWW-Authenticate')) return [] + if (!response.headers.has(paymentAuthChallengeHeader)) return [] return Challenge.fromResponseList(response) } @@ -114,9 +122,9 @@ function x402Challenges(response: Response): Challenge.Challenge[] { const paymentRequired = x402_Header.decodePaymentRequired(header) return paymentRequired.accepts.map((accepted, index) => Challenge.from({ - id: `x402-${index}`, - intent: 'exact', - method: 'x402', + id: `${x402_Types.syntheticChallengeIdPrefix}${index}`, + intent: x402_Types.exactIntent, + method: x402_Types.paymentMethod, realm: new URL(paymentRequired.resource.url).host, request: { ...accepted, @@ -127,7 +135,14 @@ function x402Challenges(response: Response): Challenge.Challenge[] { } function isX402Challenge(challenge: Challenge.Challenge | undefined): boolean { - return challenge?.method === 'x402' && challenge.intent === 'exact' + return ( + challenge?.method === x402_Types.paymentMethod && + challenge.intent === x402_Types.exactIntent && + typeof challenge.request === 'object' && + challenge.request !== null && + 'scheme' in challenge.request && + challenge.request.scheme === x402_Types.schemes[0] + ) } /** diff --git a/src/x402/Assets.ts b/src/x402/Assets.ts index 1fa57002..d88cfa3b 100644 --- a/src/x402/Assets.ts +++ b/src/x402/Assets.ts @@ -35,6 +35,7 @@ export const base = { decimals: 6, network: 'eip155:8453', transfer: { + // USDC's EIP-712 domain name differs between Base and Base Sepolia. name: 'USD Coin', type: 'eip3009', version: '2', @@ -49,6 +50,7 @@ export const baseSepolia = { decimals: 6, network: 'eip155:84532', transfer: { + // Base Sepolia test USDC signs with the shorter EIP-712 domain name. name: 'USDC', type: 'eip3009', version: '2', diff --git a/src/x402/Methods.ts b/src/x402/Methods.ts index 38dea640..b24b0947 100644 --- a/src/x402/Methods.ts +++ b/src/x402/Methods.ts @@ -9,8 +9,8 @@ import * as Types from './Types.js' * converts it into the x402 wire `extra` object. */ export const exact = Method.from({ - name: 'x402', - intent: 'exact', + name: Types.paymentMethod, + intent: Types.exactIntent, schema: { credential: { payload: Types.PaymentPayloadSchema, @@ -20,7 +20,7 @@ export const exact = Method.from({ z.transform(({ transfer, ...request }) => ({ ...request, extra: Types.transferToExtra(transfer), - scheme: 'exact' as const, + scheme: Types.schemes[0], })), ), }, diff --git a/src/x402/Types.ts b/src/x402/Types.ts index 98115e49..87f1e57f 100644 --- a/src/x402/Types.ts +++ b/src/x402/Types.ts @@ -1,13 +1,31 @@ import * as z from '../zod.js' export const versions = [2] as const + +/** mppx method name used for x402 challenges. */ +export const paymentMethod = 'x402' as const + +/** mppx intent name used for x402 exact challenges. */ +export const exactIntent = 'exact' as const + export const schemes = ['exact'] as const export const assetTransferMethods = ['eip3009', 'permit2'] as const + +/** CAIP-2 namespace prefix for EVM networks. */ export const evmNetworkPrefix = 'eip155:' as const +/** Prefix for synthetic mppx challenge IDs derived from x402 `accepts` entries. */ +export const syntheticChallengeIdPrefix = 'x402:' as const + /** x402 protocol version supported by this package. */ export type Version = 2 +/** mppx payment method name used for x402 challenges. */ +export type PaymentMethod = typeof paymentMethod + +/** mppx intent name used for x402 exact challenges. */ +export type ExactIntent = typeof exactIntent + /** x402 scheme supported by this package. */ export type Scheme = (typeof schemes)[number] diff --git a/src/x402/client/Transport.ts b/src/x402/client/Transport.ts index 087c793f..d40ad8aa 100644 --- a/src/x402/client/Transport.ts +++ b/src/x402/client/Transport.ts @@ -39,9 +39,9 @@ function paymentRequiredToChallenges( ): Challenge.Challenge[] { return paymentRequired.accepts.map((accepted, index) => Challenge.from({ - id: `x402-${index}`, - intent: 'exact', - method: 'x402', + id: `${Types.syntheticChallengeIdPrefix}${index}`, + intent: Types.exactIntent, + method: Types.paymentMethod, realm: new URL(paymentRequired.resource.url).host, request: { ...accepted, diff --git a/src/x402/server/Exact.ts b/src/x402/server/Exact.ts index d576ee90..b237eaa0 100644 --- a/src/x402/server/Exact.ts +++ b/src/x402/server/Exact.ts @@ -53,7 +53,7 @@ export function exact(parameters: par }) return Receipt.from({ - method: 'x402', + method: Types.paymentMethod, reference: settled.transaction, status: 'success', timestamp: new Date().toISOString(), diff --git a/src/x402/server/Transport.ts b/src/x402/server/Transport.ts index 0469984e..acf8be8a 100644 --- a/src/x402/server/Transport.ts +++ b/src/x402/server/Transport.ts @@ -26,8 +26,8 @@ export function http() { return Credential.from({ challenge: Challenge.from({ id: 'x402-pending', - intent: 'exact', - method: 'x402', + intent: Types.exactIntent, + method: Types.paymentMethod, realm: 'x402', request: paymentPayload.accepted, }), From 46d19af7fcedf6dc7acc0b127258c944412217e1 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 11:51:45 -0700 Subject: [PATCH 06/14] docs: show tempo and x402 server setup --- README.md | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 2d9db3a0..5fd16caf 100644 --- a/README.md +++ b/README.md @@ -81,10 +81,14 @@ const res = await fetch('https://mpp.dev/api/ping/paid') ### x402 Exact ```ts -import { Mppx, x402 } from 'mppx/server' +import { Mppx, tempo, x402 } from 'mppx/server' const mppx = Mppx.create({ methods: [ + tempo({ + currency: '0x20c0000000000000000000000000000000000000', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }), x402.exact({ config: { asset: x402.assets.baseSepolia.USDC, @@ -96,10 +100,14 @@ const mppx = Mppx.create({ }) export async function GET(request: Request) { - const result = await mppx.x402.exact({ - amount: '10000', - resource: { url: request.url }, - })(request) + const url = new URL(request.url) + const result = + url.pathname === '/mpp' + ? await mppx.tempo.charge({ amount: '1' })(request) + : await mppx.x402.exact({ + amount: '10000', + resource: { url: request.url }, + })(request) if (result.status === 402) return result.challenge return result.withReceipt(Response.json({ data: 'paid content' })) From e8c2b811e4058c31226ffff2eac6b2dfc86584c2 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 11:52:43 -0700 Subject: [PATCH 07/14] docs: show hono tempo and x402 setup --- README.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 5fd16caf..d7e7fac7 100644 --- a/README.md +++ b/README.md @@ -118,11 +118,15 @@ Existing server adapters expose the same method. For Hono: ```ts import { Hono } from 'hono' -import { Mppx, x402 } from 'mppx/hono' +import { Mppx, tempo, x402 } from 'mppx/hono' const app = new Hono() const mppx = Mppx.create({ methods: [ + tempo({ + currency: '0x20c0000000000000000000000000000000000000', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }), x402.exact({ config: { asset: x402.assets.baseSepolia.USDC, @@ -133,7 +137,8 @@ const mppx = Mppx.create({ ], }) -app.get('/paid', mppx.x402.exact({ amount: '10000' }), (c) => c.json({ ok: true })) +app.get('/mpp', mppx.tempo.charge({ amount: '1' }), (c) => c.json({ ok: true })) +app.get('/x402', mppx.x402.exact({ amount: '10000' }), (c) => c.json({ ok: true })) ``` ```ts From aba236087b6edd147d216e4799439232276f1832 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 11:55:02 -0700 Subject: [PATCH 08/14] chore: bump qs audit override --- pnpm-lock.yaml | 14 +++++++------- pnpm-workspace.yaml | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 558b7d2b..41ac7837 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -13,7 +13,7 @@ overrides: path-to-regexp@<8.4.0: 8.4.0 tar@<=7.5.10: 7.5.11 '@modelcontextprotocol/sdk@>=1.10.0 <=1.25.3': 1.26.0 - qs@>=6.7.0 <=6.14.1: 6.14.2 + qs@>=6.7.0 <=6.15.1: 6.15.2 ip-address@<=10.1.0: 10.1.1 minimatch@>=5.0.0 <5.1.8: 5.1.8 minimatch@>=9.0.0 <9.0.7: 9.0.7 @@ -3698,8 +3698,8 @@ packages: pure-rand@8.4.0: resolution: {integrity: sha512-IoM8YF/jY0hiugFo/wOWqfmarlE6J0wc6fDK1PhftMk7MGhVZl88sZimmqBBFomLOCSmcCCpsfj7wXASCpvK9A==} - qs@6.14.2: - resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} engines: {node: '>=0.6'} quansync@0.2.11: @@ -6007,7 +6007,7 @@ snapshots: http-errors: 2.0.1 iconv-lite: 0.7.2 on-finished: 2.4.1 - qs: 6.14.2 + qs: 6.15.2 raw-body: 3.0.2 type-is: 2.0.1 transitivePeerDependencies: @@ -6517,7 +6517,7 @@ snapshots: once: 1.4.0 parseurl: 1.3.3 proxy-addr: 2.0.7 - qs: 6.14.2 + qs: 6.15.2 range-parser: 1.2.1 router: 2.2.0 send: 1.2.1 @@ -7313,7 +7313,7 @@ snapshots: pure-rand@8.4.0: {} - qs@6.14.2: + qs@6.15.2: dependencies: side-channel: 1.1.0 @@ -7655,7 +7655,7 @@ snapshots: stripe@17.7.0: dependencies: '@types/node': 25.8.0 - qs: 6.14.2 + qs: 6.15.2 strtok3@10.3.5: dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7d8b3439..f425bda7 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -18,7 +18,7 @@ overrides: path-to-regexp@<8.4.0: '8.4.0' tar@<=7.5.10: '7.5.11' '@modelcontextprotocol/sdk@>=1.10.0 <=1.25.3': '1.26.0' - qs@>=6.7.0 <=6.14.1: '6.14.2' + qs@>=6.7.0 <=6.15.1: '6.15.2' ip-address@<=10.1.0: '10.1.1' minimatch@>=5.0.0 <5.1.8: '5.1.8' minimatch@>=9.0.0 <9.0.7: '9.0.7' From c94ad1df70e862a4232f3e7e9ba4d84918f90676 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 12:03:54 -0700 Subject: [PATCH 09/14] test: cover hono tempo and x402 route --- README.md | 36 +++++++++++++ src/middlewares/hono.test.ts | 99 +++++++++++++++++++++++++++++++++++- src/server/Mppx.ts | 41 +++++++++++++-- 3 files changed, 170 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index d7e7fac7..46dbe798 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,42 @@ app.get('/mpp', mppx.tempo.charge({ amount: '1' }), (c) => c.json({ ok: true })) app.get('/x402', mppx.x402.exact({ amount: '10000' }), (c) => c.json({ ok: true })) ``` +To offer both protocols from one Hono route, use the core HTTP composer inside +the Hono handler: + +```ts +import { Hono } from 'hono' +import { Mppx, tempo, x402 } from 'mppx/server' + +const app = new Hono() +const payments = Mppx.create({ + methods: [ + tempo({ + currency: '0x20c0000000000000000000000000000000000000', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }), + x402.exact({ + config: { + asset: x402.assets.baseSepolia.USDC, + facilitator: 'https://x402.org/facilitator', + payTo: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }, + }), + ], +}) + +const paid = payments.compose( + ['tempo/charge', { amount: '1' }], + ['x402/exact', { amount: '10000' }], +) + +app.get('/paid', async (c) => { + const result = await paid(c.req.raw) + if (result.status === 402) return result.challenge + return result.withReceipt(c.json({ ok: true })) +}) +``` + ```ts import { privateKeyToAccount } from 'viem/accounts' import { Mppx, x402 } from 'mppx/client' diff --git a/src/middlewares/hono.test.ts b/src/middlewares/hono.test.ts index 13657db1..1f29591e 100644 --- a/src/middlewares/hono.test.ts +++ b/src/middlewares/hono.test.ts @@ -1,9 +1,15 @@ import { serve } from '@hono/node-server' import { Hono } from 'hono' import { Challenge, Credential, Method, Receipt, z } from 'mppx' -import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client' +import { + Mppx as Mppx_client, + session as sessionIntent, + tempo as tempo_client, + x402 as x402_client, +} from 'mppx/client' import { Mppx, discovery, payment } from 'mppx/hono' -import { tempo as tempo_server } from 'mppx/server' +import { Mppx as ServerMppx, tempo as tempo_server, x402 as x402_server } from 'mppx/server' +import { paymentRequiredHeader, paymentResponseHeader, type PaymentPayload } from 'mppx/x402' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' import { beforeAll, describe, expect, test } from 'vp/test' @@ -211,8 +217,97 @@ describe('charge', () => { server.close() }) + + test('serves tempo and x402 from one Hono endpoint', async () => { + const transaction = `0x${'2'.repeat(64)}` as const + const payments = ServerMppx.create({ + methods: [ + tempo_server.charge({ + account: accounts[0], + currency: asset, + getClient: () => client, + recipient: accounts[0].address, + }), + x402_server.exact({ + config: { + asset: x402_server.assets.baseSepolia.USDC, + facilitator: { + async verify(paymentPayload: PaymentPayload) { + return { + isValid: true, + payer: payerOf(paymentPayload), + } + }, + async settle(paymentPayload: PaymentPayload) { + return { + network: paymentPayload.accepted.network, + payer: payerOf(paymentPayload), + success: true, + transaction, + } + }, + }, + payTo: accounts[0].address, + }, + }), + ], + secretKey, + }) + + const route = payments.compose( + ['tempo/charge', { amount: '0', chainId: client.chain!.id }], + ['x402/exact', { amount: '10000' }], + ) + + const app = new Hono() + app.get('/paid', async (c) => { + const result = await route(c.req.raw) + if (result.status === 402) return result.challenge + return result.withReceipt(c.json({ data: 'paid' })) + }) + + const server = await createServer(app) + const challenge = await globalThis.fetch(`${server.url}/paid`) + expect(challenge.status).toBe(402) + expect(challenge.headers.get('WWW-Authenticate')).toContain('Payment') + expect(challenge.headers.get(paymentRequiredHeader)).toBeTruthy() + + const tempoPayment = Mppx_client.create({ + methods: [ + tempo_client.charge({ + account: accounts[0], + getClient: () => client, + }), + ], + polyfill: false, + }) + const tempoResponse = await tempoPayment.fetch(`${server.url}/paid`) + expect(tempoResponse.status).toBe(200) + expect(await tempoResponse.json()).toEqual({ data: 'paid' }) + expect(tempoResponse.headers.get('Payment-Receipt')).toBeTruthy() + + const x402Payment = Mppx_client.create({ + methods: [ + x402_client.exact({ + account: accounts[0], + }), + ], + polyfill: false, + }) + const x402Response = await x402Payment.fetch(`${server.url}/paid`) + expect(x402Response.status).toBe(200) + expect(await x402Response.json()).toEqual({ data: 'paid' }) + expect(x402Response.headers.get(paymentResponseHeader)).toBeTruthy() + + server.close() + }) }) +function payerOf(paymentPayload: PaymentPayload): string { + if ('authorization' in paymentPayload.payload) return paymentPayload.payload.authorization.from + return paymentPayload.payload.permit2Authorization.from +} + describe('scope binding', () => { const scopeOpts = { amount: '1', diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 285098d3..202686b1 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -11,6 +11,8 @@ import type { MaybePromise } from '../internal/types.js' import type * as Method from '../Method.js' import * as PaymentRequest from '../PaymentRequest.js' import type * as Receipt from '../Receipt.js' +import * as x402_Header from '../x402/Header.js' +import * as x402_Types from '../x402/Types.js' import * as z from '../zod.js' import * as Html from './internal/html/config.js' import { serviceWorker } from './internal/html/serviceWorker.gen.js' @@ -2210,7 +2212,7 @@ export function compose( } })() - // Merge WWW-Authenticate headers from all 402 responses. + // Merge challenge headers from all 402 responses. const mergedHeaders = new Headers() mergedHeaders.set('Cache-Control', 'no-store') @@ -2219,6 +2221,10 @@ export function compose( const wwwAuth = response.headers.get('WWW-Authenticate') if (wwwAuth) mergedHeaders.append('WWW-Authenticate', wwwAuth) } + mergeTransportChallengeHeaders( + mergedHeaders, + results.flatMap((result) => (result.status === 402 ? [result.challenge as Response] : [])), + ) // Collect html-enabled handlers and their challenges const htmlEntries = challengeEntries.filter((entry) => entry.handler._internal?.html) @@ -2268,11 +2274,15 @@ export function compose( } } - // Non-HTML fallback: use first handler's body + // Non-HTML fallback: prefer the first Payment-auth body, otherwise use + // the first transport-specific 402 body. let body: string | null = null - for (const entry of challengeEntries) { + const bodyResponses = + challengeEntries.length > 0 + ? challengeEntries.map((entry) => entry.result.challenge as Response) + : results.flatMap((result) => (result.status === 402 ? [result.challenge as Response] : [])) + for (const response of bodyResponses) { if (!body) { - const response = entry.result.challenge as Response const contentType = response.headers.get('Content-Type') if (contentType) mergedHeaders.set('Content-Type', contentType) body = await response.text() @@ -2287,6 +2297,29 @@ export function compose( } } +function mergeTransportChallengeHeaders(headers: Headers, responses: readonly Response[]) { + const x402Headers = responses + .map((response) => response.headers.get(x402_Types.paymentRequiredHeader)) + .filter((value): value is string => value !== null) + + if (x402Headers.length > 0) { + headers.set(x402_Types.paymentRequiredHeader, mergeX402PaymentRequired(x402Headers)) + } +} + +function mergeX402PaymentRequired(values: readonly string[]): string { + const [first, ...rest] = values.map((value) => x402_Header.decodePaymentRequired(value)) + if (!first) throw new Error('Expected at least one x402 payment-required header.') + const error = [first.error, ...rest.map((value) => value.error)] + .filter((value): value is string => value !== undefined && value.length > 0) + .join('; ') + return x402_Header.encodePaymentRequired({ + ...first, + accepts: [first.accepts, ...rest.map((value) => value.accepts)].flat(), + ...(error ? { error } : {}), + }) +} + /** * Wraps a payment handler to create a Node.js HTTP listener. * From caf72885e12375914edb9b5dc472aebff46d1eee Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 12:12:24 -0700 Subject: [PATCH 10/14] test: cover composed x402 endpoints --- README.md | 4 + src/client/Transport.test.ts | 20 +++++ src/client/internal/Fetch.test.ts | 4 +- src/server/Mppx.test.ts | 138 +++++++++++++++++++++++++++++- src/x402/Exact.e2e.test.ts | 83 ++++++++++++++++++ 5 files changed, 246 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 46dbe798..9e6b6ef8 100644 --- a/README.md +++ b/README.md @@ -177,6 +177,10 @@ app.get('/paid', async (c) => { }) ``` +The same `compose()` handler can be used in other HTTP frameworks. Pass it the +framework's standard `Request`, return `result.challenge` on `402`, and wrap the +framework response with `result.withReceipt(...)` after payment succeeds. + ```ts import { privateKeyToAccount } from 'viem/accounts' import { Mppx, x402 } from 'mppx/client' diff --git a/src/client/Transport.test.ts b/src/client/Transport.test.ts index 468fcf23..84c6c3a9 100644 --- a/src/client/Transport.test.ts +++ b/src/client/Transport.test.ts @@ -109,6 +109,26 @@ describe('http', () => { }), name: 'x402 challenges', }, + { + expectedIds: [ + `${x402_Types.syntheticChallengeIdPrefix}0`, + `${x402_Types.syntheticChallengeIdPrefix}1`, + ], + expectedMethods: [x402_Types.paymentMethod, x402_Types.paymentMethod], + headers: () => ({ + 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired({ + ...x402PaymentRequired, + accepts: [ + x402PaymentRequired.accepts[0]!, + { + ...x402PaymentRequired.accepts[0]!, + amount: '20000', + }, + ], + }), + }), + name: 'multiple x402 accepts', + }, { expectedIds: [challenge.id, `${x402_Types.syntheticChallengeIdPrefix}0`], expectedMethods: ['tempo', x402_Types.paymentMethod], diff --git a/src/client/internal/Fetch.test.ts b/src/client/internal/Fetch.test.ts index 186466c9..12926159 100644 --- a/src/client/internal/Fetch.test.ts +++ b/src/client/internal/Fetch.test.ts @@ -665,8 +665,8 @@ describe('Fetch.from: 402 retry path', () => { await fetch('https://example.com/api') const retryInit = calls[1]!.init as Record - const headers = retryInit.headers as Record - expect(headers.Authorization).toBe('credential') + const headers = new Headers(retryInit.headers as HeadersInit) + expect(headers.get('Authorization')).toBe('credential') }) test('emits client events and allows challenge handler to provide credential', async () => { diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index fe0c572d..c5a3f87f 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -6,7 +6,8 @@ import { session as tempo_session_client, tempo as tempo_client, } from 'mppx/client' -import { Mppx, stripe, Store, Transport, tempo } from 'mppx/server' +import { Mppx, stripe, Store, Transport, tempo, x402 } from 'mppx/server' +import { Header as x402_Header, Types as x402_Types, type PaymentPayload } from 'mppx/x402' import { getTransactionReceipt } from 'viem/actions' import { describe, expect, test } from 'vp/test' import * as Http from '~test/Http.js' @@ -1855,6 +1856,29 @@ describe('compose', () => { }, }) + const x402Method = x402.exact({ + config: { + asset: x402.assets.baseSepolia.USDC, + facilitator: { + async verify(paymentPayload: PaymentPayload) { + return { + isValid: true, + payer: payerOf(paymentPayload), + } + }, + async settle(paymentPayload: PaymentPayload) { + return { + network: paymentPayload.accepted.network, + payer: payerOf(paymentPayload), + success: true, + transaction: `0x${'3'.repeat(64)}`, + } + }, + }, + payTo: accounts[0].address, + }, + }) + const challengeOpts = { amount: '1000', currency: '0x0000000000000000000000000000000000000001', @@ -1879,6 +1903,94 @@ describe('compose', () => { expect(wwwAuth).toContain('method="beta"') }) + test('returns composed x402 challenge headers when no credential', async () => { + const mppx = Mppx.create({ methods: [x402Method], realm, secretKey }) + + const result = await mppx.compose(['x402/exact', { amount: '10000' }])( + new Request('https://example.com/resource'), + ) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + expect(result.challenge.headers.get('WWW-Authenticate')).toBeNull() + const header = result.challenge.headers.get(x402_Types.paymentRequiredHeader) + expect(header).toBeTruthy() + expect(result.challenge.headers.get('Content-Type')).toContain('application/json') + expect(await result.challenge.json()).toEqual({}) + + const paymentRequired = x402_Header.decodePaymentRequired(header!) + expect(paymentRequired.accepts).toHaveLength(1) + expect(paymentRequired.accepts[0]).toMatchObject({ + amount: '10000', + scheme: x402_Types.schemes[0], + }) + expect(paymentRequired.resource.url).toBe('https://example.com/resource') + }) + + test('merges Payment auth and x402 challenge headers in compose()', async () => { + const mppx = Mppx.create({ methods: [alphaMethod, x402Method], realm, secretKey }) + + const result = await mppx.compose( + [alphaMethod, challengeOpts], + ['x402/exact', { amount: '10000' }], + )(new Request('https://example.com/resource')) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + const wwwAuth = result.challenge.headers.get('WWW-Authenticate') + expect(wwwAuth).toContain('method="alpha"') + + const header = result.challenge.headers.get(x402_Types.paymentRequiredHeader) + expect(header).toBeTruthy() + const paymentRequired = x402_Header.decodePaymentRequired(header!) + expect(paymentRequired.accepts.map((accepted) => accepted.amount)).toEqual(['10000']) + }) + + test('merges multiple x402 exact offers in compose()', async () => { + const mppx = Mppx.create({ methods: [x402Method], realm, secretKey }) + + const result = await mppx.compose( + ['x402/exact', { amount: '10000' }], + ['x402/exact', { amount: '20000' }], + )(new Request('https://example.com/resource')) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error() + + const header = result.challenge.headers.get(x402_Types.paymentRequiredHeader) + expect(header).toBeTruthy() + const paymentRequired = x402_Header.decodePaymentRequired(header!) + expect(paymentRequired.accepts.map((accepted) => accepted.amount)).toEqual(['10000', '20000']) + }) + + test('dispatches x402 credentials through compose()', async () => { + const mppx = Mppx.create({ methods: [alphaMethod, x402Method], realm, secretKey }) + const handle = mppx.compose([alphaMethod, challengeOpts], ['x402/exact', { amount: '10000' }]) + + const firstResult = await handle(new Request('https://example.com/resource')) + expect(firstResult.status).toBe(402) + if (firstResult.status !== 402) throw new Error() + + const paymentRequired = x402_Header.decodePaymentRequired( + firstResult.challenge.headers.get(x402_Types.paymentRequiredHeader)!, + ) + const credential = x402PaymentSignature(paymentRequired.accepts[0]!) + + const result = await handle( + new Request('https://example.com/resource', { + headers: { [x402_Types.paymentSignatureHeader]: credential }, + }), + ) + + expect(result.status).toBe(200) + if (result.status !== 200) throw new Error() + const response = result.withReceipt(new Response('paid')) + expect(response.headers.get(x402_Types.paymentResponseHeader)).toBeTruthy() + expect(await response.text()).toBe('paid') + }) + test('filters compose challenges using Accept-Payment', async () => { const mppx = Mppx.create({ methods: [alphaMethod, betaMethod], realm, secretKey }) @@ -2522,6 +2634,30 @@ describe('compose', () => { }) }) +function x402PaymentSignature(accepted: x402_Types.PaymentRequirements): string { + return x402_Header.encodePaymentSignature({ + accepted, + payload: { + authorization: { + from: accounts[0].address, + nonce: `0x${'1'.repeat(64)}`, + to: accepted.payTo, + validAfter: '0', + validBefore: '9999999999', + value: accepted.amount, + }, + signature: + '0x2d6a7588d6acca505cbf0d9a4a227e0c52c6c34008c8e8986a1283259764173608a2ce6496642e377d6da8dbbf5836e9bd15092f9ecab05ded3d6293af148b571c', + }, + x402Version: 2, + }) +} + +function payerOf(paymentPayload: PaymentPayload): string { + if ('authorization' in paymentPayload.payload) return paymentPayload.payload.authorization.from + return paymentPayload.payload.permit2Authorization.from +} + describe('compose: pre-dispatch narrowing edge cases', () => { const mockCharge = Method.from({ name: 'alpha', diff --git a/src/x402/Exact.e2e.test.ts b/src/x402/Exact.e2e.test.ts index 24f345c4..4b88567a 100644 --- a/src/x402/Exact.e2e.test.ts +++ b/src/x402/Exact.e2e.test.ts @@ -128,6 +128,89 @@ describe('x402 exact e2e', () => { server.close() } }) + + test('serves tempo and x402 from one composed live endpoint', async () => { + const payment = ServerMppx.create({ + methods: [ + tempo.charge({ + account: accounts[0], + currency: asset, + getClient: () => client, + recipient: accounts[0].address, + }), + x402Server.exact({ + config: { + asset: x402Server.assets.baseSepolia.USDC, + facilitator: { + async verify(paymentPayload) { + return { + isValid: true, + payer: payerOf(paymentPayload), + } + }, + async settle(paymentPayload) { + return { + network: paymentPayload.accepted.network, + payer: payerOf(paymentPayload), + success: true, + transaction, + } + }, + }, + payTo: accounts[0].address, + }, + }), + ], + secretKey, + }) + const paid = payment.compose( + ['tempo/charge', { amount: '0', chainId: client.chain!.id }], + ['x402/exact', { amount: '10000' }], + ) + + const server = await Http.createServer(async (req, res) => { + const request = ServerRequest.fromNodeListener(req, res) + const result = await paid(request) + if (result.status === 402) return NodeListener.sendResponse(res, result.challenge) + return NodeListener.sendResponse(res, result.withReceipt(new Response('paid ok'))) + }) + + try { + const challenge = await fetch(server.url) + expect(challenge.status).toBe(402) + expect(challenge.headers.has('WWW-Authenticate')).toBe(true) + expect(challenge.headers.has(Types.paymentRequiredHeader)).toBe(true) + + const tempoClientPayment = ClientMppx.create({ + methods: [ + tempoClient.charge({ + account: accounts[0], + getClient: () => client, + }), + ], + polyfill: false, + }) + const tempoResponse = await tempoClientPayment.fetch(server.url) + expect(tempoResponse.status).toBe(200) + expect(await tempoResponse.text()).toBe('paid ok') + expect(tempoResponse.headers.has('Payment-Receipt')).toBe(true) + + const x402ClientPayment = ClientMppx.create({ + methods: [ + x402Client.exact({ + account: accounts[0], + }), + ], + polyfill: false, + }) + const x402Response = await x402ClientPayment.fetch(server.url) + expect(x402Response.status).toBe(200) + expect(await x402Response.text()).toBe('paid ok') + expect(x402Response.headers.has(Types.paymentResponseHeader)).toBe(true) + } finally { + server.close() + } + }) }) function payerOf(paymentPayload: Types.PaymentPayload): string { From 4ab385adf3e724b52d28e40c1ae4a9a8d18b854d Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 12:20:55 -0700 Subject: [PATCH 11/14] fix: align x402 config interface --- README.md | 38 ++++++++---------- src/middlewares/hono.test.ts | 8 ++-- src/server/Mppx.test.ts | 4 +- src/server/Mppx.ts | 62 +++++++++++++++++++----------- src/x402/Exact.e2e.test.ts | 12 +++--- src/x402/PublicInterface.test-d.ts | 4 +- src/x402/server/Exact.ts | 53 +++++++++++++++++++------ 7 files changed, 112 insertions(+), 69 deletions(-) diff --git a/README.md b/README.md index 9e6b6ef8..65493cb8 100644 --- a/README.md +++ b/README.md @@ -91,9 +91,9 @@ const mppx = Mppx.create({ }), x402.exact({ config: { - asset: x402.assets.baseSepolia.USDC, + currency: x402.assets.baseSepolia.USDC, facilitator: 'https://x402.org/facilitator', - payTo: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', }, }), ], @@ -129,9 +129,9 @@ const mppx = Mppx.create({ }), x402.exact({ config: { - asset: x402.assets.baseSepolia.USDC, + currency: x402.assets.baseSepolia.USDC, facilitator: 'https://x402.org/facilitator', - payTo: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', }, }), ], @@ -149,26 +149,22 @@ import { Hono } from 'hono' import { Mppx, tempo, x402 } from 'mppx/server' const app = new Hono() +const tempoCharge = tempo.charge({ + currency: '0x20c0000000000000000000000000000000000000', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', +}) +const x402Exact = x402.exact({ + config: { + currency: x402.assets.baseSepolia.USDC, + facilitator: 'https://x402.org/facilitator', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }, +}) const payments = Mppx.create({ - methods: [ - tempo({ - currency: '0x20c0000000000000000000000000000000000000', - recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', - }), - x402.exact({ - config: { - asset: x402.assets.baseSepolia.USDC, - facilitator: 'https://x402.org/facilitator', - payTo: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', - }, - }), - ], + methods: [tempoCharge, x402Exact], }) -const paid = payments.compose( - ['tempo/charge', { amount: '1' }], - ['x402/exact', { amount: '10000' }], -) +const paid = payments.compose([tempoCharge, { amount: '1' }], [x402Exact, { amount: '10000' }]) app.get('/paid', async (c) => { const result = await paid(c.req.raw) diff --git a/src/middlewares/hono.test.ts b/src/middlewares/hono.test.ts index 1f29591e..ceed125c 100644 --- a/src/middlewares/hono.test.ts +++ b/src/middlewares/hono.test.ts @@ -230,7 +230,7 @@ describe('charge', () => { }), x402_server.exact({ config: { - asset: x402_server.assets.baseSepolia.USDC, + currency: x402_server.assets.baseSepolia.USDC, facilitator: { async verify(paymentPayload: PaymentPayload) { return { @@ -247,7 +247,7 @@ describe('charge', () => { } }, }, - payTo: accounts[0].address, + recipient: accounts[0].address, }, }), ], @@ -255,8 +255,8 @@ describe('charge', () => { }) const route = payments.compose( - ['tempo/charge', { amount: '0', chainId: client.chain!.id }], - ['x402/exact', { amount: '10000' }], + [payments.tempo.charge, { amount: '0', chainId: client.chain!.id }], + [payments.x402.exact, { amount: '10000' }], ) const app = new Hono() diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index c5a3f87f..b7829984 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1858,7 +1858,7 @@ describe('compose', () => { const x402Method = x402.exact({ config: { - asset: x402.assets.baseSepolia.USDC, + currency: x402.assets.baseSepolia.USDC, facilitator: { async verify(paymentPayload: PaymentPayload) { return { @@ -1875,7 +1875,7 @@ describe('compose', () => { } }, }, - payTo: accounts[0].address, + recipient: accounts[0].address, }, }) diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 202686b1..9bc5f42d 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -2212,19 +2212,36 @@ export function compose( } })() + const challengeResponses = results.flatMap((result) => + result.status === 402 ? [result.challenge as Response] : [], + ) + // Merge challenge headers from all 402 responses. const mergedHeaders = new Headers() mergedHeaders.set('Cache-Control', 'no-store') - for (const entry of challengeEntries) { - const response = entry.result.challenge as Response - const wwwAuth = response.headers.get('WWW-Authenticate') - if (wwwAuth) mergedHeaders.append('WWW-Authenticate', wwwAuth) + const challengeHeaderMerges = [ + { + name: 'WWW-Authenticate', + values: challengeEntries + .map((entry) => (entry.result.challenge as Response).headers.get('WWW-Authenticate')) + .filter((value): value is string => value !== null), + merge: (values: readonly string[]) => values, + }, + { + name: x402_Types.paymentRequiredHeader, + values: challengeResponses + .map((response) => response.headers.get(x402_Types.paymentRequiredHeader)) + .filter((value): value is string => value !== null), + merge: mergeX402PaymentRequiredHeaders, + }, + ] satisfies readonly ChallengeHeaderMerge[] + + for (const header of challengeHeaderMerges) { + for (const value of header.merge(header.values)) { + mergedHeaders.append(header.name, value) + } } - mergeTransportChallengeHeaders( - mergedHeaders, - results.flatMap((result) => (result.status === 402 ? [result.challenge as Response] : [])), - ) // Collect html-enabled handlers and their challenges const htmlEntries = challengeEntries.filter((entry) => entry.handler._internal?.html) @@ -2280,7 +2297,7 @@ export function compose( const bodyResponses = challengeEntries.length > 0 ? challengeEntries.map((entry) => entry.result.challenge as Response) - : results.flatMap((result) => (result.status === 402 ? [result.challenge as Response] : [])) + : challengeResponses for (const response of bodyResponses) { if (!body) { const contentType = response.headers.get('Content-Type') @@ -2297,27 +2314,26 @@ export function compose( } } -function mergeTransportChallengeHeaders(headers: Headers, responses: readonly Response[]) { - const x402Headers = responses - .map((response) => response.headers.get(x402_Types.paymentRequiredHeader)) - .filter((value): value is string => value !== null) - - if (x402Headers.length > 0) { - headers.set(x402_Types.paymentRequiredHeader, mergeX402PaymentRequired(x402Headers)) - } +type ChallengeHeaderMerge = { + name: string + values: readonly string[] + merge(values: readonly string[]): readonly string[] } -function mergeX402PaymentRequired(values: readonly string[]): string { +function mergeX402PaymentRequiredHeaders(values: readonly string[]): readonly string[] { + if (values.length === 0) return [] const [first, ...rest] = values.map((value) => x402_Header.decodePaymentRequired(value)) if (!first) throw new Error('Expected at least one x402 payment-required header.') const error = [first.error, ...rest.map((value) => value.error)] .filter((value): value is string => value !== undefined && value.length > 0) .join('; ') - return x402_Header.encodePaymentRequired({ - ...first, - accepts: [first.accepts, ...rest.map((value) => value.accepts)].flat(), - ...(error ? { error } : {}), - }) + return [ + x402_Header.encodePaymentRequired({ + ...first, + accepts: [first.accepts, ...rest.map((value) => value.accepts)].flat(), + ...(error ? { error } : {}), + }), + ] } /** diff --git a/src/x402/Exact.e2e.test.ts b/src/x402/Exact.e2e.test.ts index 4b88567a..4f893b9b 100644 --- a/src/x402/Exact.e2e.test.ts +++ b/src/x402/Exact.e2e.test.ts @@ -50,9 +50,9 @@ describe('x402 exact e2e', () => { methods: [ x402Server.exact({ config: { - asset: x402Server.assets.baseSepolia.USDC, + currency: x402Server.assets.baseSepolia.USDC, facilitator, - payTo: accounts[0].address, + recipient: accounts[0].address, }, }), ], @@ -140,7 +140,7 @@ describe('x402 exact e2e', () => { }), x402Server.exact({ config: { - asset: x402Server.assets.baseSepolia.USDC, + currency: x402Server.assets.baseSepolia.USDC, facilitator: { async verify(paymentPayload) { return { @@ -157,15 +157,15 @@ describe('x402 exact e2e', () => { } }, }, - payTo: accounts[0].address, + recipient: accounts[0].address, }, }), ], secretKey, }) const paid = payment.compose( - ['tempo/charge', { amount: '0', chainId: client.chain!.id }], - ['x402/exact', { amount: '10000' }], + [payment.tempo.charge, { amount: '0', chainId: client.chain!.id }], + [payment.x402.exact, { amount: '10000' }], ) const server = await Http.createServer(async (req, res) => { diff --git a/src/x402/PublicInterface.test-d.ts b/src/x402/PublicInterface.test-d.ts index 2fa6b2ca..f50c31ee 100644 --- a/src/x402/PublicInterface.test-d.ts +++ b/src/x402/PublicInterface.test-d.ts @@ -12,7 +12,7 @@ describe('x402 public interface', () => { methods: [ x402.exact({ config: { - asset: x402.assets.base.USDC, + currency: x402.assets.base.USDC, facilitator: { settle: async () => ({ network: 'eip155:8453', @@ -21,7 +21,7 @@ describe('x402 public interface', () => { }), verify: async () => ({ isValid: true }), }, - payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + recipient: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', }, }), ], diff --git a/src/x402/server/Exact.ts b/src/x402/server/Exact.ts index b237eaa0..13ac6a2b 100644 --- a/src/x402/server/Exact.ts +++ b/src/x402/server/Exact.ts @@ -67,21 +67,47 @@ export declare namespace exact { config: Config } - type Config = { - /** Token contract address or known x402 asset metadata. */ - asset: `0x${string}` | Assets.KnownAsset + type Config = BaseConfig & CurrencyConfig & RecipientConfig + + type BaseConfig = { /** Facilitator client or base URL. */ facilitator: string | Types.Facilitator /** Maximum time in seconds allowed for payment completion. @default 60 */ maxTimeoutSeconds?: number | undefined /** CAIP-2 network. Required for custom asset addresses; inferred for known assets. */ network?: Types.EvmNetwork | undefined - /** Recipient wallet address. */ - payTo: `0x${string}` /** Required for custom asset addresses; inferred for known assets. */ transfer?: Types.ExactTransfer | undefined } + type CurrencyConfig = + | { + /** Token contract address or known x402 asset metadata. */ + currency: `0x${string}` | Assets.KnownAsset + /** Legacy alias for `currency`. */ + asset?: `0x${string}` | Assets.KnownAsset | undefined + } + | { + /** Legacy alias for `currency`. */ + asset: `0x${string}` | Assets.KnownAsset + /** Token contract address or known x402 asset metadata. */ + currency?: `0x${string}` | Assets.KnownAsset | undefined + } + + type RecipientConfig = + | { + /** Recipient wallet address. */ + recipient: `0x${string}` + /** Legacy alias for `recipient`. */ + payTo?: `0x${string}` | undefined + } + | { + /** Legacy alias for `recipient`. */ + payTo: `0x${string}` + /** Recipient wallet address. */ + recipient?: `0x${string}` | undefined + } + type Defaults = { asset: `0x${string}` maxTimeoutSeconds: number @@ -103,16 +129,21 @@ type ResolvedConfig = exact.Defaults & { } function resolveConfig(config: exact.Config): ResolvedConfig { + const currency = config.currency ?? config.asset + const recipient = config.recipient ?? config.payTo + if (!currency) throw new Error('x402 exact requires `currency`.') + if (!recipient) throw new Error('x402 exact requires `recipient`.') + let address: `0x${string}` let network = config.network let transfer = config.transfer - if (Assets.isAsset(config.asset)) { - address = config.asset.address - network ??= config.asset.network - transfer ??= config.asset.transfer + if (Assets.isAsset(currency)) { + address = currency.address + network ??= currency.network + transfer ??= currency.transfer } else { - address = config.asset + address = currency } if (!network) throw new Error('x402 exact custom assets require `network`.') @@ -123,7 +154,7 @@ function resolveConfig(config: exact.Config): ResolvedConfig { facilitator: config.facilitator, maxTimeoutSeconds: config.maxTimeoutSeconds ?? 60, network, - payTo: config.payTo, + payTo: recipient, transfer, } } From d9b945480062451817b99c59a5b539a3b83ceb17 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 12:31:48 -0700 Subject: [PATCH 12/14] fix: align x402 currency semantics --- README.md | 13 ++++--- src/middlewares/hono.test.ts | 2 +- src/server/Mppx.test.ts | 10 ++--- src/x402/Exact.e2e.test.ts | 4 +- src/x402/Header.test.ts | 4 +- src/x402/Methods.ts | 5 ++- src/x402/PublicInterface.test-d.ts | 6 +-- src/x402/Types.ts | 3 +- src/x402/client/Exact.test.ts | 22 ++++++++--- src/x402/client/Exact.ts | 59 ++++++++++++++++++++++++------ src/x402/server/Exact.ts | 12 +++++- 11 files changed, 103 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index 65493cb8..3d13ad5d 100644 --- a/README.md +++ b/README.md @@ -105,7 +105,7 @@ export async function GET(request: Request) { url.pathname === '/mpp' ? await mppx.tempo.charge({ amount: '1' })(request) : await mppx.x402.exact({ - amount: '10000', + amount: '0.01', resource: { url: request.url }, })(request) @@ -138,9 +138,12 @@ const mppx = Mppx.create({ }) app.get('/mpp', mppx.tempo.charge({ amount: '1' }), (c) => c.json({ ok: true })) -app.get('/x402', mppx.x402.exact({ amount: '10000' }), (c) => c.json({ ok: true })) +app.get('/x402', mppx.x402.exact({ amount: '0.01' }), (c) => c.json({ ok: true })) ``` +Like Tempo, x402 route `amount` values are display-unit strings; mppx converts +them to atomic token amounts from the configured currency decimals. + To offer both protocols from one Hono route, use the core HTTP composer inside the Hono handler: @@ -164,7 +167,7 @@ const payments = Mppx.create({ methods: [tempoCharge, x402Exact], }) -const paid = payments.compose([tempoCharge, { amount: '1' }], [x402Exact, { amount: '10000' }]) +const paid = payments.compose([tempoCharge, { amount: '1' }], [x402Exact, { amount: '0.01' }]) app.get('/paid', async (c) => { const result = await paid(c.req.raw) @@ -185,8 +188,8 @@ const mppx = Mppx.create({ methods: [ x402.exact({ account: privateKeyToAccount('0x...'), - assets: [x402.assets.baseSepolia.USDC.address], - maxAmount: '10000', + currencies: [x402.assets.baseSepolia.USDC], + maxAmount: '0.01', networks: ['eip155:84532'], }), ], diff --git a/src/middlewares/hono.test.ts b/src/middlewares/hono.test.ts index ceed125c..d7c2b64e 100644 --- a/src/middlewares/hono.test.ts +++ b/src/middlewares/hono.test.ts @@ -256,7 +256,7 @@ describe('charge', () => { const route = payments.compose( [payments.tempo.charge, { amount: '0', chainId: client.chain!.id }], - [payments.x402.exact, { amount: '10000' }], + [payments.x402.exact, { amount: '0.01' }], ) const app = new Hono() diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index b7829984..1fafa06a 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1906,7 +1906,7 @@ describe('compose', () => { test('returns composed x402 challenge headers when no credential', async () => { const mppx = Mppx.create({ methods: [x402Method], realm, secretKey }) - const result = await mppx.compose(['x402/exact', { amount: '10000' }])( + const result = await mppx.compose(['x402/exact', { amount: '0.01' }])( new Request('https://example.com/resource'), ) @@ -1933,7 +1933,7 @@ describe('compose', () => { const result = await mppx.compose( [alphaMethod, challengeOpts], - ['x402/exact', { amount: '10000' }], + ['x402/exact', { amount: '0.01' }], )(new Request('https://example.com/resource')) expect(result.status).toBe(402) @@ -1952,8 +1952,8 @@ describe('compose', () => { const mppx = Mppx.create({ methods: [x402Method], realm, secretKey }) const result = await mppx.compose( - ['x402/exact', { amount: '10000' }], - ['x402/exact', { amount: '20000' }], + ['x402/exact', { amount: '0.01' }], + ['x402/exact', { amount: '0.02' }], )(new Request('https://example.com/resource')) expect(result.status).toBe(402) @@ -1967,7 +1967,7 @@ describe('compose', () => { test('dispatches x402 credentials through compose()', async () => { const mppx = Mppx.create({ methods: [alphaMethod, x402Method], realm, secretKey }) - const handle = mppx.compose([alphaMethod, challengeOpts], ['x402/exact', { amount: '10000' }]) + const handle = mppx.compose([alphaMethod, challengeOpts], ['x402/exact', { amount: '0.01' }]) const firstResult = await handle(new Request('https://example.com/resource')) expect(firstResult.status).toBe(402) diff --git a/src/x402/Exact.e2e.test.ts b/src/x402/Exact.e2e.test.ts index 4f893b9b..d4a94e0b 100644 --- a/src/x402/Exact.e2e.test.ts +++ b/src/x402/Exact.e2e.test.ts @@ -73,7 +73,7 @@ describe('x402 exact e2e', () => { if (req.url === '/x402') { const result = await x402Payment.x402.exact({ - amount: '10000', + amount: '0.01', resource: { mimeType: 'text/plain', url: new URL('/x402', request.url).toString(), @@ -165,7 +165,7 @@ describe('x402 exact e2e', () => { }) const paid = payment.compose( [payment.tempo.charge, { amount: '0', chainId: client.chain!.id }], - [payment.x402.exact, { amount: '10000' }], + [payment.x402.exact, { amount: '0.01' }], ) const server = await Http.createServer(async (req, res) => { diff --git a/src/x402/Header.test.ts b/src/x402/Header.test.ts index e78a9314..1e02dcfd 100644 --- a/src/x402/Header.test.ts +++ b/src/x402/Header.test.ts @@ -66,8 +66,9 @@ describe('x402 headers', () => { describe('x402 exact method', () => { test('maps public transfer config to wire extra', () => { const request = Methods.exact.schema.request.parse({ - amount: '10000', + amount: '0.01', asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + decimals: 6, maxTimeoutSeconds: 60, network: 'eip155:84532', payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', @@ -79,6 +80,7 @@ describe('x402 exact method', () => { }) expect(request).toMatchObject({ + amount: '10000', extra: { assetTransferMethod: 'eip3009', name: 'USDC', diff --git a/src/x402/Methods.ts b/src/x402/Methods.ts index b24b0947..6347119c 100644 --- a/src/x402/Methods.ts +++ b/src/x402/Methods.ts @@ -1,3 +1,5 @@ +import { parseUnits } from 'viem' + import * as Method from '../Method.js' import * as z from '../zod.js' import * as Types from './Types.js' @@ -17,8 +19,9 @@ export const exact = Method.from({ }, request: z.pipe( Types.ExactRequestInputSchema, - z.transform(({ transfer, ...request }) => ({ + z.transform(({ amount, decimals, transfer, ...request }) => ({ ...request, + amount: parseUnits(amount, decimals).toString(), extra: Types.transferToExtra(transfer), scheme: Types.schemes[0], })), diff --git a/src/x402/PublicInterface.test-d.ts b/src/x402/PublicInterface.test-d.ts index f50c31ee..4693f16a 100644 --- a/src/x402/PublicInterface.test-d.ts +++ b/src/x402/PublicInterface.test-d.ts @@ -29,14 +29,14 @@ describe('x402 public interface', () => { }) expectTypeOf(mppx.x402.exact).toBeFunction() - expectTypeOf(mppx.x402.exact({ amount: '10000' })).toBeFunction() + expectTypeOf(mppx.x402.exact({ amount: '0.01' })).toBeFunction() }) test('client exact exposes account config and policies', () => { const method = clientX402.exact({ account: {} as Account, - assets: ['0x036CbD53842c5426634e7929541eC2318f3dCF7e'], - maxAmount: '10000', + currencies: [x402.assets.baseSepolia.USDC], + maxAmount: '0.01', networks: ['eip155:84532'], }) diff --git a/src/x402/Types.ts b/src/x402/Types.ts index 87f1e57f..989d4802 100644 --- a/src/x402/Types.ts +++ b/src/x402/Types.ts @@ -104,8 +104,9 @@ export type ExactRequest = PaymentRequirements & { /** Public exact EVM route input before it is converted to x402 wire requirements. */ export const ExactRequestInputSchema = z.object({ - amount: atomicAmount, + amount: z.amount(), asset: address, + decimals: z.number(), maxTimeoutSeconds: positiveNumber, network: evmNetwork, payTo: address, diff --git a/src/x402/client/Exact.test.ts b/src/x402/client/Exact.test.ts index cd7324ad..a63170ad 100644 --- a/src/x402/client/Exact.test.ts +++ b/src/x402/client/Exact.test.ts @@ -2,6 +2,7 @@ import { Challenge } from 'mppx' import type { Account } from 'viem' import { describe, expect, test, vi } from 'vp/test' +import * as Assets from '../Assets.js' import * as Header from '../Header.js' import * as Types from '../Types.js' import { exact } from './Exact.js' @@ -12,13 +13,23 @@ const account = { address: '0x1111111111111111111111111111111111111111', signTypedData: vi.fn(async () => '0x1234'), } as unknown as Account +const usdc = Assets.define({ + address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + decimals: 6, + network: 'eip155:84532', + transfer: { + name: 'USDC', + type: 'eip3009', + version: '2', + }, +}) describe('x402.exact client', () => { - test('enforces max amount, network, and asset policy before signing', async () => { + test('enforces max amount, network, and currency policy before signing', async () => { const method = exact({ account, - assets: ['0x036CbD53842c5426634e7929541eC2318f3dCF7e'], - maxAmount: '10000', + currencies: [usdc], + maxAmount: '0.01', networks: ['eip155:84532'], }) @@ -42,7 +53,7 @@ describe('x402.exact client', () => { context: {}, }), ).rejects.toThrow( - 'x402 exact asset is not allowed: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.', + 'x402 exact currency is not allowed: 0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913.', ) expect(account.signTypedData).not.toHaveBeenCalled() @@ -55,7 +66,8 @@ describe('x402.exact client', () => { ...account, signTypedData, } as unknown as Account, - maxAmount: '10000', + currencies: [usdc], + maxAmount: '0.01', networks: ['eip155:84532'], }) diff --git a/src/x402/client/Exact.ts b/src/x402/client/Exact.ts index d08b45dd..86e7f672 100644 --- a/src/x402/client/Exact.ts +++ b/src/x402/client/Exact.ts @@ -1,9 +1,10 @@ import { Hex } from 'ox' import type { Account } from 'viem' -import { getAddress } from 'viem' +import { getAddress, parseUnits } from 'viem' import * as Method from '../../Method.js' import * as z from '../../zod.js' +import * as Assets from '../Assets.js' import * as Header from '../Header.js' import * as Methods from '../Methods.js' import * as Types from '../Types.js' @@ -83,12 +84,18 @@ export declare namespace exact { type Parameters = { /** Account used to sign exact EVM payment payloads. */ account: Account - /** Optional maximum atomic amount the client is willing to pay. */ + /** Optional token decimals used to parse `maxAmount` when currency metadata is not provided. */ + decimals?: number | undefined + /** Optional maximum display-unit amount the client is willing to pay. */ maxAmount?: string | undefined + /** Optional maximum atomic amount the client is willing to pay. */ + maxAtomicAmount?: string | undefined /** Optional allowlist of supported x402 EVM networks. */ networks?: readonly Types.EvmNetwork[] | undefined - /** Optional allowlist of supported asset contract addresses. */ - assets?: readonly `0x${string}`[] | undefined + /** Optional allowlist of supported currencies. */ + currencies?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined + /** Legacy alias for `currencies`. */ + assets?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined } } @@ -108,15 +115,45 @@ function chainIdOf(network: Types.EvmNetwork): number { } function assertPolicy(parameters: exact.Parameters, accepted: Types.PaymentRequirements) { - if (parameters.maxAmount !== undefined && BigInt(accepted.amount) > BigInt(parameters.maxAmount)) - throw new Error('x402 exact amount exceeds maxAmount.') - if (parameters.networks && !parameters.networks.includes(accepted.network)) throw new Error(`x402 exact network is not allowed: ${accepted.network}.`) - if (parameters.assets) { - const acceptedAsset = getAddress(accepted.asset as `0x${string}`) - const allowed = parameters.assets.some((asset) => getAddress(asset) === acceptedAsset) - if (!allowed) throw new Error(`x402 exact asset is not allowed: ${acceptedAsset}.`) + const currencies = parameters.currencies ?? parameters.assets + if (currencies) { + const acceptedCurrency = getAddress(accepted.asset as `0x${string}`) + const allowed = currencies.some( + (currency) => getAddress(addressOf(currency)) === acceptedCurrency, + ) + if (!allowed) throw new Error(`x402 exact currency is not allowed: ${acceptedCurrency}.`) + } + + if ( + parameters.maxAtomicAmount !== undefined && + BigInt(accepted.amount) > BigInt(parameters.maxAtomicAmount) + ) + throw new Error('x402 exact amount exceeds maxAtomicAmount.') + + if (parameters.maxAmount !== undefined) { + const decimals = decimalsOfAcceptedCurrency(parameters, accepted) + if (decimals === undefined) throw new Error('x402 exact maxAmount requires currency decimals.') + if (BigInt(accepted.amount) > parseUnits(parameters.maxAmount, decimals)) + throw new Error('x402 exact amount exceeds maxAmount.') } } + +function addressOf(currency: `0x${string}` | Assets.KnownAsset): `0x${string}` { + return Assets.isAsset(currency) ? currency.address : currency +} + +function decimalsOfAcceptedCurrency( + parameters: exact.Parameters, + accepted: Types.PaymentRequirements, +): number | undefined { + const currencies = parameters.currencies ?? parameters.assets + const acceptedCurrency = getAddress(accepted.asset as `0x${string}`) + const currency = currencies?.find( + (currency) => getAddress(addressOf(currency)) === acceptedCurrency, + ) + if (currency && Assets.isAsset(currency)) return currency.decimals + return parameters.decimals +} diff --git a/src/x402/server/Exact.ts b/src/x402/server/Exact.ts index 13ac6a2b..63cc060d 100644 --- a/src/x402/server/Exact.ts +++ b/src/x402/server/Exact.ts @@ -23,6 +23,7 @@ export function exact(parameters: par return Method.toServer(Methods.exact, { defaults: { asset: config.asset, + decimals: config.decimals, maxTimeoutSeconds: config.maxTimeoutSeconds, network: config.network, payTo: config.payTo, @@ -70,6 +71,8 @@ export declare namespace exact { type Config = BaseConfig & CurrencyConfig & RecipientConfig type BaseConfig = { + /** Token decimal places. Required for custom currency addresses; inferred for known assets. */ + decimals?: number | undefined /** Facilitator client or base URL. */ facilitator: string | Types.Facilitator /** Maximum time in seconds allowed for payment completion. @default 60 */ @@ -110,6 +113,7 @@ export declare namespace exact { type Defaults = { asset: `0x${string}` + decimals: number maxTimeoutSeconds: number network: Types.EvmNetwork payTo: `0x${string}` @@ -135,22 +139,26 @@ function resolveConfig(config: exact.Config): ResolvedConfig { if (!recipient) throw new Error('x402 exact requires `recipient`.') let address: `0x${string}` + let decimals = config.decimals let network = config.network let transfer = config.transfer if (Assets.isAsset(currency)) { address = currency.address + decimals ??= currency.decimals network ??= currency.network transfer ??= currency.transfer } else { address = currency } - if (!network) throw new Error('x402 exact custom assets require `network`.') - if (!transfer) throw new Error('x402 exact custom assets require `transfer`.') + if (decimals === undefined) throw new Error('x402 exact custom currencies require `decimals`.') + if (!network) throw new Error('x402 exact custom currencies require `network`.') + if (!transfer) throw new Error('x402 exact custom currencies require `transfer`.') return { asset: address, + decimals, facilitator: config.facilitator, maxTimeoutSeconds: config.maxTimeoutSeconds ?? 60, network, From 3427da2e2206bc6cf4669827f58413e783247cf6 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 15:52:01 -0700 Subject: [PATCH 13/14] fix: bind x402 payment payload resources --- .changeset/x402-resource-binding.md | 5 +++ src/x402/Exact.e2e.test.ts | 63 +++++++++++++++++++++++++++++ src/x402/server/Exact.ts | 15 +++++-- 3 files changed, 79 insertions(+), 4 deletions(-) create mode 100644 .changeset/x402-resource-binding.md diff --git a/.changeset/x402-resource-binding.md b/.changeset/x402-resource-binding.md new file mode 100644 index 00000000..7937de48 --- /dev/null +++ b/.changeset/x402-resource-binding.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Fixed x402 exact resource binding for submitted payment payloads. diff --git a/src/x402/Exact.e2e.test.ts b/src/x402/Exact.e2e.test.ts index d4a94e0b..c0574364 100644 --- a/src/x402/Exact.e2e.test.ts +++ b/src/x402/Exact.e2e.test.ts @@ -129,6 +129,69 @@ describe('x402 exact e2e', () => { } }) + test('rejects x402 payment payload replayed across resources with same requirements', async () => { + let verifyCalls = 0 + const payment = ServerMppx.create({ + methods: [ + x402Server.exact({ + config: { + currency: x402Server.assets.baseSepolia.USDC, + facilitator: { + async verify() { + verifyCalls++ + return { isValid: true } + }, + async settle(paymentPayload) { + return { + network: paymentPayload.accepted.network, + success: true, + transaction, + } + }, + }, + recipient: accounts[0].address, + }, + }), + ], + secretKey, + }) + const route = payment.x402.exact({ amount: '0.01' }) + + const routeAChallenge = await route(new Request('https://example.com/a')) + expect(routeAChallenge.status).toBe(402) + if (routeAChallenge.status !== 402) throw new Error() + + const paymentRequired = Header.decodePaymentRequired( + routeAChallenge.challenge.headers.get(Types.paymentRequiredHeader)!, + ) + const accepted = paymentRequired.accepts[0]! + const paymentSignature = Header.encodePaymentSignature({ + accepted, + payload: { + authorization: { + from: accounts[0].address, + nonce: `0x${'1'.repeat(64)}`, + to: accepted.payTo as `0x${string}`, + validAfter: '0', + validBefore: '9999999999', + value: accepted.amount, + }, + signature: `0x${'2'.repeat(130)}`, + }, + resource: paymentRequired.resource, + x402Version: 2, + }) + + const result = await route( + new Request('https://example.com/b', { + headers: { [Types.paymentSignatureHeader]: paymentSignature }, + }), + ) + + expect(result.status).toBe(402) + expect(verifyCalls).toBe(0) + }) + test('serves tempo and x402 from one composed live endpoint', async () => { const payment = ServerMppx.create({ methods: [ diff --git a/src/x402/server/Exact.ts b/src/x402/server/Exact.ts index 63cc060d..2d04aa47 100644 --- a/src/x402/server/Exact.ts +++ b/src/x402/server/Exact.ts @@ -30,17 +30,24 @@ export function exact(parameters: par transfer: config.transfer, }, transport, - async verify({ credential }) { + async verify({ credential, envelope }) { const paymentPayload = credential.payload as Types.PaymentPayload - const paymentRequirements = Types.toPaymentRequirements( - credential.challenge.request as Types.ExactRequest, - ) + const request = credential.challenge.request as Types.ExactRequest + const paymentRequirements = Types.toPaymentRequirements(request) + const expectedResource = + request.resource ?? + (envelope ? { url: envelope.capturedRequest.url.toString() } : undefined) if (!isDeepStrictEqual(paymentPayload.accepted, paymentRequirements)) throw new VerificationFailedError({ reason: 'x402 payment payload does not match route requirements', }) + if (expectedResource && !isDeepStrictEqual(paymentPayload.resource, expectedResource)) + throw new VerificationFailedError({ + reason: 'x402 payment payload resource does not match route resource', + }) + const verified = await facilitator.verify(paymentPayload, paymentRequirements) if (!verified.isValid) throw new VerificationFailedError({ From 11633dbdb2541a4019102e80c429368bf719d542 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 22 May 2026 16:49:06 -0700 Subject: [PATCH 14/14] test: include x402 resource in compose credential --- src/server/Mppx.test.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 1fafa06a..d0ddd7ea 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1976,7 +1976,7 @@ describe('compose', () => { const paymentRequired = x402_Header.decodePaymentRequired( firstResult.challenge.headers.get(x402_Types.paymentRequiredHeader)!, ) - const credential = x402PaymentSignature(paymentRequired.accepts[0]!) + const credential = x402PaymentSignature(paymentRequired.accepts[0]!, paymentRequired.resource) const result = await handle( new Request('https://example.com/resource', { @@ -2634,7 +2634,10 @@ describe('compose', () => { }) }) -function x402PaymentSignature(accepted: x402_Types.PaymentRequirements): string { +function x402PaymentSignature( + accepted: x402_Types.PaymentRequirements, + resource: x402_Types.ResourceInfo, +): string { return x402_Header.encodePaymentSignature({ accepted, payload: { @@ -2649,6 +2652,7 @@ function x402PaymentSignature(accepted: x402_Types.PaymentRequirements): string signature: '0x2d6a7588d6acca505cbf0d9a4a227e0c52c6c34008c8e8986a1283259764173608a2ce6496642e377d6da8dbbf5836e9bd15092f9ecab05ded3d6293af148b571c', }, + resource, x402Version: 2, }) }