https://github.com/quiknode-labs/mpp
https://github.com/quiknode-labs/mpp
Last synced: 1 day ago
JSON representation
- Host: GitHub
- URL: https://github.com/quiknode-labs/mpp
- Owner: quiknode-labs
- License: mit
- Created: 2026-04-21T20:42:06.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-05-01T20:47:15.000Z (about 1 month ago)
- Last Synced: 2026-05-01T22:24:18.075Z (about 1 month ago)
- Language: TypeScript
- Size: 174 KB
- Stars: 1
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- License: LICENSE
Awesome Lists containing this project
README
# @quicknode/mpp
> [!WARNING]
> **Beta.** Public API may change between minor versions until v1. Pin to an exact version in production.
SDK for extending the MPP protocol with EVM-settled payments, verified via Quicknode RPC. Gate any HTTP endpoint behind a stablecoin (or native-coin) payment — agents pay with one signature, the server verifies on-chain, and the request is forwarded. Built for the [Machine Payments Protocol](https://github.com/tempoxyz/mpp-specs).
Implements and expands on the [`draft-evm-charge-00`](https://github.com/tempoxyz/mpp-specs/blob/2f6bfcee6f9e448d2ded15dc350dc92967e17513/specs/methods/evm/draft-evm-charge-00.md) spec
with all three non-trivial credential types:
| Type | Binding | Gas | UX |
| ----------------------- | -------------------------------- | ----------- | ------------------------------------- |
| `permit2` (RECOMMENDED) | Strong (EIP-712 witness) | Server pays | One signature, any ERC-20 |
| `authorization` | Strong (on-chain nonce) | Server pays | One signature, USDC / EIP-3009 tokens |
| `hash` | Weakest (post-hoc receipt match) | Client pays | Client broadcasts + waits |
> [!CAUTION]
> The `hash` credential is **post-hoc receipt matching only** — it binds nothing to the specific challenge. Any historical Transfer to the recipient that matches the requested token + amount can be claimed as proof of payment, once each. To narrow the replay window, set `maxReceiptAgeSeconds` on the server (see [Configuration](#configuration)). Even then, concurrent third-party payments to the same recipient for the same amount within the window can still leak through. For payments where stronger binding matters, prefer `permit2` or `authorization`.
## Contents
- [Install](#install)
- [Compatibility](#compatibility)
- [Server — accept payments](#server--accept-payments)
- [Client — pay for content](#client--pay-for-content)
- [Rate limits](#rate-limits)
- [Configuration](#configuration)
- [Live testing on Base Sepolia](#live-testing-on-base-sepolia)
- [Versioning](#versioning)
- [Changelog](#changelog)
- [Contributing](#contributing)
- [Security](#security)
- [License](#license)
## Install
```bash
npm install @quicknode/mpp mppx viem
```
## Compatibility
- **Node.js** ≥ 22
- **Cloudflare Workers**, **Bun**, and other WebCrypto-capable runtimes
- **Browsers** — the client (`@quicknode/mpp/client`) runs in-browser for agent UIs. The server modules import Node-only code and are not browser-bundleable
- **TypeScript** — types ship in the package; no separate `@types/*` install needed
## Server — accept payments
```ts
import { Mppx, evm } from "@quicknode/mpp/server";
const mppx = Mppx.create({
methods: [
evm.charge({
recipient: "0xMerchantWallet",
chain: "base",
submitter: { privateKey: process.env.SUBMITTER_PK! },
}),
],
secretKey: process.env.MPP_SECRET_KEY!,
});
// mppx.evm.charge({ amount: '0.01', decimals: 6 })(request) → 402 challenge or verified receipt
```
No `rpcUrl`? The SDK uses Quicknode's shared public endpoint for the chosen chain. Good for local dev and low-volume workloads. When you start seeing `QuicknodeRateLimitError`, upgrade at [quicknode.com](https://www.quicknode.com/?utm_source=mpp-sdk) and pass your dedicated endpoint via `rpcUrl`.
Scope accepted types per-server:
```ts
evm.charge({
recipient,
chain: "base",
rpcUrl, // optional override; omit to use public endpoint
credentialTypes: ["permit2", "authorization"], // drop 'hash' if you don't want client-paid flows
submitter: { privateKey: SUBMITTER_PK },
});
```
## Client — pay for content
```ts
import { Mppx, evm } from "@quicknode/mpp/client";
import { privateKeyToAccount } from "viem/accounts";
const { fetch } = Mppx.create({
methods: [
evm.charge({
account: privateKeyToAccount(process.env.AGENT_PK! as `0x${string}`),
// rpcUrl only needed if you want to allow the 'hash' credential path
}),
],
});
// Auto-handles 402 → pay → retry
const res = await fetch("https://api.merchant.com/premium");
```
Set client preference order:
```ts
evm.charge({
account,
prefer: ["authorization", "permit2"], // skip 'hash' entirely
});
```
## Rate limits
The default public RPC is rate-limited per IP. When the limit is exceeded, the SDK throws `QuicknodeRateLimitError`:
```ts
import { QuicknodeRateLimitError } from "@quicknode/mpp/server";
try {
await mppx.evm.charge(/* ... */);
} catch (err) {
if (err instanceof QuicknodeRateLimitError) {
console.error(`Rate limited on ${err.chain}. Upgrade: ${err.upgradeUrl}`);
}
}
```
To avoid the limit entirely, pass your own `rpcUrl` from any Quicknode plan.
## Configuration
### `evm.charge` (server)
| Option | Required | Default | Notes |
| ----------------- | --------------------------------------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `recipient` | ✓ | — | Merchant wallet (receives USDC) |
| `chain` | ✓ | — | `'base' \| 'ethereum' \| 'arbitrum' \| 'polygon' \| 'optimism' \| 'avalanche' \| 'linea' \| 'unichain' \| 'base-sepolia'` |
| `rpcUrl` | — | — | Defaults to Quicknode public endpoint for the chain. Rate-limited per IP. |
| `submitter` | when `credentialTypes` contains `permit2`/`authorization` | — | `{ privateKey }` or `{ account }` |
| `credentialTypes` | | per-token allowed set | Draft-ordered preference list |
| `token` | | `'USDC'` | Curated symbol: `USDC \| EURC \| WETH \| USDT`. Mutually exclusive with `customToken`. |
| `customToken` | | — | Caller-supplied `{ address, decimals, symbol?, name?, version?, credentialTypes? }`. Use for any ERC-20 by address, or for native (zero-address). See below. |
| `confirmations` | | per-chain default | Block-depth check for `hash` credential |
| `maxReceiptAgeSeconds` | | — | If set, rejects `hash` credentials whose receipt block is older than N seconds at verification time. Closes the historical-Transfer-replay class. Recommended ≥ slowest expected confirmation window (e.g. 600 for L1, 60 for fast L2). |
| `store` | | `Store.memory()` | Any mppx `AtomicStore` (Cloudflare KV, Redis, Upstash) |
### `evm.charge` (client)
| Option | Required | Notes |
| ------------------------ | ------------------------ | ----------------------------------------------- |
| `account` / `privateKey` | one of | Viem `Account` or raw `0x...` hex |
| `rpcUrl` | only if `hash` is chosen | Used to broadcast the ERC-20 transfer |
| `prefer` | | `['permit2','authorization','hash']` by default |
### Permit2 one-time approval
Before the agent can use `permit2`, it must approve Permit2 on each token:
```ts
// One-time, from the agent's wallet:
await walletClient.writeContract({
address: USDC_ADDRESS,
abi: parseAbi(["function approve(address,uint256)"]),
functionName: "approve",
args: ["0x000000000022D473030F116dDEE9F6B43aC78BA3", 2n ** 256n - 1n],
});
```
### Custom tokens & native settlement
Pass `customToken` instead of `token` to settle in any ERC-20 by address, or
in the chain's native coin (ETH / MATIC / AVAX / …):
```ts
// Any ERC-20 by address — e.g. DAI on mainnet
import { evm } from "@quicknode/mpp/server";
evm.charge({
chain: "ethereum",
recipient,
submitter: { privateKey: SUBMITTER_PK },
customToken: {
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F", // DAI
decimals: 18,
symbol: "DAI",
},
});
```
```ts
// Native chain coin — set address to NATIVE_TOKEN_ADDRESS (zero address)
import { evm, NATIVE_TOKEN_ADDRESS } from "@quicknode/mpp/server";
evm.charge({
chain: "base",
recipient,
customToken: {
address: NATIVE_TOKEN_ADDRESS,
decimals: 18,
symbol: "ETH",
},
// No `submitter` needed — native settlement only supports the `hash`
// credential, which the client broadcasts itself.
});
```
`customToken` fields:
| Field | Required | Notes |
| ----------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `address` | ✓ | ERC-20 contract address. Use `NATIVE_TOKEN_ADDRESS` for the chain's native coin. |
| `decimals` | ✓ | 18 for native ETH / MATIC / AVAX. |
| `symbol` | | Display only. |
| `name`, `version` | | EIP-712 domain values. Pass these for `authorization` (EIP-3009) when the token's on-chain `name()` / `version()` reverts or differs from its EIP-712 domain. |
| `credentialTypes` | | Defaults: `['permit2','hash']` for ERC-20, `['hash']` for native. |
Defaults intentionally exclude `authorization` for custom ERC-20s: only Circle
FiatTokens (USDC, EURC) implement EIP-3009 reliably. Opt in by passing
`credentialTypes: ['authorization', ...]` if your token implements it.
Native settlement is restricted to the `hash` credential and to direct EOA
sends — `tx.input === '0x'`, `tx.to === recipient`, `tx.value === amount`.
Contract-mediated native transfers aren't accepted by the verifier.
> **Spec note**: native settlement (zero-address `currency`) is a
> non-normative extension to
> [`draft-evm-charge-00`](https://github.com/tempoxyz/mpp-specs/blob/2f6bfcee6f9e448d2ded15dc350dc92967e17513/specs/methods/evm/draft-evm-charge-00.md),
> which scopes itself to ERC-20 transfers. Custom ERC-20 addresses are
> spec-compliant — the spec defines `currency` as a 20-byte hex string.
## Live testing on Base Sepolia
```bash
export RPC_URL=https://base-sepolia.quiknode.pro/
export PAYER_PK=0x... # funded with Base Sepolia USDC
export SUBMITTER_PK=0x... # funded with Base Sepolia ETH (for permit2/authorization)
export RECIPIENT=0x...
npx tsx scripts/live-sepolia.ts --type hash
npx tsx scripts/live-sepolia.ts --type authorization
npx tsx scripts/live-sepolia.ts --type permit2
# Or use the zero-config path (no RPC_URL env needed):
npx tsx scripts/live-sepolia.ts --type hash --use-default-rpc
```
### Integration tests (opt-in)
A read-only sanity check against the default public endpoint is gated behind an env var:
```bash
MPP_INTEGRATION=1 npm test -- --test-name-pattern "public rpc"
```
Runs one `getChainId` call per supported chain. Requires real `PUBLIC_RPC_PREFIX`/`PUBLIC_RPC_TOKEN` values in `src/constants.ts`.
## Versioning
This package follows [SemVer](https://semver.org/) with a beta caveat:
- **Until 1.0**, minor versions may include breaking changes when [`draft-evm-charge-00`](https://github.com/tempoxyz/mpp-specs/blob/2f6bfcee6f9e448d2ded15dc350dc92967e17513/specs/methods/evm/draft-evm-charge-00.md) evolves or the public API needs to change. Pin an exact version in production.
- **Public API** = everything exported from `@quicknode/mpp`, `@quicknode/mpp/server`, `@quicknode/mpp/client`, their submodules, and `@quicknode/mpp/constants`. Anything under `internal/` is private and may change without notice.
## Changelog
See [GitHub Releases](https://github.com/quiknode-labs/mpp/releases) for per-version changes.
## Contributing
Issues and PRs welcome at [quiknode-labs/mpp](https://github.com/quiknode-labs/mpp). Before opening a PR:
```bash
npm run verify # lint + typecheck + tests + build
```
## Security
Found a vulnerability? Please email security@quicknode.com instead of opening a public issue.
## License
MIT