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
5 changes: 5 additions & 0 deletions .changeset/viem-token-sets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added viem token definitions and token sets to make managing tokens simpler.
17 changes: 15 additions & 2 deletions src/evm/PublicInterface.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<typeof serverAssets.base.USDC>()
expectTypeOf(
evmRoot.assets.fromToken(usdc, {
chainId: clientChains.base,
transfer: { type: 'eip3009', version: '2' },
}),
).toMatchTypeOf<typeof serverAssets.base.USDC>()
expectTypeOf(clientAssets.baseSepolia.USDC).toMatchTypeOf<
typeof serverAssets.baseSepolia.USDC
>()
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
248 changes: 248 additions & 0 deletions src/evm/client/Charge.test.ts
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -96,4 +97,251 @@ 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',
}),
}),
)
})

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('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({
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()
})
})
35 changes: 12 additions & 23 deletions src/evm/client/Charge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand All @@ -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}.`)
}
Expand All @@ -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,
Expand All @@ -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
}
Loading
Loading