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/.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/README.md b/README.md index a625c045..3d13ad5d 100644 --- a/README.md +++ b/README.md @@ -78,6 +78,130 @@ Mppx.create({ const res = await fetch('https://mpp.dev/api/ping/paid') ``` +### x402 Exact + +```ts +import { Mppx, tempo, x402 } from 'mppx/server' + +const mppx = Mppx.create({ + methods: [ + tempo({ + currency: '0x20c0000000000000000000000000000000000000', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }), + x402.exact({ + config: { + currency: x402.assets.baseSepolia.USDC, + facilitator: 'https://x402.org/facilitator', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }, + }), + ], +}) + +export async function GET(request: Request) { + const url = new URL(request.url) + const result = + url.pathname === '/mpp' + ? await mppx.tempo.charge({ amount: '1' })(request) + : await mppx.x402.exact({ + amount: '0.01', + 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, tempo, x402 } from 'mppx/hono' + +const app = new Hono() +const mppx = Mppx.create({ + methods: [ + tempo({ + currency: '0x20c0000000000000000000000000000000000000', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }), + x402.exact({ + config: { + currency: x402.assets.baseSepolia.USDC, + facilitator: 'https://x402.org/facilitator', + recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00', + }, + }), + ], +}) + +app.get('/mpp', mppx.tempo.charge({ amount: '1' }), (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: + +```ts +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: [tempoCharge, x402Exact], +}) + +const paid = payments.compose([tempoCharge, { amount: '1' }], [x402Exact, { amount: '0.01' }]) + +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 })) +}) +``` + +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' + +const mppx = Mppx.create({ + methods: [ + x402.exact({ + account: privateKeyToAccount('0x...'), + currencies: [x402.assets.baseSepolia.USDC], + maxAmount: '0.01', + networks: ['eip155:84532'], + }), + ], +}) + +const res = await mppx.fetch('https://api.example.com/paid') +``` + +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 | 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/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' 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/Transport.test.ts b/src/client/Transport.test.ts index 4017bd97..84c6c3a9 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, Types as x402_Types, type PaymentRequired } from 'mppx/x402' import { describe, expect, test } from 'vp/test' const realm = 'api.example.com' @@ -23,27 +24,34 @@ const credential = Credential.from({ payload: { signature: '0xabc123', type: 'transaction' }, }) +const x402PaymentRequired = { + accepts: [ + { + amount: '10000', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + maxTimeoutSeconds: 60, + network: 'eip155:84532', + payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + scheme: x402_Types.schemes[0], + }, + ], + resource: { + url: 'https://api.example.com/x402', + }, + x402Version: 2, +} satisfies PaymentRequired + 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) }) }) @@ -80,32 +88,112 @@ 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_Types.syntheticChallengeIdPrefix}0`], + expectedMethods: [x402_Types.paymentMethod], + headers: () => ({ + 'PAYMENT-REQUIRED': x402_Header.encodePaymentRequired(x402PaymentRequired), + }), + 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], + 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) }) }) describe('setCredential', () => { - test('default', () => { + 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_Types.syntheticChallengeIdPrefix}0`, + intent: x402_Types.exactIntent, + method: x402_Types.paymentMethod, + realm: 'api.example.com', + 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', () => { diff --git a/src/client/Transport.ts b/src/client/Transport.ts index dfc6a9c5..db5eb4d8 100644 --- a/src/client/Transport.ts +++ b/src/client/Transport.ts @@ -1,6 +1,12 @@ 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' + +const paymentRequiredStatus = 402 +const paymentAuthChallengeHeader = 'WWW-Authenticate' +const paymentAuthCredentialHeader = 'Authorization' /** * Client-side transport adapter. @@ -18,10 +24,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 @@ -55,33 +72,79 @@ 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({ name: 'http', isPaymentRequired(response) { - return response.status === 402 + return response.status === paymentRequiredStatus }, getChallenges(response) { - return Challenge.fromResponseList(response) + return paymentRequiredChallenges(response) }, getChallenge(response) { - return Challenge.fromResponse(response) + const challenge = paymentRequiredChallenges(response)[0] + if (!challenge) throw new Error('No challenge in response.') + return challenge }, - setCredential(request, credential) { + setCredential(request, credential, options) { const headers = new Headers(request.headers) - headers.set('Authorization', credential) + if (isX402Challenge(options?.challenge)) { + headers.set(x402_Types.paymentSignatureHeader, credential) + } else { + 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(paymentAuthChallengeHeader)) 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_Types.syntheticChallengeIdPrefix}${index}`, + intent: x402_Types.exactIntent, + method: x402_Types.paymentMethod, + realm: new URL(paymentRequired.resource.url).host, + request: { + ...accepted, + resource: paymentRequired.resource, + }, + }), + ) +} + +function isX402Challenge(challenge: Challenge.Challenge | undefined): boolean { + 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] + ) +} + /** * MCP transport for client-side payment handling. * 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.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/client/internal/Fetch.ts b/src/client/internal/Fetch.ts index 0d02ebac..0e2723b0 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,17 @@ 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, + { challenge: selectedChallenge }, + ), + ) if (paymentResponse.ok) await events.emit( 'payment.response', @@ -335,6 +345,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 +700,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/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/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..d7c2b64e 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' @@ -53,6 +59,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( @@ -187,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: { + currency: 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, + } + }, + }, + recipient: accounts[0].address, + }, + }), + ], + secretKey, + }) + + const route = payments.compose( + [payments.tempo.charge, { amount: '0', chainId: client.chain!.id }], + [payments.x402.exact, { amount: '0.01' }], + ) + + 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/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.test.ts b/src/server/Mppx.test.ts index fe0c572d..d0ddd7ea 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: { + currency: 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)}`, + } + }, + }, + recipient: 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: '0.01' }])( + 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: '0.01' }], + )(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: '0.01' }], + ['x402/exact', { amount: '0.02' }], + )(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: '0.01' }]) + + 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]!, paymentRequired.resource) + + 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,34 @@ describe('compose', () => { }) }) +function x402PaymentSignature( + accepted: x402_Types.PaymentRequirements, + resource: x402_Types.ResourceInfo, +): 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', + }, + resource, + 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/server/Mppx.ts b/src/server/Mppx.ts index 061934fc..9bc5f42d 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' @@ -790,7 +792,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 +900,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) @@ -2199,14 +2212,35 @@ export function compose( } })() - // Merge WWW-Authenticate headers from all 402 responses. + 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) + } } // Collect html-enabled handlers and their challenges @@ -2257,11 +2291,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) + : challengeResponses + 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() @@ -2276,6 +2314,28 @@ export function compose( } } +type ChallengeHeaderMerge = { + name: string + values: readonly string[] + merge(values: readonly string[]): readonly 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 } : {}), + }), + ] +} + /** * Wraps a payment handler to create a Node.js HTTP listener. * 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..d88cfa3b --- /dev/null +++ b/src/x402/Assets.ts @@ -0,0 +1,65 @@ +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: { + // USDC's EIP-712 domain name differs between Base and Base Sepolia. + 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: { + // Base Sepolia test USDC signs with the shorter EIP-712 domain name. + 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..c0574364 --- /dev/null +++ b/src/x402/Exact.e2e.test.ts @@ -0,0 +1,282 @@ +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: { + currency: x402Server.assets.baseSepolia.USDC, + facilitator, + recipient: 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: '0.01', + 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, + }) + 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() + } + }) + + 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: [ + tempo.charge({ + account: accounts[0], + currency: asset, + getClient: () => client, + recipient: accounts[0].address, + }), + x402Server.exact({ + config: { + currency: 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, + } + }, + }, + recipient: accounts[0].address, + }, + }), + ], + secretKey, + }) + const paid = payment.compose( + [payment.tempo.charge, { amount: '0', chainId: client.chain!.id }], + [payment.x402.exact, { amount: '0.01' }], + ) + + 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 { + 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..1e02dcfd --- /dev/null +++ b/src/x402/Header.test.ts @@ -0,0 +1,93 @@ +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: '0.01', + asset: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + decimals: 6, + maxTimeoutSeconds: 60, + network: 'eip155:84532', + payTo: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + transfer: { + name: 'USDC', + type: 'eip3009', + version: '2', + }, + }) + + expect(request).toMatchObject({ + amount: '10000', + 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..77bf358f --- /dev/null +++ b/src/x402/Header.ts @@ -0,0 +1,34 @@ +import * as HeaderCodec from '../internal/HeaderCodec.js' +import { + PaymentPayloadSchema, + PaymentRequiredSchema, + SettleResponseSchema, + type PaymentPayload, + type PaymentRequired, + type SettleResponse, +} from './Types.js' + +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 = + 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 diff --git a/src/x402/Methods.ts b/src/x402/Methods.ts new file mode 100644 index 00000000..6347119c --- /dev/null +++ b/src/x402/Methods.ts @@ -0,0 +1,30 @@ +import { parseUnits } from 'viem' + +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: Types.paymentMethod, + intent: Types.exactIntent, + schema: { + credential: { + payload: Types.PaymentPayloadSchema, + }, + request: z.pipe( + Types.ExactRequestInputSchema, + 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 new file mode 100644 index 00000000..4693f16a --- /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: { + currency: x402.assets.base.USDC, + facilitator: { + settle: async () => ({ + network: 'eip155:8453', + success: true, + transaction: `0x${'1'.repeat(64)}`, + }), + verify: async () => ({ isValid: true }), + }, + recipient: '0x209693Bc6afc0C5328bA36FaF03C514EF312287C', + }, + }), + ], + secretKey, + }) + + expectTypeOf(mppx.x402.exact).toBeFunction() + expectTypeOf(mppx.x402.exact({ amount: '0.01' })).toBeFunction() + }) + + test('client exact exposes account config and policies', () => { + const method = clientX402.exact({ + account: {} as Account, + currencies: [x402.assets.baseSepolia.USDC], + maxAmount: '0.01', + 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..989d4802 --- /dev/null +++ b/src/x402/Types.ts @@ -0,0 +1,258 @@ +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] + +/** x402 exact EVM asset transfer method. */ +export type AssetTransferMethod = (typeof assetTransferMethods)[number] + +/** CAIP-2 EVM network identifier. */ +export type EvmNetwork = `${typeof evmNetworkPrefix}${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(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({ + 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: z.amount(), + asset: address, + decimals: z.number(), + 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..a63170ad --- /dev/null +++ b/src/x402/client/Exact.test.ts @@ -0,0 +1,108 @@ +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' + +type X402Challenge = Parameters['createCredential']>[0]['challenge'] + +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 currency policy before signing', async () => { + const method = exact({ + account, + currencies: [usdc], + maxAmount: '0.01', + 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 currency 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, + currencies: [usdc], + maxAmount: '0.01', + 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..86e7f672 --- /dev/null +++ b/src/x402/client/Exact.ts @@ -0,0 +1,159 @@ +import { Hex } from 'ox' +import type { Account } 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' + +/** + * 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 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 currencies. */ + currencies?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined + /** Legacy alias for `currencies`. */ + assets?: readonly (`0x${string}` | Assets.KnownAsset)[] | 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(Types.evmNetworkPrefix.length)) +} + +function assertPolicy(parameters: exact.Parameters, accepted: Types.PaymentRequirements) { + if (parameters.networks && !parameters.networks.includes(accepted.network)) + throw new Error(`x402 exact network is not allowed: ${accepted.network}.`) + + 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/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..d40ad8aa --- /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: `${Types.syntheticChallengeIdPrefix}${index}`, + intent: Types.exactIntent, + method: Types.paymentMethod, + 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..2d04aa47 --- /dev/null +++ b/src/x402/server/Exact.ts @@ -0,0 +1,203 @@ +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, + decimals: config.decimals, + maxTimeoutSeconds: config.maxTimeoutSeconds, + network: config.network, + payTo: config.payTo, + transfer: config.transfer, + }, + transport, + async verify({ credential, envelope }) { + const paymentPayload = credential.payload as Types.PaymentPayload + 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({ + 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: Types.paymentMethod, + reference: settled.transaction, + status: 'success', + timestamp: new Date().toISOString(), + }) + }, + }) +} + +export declare namespace exact { + type Parameters = { + config: Config + } + + 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 */ + maxTimeoutSeconds?: number | undefined + /** CAIP-2 network. Required for custom asset addresses; inferred for known assets. */ + network?: Types.EvmNetwork | undefined + /** 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}` + decimals: number + 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 { + 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 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 (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, + payTo: recipient, + 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..acf8be8a --- /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: Types.exactIntent, + method: Types.paymentMethod, + 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"],