Skip to content

feat: add x402 exact support#474

Open
brendanjryan wants to merge 14 commits into
wevm:mainfrom
brendanjryan:brendanjryan/x402-exact
Open

feat: add x402 exact support#474
brendanjryan wants to merge 14 commits into
wevm:mainfrom
brendanjryan:brendanjryan/x402-exact

Conversation

@brendanjryan
Copy link
Copy Markdown
Collaborator

@brendanjryan brendanjryan commented May 22, 2026

Summary

Added x402 exact support with self-owned core types for v2 exact EVM payments, known USDC currencies, x402-specific header codecs, multiplexed HTTP client transport, server transports, and public x402.exact helpers.

Motivation

Exposing a simple interface over the most common x402 method makes it easier for existing MPP users to add x402 support with minimal dependancies and a tighter security model

Examples

Existing server with Tempo MPP and x402:

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({ ok: true }))
}

Hono with Tempo MPP and x402:

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 }))

Multiple payment methods on a single endpoint:

const paid = mppx.compose(
  [mppx.tempo.charge, { amount: '1' }],
  [mppx.x402.exact, { 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 }))
})

Main public interface:

  • Server: x402.exact({ config: { currency, facilitator, recipient, decimals?, maxTimeoutSeconds?, network?, transfer?, asset?, payTo? } })
  • Route: mppx.x402.exact({ amount, resource? }), where amount is a display-unit string like Tempo
  • Compose: mppx.compose([mppx.tempo.charge, { amount }], [mppx.x402.exact, { amount }]) to serve multiple payment methods from one endpoint
  • Client: x402.exact({ account, currencies?, maxAmount?, maxAtomicAmount?, networks?, decimals?, assets? })
  • Currencies: x402.assets.base.USDC, x402.assets.baseSepolia.USDC, x402.assets.define(...)

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 22, 2026

Open in StackBlitz

npm i https://pkg.pr.new/mppx@474

commit: 11633db

@brendanjryan brendanjryan marked this pull request as ready for review May 22, 2026 19:27
@brendanjryan brendanjryan requested a review from jxom May 22, 2026 19:35
@brendanjryan brendanjryan force-pushed the brendanjryan/x402-exact branch from 9cc0f6e to d9b9454 Compare May 22, 2026 20:30
@TateLyman
Copy link
Copy Markdown

Source-readback note on the new x402 exact flow: I think the server currently verifies the paid accepted requirements, but does not bind the incoming PAYMENT-SIGNATURE resource to the route being served.

What I’m seeing:

  • src/x402/server/Transport.ts#getCredential() decodes the submitted PAYMENT-SIGNATURE and builds the pending credential from paymentPayload.accepted.
  • bindCredential() then swaps in the canonical route challenge, but keeps the original payload unchanged.
  • src/x402/server/Exact.ts#verify() compares paymentPayload.accepted to Types.toPaymentRequirements(credential.challenge.request), then calls facilitator verify / settle.
  • Types.toPaymentRequirements() intentionally strips resource, so paymentPayload.resource is never compared to the current route resource.

That seems to allow a signed payment payload produced from one route challenge to be submitted to a different route with the same amount / asset / network / payTo / transfer metadata. The facilitator can still validate and settle the token transfer, but the server-side check has lost the x402 resource binding before release of content.

A low-friction patch would be to check this in bindCredential() while both the route challenge, submitted credential.payload, and current input.url are still available. For example, derive the expected resource from (challenge.request as Types.ExactRequest).resource ?? { url: input.url }, require paymentPayload.resource to match it, and return a credential error before settlement when it does not.

No wallet signing, payment headers, paid calls, or live facilitator calls were used for this pass.

@TateLyman
Copy link
Copy Markdown

Re-checked commit 3427da2 with a no-payment local pass. The new resource check in src/x402/server/Exact.ts binds paymentPayload.resource to the route resource before facilitator verify / settle, and the added e2e regression covers replaying the same requirements across /a and /b.

Local repro I ran after building generated assets:

corepack pnpm install --frozen-lockfile
corepack pnpm build
node --import tsx - <<'TS'
# Constructed an x402 exact route, replayed a PAYMENT-SIGNATURE from https://example.com/a against https://example.com/b, and asserted:
# {"crossResourceStatus":402,"sameResourceStatus":200,"verifyCalls":1}
TS

I also tried the repo test runner for src/x402/Exact.e2e.test.ts, but local global setup requires a working container runtime for Tempo and failed before running tests. The focused script above exercised the resource-binding path without sending payment headers to any live service, wallet signing, paid calls, or facilitator network calls.

@TateLyman
Copy link
Copy Markdown

CI note from the failing runtime shard: the remaining failure looks like test fixture drift from the resource-binding fix, not the resource check itself.

The failing test is src/server/Mppx.test.ts > compose > dispatches x402 credentials through compose() with expected 402 to be 200. That path calls:

const paymentRequired = x402_Header.decodePaymentRequired(...)
const credential = x402PaymentSignature(paymentRequired.accepts[0]!)

But x402PaymentSignature(accepted) at the bottom of the file builds a PAYMENT-SIGNATURE from only the accept leg and does not include paymentRequired.resource. With the new server check, that signature correctly stays at 402 because there is no submitted resource to compare to the route resource. Likely fix: pass the decoded paymentRequired.resource into the helper and include resource in the encoded payment signature for that compose happy path.

No additional live/payment calls used; this is just CI log + source readback.

@TateLyman
Copy link
Copy Markdown

Final re-check at 11633db: the compose fixture drift is fixed and the GitHub check set is green from the public PR view. The happy-path credential now carries the decoded 402 resource, so the resource-binding guard added in Exact.ts can reject cross-route replay without breaking the composed endpoint path.

I do not have another blocker from this no-payment pass. No wallet signing, live payment headers, paid calls, or facilitator network calls used.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants