Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
126 changes: 63 additions & 63 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,16 @@
> [!WARNING]
> **Beta.** Public API may change between minor versions until v1. Pin to an exact version in production.

First-party MPP payment method for 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).
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 IETF [`draft-evm-charge-00`](https://github.com/tempoxyz/mpp-specs/blob/2f6bfcee6f9e448d2ded15dc350dc92967e17513/specs/methods/evm/draft-evm-charge-00.md)
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 |
| 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 |

## Contents

Expand Down Expand Up @@ -45,41 +45,41 @@ npm install @quicknode/mpp mppx viem
## Server — accept payments

```ts
import { Mppx, evm } from '@quicknode/mpp/server'
import { Mppx, evm } from "@quicknode/mpp/server";

const mppx = Mppx.create({
methods: [
evm.charge({
recipient: '0xMerchantWallet',
chain: 'base',
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`.
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',
chain: "base",
rpcUrl, // optional override; omit to use public endpoint
credentialTypes: ['permit2', 'authorization'], // drop 'hash' if you don't want client-paid flows
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'
import { Mppx, evm } from "@quicknode/mpp/client";
import { privateKeyToAccount } from "viem/accounts";

const { fetch } = Mppx.create({
methods: [
Expand All @@ -88,62 +88,62 @@ const { fetch } = Mppx.create({
// 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')
const res = await fetch("https://api.merchant.com/premium");
```

Set client preference order:

```ts
evm.charge({
account,
prefer: ['authorization', 'permit2'], // skip 'hash' entirely
})
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`:
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'
import { QuicknodeRateLimitError } from "@quicknode/mpp/server";

try {
await mppx.evm.charge(/* ... */)
await mppx.evm.charge(/* ... */);
} catch (err) {
if (err instanceof QuickNodeRateLimitError) {
console.error(`Rate limited on ${err.chain}. Upgrade: ${err.upgradeUrl}`)
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.
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 |
| `store` | | `Store.memory()` | Any mppx `AtomicStore` (Cloudflare KV, Redis, Upstash) |
| 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 |
| `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 |
| 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

Expand All @@ -153,10 +153,10 @@ Before the agent can use `permit2`, it must approve Permit2 on each token:
// 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],
})
abi: parseAbi(["function approve(address,uint256)"]),
functionName: "approve",
args: ["0x000000000022D473030F116dDEE9F6B43aC78BA3", 2n ** 256n - 1n],
});
```

### Custom tokens & native settlement
Expand All @@ -166,46 +166,46 @@ 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'
import { evm } from "@quicknode/mpp/server";

evm.charge({
chain: 'ethereum',
chain: "ethereum",
recipient,
submitter: { privateKey: SUBMITTER_PK },
customToken: {
address: '0x6B175474E89094C44Da98b954EedeAC495271d0F', // DAI
address: "0x6B175474E89094C44Da98b954EedeAC495271d0F", // DAI
decimals: 18,
symbol: 'DAI',
symbol: "DAI",
},
})
});
```

```ts
// Native chain coin — set address to NATIVE_TOKEN_ADDRESS (zero address)
import { evm, NATIVE_TOKEN_ADDRESS } from '@quicknode/mpp/server'
import { evm, NATIVE_TOKEN_ADDRESS } from "@quicknode/mpp/server";

evm.charge({
chain: 'base',
chain: "base",
recipient,
customToken: {
address: NATIVE_TOKEN_ADDRESS,
decimals: 18,
symbol: 'ETH',
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. |
| 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
Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "@quicknode/mpp",
"version": "0.2.0",
"description": "QuickNode payment methods for MPP (Machine Payments Protocol)",
"description": "Quicknode payment methods for MPP (Machine Payments Protocol)",
"license": "MIT",
"type": "module",
"sideEffects": false,
Expand Down Expand Up @@ -94,4 +94,4 @@
"usdc",
"agent"
]
}
}
2 changes: 1 addition & 1 deletion scripts/live-sepolia.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ const payer = privateKeyToAccount(PAYER_PK)
const submitter = SUBMITTER_PK ? privateKeyToAccount(SUBMITTER_PK) : undefined

const EFFECTIVE_RPC = useDefaultRpc ? defaultRpcUrl('base-sepolia') : (RPC_URL as string)
console.log(`▶ RPC: ${useDefaultRpc ? 'QuickNode public (default)' : 'custom'}`)
console.log(`▶ RPC: ${useDefaultRpc ? 'Quicknode public (default)' : 'custom'}`)

const CHAIN_ID = CHAIN_IDS['base-sepolia']
const TOKEN = USDC_CONTRACTS['base-sepolia']
Expand Down
6 changes: 3 additions & 3 deletions scripts/rate-limit-demo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/**
* Fires requests in a loop against the SDK's default public RPC until
* it hits a 429, then prints the resulting QuickNodeRateLimitError to
* it hits a 429, then prints the resulting QuicknodeRateLimitError to
* validate the upgrade CTA copy.
*
* Usage: npx tsx scripts/rate-limit-demo.ts [chain]
Expand All @@ -11,7 +11,7 @@

import { createPublicClient } from 'viem'
import { type SupportedChain, defaultRpcUrl } from '../src/constants.js'
import { QuickNodeRateLimitError } from '../src/errors.js'
import { QuicknodeRateLimitError } from '../src/errors.js'
import { getViemChain } from '../src/internal/chain.js'
import { defaultTransport } from '../src/internal/transport.js'

Expand All @@ -30,7 +30,7 @@ while (true) {
try {
await client.getChainId()
} catch (err) {
if (err instanceof QuickNodeRateLimitError) {
if (err instanceof QuicknodeRateLimitError) {
const elapsed = ((Date.now() - start) / 1000).toFixed(1)
console.log(`\n✖ Rate-limited after ${i} requests in ${elapsed}s\n`)
console.log('Error:', err.message)
Expand Down
12 changes: 6 additions & 6 deletions src/__tests__/exports.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import * as client from '../client/index.js'
import * as root from '../index.js'
import * as server from '../server/index.js'

test('QuickNodeRateLimitError exported from root', () => {
assert.equal(typeof root.QuickNodeRateLimitError, 'function')
test('QuicknodeRateLimitError exported from root', () => {
assert.equal(typeof root.QuicknodeRateLimitError, 'function')
})

test('QuickNodeRateLimitError exported from server', () => {
assert.equal(typeof server.QuickNodeRateLimitError, 'function')
test('QuicknodeRateLimitError exported from server', () => {
assert.equal(typeof server.QuicknodeRateLimitError, 'function')
})

test('QuickNodeRateLimitError exported from client', () => {
assert.equal(typeof client.QuickNodeRateLimitError, 'function')
test('QuicknodeRateLimitError exported from client', () => {
assert.equal(typeof client.QuicknodeRateLimitError, 'function')
})
2 changes: 1 addition & 1 deletion src/client/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,4 @@ export { NATIVE_TOKEN_ADDRESS } from '../constants.js'
export { charge }
export const evm = { charge }
export { Mppx } from 'mppx/client'
export { QuickNodeRateLimitError } from '../errors.js'
export { QuicknodeRateLimitError } from '../errors.js'
4 changes: 2 additions & 2 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export const TRANSFER_EVENT_TOPIC =
'0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef' as const

/**
* Pre-provisioned QuickNode endpoint used when the SDK is configured without
* Pre-provisioned Quicknode endpoint used when the SDK is configured without
* an explicit rpcUrl. Rate-limited per IP. See README for upgrade path.
*/
export const PUBLIC_RPC_PREFIX = 'dimensional-red-surf'
Expand Down Expand Up @@ -212,7 +212,7 @@ export const CHAIN_PATH_SUFFIXES: Partial<Record<SupportedChain, string>> = {
}

/**
* Known QuickNode path suffixes for chains not yet in `SupportedChain`.
* Known Quicknode path suffixes for chains not yet in `SupportedChain`.
* Keep these wired up so adding the chain later is a one-line change in
* `CHAIN_PATH_SUFFIXES`. Not referenced by runtime code today.
*/
Expand Down
26 changes: 13 additions & 13 deletions src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,36 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { QuickNodeRateLimitError } from './errors.js'
import { QuicknodeRateLimitError } from './errors.js'

test('QuickNodeRateLimitError has canonical code and chain', () => {
const err = new QuickNodeRateLimitError('base')
test('QuicknodeRateLimitError has canonical code and chain', () => {
const err = new QuicknodeRateLimitError('base')
assert.equal(err.code, 'QUICKNODE_RATE_LIMITED')
assert.equal(err.chain, 'base')
assert.equal(err.name, 'QuickNodeRateLimitError')
assert.equal(err.name, 'QuicknodeRateLimitError')
})

test('QuickNodeRateLimitError exposes upgrade URL with UTM', () => {
const err = new QuickNodeRateLimitError('optimism')
test('QuicknodeRateLimitError exposes upgrade URL with UTM', () => {
const err = new QuicknodeRateLimitError('optimism')
assert.match(err.upgradeUrl, /^https:\/\/www\.quicknode\.com/)
assert.match(err.upgradeUrl, /utm_source=mpp-sdk/)
assert.match(err.message, /optimism/)
assert.match(err.message, /quicknode\.com/)
})

test('QuickNodeRateLimitError records retryAfter when provided', () => {
const err = new QuickNodeRateLimitError('base', 30)
test('QuicknodeRateLimitError records retryAfter when provided', () => {
const err = new QuicknodeRateLimitError('base', 30)
assert.equal(err.retryAfterSeconds, 30)
assert.match(err.message, /retry after 30s/)
})

test('QuickNodeRateLimitError omits retryAfter clause when unset', () => {
const err = new QuickNodeRateLimitError('base')
test('QuicknodeRateLimitError omits retryAfter clause when unset', () => {
const err = new QuicknodeRateLimitError('base')
assert.equal(err.retryAfterSeconds, undefined)
assert.doesNotMatch(err.message, /retry after/)
})

test('QuickNodeRateLimitError is an instance of Error', () => {
const err = new QuickNodeRateLimitError('base')
test('QuicknodeRateLimitError is an instance of Error', () => {
const err = new QuicknodeRateLimitError('base')
assert.ok(err instanceof Error)
assert.ok(err instanceof QuickNodeRateLimitError)
assert.ok(err instanceof QuicknodeRateLimitError)
})
Loading
Loading