From 1fc8a7800ef91309cabce34ed01d7d07edfec2dd Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:54:19 -0700 Subject: [PATCH 1/5] feat: support viem token sets --- .changeset/viem-token-sets.md | 5 ++ src/evm/PublicInterface.test-d.ts | 17 ++++- src/evm/client/Charge.test.ts | 42 +++++++++++ src/evm/client/Charge.ts | 35 ++++----- src/evm/server/Charge.test.ts | 46 ++++++++++++ src/evm/server/Charge.ts | 31 ++++---- src/x402/Assets.ts | 116 ++++++++++++++++++++++++++++++ src/x402/client/Exact.ts | 26 ++----- 8 files changed, 262 insertions(+), 56 deletions(-) create mode 100644 .changeset/viem-token-sets.md diff --git a/.changeset/viem-token-sets.md b/.changeset/viem-token-sets.md new file mode 100644 index 00000000..ad72eef3 --- /dev/null +++ b/.changeset/viem-token-sets.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added EVM and x402 compatibility with viem token definitions and token sets. diff --git a/src/evm/PublicInterface.test-d.ts b/src/evm/PublicInterface.test-d.ts index 8b307e75..b67a3eac 100644 --- a/src/evm/PublicInterface.test-d.ts +++ b/src/evm/PublicInterface.test-d.ts @@ -7,6 +7,7 @@ import { } from 'mppx/evm/client' import { assets as serverAssets, charge as serverCharge, evm as serverEvm } from 'mppx/evm/server' import type { Account } from 'viem' +import { tokens, usdc } from 'viem/tokens' import { describe, expectTypeOf, test } from 'vp/test' import { Mppx as ClientMppx } from '../client/index.js' @@ -30,6 +31,12 @@ const facilitator = { describe('evm public interface', () => { test('exports EVM asset metadata from root and subpaths', () => { expectTypeOf(evmRoot.assets.base.USDC).toMatchTypeOf() + expectTypeOf( + evmRoot.assets.fromToken(usdc, { + chainId: clientChains.base, + transfer: { type: 'eip3009', version: '2' }, + }), + ).toMatchTypeOf() expectTypeOf(clientAssets.baseSepolia.USDC).toMatchTypeOf< typeof serverAssets.baseSepolia.USDC >() @@ -53,6 +60,13 @@ describe('evm public interface', () => { const mppx = ServerMppx.create({ methods: [ + serverEvm({ + authorization: { name: 'USD Coin', version: '2' }, + chainId: clientChains.base, + currency: usdc, + recipient, + x402: { facilitator }, + }), serverEvm({ currency: serverAssets.base.USDC, recipient, @@ -90,9 +104,8 @@ describe('evm public interface', () => { methods: [ clientEvm({ account, - currencies: [clientAssets.baseSepolia.USDC], + currencies: tokens.popular, maxAmount: '0.01', - networks: [clientEvm.chains.baseSepolia], }), ], polyfill: false, diff --git a/src/evm/client/Charge.test.ts b/src/evm/client/Charge.test.ts index 744281fd..79478dac 100644 --- a/src/evm/client/Charge.test.ts +++ b/src/evm/client/Charge.test.ts @@ -1,5 +1,6 @@ import { Challenge } from 'mppx' import type { Account } from 'viem' +import { tokens } from 'viem/tokens' import { describe, expect, test, vi } from 'vp/test' import * as Assets from '../Assets.js' @@ -96,4 +97,45 @@ describe('evm charge client', () => { 'EVM raw currency allowlists require networks.', ) }) + + test('accepts viem token sets for currency policy and decimals', async () => { + const signTypedData = vi.fn(async () => '0x1234') + const client = charge({ + account: { + ...account, + signTypedData, + } as unknown as Account, + authorization: { name: 'USD Coin', version: '2' }, + currencies: tokens.popular, + maxAmount: '1', + }) + const challenge = Challenge.from({ + id: 'native', + intent: 'charge', + method: 'evm', + realm: 'api.example.com', + request: { + amount: '1000000', + currency: Assets.baseSepolia.USDC.address, + methodDetails: { + chainId: 84532, + credentialTypes: ['authorization'], + decimals: 18, + }, + recipient: '0x2222222222222222222222222222222222222222', + }, + }) + + await client.createCredential({ challenge } as never) + + expect(signTypedData).toHaveBeenCalledWith( + expect.objectContaining({ + domain: expect.objectContaining({ + name: 'USD Coin', + verifyingContract: Assets.baseSepolia.USDC.address, + version: '2', + }), + }), + ) + }) }) diff --git a/src/evm/client/Charge.ts b/src/evm/client/Charge.ts index 1ea5091c..c7b0430e 100644 --- a/src/evm/client/Charge.ts +++ b/src/evm/client/Charge.ts @@ -106,9 +106,9 @@ export declare namespace charge { /** Optional allowlist of supported EVM chain IDs. */ networks?: readonly number[] | undefined /** Optional allowlist of supported currencies. */ - currencies?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined + currencies?: readonly Assets.Currency[] | undefined /** Legacy alias for `currencies`. */ - assets?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined + assets?: readonly Assets.Currency[] | undefined } } @@ -127,12 +127,12 @@ function assertPolicy(parameters: charge.Parameters, request: Types.ChargeReques throw new Error(`EVM chain ID is not allowed: ${chainId}.`) const currencies = parameters.currencies ?? parameters.assets - if (currencies?.some((currency) => !Assets.isAsset(currency)) && !parameters.networks?.length) + if (currencies?.some((currency) => Assets.isRawAddress(currency)) && !parameters.networks?.length) throw new Error('EVM raw currency allowlists require networks.') if (currencies) { const acceptedCurrency = getAddress(request.currency as `0x${string}`) const allowed = currencies.some((currency) => - currencyMatches(currency, acceptedCurrency, network), + Assets.matches(currency, acceptedCurrency, network), ) if (!allowed) throw new Error(`EVM currency is not allowed: ${acceptedCurrency}.`) } @@ -159,30 +159,18 @@ function resolveAuthorization( const acceptedCurrency = getAddress(request.currency as `0x${string}`) const network = Types.networkOf(request.methodDetails.chainId) const currency = currencies?.find((currency) => - currencyMatches(currency, acceptedCurrency, network), + Assets.matches(currency, acceptedCurrency, network), ) - if (currency && Assets.isAsset(currency) && currency.transfer.type === Types.eip3009) + const resolved = currency ? Assets.resolve(currency, network) : undefined + if (resolved?.transfer?.type === Types.eip3009) return { - name: currency.transfer.name, - version: currency.transfer.version, + name: resolved.transfer.name, + version: resolved.transfer.version, } if (parameters.authorization) return parameters.authorization throw new Error('EVM authorization requires token name and version.') } -function addressOf(currency: `0x${string}` | Assets.KnownAsset): `0x${string}` { - return Assets.isAsset(currency) ? currency.address : currency -} - -function currencyMatches( - currency: `0x${string}` | Assets.KnownAsset, - acceptedCurrency: `0x${string}`, - network: Types.EvmNetwork, -): boolean { - if (getAddress(addressOf(currency)) !== acceptedCurrency) return false - return !Assets.isAsset(currency) || currency.network === network -} - function decimalsOfAcceptedCurrency( parameters: charge.Parameters, request: Types.ChargeRequest, @@ -191,8 +179,9 @@ function decimalsOfAcceptedCurrency( const acceptedCurrency = getAddress(request.currency as `0x${string}`) const network = Types.networkOf(request.methodDetails.chainId) const currency = currencies?.find((currency) => - currencyMatches(currency, acceptedCurrency, network), + Assets.matches(currency, acceptedCurrency, network), ) - if (currency && Assets.isAsset(currency)) return currency.decimals + const resolved = currency ? Assets.resolve(currency, network) : undefined + if (resolved?.decimals !== undefined) return resolved.decimals return parameters.decimals } diff --git a/src/evm/server/Charge.test.ts b/src/evm/server/Charge.test.ts index 26f83937..d3412412 100644 --- a/src/evm/server/Charge.test.ts +++ b/src/evm/server/Charge.test.ts @@ -4,6 +4,7 @@ import { Types as evm_Types } from 'mppx/evm' import { evm, Mppx } from 'mppx/server' import { Header as x402_Header, Types as x402_Types, type PaymentPayload } from 'mppx/x402' import { privateKeyToAccount } from 'viem/accounts' +import { usdc } from 'viem/tokens' import { describe, expect, test } from 'vp/test' const currency = '0x036CbD53842c5426634e7929541eC2318f3dCF7e' @@ -14,6 +15,51 @@ const account = privateKeyToAccount( ) describe('evm charge server', () => { + test('resolves viem token currency config', async () => { + const mppx = Mppx.create({ + methods: [ + evm({ + authorization: { name: 'USD Coin', version: '2' }, + chainId: 84532, + currency: usdc, + recipient, + x402: { + facilitator: { + async verify() { + return { isValid: true } + }, + async settle() { + return { + network: evm_Types.networkOf(84532), + success: true, + transaction, + } + }, + }, + }, + }), + ], + secretKey: 'test-secret-key-test-secret-key-32', + }) + const route = mppx.evm.charge({ amount: '0.25' }) + + const first = await route(new Request('https://example.com/paid')) + + expect(first.status).toBe(402) + if (first.status !== 402) throw new Error() + const challenge = Challenge.fromResponse(first.challenge) + expect(challenge.request).toEqual({ + amount: '250000', + currency, + methodDetails: { + chainId: 84532, + credentialTypes: ['authorization'], + decimals: 6, + }, + recipient, + }) + }) + test('settles native Payment-auth authorization credentials', async () => { let facilitated: | { diff --git a/src/evm/server/Charge.ts b/src/evm/server/Charge.ts index 45eb9499..9f455406 100644 --- a/src/evm/server/Charge.ts +++ b/src/evm/server/Charge.ts @@ -110,11 +110,11 @@ export declare namespace charge { type NativeConfig = BaseConfig & CurrencyConfig & RecipientConfig type BaseConfig = { - /** EIP-3009 token domain metadata. Required for custom currency addresses; inferred for known assets. */ + /** EIP-3009 token domain metadata. Required for raw addresses and viem tokens; inferred for known assets. */ authorization?: Types.AuthorizationConfig | undefined - /** EVM chain ID. Required for custom currency addresses; inferred for known assets. */ + /** EVM chain ID. Required for raw addresses and viem tokens; inferred for known assets. */ chainId?: number | undefined - /** Token decimal places. Required for custom currency addresses; inferred for known assets. */ + /** Token decimal places. Required for raw addresses; inferred for known assets and viem tokens. */ decimals?: number | undefined /** Custom settlement override. If omitted, `x402.facilitator` is used. */ settle?: SettleAuthorization | undefined @@ -123,8 +123,8 @@ export declare namespace charge { } type CurrencyConfig = { - /** Token contract address or known EVM asset metadata. */ - currency: `0x${string}` | Assets.KnownAsset + /** Token contract address, known EVM asset metadata, or viem token definition. */ + currency: Assets.Currency } type RecipientConfig = { @@ -197,17 +197,24 @@ function resolveConfig(config: charge.NativeConfig): ResolvedConfig { let decimals = config.decimals if (Assets.isAsset(currency)) { - address = currency.address - chainId ??= Number(currency.network.slice('eip155:'.length)) - decimals ??= currency.decimals - if (currency.transfer.type === Types.eip3009) { + chainId ??= Assets.toChainId(currency.network) + } + + if (chainId !== undefined) { + const resolved = Assets.resolve(currency, Types.networkOf(chainId)) + if (!resolved) throw new Error(`EVM currency is not available on chain ID ${chainId}.`) + address = resolved.address + decimals ??= resolved.decimals + if (resolved.transfer?.type === Types.eip3009) { authorization ??= { - name: currency.transfer.name, - version: currency.transfer.version, + name: resolved.transfer.name, + version: resolved.transfer.version, } } - } else { + } else if (Assets.isRawAddress(currency)) { address = currency + } else { + throw new Error('EVM authorization requires `chainId`.') } if (!authorization) throw new Error('EVM authorization requires `authorization` metadata.') diff --git a/src/x402/Assets.ts b/src/x402/Assets.ts index d88cfa3b..83add53e 100644 --- a/src/x402/Assets.ts +++ b/src/x402/Assets.ts @@ -1,3 +1,6 @@ +import { getAddress } from 'viem' +import type { Token } from 'viem/tokens' + import type { Asset, EvmNetwork, ExactTransfer } from './Types.js' const knownAsset = Symbol('mppx.x402.asset') @@ -8,6 +11,12 @@ export type KnownAsset = Asset & { network: EvmNetwork } +/** Viem token metadata from `viem/tokens`. */ +export type ViemToken = Token + +/** Currency metadata accepted by EVM and x402 payment config. */ +export type Currency = `0x${string}` | KnownAsset | ViemToken + /** Creates typed x402 asset metadata for custom tokens. */ export function define(parameters: define.Parameters): KnownAsset { return { @@ -28,6 +37,30 @@ export declare namespace define { } } +/** Creates x402 asset metadata from a `viem/tokens` token definition. */ +export function fromToken(token: ViemToken, parameters: fromToken.Parameters): KnownAsset { + const resolved = token(parameters.chainId) + return define({ + address: resolved.address, + decimals: resolved.decimals, + network: toNetwork(parameters.chainId), + transfer: withTokenDefaults(parameters.transfer, resolved), + }) +} + +export declare namespace fromToken { + type Parameters = { + chainId: number + transfer: Transfer + } + + type Transfer = + | (Omit, 'name'> & { + name?: string | undefined + }) + | Extract +} + /** Base network known assets. */ export const base = { USDC: define({ @@ -63,3 +96,86 @@ export function isAsset(value: unknown): value is KnownAsset { if (typeof value !== 'object' || value === null) return false return (value as Partial)[knownAsset] === true } + +/** Returns true when a value is a `viem/tokens` token definition. */ +export function isToken(value: unknown): value is ViemToken { + return ( + typeof value === 'function' && + typeof (value as Partial).addresses === 'object' && + typeof (value as Partial).decimals === 'number' + ) +} + +/** Returns true when a currency is a raw address without chain metadata. */ +export function isRawAddress(currency: Currency): currency is `0x${string}` { + return typeof currency === 'string' +} + +/** Resolves currency metadata for an EVM network. */ +export function resolve(currency: Currency, network: EvmNetwork): resolve.Result | undefined { + if (isAsset(currency)) { + if (currency.network !== network) return undefined + return { + address: currency.address, + decimals: currency.decimals, + transfer: currency.transfer, + } + } + + if (isToken(currency)) { + const address = currency.addresses[toChainId(network)] + if (!address) return undefined + return { + address, + decimals: currency.decimals, + name: currency.name, + } + } + + return { + address: currency, + } +} + +export declare namespace resolve { + type Result = { + address: `0x${string}` + decimals?: number | undefined + name?: string | undefined + transfer?: ExactTransfer | undefined + } +} + +/** Returns true when a currency resolves to the accepted address on the network. */ +export function matches( + currency: Currency, + acceptedCurrency: `0x${string}`, + network: EvmNetwork, +): boolean { + const resolved = resolve(currency, network) + if (!resolved) return false + return getAddress(resolved.address) === acceptedCurrency +} + +/** Converts an EVM chain ID to a CAIP-2 network identifier. */ +export function toNetwork(chainId: number): EvmNetwork { + return `eip155:${chainId}` +} + +/** Converts a CAIP-2 EVM network identifier to a chain ID. */ +export function toChainId(network: EvmNetwork): number { + return Number(network.slice('eip155:'.length)) +} + +function withTokenDefaults( + transfer: fromToken.Transfer, + token: ReturnType, +): ExactTransfer { + if (transfer.type !== 'eip3009') return transfer + if (transfer.name) return { ...transfer, name: transfer.name } + if (!token.name) throw new Error('EIP-3009 token assets require a token name.') + return { + ...transfer, + name: token.name, + } +} diff --git a/src/x402/client/Exact.ts b/src/x402/client/Exact.ts index 5cbfdd54..0c2bc393 100644 --- a/src/x402/client/Exact.ts +++ b/src/x402/client/Exact.ts @@ -99,9 +99,9 @@ export type Config = { /** Optional allowlist of supported EVM chain IDs. */ networks?: readonly number[] | undefined /** Optional allowlist of supported currencies. */ - currencies?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined + currencies?: readonly Assets.Currency[] | undefined /** Legacy alias for `currencies`. */ - assets?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined + assets?: readonly Assets.Currency[] | undefined } const authorizationTypes = { @@ -125,12 +125,12 @@ function assertPolicy(parameters: Config, accepted: Types.PaymentRequirements) { throw new Error(`x402 exact chain ID is not allowed: ${chainId}.`) const currencies = parameters.currencies ?? parameters.assets - if (currencies?.some((currency) => !Assets.isAsset(currency)) && !parameters.networks?.length) + if (currencies?.some((currency) => Assets.isRawAddress(currency)) && !parameters.networks?.length) throw new Error('x402 exact raw currency allowlists require networks.') if (currencies) { const acceptedCurrency = getAddress(accepted.asset as `0x${string}`) const allowed = currencies.some((currency) => - currencyMatches(currency, acceptedCurrency, accepted.network), + Assets.matches(currency, acceptedCurrency, accepted.network), ) if (!allowed) throw new Error(`x402 exact currency is not allowed: ${acceptedCurrency}.`) } @@ -149,19 +149,6 @@ function assertPolicy(parameters: Config, accepted: Types.PaymentRequirements) { } } -function addressOf(currency: `0x${string}` | Assets.KnownAsset): `0x${string}` { - return Assets.isAsset(currency) ? currency.address : currency -} - -function currencyMatches( - currency: `0x${string}` | Assets.KnownAsset, - acceptedCurrency: `0x${string}`, - network: Types.EvmNetwork, -): boolean { - if (getAddress(addressOf(currency)) !== acceptedCurrency) return false - return !Assets.isAsset(currency) || currency.network === network -} - function decimalsOfAcceptedCurrency( parameters: Config, accepted: Types.PaymentRequirements, @@ -169,9 +156,10 @@ function decimalsOfAcceptedCurrency( const currencies = parameters.currencies ?? parameters.assets const acceptedCurrency = getAddress(accepted.asset as `0x${string}`) const currency = currencies?.find((currency) => - currencyMatches(currency, acceptedCurrency, accepted.network), + Assets.matches(currency, acceptedCurrency, accepted.network), ) - if (currency && Assets.isAsset(currency)) return currency.decimals + const resolved = currency ? Assets.resolve(currency, accepted.network) : undefined + if (resolved?.decimals !== undefined) return resolved.decimals return parameters.decimals } From 99a1abc11ea4e761d10e569fab4a161a14e9857a Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:58:07 -0700 Subject: [PATCH 2/5] Update viem-token-sets.md --- .changeset/viem-token-sets.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/viem-token-sets.md b/.changeset/viem-token-sets.md index ad72eef3..15a80838 100644 --- a/.changeset/viem-token-sets.md +++ b/.changeset/viem-token-sets.md @@ -2,4 +2,4 @@ 'mppx': patch --- -Added EVM and x402 compatibility with viem token definitions and token sets. +Added viem token definitions and token sets to make managing tokens simpler. From 971dc89c175cd89cc435e96eccec12834304f81d Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:12:21 -0700 Subject: [PATCH 3/5] test: cover viem token assets --- src/evm/client/Charge.test.ts | 173 ++++++++++++++++++++++++++++++ src/evm/server/Charge.test.ts | 83 ++++++++++++++ src/x402/Assets.test.ts | 196 ++++++++++++++++++++++++++++++++++ src/x402/client/Exact.test.ts | 129 ++++++++++++++++++++++ 4 files changed, 581 insertions(+) create mode 100644 src/x402/Assets.test.ts diff --git a/src/evm/client/Charge.test.ts b/src/evm/client/Charge.test.ts index 79478dac..ac1edee4 100644 --- a/src/evm/client/Charge.test.ts +++ b/src/evm/client/Charge.test.ts @@ -138,4 +138,177 @@ describe('evm charge client', () => { }), ) }) + + test('uses viem token decimals for native authorization maxAmount policy', async () => { + const signTypedData = vi.fn(async () => '0x1234') + const client = charge({ + account: { + ...account, + signTypedData, + } as unknown as Account, + authorization: { name: 'USD Coin', version: '2' }, + currencies: tokens.popular, + maxAmount: '1', + }) + const challenge = Challenge.from({ + id: 'native', + intent: 'charge', + method: 'evm', + realm: 'api.example.com', + request: { + amount: '1000001', + currency: Assets.baseSepolia.USDC.address, + methodDetails: { + chainId: 84532, + credentialTypes: ['authorization'], + decimals: 18, + }, + recipient: '0x2222222222222222222222222222222222222222', + }, + }) + + await expect(client.createCredential({ challenge } as never)).rejects.toThrow( + 'EVM charge amount exceeds maxAmount.', + ) + expect(signTypedData).not.toHaveBeenCalled() + }) + + test('accepts viem token sets through legacy assets policy', async () => { + const signTypedData = vi.fn(async () => '0x1234') + const client = charge({ + account: { + ...account, + signTypedData, + } as unknown as Account, + assets: tokens.popular, + authorization: { name: 'USD Coin', version: '2' }, + maxAmount: '1', + }) + const challenge = Challenge.from({ + id: 'native', + intent: 'charge', + method: 'evm', + realm: 'api.example.com', + request: { + amount: '1000000', + currency: Assets.baseSepolia.USDC.address, + methodDetails: { + chainId: 84532, + credentialTypes: ['authorization'], + decimals: 18, + }, + recipient: '0x2222222222222222222222222222222222222222', + }, + }) + + await client.createCredential({ challenge } as never) + + expect(signTypedData).toHaveBeenCalledOnce() + }) + + test('uses known asset metadata for native authorization policy and signing domain', async () => { + const signTypedData = vi.fn(async () => '0x1234') + const client = charge({ + account: { + ...account, + signTypedData, + } as unknown as Account, + currencies: [Assets.baseSepolia.USDC], + maxAmount: '0.01', + }) + const challenge = Challenge.from({ + id: 'native', + intent: 'charge', + method: 'evm', + realm: 'api.example.com', + request: { + amount: '10000', + currency: Assets.baseSepolia.USDC.address, + methodDetails: { + chainId: 84532, + credentialTypes: ['authorization'], + decimals: 18, + }, + recipient: '0x2222222222222222222222222222222222222222', + }, + }) + + await client.createCredential({ challenge } as never) + + expect(signTypedData).toHaveBeenCalledOnce() + expect(signTypedData).toHaveBeenCalledWith( + expect.objectContaining({ + domain: expect.objectContaining({ + name: 'USDC', + version: '2', + }), + }), + ) + }) + + test('uses known asset decimals for native authorization maxAmount policy', async () => { + const signTypedData = vi.fn(async () => '0x1234') + const client = charge({ + account: { + ...account, + signTypedData, + } as unknown as Account, + currencies: [Assets.baseSepolia.USDC], + maxAmount: '0.01', + }) + const challenge = Challenge.from({ + id: 'native', + intent: 'charge', + method: 'evm', + realm: 'api.example.com', + request: { + amount: '10001', + currency: Assets.baseSepolia.USDC.address, + methodDetails: { + chainId: 84532, + credentialTypes: ['authorization'], + decimals: 18, + }, + recipient: '0x2222222222222222222222222222222222222222', + }, + }) + + await expect(client.createCredential({ challenge } as never)).rejects.toThrow( + 'EVM charge amount exceeds maxAmount.', + ) + expect(signTypedData).not.toHaveBeenCalled() + }) + + test('pins known asset native authorization policy to its network', async () => { + const signTypedData = vi.fn(async () => '0x1234') + const client = charge({ + account: { + ...account, + signTypedData, + } as unknown as Account, + currencies: [Assets.baseSepolia.USDC], + maxAmount: '0.01', + }) + const challenge = Challenge.from({ + id: 'native', + intent: 'charge', + method: 'evm', + realm: 'api.example.com', + request: { + amount: '10000', + currency: Assets.baseSepolia.USDC.address, + methodDetails: { + chainId: 8453, + credentialTypes: ['authorization'], + decimals: 6, + }, + recipient: '0x2222222222222222222222222222222222222222', + }, + }) + + await expect(client.createCredential({ challenge } as never)).rejects.toThrow( + 'EVM currency is not allowed: 0x036CbD53842c5426634e7929541eC2318f3dCF7e.', + ) + expect(signTypedData).not.toHaveBeenCalled() + }) }) diff --git a/src/evm/server/Charge.test.ts b/src/evm/server/Charge.test.ts index d3412412..888dc324 100644 --- a/src/evm/server/Charge.test.ts +++ b/src/evm/server/Charge.test.ts @@ -60,6 +60,89 @@ describe('evm charge server', () => { }) }) + test('requires chain ID for viem token currency config', () => { + expect(() => + evm({ + authorization: { name: 'USD Coin', version: '2' }, + currency: usdc, + recipient, + settle: async () => ({ reference: transaction }), + }), + ).toThrow('EVM authorization requires `chainId`.') + }) + + test('rejects viem token currency unavailable on configured chain', () => { + expect(() => + evm({ + authorization: { name: 'USD Coin', version: '2' }, + chainId: 999_999, + currency: usdc, + recipient, + settle: async () => ({ reference: transaction }), + }), + ).toThrow('EVM currency is not available on chain ID 999999.') + }) + + test('requires authorization metadata for viem token currency config', () => { + expect(() => + evm({ + chainId: 84532, + currency: usdc, + recipient, + settle: async () => ({ reference: transaction }), + }), + ).toThrow('EVM authorization requires `authorization` metadata.') + }) + + test('infers native charge defaults from known asset metadata', async () => { + const mppx = Mppx.create({ + methods: [ + evm({ + currency: evm.assets.base.USDC, + recipient, + settle: async () => ({ reference: transaction }), + }), + ], + secretKey: 'test-secret-key-test-secret-key-32', + }) + const route = mppx.evm.charge({ amount: '0.25' }) + + const response = await route(new Request('https://example.com/paid')) + + expect(response.status).toBe(402) + if (response.status !== 402) throw new Error() + const challenge = Challenge.fromResponse(response.challenge) + expect(challenge.request).toEqual({ + amount: '250000', + currency: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + methodDetails: { + chainId: 8453, + credentialTypes: ['authorization'], + decimals: 6, + }, + recipient, + }) + }) + + test('requires authorization metadata for known assets without EIP-3009 transfer metadata', () => { + const permit2Asset = evm.assets.define({ + address: currency, + decimals: 6, + network: 'eip155:84532', + transfer: { + type: 'permit2', + }, + }) + + expect(() => + evm({ + currency: permit2Asset, + recipient, + settle: async () => ({ reference: transaction }), + }), + ).toThrow('EVM authorization requires `authorization` metadata.') + }) + test('settles native Payment-auth authorization credentials', async () => { let facilitated: | { diff --git a/src/x402/Assets.test.ts b/src/x402/Assets.test.ts new file mode 100644 index 00000000..e51f37ed --- /dev/null +++ b/src/x402/Assets.test.ts @@ -0,0 +1,196 @@ +import { usdc } from 'viem/tokens' +import { describe, expect, test } from 'vp/test' + +import * as Assets from './Assets.js' + +describe('x402 assets', () => { + test('defines branded asset metadata', () => { + const asset = Assets.define({ + address: '0x1111111111111111111111111111111111111111', + decimals: 18, + network: 'eip155:1', + transfer: { + name: 'USD Coin', + type: 'eip3009', + version: '2', + }, + }) + + expect(Assets.isAsset(asset)).toBe(true) + expect(asset).toMatchObject({ + address: '0x1111111111111111111111111111111111111111', + decimals: 18, + network: 'eip155:1', + transfer: { + name: 'USD Coin', + type: 'eip3009', + version: '2', + }, + }) + }) + + test('rejects unbranded values', () => { + expect(Assets.isAsset(null)).toBe(false) + expect(Assets.isAsset('0x1111111111111111111111111111111111111111')).toBe(false) + expect( + Assets.isAsset({ + address: '0x1111111111111111111111111111111111111111', + decimals: 18, + network: 'eip155:1', + transfer: { + name: 'USD Coin', + type: 'eip3009', + version: '2', + }, + }), + ).toBe(false) + }) + + test('defines asset metadata from viem tokens', () => { + const asset = Assets.fromToken(usdc, { + chainId: 84532, + transfer: { + type: 'eip3009', + version: '2', + }, + }) + + expect(Assets.isAsset(asset)).toBe(true) + expect(asset).toMatchObject({ + address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + decimals: 6, + network: 'eip155:84532', + transfer: { + name: 'USD Coin', + type: 'eip3009', + version: '2', + }, + }) + }) + + test('allows viem token transfer metadata overrides', () => { + const eip3009Asset = Assets.fromToken(usdc, { + chainId: 84532, + transfer: { + name: 'USDC', + type: 'eip3009', + version: '2', + }, + }) + const permit2Asset = Assets.fromToken(usdc, { + chainId: 84532, + transfer: { + type: 'permit2', + }, + }) + + expect(eip3009Asset.transfer).toEqual({ + name: 'USDC', + type: 'eip3009', + version: '2', + }) + expect(permit2Asset.transfer).toEqual({ + type: 'permit2', + }) + }) + + test('requires token names for EIP-3009 viem token assets', () => { + const tokenWithoutName = Object.assign( + () => ({ + address: '0x1111111111111111111111111111111111111111', + decimals: 6, + symbol: 'TEST', + }), + { + addresses: { 1: '0x1111111111111111111111111111111111111111' }, + decimals: 6, + }, + ) as unknown as Assets.ViemToken + + expect(() => + Assets.fromToken(tokenWithoutName, { + chainId: 1, + transfer: { + type: 'eip3009', + version: '2', + }, + }), + ).toThrow('EIP-3009 token assets require a token name.') + }) + + test('identifies currency kinds and converts networks', () => { + expect(Assets.isToken(usdc)).toBe(true) + expect(Assets.isToken(Assets.baseSepolia.USDC)).toBe(false) + expect(Assets.isRawAddress('0x1111111111111111111111111111111111111111')).toBe(true) + expect(Assets.isRawAddress(Assets.baseSepolia.USDC)).toBe(false) + expect(Assets.toNetwork(84532)).toBe('eip155:84532') + expect(Assets.toChainId('eip155:84532')).toBe(84532) + }) + + test('resolves and matches raw addresses, known assets, and viem tokens', () => { + expect(Assets.resolve('0x1111111111111111111111111111111111111111', 'eip155:84532')).toEqual({ + address: '0x1111111111111111111111111111111111111111', + }) + expect(Assets.resolve(Assets.baseSepolia.USDC, 'eip155:84532')).toEqual({ + address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + decimals: 6, + transfer: { + name: 'USDC', + type: 'eip3009', + version: '2', + }, + }) + expect(Assets.resolve(Assets.baseSepolia.USDC, 'eip155:8453')).toBeUndefined() + expect(Assets.resolve(usdc, 'eip155:84532')).toEqual({ + address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + decimals: 6, + name: 'USD Coin', + }) + expect(Assets.resolve(usdc, 'eip155:999999')).toBeUndefined() + expect( + Assets.matches(usdc, '0x036CbD53842c5426634e7929541eC2318f3dCF7e', 'eip155:84532'), + ).toBe(true) + expect( + Assets.matches( + '0x1111111111111111111111111111111111111111', + '0x1111111111111111111111111111111111111111', + 'eip155:84532', + ), + ).toBe(true) + expect( + Assets.matches( + Assets.baseSepolia.USDC, + '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + 'eip155:84532', + ), + ).toBe(false) + }) + + test('exports Base USDC metadata', () => { + expect(Assets.isAsset(Assets.base.USDC)).toBe(true) + expect(Assets.base.USDC).toMatchObject({ + address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913', + decimals: 6, + network: 'eip155:8453', + transfer: { + name: 'USD Coin', + type: 'eip3009', + version: '2', + }, + }) + }) + + test('exports Base Sepolia USDC metadata', () => { + expect(Assets.isAsset(Assets.baseSepolia.USDC)).toBe(true) + expect(Assets.baseSepolia.USDC).toMatchObject({ + address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e', + decimals: 6, + network: 'eip155:84532', + transfer: { + name: 'USDC', + type: 'eip3009', + version: '2', + }, + }) + }) +}) diff --git a/src/x402/client/Exact.test.ts b/src/x402/client/Exact.test.ts index bb1c2296..d6f3a824 100644 --- a/src/x402/client/Exact.test.ts +++ b/src/x402/client/Exact.test.ts @@ -1,5 +1,6 @@ import { Challenge } from 'mppx' import type { Account } from 'viem' +import { tokens } from 'viem/tokens' import { describe, expect, test, vi } from 'vp/test' import * as Chains from '../../evm/Chains.js' @@ -78,6 +79,134 @@ describe('x402 exact credential helper', () => { ).rejects.toThrow('x402 exact raw currency allowlists require networks.') }) + test('uses known asset metadata for currency policy and maxAmount decimals', async () => { + const signTypedData = vi.fn(async () => '0x1234') + + const credential = await createCredential({ + challenge: challenge(), + config: { + account: { + ...account, + signTypedData, + } as unknown as Account, + currencies: [Assets.baseSepolia.USDC], + maxAmount: '0.01', + }, + context: {}, + }) + const paymentPayload = Header.decodePaymentSignature(credential) + + expect(signTypedData).toHaveBeenCalledOnce() + expect(paymentPayload.accepted.asset).toBe(Assets.baseSepolia.USDC.address) + }) + + test('pins known asset currency policy to its network', async () => { + const signTypedData = vi.fn(async () => '0x1234') + + await expect( + createCredential({ + challenge: challenge({ network: 'eip155:8453' }), + config: { + account: { + ...account, + signTypedData, + } as unknown as Account, + currencies: [Assets.baseSepolia.USDC], + maxAmount: '0.01', + }, + context: {}, + }), + ).rejects.toThrow( + 'x402 exact currency is not allowed: 0x036CbD53842c5426634e7929541eC2318f3dCF7e.', + ) + expect(signTypedData).not.toHaveBeenCalled() + }) + + test('accepts viem token sets for currency policy and decimals', async () => { + const signTypedData = vi.fn(async () => '0x1234') + + const credential = await createCredential({ + challenge: challenge({ amount: '1000000' }), + config: { + account: { + ...account, + signTypedData, + } as unknown as Account, + currencies: tokens.popular, + maxAmount: '1', + }, + context: {}, + }) + const paymentPayload = Header.decodePaymentSignature(credential) + + expect(signTypedData).toHaveBeenCalledOnce() + expect(paymentPayload.accepted.asset).toBe(Assets.baseSepolia.USDC.address) + }) + + test('uses viem token decimals for maxAmount policy', async () => { + const signTypedData = vi.fn(async () => '0x1234') + + await expect( + createCredential({ + challenge: challenge({ amount: '1000001' }), + config: { + account: { + ...account, + signTypedData, + } as unknown as Account, + currencies: tokens.popular, + maxAmount: '1', + }, + context: {}, + }), + ).rejects.toThrow('x402 exact amount exceeds maxAmount.') + expect(signTypedData).not.toHaveBeenCalled() + }) + + test('accepts viem token sets through legacy assets policy', async () => { + const signTypedData = vi.fn(async () => '0x1234') + + const credential = await createCredential({ + challenge: challenge({ amount: '1000000' }), + config: { + account: { + ...account, + signTypedData, + } as unknown as Account, + assets: tokens.popular, + maxAmount: '1', + }, + context: {}, + }) + + expect(signTypedData).toHaveBeenCalledOnce() + expect(Header.decodePaymentSignature(credential).accepted.asset).toBe( + Assets.baseSepolia.USDC.address, + ) + }) + + test('pins viem token currency policy to available networks', async () => { + const signTypedData = vi.fn(async () => '0x1234') + + await expect( + createCredential({ + challenge: challenge({ network: 'eip155:999999' }), + config: { + account: { + ...account, + signTypedData, + } as unknown as Account, + currencies: tokens.popular, + maxAmount: '1', + }, + context: {}, + }), + ).rejects.toThrow( + 'x402 exact currency is not allowed: 0x036CbD53842c5426634e7929541eC2318f3dCF7e.', + ) + expect(signTypedData).not.toHaveBeenCalled() + }) + test('signs EIP-3009 exact payment payloads', async () => { const signTypedData = vi.fn(async () => '0x1234') const config = { From 42e4842aaf6a15b67563f603876a7fad05bf0136 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:15:21 -0700 Subject: [PATCH 4/5] test: format asset coverage --- src/x402/Assets.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/x402/Assets.test.ts b/src/x402/Assets.test.ts index e51f37ed..3afd2286 100644 --- a/src/x402/Assets.test.ts +++ b/src/x402/Assets.test.ts @@ -147,9 +147,9 @@ describe('x402 assets', () => { name: 'USD Coin', }) expect(Assets.resolve(usdc, 'eip155:999999')).toBeUndefined() - expect( - Assets.matches(usdc, '0x036CbD53842c5426634e7929541eC2318f3dCF7e', 'eip155:84532'), - ).toBe(true) + expect(Assets.matches(usdc, '0x036CbD53842c5426634e7929541eC2318f3dCF7e', 'eip155:84532')).toBe( + true, + ) expect( Assets.matches( '0x1111111111111111111111111111111111111111', From 57e408de0b7e8850adb9fef3bd1f5ba0c0d8e8c4 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Thu, 2 Jul 2026 08:29:24 -0700 Subject: [PATCH 5/5] test: cover token asset edge cases --- src/evm/client/Charge.test.ts | 33 +++++++++++++++++++++++++++++++++ src/evm/server/Charge.test.ts | 11 +++++++++++ src/x402/Assets.test.ts | 15 +++++++++++++++ src/x402/Assets.ts | 2 +- 4 files changed, 60 insertions(+), 1 deletion(-) diff --git a/src/evm/client/Charge.test.ts b/src/evm/client/Charge.test.ts index ac1edee4..7d4a67d3 100644 --- a/src/evm/client/Charge.test.ts +++ b/src/evm/client/Charge.test.ts @@ -173,6 +173,39 @@ describe('evm charge client', () => { expect(signTypedData).not.toHaveBeenCalled() }) + test('requires authorization metadata when signing viem token currencies', async () => { + const signTypedData = vi.fn(async () => '0x1234') + const client = charge({ + account: { + ...account, + signTypedData, + } as unknown as Account, + currencies: tokens.popular, + maxAmount: '1', + }) + const challenge = Challenge.from({ + id: 'native', + intent: 'charge', + method: 'evm', + realm: 'api.example.com', + request: { + amount: '1000000', + currency: Assets.baseSepolia.USDC.address, + methodDetails: { + chainId: 84532, + credentialTypes: ['authorization'], + decimals: 18, + }, + recipient: '0x2222222222222222222222222222222222222222', + }, + }) + + await expect(client.createCredential({ challenge } as never)).rejects.toThrow( + 'EVM authorization requires token name and version.', + ) + expect(signTypedData).not.toHaveBeenCalled() + }) + test('accepts viem token sets through legacy assets policy', async () => { const signTypedData = vi.fn(async () => '0x1234') const client = charge({ diff --git a/src/evm/server/Charge.test.ts b/src/evm/server/Charge.test.ts index 888dc324..1a1894f0 100644 --- a/src/evm/server/Charge.test.ts +++ b/src/evm/server/Charge.test.ts @@ -94,6 +94,17 @@ describe('evm charge server', () => { ).toThrow('EVM authorization requires `authorization` metadata.') }) + test('rejects known asset currency configured for a different chain', () => { + expect(() => + evm({ + chainId: 8453, + currency: evm.assets.baseSepolia.USDC, + recipient, + settle: async () => ({ reference: transaction }), + }), + ).toThrow('EVM currency is not available on chain ID 8453.') + }) + test('infers native charge defaults from known asset metadata', async () => { const mppx = Mppx.create({ methods: [ diff --git a/src/x402/Assets.test.ts b/src/x402/Assets.test.ts index 3afd2286..8dcda48c 100644 --- a/src/x402/Assets.test.ts +++ b/src/x402/Assets.test.ts @@ -150,6 +150,9 @@ describe('x402 assets', () => { expect(Assets.matches(usdc, '0x036CbD53842c5426634e7929541eC2318f3dCF7e', 'eip155:84532')).toBe( true, ) + expect(Assets.matches(usdc, '0x036cbd53842c5426634e7929541ec2318f3dcf7e', 'eip155:84532')).toBe( + true, + ) expect( Assets.matches( '0x1111111111111111111111111111111111111111', @@ -166,6 +169,18 @@ describe('x402 assets', () => { ).toBe(false) }) + test('throws when creating assets from tokens unavailable on a chain', () => { + expect(() => + Assets.fromToken(usdc, { + chainId: 999_999, + transfer: { + type: 'eip3009', + version: '2', + }, + }), + ).toThrow('Token has no address for chain id "999999".') + }) + test('exports Base USDC metadata', () => { expect(Assets.isAsset(Assets.base.USDC)).toBe(true) expect(Assets.base.USDC).toMatchObject({ diff --git a/src/x402/Assets.ts b/src/x402/Assets.ts index 83add53e..fdda2d06 100644 --- a/src/x402/Assets.ts +++ b/src/x402/Assets.ts @@ -154,7 +154,7 @@ export function matches( ): boolean { const resolved = resolve(currency, network) if (!resolved) return false - return getAddress(resolved.address) === acceptedCurrency + return getAddress(resolved.address) === getAddress(acceptedCurrency) } /** Converts an EVM chain ID to a CAIP-2 network identifier. */