Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
24 commits
Select commit Hold shift + click to select a range
4c76878
feat: add x402 exact support
brendanjryan May 22, 2026
23fa215
docs: clarify x402 transport usage
brendanjryan May 22, 2026
5f4706f
fix: multiplex x402 http transport
brendanjryan May 22, 2026
49357a5
test: cover multiplexed http transport
brendanjryan May 22, 2026
04dadea
fix: strengthen x402 transport constants
brendanjryan May 22, 2026
ab0bd4c
docs: show tempo and x402 server setup
brendanjryan May 22, 2026
bc39c41
docs: show hono tempo and x402 setup
brendanjryan May 22, 2026
21c6981
test: cover hono tempo and x402 route
brendanjryan May 22, 2026
1eaa971
test: cover composed x402 endpoints
brendanjryan May 22, 2026
f7f210f
fix: align x402 config interface
brendanjryan May 22, 2026
ca6fad2
fix: align x402 currency semantics
brendanjryan May 22, 2026
e3d5280
fix: bind x402 payment payload resources
brendanjryan May 22, 2026
0eca0ec
test: include x402 resource in compose credential
brendanjryan May 22, 2026
2ac977e
feat: expose x402 exact as evm charge
brendanjryan Jun 1, 2026
c859311
test: cover evm charge adapter API
brendanjryan Jun 1, 2026
e4944f8
feat: add native evm authorization charge
brendanjryan Jun 1, 2026
f5d41ef
fix: address evm charge review feedback
brendanjryan Jun 1, 2026
fe3d8f6
fix: harden evm x402 compatibility
brendanjryan Jun 1, 2026
18c999d
Merge branch 'main' into brendanjryan/x402-exact
brendanjryan Jun 1, 2026
e9aa5d3
refactor: split evm charge wire paths
brendanjryan Jun 1, 2026
a061149
refactor: simplify evm x402 path naming
brendanjryan Jun 1, 2026
20807f8
refactor: normalize evm charge path types
brendanjryan Jun 1, 2026
c2a12dc
chore: consolidate x402 changeset
brendanjryan Jun 1, 2026
5347b31
refactor: remove hidden x402 exact methods
brendanjryan Jun 1, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/x402-exact-types.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added EVM charge support with x402 exact compatibility and resource-bound payment payload verification.
128 changes: 128 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,134 @@ Mppx.create({
const res = await fetch('https://mpp.dev/api/ping/paid')
```

### EVM Charge

```ts
import { evm, Mppx, tempo } from 'mppx/server'

const mppx = Mppx.create({
methods: [
tempo({
currency: '0x20c0000000000000000000000000000000000000',
recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
}),
evm.charge({
currency: evm.assets.baseSepolia.USDC,
recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
x402: {
facilitator: 'https://x402.org/facilitator',
},
}),
],
})

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.evm.charge({
amount: '0.01',
})(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 { evm, Mppx, tempo } from 'mppx/hono'

const app = new Hono()
const mppx = Mppx.create({
methods: [
tempo({
currency: '0x20c0000000000000000000000000000000000000',
recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
}),
evm.charge({
currency: evm.assets.baseSepolia.USDC,
recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
x402: {
facilitator: 'https://x402.org/facilitator',
},
}),
],
})

app.get('/mpp', mppx.tempo.charge({ amount: '1' }), (c) => c.json({ ok: true }))
app.get('/evm', mppx.evm.charge({ amount: '0.01' }), (c) => c.json({ ok: true }))
```

Like Tempo, EVM charge 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 { evm, Mppx, tempo } from 'mppx/server'

const app = new Hono()
const tempoCharge = tempo.charge({
currency: '0x20c0000000000000000000000000000000000000',
recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
})
const evmCharge = evm.charge({
currency: evm.assets.baseSepolia.USDC,
recipient: '0x742d35Cc6634c0532925a3b844bC9e7595F8fE00',
x402: {
facilitator: 'https://x402.org/facilitator',
},
})
const payments = Mppx.create({
methods: [tempoCharge, evmCharge],
})

const paid = payments.compose([tempoCharge, { amount: '1' }], [evmCharge, { 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 { evm, Mppx } from 'mppx/client'

const mppx = Mppx.create({
methods: [
evm.charge({
account: privateKeyToAccount('0x...'),
currencies: [evm.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.

EVM charge uses Payment auth by default and currently supports the
`authorization` credential type. `x402.facilitator` handles automatic
settlement and x402 exact compatibility; pass `settle` when you want to override
settlement yourself.

## Examples

| Example | Description |
Expand Down
30 changes: 30 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@
"src": "./src/discovery/index.ts",
"default": "./dist/discovery/index.js"
},
"./evm": {
"types": "./dist/evm/index.d.ts",
"src": "./src/evm/index.ts",
"default": "./dist/evm/index.js"
},
"./evm/client": {
"types": "./dist/evm/client/index.d.ts",
"src": "./src/evm/client/index.ts",
"default": "./dist/evm/client/index.js"
},
"./evm/server": {
"types": "./dist/evm/server/index.d.ts",
"src": "./src/evm/server/index.ts",
"default": "./dist/evm/server/index.js"
},
"./mcp-sdk/client": {
"types": "./dist/mcp-sdk/client/index.d.ts",
"src": "./src/mcp-sdk/client/index.ts",
Expand Down Expand Up @@ -130,6 +145,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",
Expand Down
4 changes: 4 additions & 0 deletions src/Receipt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import * as z from './zod.js'
* ```
*/
export const Schema = z.object({
/** Optional chain ID for chain-settled payment methods. */
chainId: z.optional(z.number()),
/** Optional challenge identifier this receipt settles. */
challengeId: z.optional(z.string()),
/** Payment method used (e.g., "tempo", "stripe"). */
method: z.string(),
/** Method-specific reference (e.g., transaction hash). */
Expand Down
1 change: 1 addition & 0 deletions src/client/Methods.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { evm } from '../evm/client/index.js'
export { stripe } from '../stripe/client/index.js'
export { subscription } from '../tempo/client/Subscription.js'
export { tempo } from '../tempo/client/index.js'
Expand Down
1 change: 1 addition & 0 deletions src/client/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ export function create<
...(resolvedOnChallenge && { onChallenge: resolvedOnChallenge }),
...(orderChallenges && { orderChallenges }),
methods,
transport: transport as never,
} satisfies Fetch.from.Config<FlattenMethods<methods>>
const fetch = Fetch.from<FlattenMethods<methods>>(config_fetch)

Expand Down
148 changes: 118 additions & 30 deletions src/client/Transport.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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)
})
})

Expand Down Expand Up @@ -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', () => {
Expand Down
Loading
Loading