diff --git a/src/constants.ts b/src/constants.ts index 787ae9c..7c6d19e 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,5 @@ import { parseAbi } from 'viem' +import type { CredentialType } from './types.js' export type SupportedChain = | 'base' @@ -10,6 +11,12 @@ export type SupportedChain = | 'linea' | 'unichain' | 'base-sepolia' + | 'ethereum-sepolia' + | 'arbitrum-sepolia' + | 'optimism-sepolia' + | 'polygon-amoy' + | 'scroll-sepolia' + | 'linea-sepolia' export const CHAIN_IDS: Record = { base: 8453, @@ -21,6 +28,12 @@ export const CHAIN_IDS: Record = { linea: 59144, unichain: 130, 'base-sepolia': 84532, + 'ethereum-sepolia': 11155111, + 'arbitrum-sepolia': 421614, + 'optimism-sepolia': 11155420, + 'polygon-amoy': 80002, + 'scroll-sepolia': 534351, + 'linea-sepolia': 59141, } export const USDC_CONTRACTS: Record = { @@ -33,6 +46,12 @@ export const USDC_CONTRACTS: Record = { linea: '0x176211869cA2b568f2A7D4EE941E073a821EE1ff', unichain: '0x078D782b760474a361dDA0AF3839290b0EF57AD6', 'base-sepolia': '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + 'ethereum-sepolia': '0x1c7D4B196Cb0C7B01d743Fbc6116a902379C7238', + 'arbitrum-sepolia': '0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d', + 'optimism-sepolia': '0x5fd84259d66Cd46123540766Be93DFE6D43130D7', + 'polygon-amoy': '0x41E94Eb019C0762f9Bfcf9Fb1E58725BfB0e7582', + 'scroll-sepolia': '0x4d7ff95a5e86b0aaade01df5adadded72c54a698', + 'linea-sepolia': '0xFEce4462D57bD51A6A552365A011b95f0E16d9B7', } export const DEFAULT_CONFIRMATIONS: Record = { @@ -45,6 +64,79 @@ export const DEFAULT_CONFIRMATIONS: Record = { linea: 1, unichain: 1, 'base-sepolia': 1, + 'ethereum-sepolia': 0, + 'arbitrum-sepolia': 0, + 'optimism-sepolia': 0, + 'polygon-amoy': 0, + 'scroll-sepolia': 0, + 'linea-sepolia': 0, +} + +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. +// +// 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. +// +// WETH carve-out: only canonical native ETH wrappers are listed. Polygon +// (native MATIC) and Avalanche (native AVAX) carry "WETH" only as bridged +// assets, which is a different trust model — those entries are intentionally +// absent. +export const TOKEN_CONTRACTS: Record< + SupportedToken, + Partial> +> = { + USDC: { ...USDC_CONTRACTS }, + EURC: { + ethereum: '0x1aBaEA1f7C830bD89Acc67eC4af516284b1bC33c', + base: '0x60a3E35Cc302bFA44Cb288Bc5a4F316Fdb1adb42', + avalanche: '0xC891EB4cbdEFf6e073e859e987815Ed1505c2ACD', + 'ethereum-sepolia': '0x08210f9170f89ab7658f0b5e3ff39b0e03c594d4', + }, + WETH: { + ethereum: '0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2', + base: '0x4200000000000000000000000000000000000006', + optimism: '0x4200000000000000000000000000000000000006', + arbitrum: '0x82aF49447D8a07e3bd95BD0d56f35241523fBab1', + linea: '0xe5D7C2a44FfDDf6b295A15c148167daaAf5Cf34f', + unichain: '0x4200000000000000000000000000000000000006', + 'ethereum-sepolia': '0x7b79995e5f793a07bc00c21412e50ecae098e7f9', + 'arbitrum-sepolia': '0x2836ae2ea2c013acd38028fd0c77b92cccfa2ee4', + 'polygon-amoy': '0x52ef3d68bab452a294342dc3e5f464d7f610f72e', + 'scroll-sepolia': '0x5300000000000000000000000000000000000004', + }, + // Mainnet only — Tether issues these. Testnet USDT is intentionally absent + // because the candidates are community deployments without provenance. + USDT: { + ethereum: '0xdAC17F958D2ee523a2206206994597C13D831ec7', + arbitrum: '0xFd086bC7CD5C481DCC9C85ebE478A1C0b69FCbb9', + optimism: '0x94b008aA00579c1307B0EF2c499aD98a8ce58e58', + polygon: '0xc2132D05D31c914a87C6611C10748AEb04B58e8F', + avalanche: '0x9702230A8Ea53601f5cD2dc00fDBc13d4dF4A8c7', + }, +} + +export const TOKEN_DECIMALS: Record = { + USDC: 6, + EURC: 6, + WETH: 18, + USDT: 6, +} + +// Per-token credential-type compatibility. EIP-3009 transferWithAuthorization +// is implemented by Circle's FiatToken family (USDC, EURC). WETH and USDT +// lack it (USDT mainnet uses its own non-standard approve/transferFrom), +// so those tokens accept Permit2 + on-chain hash only. +export const TOKEN_CREDENTIAL_TYPES: Record = { + USDC: ['permit2', 'authorization', 'hash'], + EURC: ['permit2', 'authorization', 'hash'], + WETH: ['permit2', 'hash'], + USDT: ['permit2', 'hash'], } export const PERMIT2_ADDRESS: `0x${string}` = '0x000000000022D473030F116dDEE9F6B43aC78BA3' @@ -91,6 +183,12 @@ export const CHAIN_SLUGS: Record = { linea: 'linea-mainnet', unichain: 'unichain-mainnet', 'base-sepolia': 'base-sepolia', + 'ethereum-sepolia': 'ethereum-sepolia', + 'arbitrum-sepolia': 'arbitrum-sepolia', + 'optimism-sepolia': 'optimism-sepolia', + 'polygon-amoy': 'matic-amoy', + 'scroll-sepolia': 'scroll-sepolia', + 'linea-sepolia': 'linea-sepolia', } /** diff --git a/src/index.ts b/src/index.ts index ae60860..983d2b0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,9 @@ -export type { SupportedChain } from './constants.js' +export type { SupportedChain, SupportedToken } from './constants.js' +export { + TOKEN_CONTRACTS, + TOKEN_CREDENTIAL_TYPES, + TOKEN_DECIMALS, +} from './constants.js' export { QuickNodeRateLimitError } from './errors.js' export { charge } from './Methods.js' export type { diff --git a/src/internal/chain.ts b/src/internal/chain.ts index 4b41bca..06197bd 100644 --- a/src/internal/chain.ts +++ b/src/internal/chain.ts @@ -1,13 +1,19 @@ import type { Chain } from 'viem' import { arbitrum, + arbitrumSepolia, avalanche, base, baseSepolia, linea, + lineaSepolia, mainnet, optimism, + optimismSepolia, polygon, + polygonAmoy, + scrollSepolia, + sepolia, unichain, } from 'viem/chains' import type { SupportedChain } from '../constants.js' @@ -22,6 +28,12 @@ const chains: Record = { linea, unichain, 'base-sepolia': baseSepolia, + 'ethereum-sepolia': sepolia, + 'arbitrum-sepolia': arbitrumSepolia, + 'optimism-sepolia': optimismSepolia, + 'polygon-amoy': polygonAmoy, + 'scroll-sepolia': scrollSepolia, + 'linea-sepolia': lineaSepolia, } export function getViemChain(chain: SupportedChain): Chain { diff --git a/src/server/Charge.ts b/src/server/Charge.ts index 64a462c..25adcb9 100644 --- a/src/server/Charge.ts +++ b/src/server/Charge.ts @@ -7,18 +7,20 @@ import { defaultRpcUrl, ERC20_ABI, PERMIT2_ADDRESS, - USDC_CONTRACTS, + type SupportedToken, + TOKEN_CONTRACTS, + TOKEN_CREDENTIAL_TYPES, + TOKEN_DECIMALS, } from '../constants.js' import { resolveSigner } from '../internal/account.js' import { logDefaultTransportOnce } from '../internal/transport.js' import { charge as chargeMethod } from '../Methods.js' -import { - type AuthorizationPayload, - type CredentialType, - credentialTypes, - type HashPayload, - type Permit2Payload, - type ServerParameters, +import type { + AuthorizationPayload, + CredentialType, + HashPayload, + Permit2Payload, + ServerParameters, } from '../types.js' import { getPublicClient, getWalletClient } from './rpc.js' import { verifyAuthorization } from './verifiers/authorization.js' @@ -52,8 +54,32 @@ export function charge(parameters: ServerParameters) { store: storeInput, submitter, } = parameters - const acceptedTypes: readonly CredentialType[] = acceptedTypesInput ?? credentialTypes - const tokenAddress = USDC_CONTRACTS[chain] + 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)' + }`, + ) + } + const tokenDecimals = TOKEN_DECIMALS[tokenSymbol] + const allowedTypes = TOKEN_CREDENTIAL_TYPES[tokenSymbol] + // 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 + // because the universal default includes 'authorization'. + const acceptedTypes: readonly CredentialType[] = acceptedTypesInput ?? allowedTypes + 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(', ')}.`, + ) + } const chainId = CHAIN_IDS[chain] const confirmations = confirmationsInput ?? DEFAULT_CONFIRMATIONS[chain] const store = storeInput ?? (Store.memory() as NonNullable) @@ -111,7 +137,7 @@ export function charge(parameters: ServerParameters) { return Method.toServer(chargeMethod, { defaults: { currency: tokenAddress, - decimals: 6, + decimals: tokenDecimals, recipient, }, async request({ request }) { diff --git a/src/types.ts b/src/types.ts index 641e0fb..4e137c8 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,5 +1,5 @@ import type { Account, Address, Hash, Hex } from 'viem' -import type { SupportedChain } from './constants.js' +import type { SupportedChain, SupportedToken } from './constants.js' export const credentialTypes = ['permit2', 'authorization', 'hash'] as const @@ -48,8 +48,14 @@ export type ServerParameters = { rpcUrl?: string /** Target chain for settlement. */ chain: SupportedChain - /** ERC-20 token symbol — resolves to a contract address via the chain's token map. @default 'USDC' */ - token?: 'USDC' + /** + * 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' + */ + token?: SupportedToken /** * Accepted credential types, advertised in the challenge. Ordered from most to * least preferred (draft-evm-charge-00 §3). @default ['permit2','authorization','hash']