diff --git a/README.md b/README.md index bfd6b57..461df8b 100644 --- a/README.md +++ b/README.md @@ -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) | @@ -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 diff --git a/src/client/hash.ts b/src/client/hash.ts index 3763708..fae0fc1 100644 --- a/src/client/hash.ts +++ b/src/client/hash.ts @@ -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' @@ -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 } } diff --git a/src/client/index.ts b/src/client/index.ts index 2161952..57b477f 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -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' diff --git a/src/constants.ts b/src/constants.ts index 7c6d19e..93d3d1d 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -74,14 +74,14 @@ export const DEFAULT_CONFIRMATIONS: Record = { 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 @@ -141,6 +141,17 @@ export const TOKEN_CREDENTIAL_TYPES: Record { assert.throws( @@ -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') +}) diff --git a/src/server/Charge.ts b/src/server/Charge.ts index 25adcb9..21582d5 100644 --- a/src/server/Charge.ts +++ b/src/server/Charge.ts @@ -6,6 +6,7 @@ import { DEFAULT_CONFIRMATIONS, defaultRpcUrl, ERC20_ABI, + NATIVE_TOKEN_ADDRESS, PERMIT2_ADDRESS, type SupportedToken, TOKEN_CONTRACTS, @@ -15,12 +16,13 @@ import { import { resolveSigner } from '../internal/account.js' import { logDefaultTransportOnce } from '../internal/transport.js' import { charge as chargeMethod } from '../Methods.js' -import type { - AuthorizationPayload, - CredentialType, - HashPayload, - Permit2Payload, - ServerParameters, +import { + type AuthorizationPayload, + type CredentialType, + credentialTypes as canonicalCredentialTypes, + type HashPayload, + type Permit2Payload, + type ServerParameters, } from '../types.js' import { getPublicClient, getWalletClient } from './rpc.js' import { verifyAuthorization } from './verifiers/authorization.js' @@ -53,21 +55,77 @@ export function charge(parameters: ServerParameters) { confirmations: confirmationsInput, store: storeInput, submitter, + customToken, } = parameters - const tokenSymbol: SupportedToken = parameters.token ?? 'USDC' - const tokenAddress = TOKEN_CONTRACTS[tokenSymbol]?.[chain] - if (!tokenAddress) { - const supportedOnChain = (Object.keys(TOKEN_CONTRACTS) as SupportedToken[]) - .filter((t) => TOKEN_CONTRACTS[t][chain]) - .join(', ') - throw new Error( - `${tokenSymbol} is not deployed on ${chain}. Supported tokens for this chain: ${ - supportedOnChain || '(none)' - }`, - ) + + if (parameters.token && customToken) { + throw new Error('evm.charge: pass either `token` or `customToken`, not both.') } - const tokenDecimals = TOKEN_DECIMALS[tokenSymbol] - const allowedTypes = TOKEN_CREDENTIAL_TYPES[tokenSymbol] + + let tokenAddress: Address + let tokenDecimals: number + let tokenLabel: string + let allowedTypes: readonly CredentialType[] + let isNative: boolean + let nameOverride: string | undefined + let versionOverride: string | undefined + + if (customToken) { + tokenAddress = getAddress(customToken.address) + tokenDecimals = customToken.decimals + tokenLabel = customToken.symbol ?? tokenAddress + isNative = tokenAddress === getAddress(NATIVE_TOKEN_ADDRESS) + const defaults: readonly CredentialType[] = isNative ? ['hash'] : ['permit2', 'hash'] + if (customToken.credentialTypes) { + const unknown = customToken.credentialTypes.filter( + (t) => !canonicalCredentialTypes.includes(t), + ) + if (unknown.length) { + throw new Error( + `customToken.credentialTypes contains unknown values: ${unknown.join(', ')}. ` + + `Supported: ${canonicalCredentialTypes.join(', ')}.`, + ) + } + } + allowedTypes = customToken.credentialTypes ?? defaults + if (isNative && allowedTypes.some((t) => t !== 'hash')) { + throw new Error( + "Native token (zero address) only supports the 'hash' credential. " + + 'Remove non-hash entries from credentialTypes.', + ) + } + // EIP-712 domains use `name` AND `version` together. Allowing one + // override but not the other risks shipping a challenge with one field + // populated and the other silently dropped (e.g. when the on-chain + // probe for the missing field reverts), which fails verification at + // the client. Require both or neither. + if ((customToken.name === undefined) !== (customToken.version === undefined)) { + throw new Error( + 'customToken.name and customToken.version must be provided together (or neither).', + ) + } + nameOverride = customToken.name + versionOverride = customToken.version + } else { + const tokenSymbol: SupportedToken = parameters.token ?? 'USDC' + const curatedAddress = TOKEN_CONTRACTS[tokenSymbol]?.[chain] + if (!curatedAddress) { + const supportedOnChain = (Object.keys(TOKEN_CONTRACTS) as SupportedToken[]) + .filter((t) => TOKEN_CONTRACTS[t][chain]) + .join(', ') + throw new Error( + `${tokenSymbol} is not deployed on ${chain}. Supported tokens for this chain: ${ + supportedOnChain || '(none)' + }`, + ) + } + tokenAddress = getAddress(curatedAddress) + tokenDecimals = TOKEN_DECIMALS[tokenSymbol] + tokenLabel = tokenSymbol + allowedTypes = TOKEN_CREDENTIAL_TYPES[tokenSymbol] + isNative = false + } + // When the caller omits `credentialTypes`, default to the per-token allowed // set rather than the universal one. Otherwise tokens like WETH and USDT — // which lack EIP-3009 — would throw on every zero-config construction @@ -76,8 +134,8 @@ export function charge(parameters: ServerParameters) { const invalidTypes = acceptedTypes.filter((t) => !allowedTypes.includes(t)) if (invalidTypes.length) { throw new Error( - `${tokenSymbol} does not support credential types: ${invalidTypes.join(', ')}. ` + - `Supported on ${tokenSymbol}: ${allowedTypes.join(', ')}.`, + `${tokenLabel} does not support credential types: ${invalidTypes.join(', ')}. ` + + `Supported on ${tokenLabel}: ${allowedTypes.join(', ')}.`, ) } const chainId = CHAIN_IDS[chain] @@ -132,7 +190,10 @@ export function charge(parameters: ServerParameters) { return tokenMetadata } - const needsMetadata = acceptedTypes.includes('authorization') + // Native cannot use authorization (no EIP-3009 for native value transfers), + // and we already rejected non-hash credentialTypes for native above. Skip + // the on-chain metadata probe entirely for native — there is no contract. + const needsMetadata = !isNative && acceptedTypes.includes('authorization') return Method.toServer(chargeMethod, { defaults: { @@ -141,7 +202,15 @@ export function charge(parameters: ServerParameters) { recipient, }, async request({ request }) { - const metadata = needsMetadata ? await getTokenMetadata().catch(() => undefined) : undefined + // Caller-supplied overrides win over on-chain reads. Useful for tokens + // whose `name()`/`version()` reverts or whose EIP-712 domain values + // differ from their ERC-20 metadata. `name` and `version` are + // validated as both-or-neither at construction, so checking one is + // sufficient to know whether overrides cover the full domain. + const probeMetadata = + needsMetadata && nameOverride === undefined + ? await getTokenMetadata().catch(() => undefined) + : undefined return { ...request, chainId: request.chainId ?? chainId, @@ -154,8 +223,8 @@ export function charge(parameters: ServerParameters) { (acceptedTypes.includes('permit2') && walletClient?.account?.address ? walletClient.account.address : undefined), - tokenName: request.tokenName ?? metadata?.name, - tokenVersion: request.tokenVersion ?? metadata?.version, + tokenName: request.tokenName ?? nameOverride ?? probeMetadata?.name, + tokenVersion: request.tokenVersion ?? versionOverride ?? probeMetadata?.version, } }, async verify({ credential, request }) { diff --git a/src/server/index.ts b/src/server/index.ts index a2303ed..4f1fbb9 100644 --- a/src/server/index.ts +++ b/src/server/index.ts @@ -1,6 +1,8 @@ import { charge } from './Charge.js' +export { NATIVE_TOKEN_ADDRESS } from '../constants.js' export { QuickNodeRateLimitError } from '../errors.js' +export type { CustomTokenConfig } from '../types.js' export { charge } export const evm = { charge } export { Expires, Mppx, Store } from 'mppx/server' diff --git a/src/server/verifiers/hash.ts b/src/server/verifiers/hash.ts index 54d37e0..027d773 100644 --- a/src/server/verifiers/hash.ts +++ b/src/server/verifiers/hash.ts @@ -1,6 +1,6 @@ import { Errors, Receipt } from 'mppx' import { getAddress, type PublicClient, parseEventLogs } from 'viem' -import { ERC20_ABI } from '../../constants.js' +import { ERC20_ABI, NATIVE_TOKEN_ADDRESS } from '../../constants.js' import type { HashPayload } from '../../types.js' import { type ChargeStore, hashKey, markUsed, releaseUse } from '../replay.js' @@ -43,26 +43,43 @@ export async function verifyHash(parameters: { throw new Errors.VerificationFailedError({ reason: 'Transaction reverted on-chain' }) } - const logs = parseEventLogs({ - abi: ERC20_ABI, - eventName: 'Transfer', - logs: receipt.logs, - }) - const expectedToken = getAddress(currency) const expectedRecipient = getAddress(recipient) const expectedAmount = BigInt(amount) - const match = logs.find( - (log) => - getAddress(log.address) === expectedToken && - getAddress(log.args.to) === expectedRecipient && - log.args.value === expectedAmount, - ) - if (!match) { - throw new Errors.VerificationFailedError({ - reason: 'No matching Transfer log in transaction', + if (expectedToken === getAddress(NATIVE_TOKEN_ADDRESS)) { + // Native chain coin: verify tx.to / tx.value directly. Restricted to + // direct EOA sends (`tx.input === '0x'`); contract-mediated native + // transfers are out of scope for this credential. + const tx = await client.getTransaction({ hash: txHash }) + if ( + !tx.to || + getAddress(tx.to) !== expectedRecipient || + tx.value !== expectedAmount || + tx.input !== '0x' + ) { + throw new Errors.VerificationFailedError({ + reason: 'Transaction does not match expected native transfer', + }) + } + } else { + const logs = parseEventLogs({ + abi: ERC20_ABI, + eventName: 'Transfer', + logs: receipt.logs, }) + + const match = logs.find( + (log) => + getAddress(log.address) === expectedToken && + getAddress(log.args.to) === expectedRecipient && + log.args.value === expectedAmount, + ) + if (!match) { + throw new Errors.VerificationFailedError({ + reason: 'No matching Transfer log in transaction', + }) + } } const latest = await client.getBlockNumber() diff --git a/src/types.ts b/src/types.ts index 4e137c8..97f8638 100644 --- a/src/types.ts +++ b/src/types.ts @@ -37,6 +37,43 @@ export type ChargeReplayItemMap = { [key: `mpp:evm:charge:${string}`]: number } +/** + * Caller-supplied token configuration for `evm.charge`. Pass as `customToken` + * on the server to settle in any ERC-20 outside the curated `SupportedToken` + * set, or in the chain's native coin (ETH / MATIC / AVAX …) by setting + * `address` to `NATIVE_TOKEN_ADDRESS` (zero address). + * + * Mutually exclusive with the `token` field on `ServerParameters`. + */ +export type CustomTokenConfig = { + /** + * ERC-20 contract address. Use `NATIVE_TOKEN_ADDRESS` (zero address) to + * signal native chain coin settlement. + */ + address: Address + /** Token decimals. 18 for native ETH / MATIC / AVAX. */ + decimals: number + /** Display-only symbol (e.g. 'ETH', 'DAI'). Not validated. */ + symbol?: string + /** + * EIP-712 token name. Forwarded to clients in the challenge for the + * `authorization` credential. Overrides the on-chain `name()` read. + */ + name?: string + /** + * EIP-712 token version. Forwarded to clients in the challenge for the + * `authorization` credential. Overrides the on-chain `version()` read. + */ + version?: string + /** + * Accepted credential types. Defaults to `['permit2','hash']` for ERC-20 + * tokens and `['hash']` for native. EIP-3009 (`authorization`) is opt-in + * for custom tokens — only enable it if the token implements EIP-3009 + * correctly. Native settlement only supports `hash`. + */ + credentialTypes?: readonly CredentialType[] +} + export type ServerParameters = { /** Merchant wallet that receives the USDC transfer. */ recipient: Address @@ -49,13 +86,24 @@ export type ServerParameters = { /** Target chain for settlement. */ chain: SupportedChain /** - * ERC-20 token symbol — resolves to a contract address via TOKEN_CONTRACTS. - * Testnet token availability is sparse outside USDC: EURC ships only on - * ethereum-sepolia, WETH on a subset of testnets. Authorization (EIP-3009) - * is supported only by Circle's FiatToken family (USDC, EURC); WETH is - * permit2 + hash only. @default 'USDC' + * Curated ERC-20 token symbol — resolves to a contract address via + * TOKEN_CONTRACTS. Testnet token availability is sparse outside USDC: + * EURC ships only on ethereum-sepolia, WETH on a subset of testnets. + * Authorization (EIP-3009) is supported only by Circle's FiatToken family + * (USDC, EURC); WETH is permit2 + hash only. + * + * Mutually exclusive with `customToken`. To settle in a non-curated ERC-20 + * or in the chain's native coin, use `customToken` instead. + * @default 'USDC' (only when `customToken` is also unset) */ token?: SupportedToken + /** + * Caller-supplied token configuration. Use this instead of `token` to + * settle in any ERC-20 (by address) or in the chain's native coin + * (by setting `address` to `NATIVE_TOKEN_ADDRESS`). Mutually exclusive + * with `token`. + */ + customToken?: CustomTokenConfig /** * Accepted credential types, advertised in the challenge. Ordered from most to * least preferred (draft-evm-charge-00 §3). @default ['permit2','authorization','hash']