Machine Payments Protocol (MPP)
This quickstart will guide you through implementing an Arbitrum-specific payment method plugin for the mppx library, which implements the Machine Payments Protocol (MPP). MPP defines a generic challenge → credential → settlement flow for payments between two parties:
- Server: the merchant/payee (the one wants to get paid)
- Client: the payer (a user or AI agent)
mppx itself is payment-method-agnostic. This plugin provides the methods for settling payments on Arbitrum One or Arbitrum Sepolia using ERC-20 stablecoins (currently USDC).
Core concepts
The client (payer) never broadcasts a transaction and never pays gas.
- The merchant requests payment (issues a challenge).
- The payer signs an EIP-712 typed-data authorization offchain—no gas, no prior onchain approval is needed.
- The merchant's server submits the signature onchain, completing the fund transfer. The merchant pays for the gas.
This is exactly the "402 Payment Required"—style flow you'd want for machine/agent commerce: an HTTP request hits a paywalled endpoint, the agent signs a payment authorization, and the server settles it atomically before serving the response.
It supports two settlement mechanisms:
| Type | Onchain mechanism | Supports splits? | Need prior approval? |
|---|---|---|---|
| authorization | EIP-3009 transferWithAuthorization (native to USDC) | ❌ | ❌ |
| permit2 | Uniswamp's Permit2 permitWitnessTransferFrom | ✅ (pay multiple recipients in one transaction) | ✅ payer must pre-approve Permit2 on the token once |
transaction and hash credential types are stubbed but intentionally not implemented—these have weaker challenge-binding and invite fraud potential.

MPP Flow
What the client does
charge() returns a Method.toClient handler. In createCredential:
- Validates the challenge’s
chainIdis supported and unexpired. - Checks the payer’s token balance onchain (
balanceOf). - Branches on
credentialTypes:
- permit2 (or undefined): builds permitted/
transferDetailsarrays (handling splits, with the primary recipient pushed to the front), derives the nonce from a challenge hash, and signs the Permit2 witness typed-data. - authorization: derives nonce =
keccak256(challenge.id,challenge.realm) for challenge-binding (anti-replay), looks up the token’s EIP-712 domain from the local erc3009Tokens registry (src/default.ts — not an on-chain query), and signs theTransferWithAuthorizationstruct.
- Returns
Credential.serialize(...). No transaction is broadcast.
What the server does
charge() returns a Method.toServer handler. In verify(credential, request), it independently re-derives and re-checks every value the client claimed (recipient, amount, deadline, nonce/challenge-hash, signature via verifyTypedData, balance, and, for permit2, the Permit2 allowance and split amounts). Then it:
- Simulates the transaction with
eth_call(so a bad credential doesn’t waste gas). - Submits
transferWithAuthorization(authorization) orpermitWitnessTransferFrom(permit2) from the merchant’s account. waitForTransactionReceipt, then verifies the emitted Transfer logs match the expected recipients/amounts.- Returns an mppx Receipt:
method: "arbitrum", status: "success", timestamp, reference: txHash.
How to implement it — server (merchant) side
mppx has an Express adapter:
import express from 'express';
import { Mppx } from 'mppx/express';
import { privateKeyToAccount } from 'viem/accounts';
import { charge } from '@arbitrum/mpp/server';
import * as defaults from '@arbitrum/mpp/default';
const account = privateKeyToAccount(process.env.SERVER_PRIVATE_KEY as `0x${string}`);
const app = express();
const mppx = Mppx.create({
methods: [
charge({
recipient: account.address, // where funds land
currency: defaults.TOKEN_CONTRACTS.USDC_ARBITRUM_SEPOLIA, // which token
methodDetails: { chainId: 421614, decimals: 6 },
account, // pays gas to settle
}),
],
secretKey: process.env.SERVER_PRIVATE_KEY,
});
// Gate an endpoint behind a charge:
app.get(
'/authorization',
mppx.charge({
amount: '1000', // raw units: 1000 = 0.001 USDC (6 decimals)
description: 'My favorite food',
methodDetails: { chainId: 421614, credentialTypes: ['authorization'] },
}),
(req, res) => res.json({ data: 'authorization worked!' }), // only runs after payment settles
);
app.listen(3000);
- Set credentialTypes: ['permit2'] instead to use Permit2, and add a splits: [...] array to pay multiple recipients in one transaction.
- ⚠️ Amount is in raw token units, not human-readable—human-readable decimals aren’t supported yet.
How to implement it - client (payer) side
import { Mppx } from 'mppx/client';
import { privateKeyToAccount } from 'viem/accounts';
import { charge } from '@arbitrum/mpp/client';
const account = privateKeyToAccount(process.env.CLIENT_PRIVATE_KEY as `0x${string}`);
const mppx = Mppx.create({
methods: [charge({ account, chainId: 421614 })],
});
// mppx intercepts the 402, signs the challenge, retries automatically:
const response = await mppx.fetch('http://localhost:3000/authorization');
const data = await response.json();
const receipt = response.headers.get('payment-receipt'); // base64-encoded mppx Receipt
console.log(Buffer.from(receipt!, 'base64').toString('binary'));
Run the bundled example locally
pnpm install
# .env (copy from .env.example)
CLIENT_PRIVATE_KEY=0x... # this wallet needs USDC on Arbitrum Sepolia
SERVER_PRIVATE_KEY=0x... # this wallet needs ETH (gas) on Arbitrum Sepolia
# Terminal 1
npm run server # tsx test/server → listens on :3000
# Terminal 2
npm run client # tsx test/client → hits /authorization, signs, settles
Funding requirements
- Server needs ETH on the chain (it pays gas to submit the settlement transaction).
- Client needs USDC on the same chain (the funds being pulled).
- For Permit2, the client must first approve the Permit2 contract (
0x000000000022D473030F116dDEE9F6B43aC78BA3) as a spender on the USDC token—Permit2 can’t move tokens it hasn’t been allowed to.
Other useful commands
npm test # vitest run (includes anvil-fork e2e tests under test/e2e)
npm run build # rm -rf dist && tsc → dist/ (clean publishable build)
npm run lint # eslint --fix
npm run format # prettier --write
E2E tests fork a live network via @viem/anvil (set FORK_URL in .env) so they exercise the real USDC and Permit2 contracts without spending real funds.
Current limitations
- Only USDC on Arbitrum One/Sepolia is registered. Adding a token = adding its address + EIP-712 name/version/chainId there.
- amount is raw units only—no human-readable decimal conversion yet.
- For authorization, the
validBeforeexpiry is trusted from the server’s challenge; a far-future expiry theoretically widens the window in which an unsubmitted authorization could be settled late. (Note: the EIP-3009 nonce is challenge-bound—keccak256(id, realm) — and single-use onchain, so a literal replay of an already-settled authorization is blocked once the nonce is consumed.) - transaction and hash credential types are intentionally unimplemented (weak challenge-binding).
- Status is v0.1.0 — early/experimental.