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
67 changes: 65 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,9 @@ To avoid the limit entirely, pass your own `rpcUrl` from any QuickNode plan.
| `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` | | `['permit2','authorization','hash']` | Draft-ordered preference list |
| `token` | | `'USDC'` | Only USDC supported in v0.1 |
| `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) |

Expand All @@ -133,6 +134,68 @@ await walletClient.writeContract({
})
```

### 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/main/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
Expand Down
41 changes: 31 additions & 10 deletions src/client/hash.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
import { type Account, createPublicClient, createWalletClient, type Hex, http } from 'viem'
import { CHAIN_IDS, defaultRpcUrl, ERC20_ABI, type SupportedChain } from '../constants.js'
import {
type Account,
createPublicClient,
createWalletClient,
getAddress,
type Hex,
http,
} from 'viem'
import {
CHAIN_IDS,
defaultRpcUrl,
ERC20_ABI,
NATIVE_TOKEN_ADDRESS,
type SupportedChain,
} from '../constants.js'
import { getViemChainById } from '../internal/chain.js'
import { defaultTransport } from '../internal/transport.js'
import type { HashPayload } from '../types.js'
Expand Down Expand Up @@ -39,14 +52,22 @@ export async function createHashCredential(parameters: {
const walletClient = createWalletClient({ account, chain, transport })
const publicClient = createPublicClient({ chain, transport })

const txHash = await walletClient.writeContract({
address: tokenAddress,
abi: ERC20_ABI,
functionName: 'transfer',
args: [recipient, amount],
account,
chain,
})
const isNative = getAddress(tokenAddress) === getAddress(NATIVE_TOKEN_ADDRESS)
const txHash = isNative
? await walletClient.sendTransaction({
to: recipient,
value: amount,
account,
chain,
})
: await walletClient.writeContract({
address: tokenAddress,
abi: ERC20_ABI,
functionName: 'transfer',
args: [recipient, amount],
account,
chain,
})
await publicClient.waitForTransactionReceipt({ hash: txHash })
return { type: 'hash', txHash, chainId }
}
1 change: 1 addition & 0 deletions src/client/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { charge } from './Charge.js'

export { NATIVE_TOKEN_ADDRESS } from '../constants.js'
export { charge }
export const evm = { charge }
export { Mppx } from 'mppx/client'
Expand Down
25 changes: 18 additions & 7 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,14 +74,14 @@ export const DEFAULT_CONFIRMATIONS: Record<SupportedChain, number> = {

export type SupportedToken = 'USDC' | 'EURC' | 'WETH' | 'USDT'

// `Partial` because not every token is deployed on every chain. Admin layer
// must reject creating a payment option for a (chain, token) pair that
// resolves to undefined.
// `Partial` because not every token is deployed on every chain. Callers
// must handle a missing (chain, token) pair — `evm.charge` throws at
// construction time when the resolved address is undefined.
//
// Provenance rule (see agent-proxy/docs/decisions.md): every entry must be
// either issuer-deployed (Circle for USDC/EURC, Tether for mainnet USDT,
// chain team for canonical WETH wrappers) or verified on-chain against a
// known audited bytecode hash. Community-deployed tokens are not first-class.
// Provenance rule for entries in this table: every address is either
// issuer-deployed (Circle for USDC/EURC, Tether for mainnet USDT, the
// canonical chain-team WETH wrapper) or verified on-chain. Community
// deployments are not first-class — bring your own via `customToken`.
//
// WETH carve-out: only canonical native ETH wrappers are listed. Polygon
// (native MATIC) and Avalanche (native AVAX) carry "WETH" only as bridged
Expand Down Expand Up @@ -141,6 +141,17 @@ export const TOKEN_CREDENTIAL_TYPES: Record<SupportedToken, readonly CredentialT

export const PERMIT2_ADDRESS: `0x${string}` = '0x000000000022D473030F116dDEE9F6B43aC78BA3'

/**
* Sentinel address used in the `currency` field of an evm.charge challenge to
* signal a native chain coin transfer (ETH / MATIC / AVAX …). Pass it as
* `customToken.address` on the server to settle in the chain's native coin.
*
* Only the `hash` credential is supported for native settlement — Permit2 and
* EIP-3009 do not apply to native value transfers. This is a non-normative
* extension to draft-evm-charge-00, which scopes itself to ERC-20 transfers.
*/
export const NATIVE_TOKEN_ADDRESS: `0x${string}` = '0x0000000000000000000000000000000000000000'

export const ERC20_ABI = parseAbi([
'function transfer(address to, uint256 amount) returns (bool)',
'function balanceOf(address owner) view returns (uint256)',
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type { SupportedChain, SupportedToken } from './constants.js'
export {
NATIVE_TOKEN_ADDRESS,
TOKEN_CONTRACTS,
TOKEN_CREDENTIAL_TYPES,
TOKEN_DECIMALS,
Expand All @@ -11,6 +12,7 @@ export type {
ChargeCredential,
ClientParameters,
CredentialType,
CustomTokenConfig,
HashPayload,
Permit2Payload,
ServerParameters,
Expand Down
148 changes: 148 additions & 0 deletions src/server/Charge.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { NATIVE_TOKEN_ADDRESS } from '../constants.js'
import { charge } from './Charge.js'

const RECIPIENT = '0x1111111111111111111111111111111111111111'
const RPC = 'https://example.com/rpc'
const SUBMITTER = '0x0000000000000000000000000000000000000000000000000000000000000001' as const
const DAI_MAINNET = '0x6B175474E89094C44Da98b954EedeAC495271d0F' as const

test('charge() throws when submitter is missing and permit2 is accepted', () => {
assert.throws(
Expand Down Expand Up @@ -72,3 +75,148 @@ test('charge() accepts explicit rpcUrl (existing behavior preserved)', () => {
})
assert.equal(method.name, 'evm')
})

test('charge() throws when both `token` and `customToken` are set', () => {
assert.throws(
() =>
charge({
recipient: RECIPIENT,
chain: 'ethereum',
rpcUrl: RPC,
token: 'USDC',
customToken: { address: DAI_MAINNET, decimals: 18 },
credentialTypes: ['hash'],
}),
/either `token` or `customToken`/i,
)
})

test('charge() throws when native customToken accepts non-hash credentials', () => {
assert.throws(
() =>
charge({
recipient: RECIPIENT,
chain: 'base',
rpcUrl: RPC,
submitter: { privateKey: SUBMITTER },
customToken: {
address: NATIVE_TOKEN_ADDRESS,
decimals: 18,
credentialTypes: ['permit2', 'hash'],
},
}),
/native.*hash/i,
)
})

test('charge() with native customToken defaults to [hash] credential', () => {
const method = charge({
recipient: RECIPIENT,
chain: 'base',
rpcUrl: RPC,
customToken: { address: NATIVE_TOKEN_ADDRESS, decimals: 18, symbol: 'ETH' },
})
assert.equal(method.name, 'evm')
assert.equal(method.intent, 'charge')
})

test('charge() with custom ERC-20 customToken defaults to [permit2, hash] credentials', () => {
const method = charge({
recipient: RECIPIENT,
chain: 'ethereum',
rpcUrl: RPC,
submitter: { privateKey: SUBMITTER },
customToken: { address: DAI_MAINNET, decimals: 18, symbol: 'DAI' },
})
assert.equal(method.name, 'evm')
assert.equal(method.intent, 'charge')
})

test('charge() rejects authorization on a custom ERC-20 by default', () => {
assert.throws(
() =>
charge({
recipient: RECIPIENT,
chain: 'ethereum',
rpcUrl: RPC,
submitter: { privateKey: SUBMITTER },
customToken: { address: DAI_MAINNET, decimals: 18, symbol: 'DAI' },
credentialTypes: ['authorization'],
}),
/does not support credential types: authorization/i,
)
})

test('charge() throws when customToken.credentialTypes contains unknown values', () => {
assert.throws(
() =>
charge({
recipient: RECIPIENT,
chain: 'ethereum',
rpcUrl: RPC,
submitter: { privateKey: SUBMITTER },
customToken: {
address: DAI_MAINNET,
decimals: 18,
// biome-ignore lint/suspicious/noExplicitAny: simulate JS caller bypassing types
credentialTypes: ['permit2', 'lol-not-real'] as any,
},
}),
/unknown values: lol-not-real/i,
)
})

test('charge() throws when customToken.name is set without version', () => {
assert.throws(
() =>
charge({
recipient: RECIPIENT,
chain: 'ethereum',
rpcUrl: RPC,
submitter: { privateKey: SUBMITTER },
customToken: {
address: DAI_MAINNET,
decimals: 18,
name: 'Dai Stablecoin',
},
}),
/name and customToken\.version must be provided together/i,
)
})

test('charge() throws when customToken.version is set without name', () => {
assert.throws(
() =>
charge({
recipient: RECIPIENT,
chain: 'ethereum',
rpcUrl: RPC,
submitter: { privateKey: SUBMITTER },
customToken: {
address: DAI_MAINNET,
decimals: 18,
version: '1',
},
}),
/name and customToken\.version must be provided together/i,
)
})

test('charge() honors custom credentialTypes on a custom ERC-20 (opt-in authorization)', () => {
const method = charge({
recipient: RECIPIENT,
chain: 'ethereum',
rpcUrl: RPC,
submitter: { privateKey: SUBMITTER },
customToken: {
address: DAI_MAINNET,
decimals: 18,
symbol: 'DAI',
credentialTypes: ['authorization', 'permit2', 'hash'],
name: 'Dai Stablecoin',
version: '1',
},
credentialTypes: ['authorization'],
})
assert.equal(method.name, 'evm')
})
Loading
Loading