mpp
by @tenequm
Build with MPP (Machine Payments Protocol) - the open protocol for machine-to-machine payments over HTTP 402. Use when developing paid APIs, payment-gated co...
Self-payment trap: The payer and recipient cannot be the same wallet address. When testing with npx mppx, create a separate client account (npx mppx account create -a client) and fund it separately.
Recipient wallet initialization: TIP-20 token accounts on Tempo must be initialized before they can receive tokens (similar to Solana ATAs). Send a tiny amount (e.g. 0.01 USDC) to the recipient address first: tempo wallet transfer 0.01 .
Server
Set realm explicitly for mppscan attribution. The realm value is hashed into Tempo's attribution memo (bytes 5-14 of the 32-byte transferWithMemo data) and is how mppscan correlates on-chain transactions to registered servers. Mppx.create() auto-detects realm from env vars (MPP_REALM, FLY_APP_NAME, HEROKU_APP_NAME, HOST, HOSTNAME, RAILWAY_PUBLIC_DOMAIN, RENDER_EXTERNAL_HOSTNAME, VERCEL_URL, WEBSITE_HOSTNAME). On PaaS platforms (Vercel, Railway, Heroku) these are stable app names and work fine. In Kubernetes, HOSTNAME is the pod name (e.g. web-69d986c8d8-6dtdx) which rotates on every deploy - causing a new server fingerprint each time, so mppscan can't track your transactions. Fix by setting MPP_REALM env var to your stable public domain or passing realm directly:
Mppx.create({
methods: [tempo({ ... })],
realm: 'web.surf.cascade.fyi', // or process.env.MPP_REALM
secretKey,
})
tempo() vs explicit registration: tempo({ ... }) registers both charge and session intents with shared config. When you need different config per intent (e.g. session needs store and sse: { poll: true } but charge doesn't), register them explicitly:
import { Mppx, Store, tempo } from 'mppx/server'
Mppx.create({
methods: [
tempo.charge({ currency, recipient }),
tempo.session({ currency, recipient, store: Store.memory(), sse: { poll: true } }),
],
secretKey,
})
Hono multiple headers: c.header(name, value) replaces by default. When emitting multiple WWW-Authenticate values (e.g. charge + session intents), the second call silently overwrites the first. Prefer using mppx.compose() which handles multi-header emission correctly. If composing manually, use { append: true }:
c.header('WWW-Authenticate', chargeWwwAuth)
c.header('WWW-Authenticate', sessionWwwAuth, { append: true })
CORS headers: WWW-Authenticate and Payment-Receipt must be listed in access-control-expose-headers or browsers/clients won't see them.
SSE utilities import path: Session.Sse.iterateData is exported from mppx/tempo, NOT mppx/server:
import { Mppx, Store, tempo } from 'mppx/server'
import { Session } from 'mppx/tempo'
const iterateSseData = Session.Sse.iterateData
Stores
Never use Store.memory() in production. It loses all channel state on server restart/redeploy. When state is lost, the server can't close channels or settle funds - client deposits stay locked in escrow indefinitely. Use a persistent store.
Built-in store adapters (all handle BigInt serialization via ox's Json module):
import { Store } from 'mppx/server'Store.memory() // development only
Store.redis(redisClient) // ioredis, node-redis, Valkey (added in 0.4.9)
Store.upstash(upstashClient) // Upstash Redis / Vercel KV
Store.cloudflare(kvNamespace) // Cloudflare KV
Store.from({ get, put, delete }) // custom adapter
AtomicStore (0.5.7+): Extends Store with an update(key, fn) method for safe concurrent read-modify-write. Used internally for replay protection and channel state. All built-in adapters (redis, upstash, cloudflare) support atomic updates. Custom adapters via Store.from() get an optimistic-retry implementation automatically.
Polling mode: If your store doesn't implement the optional waitForUpdate() method (e.g. custom adapters via Store.from()), pass sse: { poll: true } to tempo.session(). Otherwise SSE streams will hang waiting for event-driven wakeups that never come.
Channel Recovery After Restarts
Pass channelId to mppx.session() so returning clients recover existing on-chain channels instead of opening new ones (which locks more funds in escrow). See references/sessions.md for the full pattern with Credential.fromRequest() and tryRecoverChannel().
Request Handling
Session voucher POSTs have no body. Mid-stream voucher POSTs carry only Authorization: Payment - no JSON body. If your middleware decides charge vs session based on body.stream, vouchers will hit the charge path. Check the credential's intent instead. As of mppx 0.4.9, the SDK skips route amount/currency/recipient validation for topUp and voucher credentials (the on-chain voucher signature is the real validation), so body-derived pricing mismatches no longer cause spurious 402 rejections.
Clone the request before reading the body. request.json() consumes the Request body. If you parse the body first and then pass the original request to mppx.session() or mppx.charge(), the mppx handler gets an empty body and returns 402. Clone before reading.
Pricing & Streaming
Cheap model zero-charge floor: Tempo USDC has 6-decimal precision. For very cheap models, per-token cost like (0.10 / 1_000_000) * 1.3 = 0.00000013 rounds to "0.000000" via toFixed(6) - effectively zero. Add a minimum tick cost floor:
const MIN_TICK_COST = 0.000001 // smallest Tempo USDC unit (6 decimals)
const tickCost = Math.max((outputRate / 1_000_000) * margin, MIN_TICK_COST)
SSE chunks != tokens: Per-SSE-event stream.charge() is an acceptable approximation. stream.charge() is serial (Redis GET + SET per call, per-channelId mutex) - no bulk API exists yet.
Add upstream timeouts: Always use AbortSignal.timeout() on upstream fetches. A stalled upstream holds the payment channel open, locking client funds.
Infrastructure
Nginx proxy buffer overflow: Large 402 headers can exceed nginx's default 4k proxy_buffer_size, causing 502 Bad Gateway. Fix: nginx.ingress.kubernetes.io/proxy-buffer-size: "16k". Debug: port-forward directly to the pod - if you get 402, the issue is in the ingress layer.
Client / Tempo CLI
CLI defaults to mainnet (0.5.4+): The mppx CLI now defaults to Tempo mainnet when --rpc-url is omitted. Previously it defaulted to testnet. Use --rpc-url or set MPPX_RPC_URL/RPC_URL env vars for testnet.
Stale sessions after redeploy: When the server redeploys and loses in-memory session state, clients get "Session invalidation claim for channel 0x... was not confirmed on-chain". Fix: tempo wallet sessions close or tempo wallet sessions sync. Dispute window is 4-15 min.
clawhub install mpp