mppx 0.8.4 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,211 @@
1
+ import { usdc } from 'viem/tokens'
2
+ import { describe, expect, test } from 'vp/test'
3
+
4
+ import * as Assets from './Assets.js'
5
+
6
+ describe('x402 assets', () => {
7
+ test('defines branded asset metadata', () => {
8
+ const asset = Assets.define({
9
+ address: '0x1111111111111111111111111111111111111111',
10
+ decimals: 18,
11
+ network: 'eip155:1',
12
+ transfer: {
13
+ name: 'USD Coin',
14
+ type: 'eip3009',
15
+ version: '2',
16
+ },
17
+ })
18
+
19
+ expect(Assets.isAsset(asset)).toBe(true)
20
+ expect(asset).toMatchObject({
21
+ address: '0x1111111111111111111111111111111111111111',
22
+ decimals: 18,
23
+ network: 'eip155:1',
24
+ transfer: {
25
+ name: 'USD Coin',
26
+ type: 'eip3009',
27
+ version: '2',
28
+ },
29
+ })
30
+ })
31
+
32
+ test('rejects unbranded values', () => {
33
+ expect(Assets.isAsset(null)).toBe(false)
34
+ expect(Assets.isAsset('0x1111111111111111111111111111111111111111')).toBe(false)
35
+ expect(
36
+ Assets.isAsset({
37
+ address: '0x1111111111111111111111111111111111111111',
38
+ decimals: 18,
39
+ network: 'eip155:1',
40
+ transfer: {
41
+ name: 'USD Coin',
42
+ type: 'eip3009',
43
+ version: '2',
44
+ },
45
+ }),
46
+ ).toBe(false)
47
+ })
48
+
49
+ test('defines asset metadata from viem tokens', () => {
50
+ const asset = Assets.fromToken(usdc, {
51
+ chainId: 84532,
52
+ transfer: {
53
+ type: 'eip3009',
54
+ version: '2',
55
+ },
56
+ })
57
+
58
+ expect(Assets.isAsset(asset)).toBe(true)
59
+ expect(asset).toMatchObject({
60
+ address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
61
+ decimals: 6,
62
+ network: 'eip155:84532',
63
+ transfer: {
64
+ name: 'USD Coin',
65
+ type: 'eip3009',
66
+ version: '2',
67
+ },
68
+ })
69
+ })
70
+
71
+ test('allows viem token transfer metadata overrides', () => {
72
+ const eip3009Asset = Assets.fromToken(usdc, {
73
+ chainId: 84532,
74
+ transfer: {
75
+ name: 'USDC',
76
+ type: 'eip3009',
77
+ version: '2',
78
+ },
79
+ })
80
+ const permit2Asset = Assets.fromToken(usdc, {
81
+ chainId: 84532,
82
+ transfer: {
83
+ type: 'permit2',
84
+ },
85
+ })
86
+
87
+ expect(eip3009Asset.transfer).toEqual({
88
+ name: 'USDC',
89
+ type: 'eip3009',
90
+ version: '2',
91
+ })
92
+ expect(permit2Asset.transfer).toEqual({
93
+ type: 'permit2',
94
+ })
95
+ })
96
+
97
+ test('requires token names for EIP-3009 viem token assets', () => {
98
+ const tokenWithoutName = Object.assign(
99
+ () => ({
100
+ address: '0x1111111111111111111111111111111111111111',
101
+ decimals: 6,
102
+ symbol: 'TEST',
103
+ }),
104
+ {
105
+ addresses: { 1: '0x1111111111111111111111111111111111111111' },
106
+ decimals: 6,
107
+ },
108
+ ) as unknown as Assets.ViemToken
109
+
110
+ expect(() =>
111
+ Assets.fromToken(tokenWithoutName, {
112
+ chainId: 1,
113
+ transfer: {
114
+ type: 'eip3009',
115
+ version: '2',
116
+ },
117
+ }),
118
+ ).toThrow('EIP-3009 token assets require a token name.')
119
+ })
120
+
121
+ test('identifies currency kinds and converts networks', () => {
122
+ expect(Assets.isToken(usdc)).toBe(true)
123
+ expect(Assets.isToken(Assets.baseSepolia.USDC)).toBe(false)
124
+ expect(Assets.isRawAddress('0x1111111111111111111111111111111111111111')).toBe(true)
125
+ expect(Assets.isRawAddress(Assets.baseSepolia.USDC)).toBe(false)
126
+ expect(Assets.toNetwork(84532)).toBe('eip155:84532')
127
+ expect(Assets.toChainId('eip155:84532')).toBe(84532)
128
+ })
129
+
130
+ test('resolves and matches raw addresses, known assets, and viem tokens', () => {
131
+ expect(Assets.resolve('0x1111111111111111111111111111111111111111', 'eip155:84532')).toEqual({
132
+ address: '0x1111111111111111111111111111111111111111',
133
+ })
134
+ expect(Assets.resolve(Assets.baseSepolia.USDC, 'eip155:84532')).toEqual({
135
+ address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
136
+ decimals: 6,
137
+ transfer: {
138
+ name: 'USDC',
139
+ type: 'eip3009',
140
+ version: '2',
141
+ },
142
+ })
143
+ expect(Assets.resolve(Assets.baseSepolia.USDC, 'eip155:8453')).toBeUndefined()
144
+ expect(Assets.resolve(usdc, 'eip155:84532')).toEqual({
145
+ address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
146
+ decimals: 6,
147
+ name: 'USD Coin',
148
+ })
149
+ expect(Assets.resolve(usdc, 'eip155:999999')).toBeUndefined()
150
+ expect(Assets.matches(usdc, '0x036CbD53842c5426634e7929541eC2318f3dCF7e', 'eip155:84532')).toBe(
151
+ true,
152
+ )
153
+ expect(Assets.matches(usdc, '0x036cbd53842c5426634e7929541ec2318f3dcf7e', 'eip155:84532')).toBe(
154
+ true,
155
+ )
156
+ expect(
157
+ Assets.matches(
158
+ '0x1111111111111111111111111111111111111111',
159
+ '0x1111111111111111111111111111111111111111',
160
+ 'eip155:84532',
161
+ ),
162
+ ).toBe(true)
163
+ expect(
164
+ Assets.matches(
165
+ Assets.baseSepolia.USDC,
166
+ '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
167
+ 'eip155:84532',
168
+ ),
169
+ ).toBe(false)
170
+ })
171
+
172
+ test('throws when creating assets from tokens unavailable on a chain', () => {
173
+ expect(() =>
174
+ Assets.fromToken(usdc, {
175
+ chainId: 999_999,
176
+ transfer: {
177
+ type: 'eip3009',
178
+ version: '2',
179
+ },
180
+ }),
181
+ ).toThrow('Token has no address for chain id "999999".')
182
+ })
183
+
184
+ test('exports Base USDC metadata', () => {
185
+ expect(Assets.isAsset(Assets.base.USDC)).toBe(true)
186
+ expect(Assets.base.USDC).toMatchObject({
187
+ address: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
188
+ decimals: 6,
189
+ network: 'eip155:8453',
190
+ transfer: {
191
+ name: 'USD Coin',
192
+ type: 'eip3009',
193
+ version: '2',
194
+ },
195
+ })
196
+ })
197
+
198
+ test('exports Base Sepolia USDC metadata', () => {
199
+ expect(Assets.isAsset(Assets.baseSepolia.USDC)).toBe(true)
200
+ expect(Assets.baseSepolia.USDC).toMatchObject({
201
+ address: '0x036CbD53842c5426634e7929541eC2318f3dCF7e',
202
+ decimals: 6,
203
+ network: 'eip155:84532',
204
+ transfer: {
205
+ name: 'USDC',
206
+ type: 'eip3009',
207
+ version: '2',
208
+ },
209
+ })
210
+ })
211
+ })
@@ -1,3 +1,6 @@
1
+ import { getAddress } from 'viem'
2
+ import type { Token } from 'viem/tokens'
3
+
1
4
  import type { Asset, EvmNetwork, ExactTransfer } from './Types.js'
2
5
 
3
6
  const knownAsset = Symbol('mppx.x402.asset')
@@ -8,6 +11,12 @@ export type KnownAsset = Asset & {
8
11
  network: EvmNetwork
9
12
  }
10
13
 
14
+ /** Viem token metadata from `viem/tokens`. */
15
+ export type ViemToken = Token
16
+
17
+ /** Currency metadata accepted by EVM and x402 payment config. */
18
+ export type Currency = `0x${string}` | KnownAsset | ViemToken
19
+
11
20
  /** Creates typed x402 asset metadata for custom tokens. */
12
21
  export function define(parameters: define.Parameters): KnownAsset {
13
22
  return {
@@ -28,6 +37,30 @@ export declare namespace define {
28
37
  }
29
38
  }
30
39
 
40
+ /** Creates x402 asset metadata from a `viem/tokens` token definition. */
41
+ export function fromToken(token: ViemToken, parameters: fromToken.Parameters): KnownAsset {
42
+ const resolved = token(parameters.chainId)
43
+ return define({
44
+ address: resolved.address,
45
+ decimals: resolved.decimals,
46
+ network: toNetwork(parameters.chainId),
47
+ transfer: withTokenDefaults(parameters.transfer, resolved),
48
+ })
49
+ }
50
+
51
+ export declare namespace fromToken {
52
+ type Parameters = {
53
+ chainId: number
54
+ transfer: Transfer
55
+ }
56
+
57
+ type Transfer =
58
+ | (Omit<Extract<ExactTransfer, { type: 'eip3009' }>, 'name'> & {
59
+ name?: string | undefined
60
+ })
61
+ | Extract<ExactTransfer, { type: 'permit2' }>
62
+ }
63
+
31
64
  /** Base network known assets. */
32
65
  export const base = {
33
66
  USDC: define({
@@ -63,3 +96,86 @@ export function isAsset(value: unknown): value is KnownAsset {
63
96
  if (typeof value !== 'object' || value === null) return false
64
97
  return (value as Partial<KnownAsset>)[knownAsset] === true
65
98
  }
99
+
100
+ /** Returns true when a value is a `viem/tokens` token definition. */
101
+ export function isToken(value: unknown): value is ViemToken {
102
+ return (
103
+ typeof value === 'function' &&
104
+ typeof (value as Partial<ViemToken>).addresses === 'object' &&
105
+ typeof (value as Partial<ViemToken>).decimals === 'number'
106
+ )
107
+ }
108
+
109
+ /** Returns true when a currency is a raw address without chain metadata. */
110
+ export function isRawAddress(currency: Currency): currency is `0x${string}` {
111
+ return typeof currency === 'string'
112
+ }
113
+
114
+ /** Resolves currency metadata for an EVM network. */
115
+ export function resolve(currency: Currency, network: EvmNetwork): resolve.Result | undefined {
116
+ if (isAsset(currency)) {
117
+ if (currency.network !== network) return undefined
118
+ return {
119
+ address: currency.address,
120
+ decimals: currency.decimals,
121
+ transfer: currency.transfer,
122
+ }
123
+ }
124
+
125
+ if (isToken(currency)) {
126
+ const address = currency.addresses[toChainId(network)]
127
+ if (!address) return undefined
128
+ return {
129
+ address,
130
+ decimals: currency.decimals,
131
+ name: currency.name,
132
+ }
133
+ }
134
+
135
+ return {
136
+ address: currency,
137
+ }
138
+ }
139
+
140
+ export declare namespace resolve {
141
+ type Result = {
142
+ address: `0x${string}`
143
+ decimals?: number | undefined
144
+ name?: string | undefined
145
+ transfer?: ExactTransfer | undefined
146
+ }
147
+ }
148
+
149
+ /** Returns true when a currency resolves to the accepted address on the network. */
150
+ export function matches(
151
+ currency: Currency,
152
+ acceptedCurrency: `0x${string}`,
153
+ network: EvmNetwork,
154
+ ): boolean {
155
+ const resolved = resolve(currency, network)
156
+ if (!resolved) return false
157
+ return getAddress(resolved.address) === getAddress(acceptedCurrency)
158
+ }
159
+
160
+ /** Converts an EVM chain ID to a CAIP-2 network identifier. */
161
+ export function toNetwork(chainId: number): EvmNetwork {
162
+ return `eip155:${chainId}`
163
+ }
164
+
165
+ /** Converts a CAIP-2 EVM network identifier to a chain ID. */
166
+ export function toChainId(network: EvmNetwork): number {
167
+ return Number(network.slice('eip155:'.length))
168
+ }
169
+
170
+ function withTokenDefaults(
171
+ transfer: fromToken.Transfer,
172
+ token: ReturnType<ViemToken>,
173
+ ): ExactTransfer {
174
+ if (transfer.type !== 'eip3009') return transfer
175
+ if (transfer.name) return { ...transfer, name: transfer.name }
176
+ if (!token.name) throw new Error('EIP-3009 token assets require a token name.')
177
+ return {
178
+ ...transfer,
179
+ name: token.name,
180
+ }
181
+ }
@@ -1,5 +1,6 @@
1
1
  import { Challenge } from 'mppx'
2
2
  import type { Account } from 'viem'
3
+ import { tokens } from 'viem/tokens'
3
4
  import { describe, expect, test, vi } from 'vp/test'
4
5
 
5
6
  import * as Chains from '../../evm/Chains.js'
@@ -78,6 +79,134 @@ describe('x402 exact credential helper', () => {
78
79
  ).rejects.toThrow('x402 exact raw currency allowlists require networks.')
79
80
  })
80
81
 
82
+ test('uses known asset metadata for currency policy and maxAmount decimals', async () => {
83
+ const signTypedData = vi.fn(async () => '0x1234')
84
+
85
+ const credential = await createCredential({
86
+ challenge: challenge(),
87
+ config: {
88
+ account: {
89
+ ...account,
90
+ signTypedData,
91
+ } as unknown as Account,
92
+ currencies: [Assets.baseSepolia.USDC],
93
+ maxAmount: '0.01',
94
+ },
95
+ context: {},
96
+ })
97
+ const paymentPayload = Header.decodePaymentSignature(credential)
98
+
99
+ expect(signTypedData).toHaveBeenCalledOnce()
100
+ expect(paymentPayload.accepted.asset).toBe(Assets.baseSepolia.USDC.address)
101
+ })
102
+
103
+ test('pins known asset currency policy to its network', async () => {
104
+ const signTypedData = vi.fn(async () => '0x1234')
105
+
106
+ await expect(
107
+ createCredential({
108
+ challenge: challenge({ network: 'eip155:8453' }),
109
+ config: {
110
+ account: {
111
+ ...account,
112
+ signTypedData,
113
+ } as unknown as Account,
114
+ currencies: [Assets.baseSepolia.USDC],
115
+ maxAmount: '0.01',
116
+ },
117
+ context: {},
118
+ }),
119
+ ).rejects.toThrow(
120
+ 'x402 exact currency is not allowed: 0x036CbD53842c5426634e7929541eC2318f3dCF7e.',
121
+ )
122
+ expect(signTypedData).not.toHaveBeenCalled()
123
+ })
124
+
125
+ test('accepts viem token sets for currency policy and decimals', async () => {
126
+ const signTypedData = vi.fn(async () => '0x1234')
127
+
128
+ const credential = await createCredential({
129
+ challenge: challenge({ amount: '1000000' }),
130
+ config: {
131
+ account: {
132
+ ...account,
133
+ signTypedData,
134
+ } as unknown as Account,
135
+ currencies: tokens.popular,
136
+ maxAmount: '1',
137
+ },
138
+ context: {},
139
+ })
140
+ const paymentPayload = Header.decodePaymentSignature(credential)
141
+
142
+ expect(signTypedData).toHaveBeenCalledOnce()
143
+ expect(paymentPayload.accepted.asset).toBe(Assets.baseSepolia.USDC.address)
144
+ })
145
+
146
+ test('uses viem token decimals for maxAmount policy', async () => {
147
+ const signTypedData = vi.fn(async () => '0x1234')
148
+
149
+ await expect(
150
+ createCredential({
151
+ challenge: challenge({ amount: '1000001' }),
152
+ config: {
153
+ account: {
154
+ ...account,
155
+ signTypedData,
156
+ } as unknown as Account,
157
+ currencies: tokens.popular,
158
+ maxAmount: '1',
159
+ },
160
+ context: {},
161
+ }),
162
+ ).rejects.toThrow('x402 exact amount exceeds maxAmount.')
163
+ expect(signTypedData).not.toHaveBeenCalled()
164
+ })
165
+
166
+ test('accepts viem token sets through legacy assets policy', async () => {
167
+ const signTypedData = vi.fn(async () => '0x1234')
168
+
169
+ const credential = await createCredential({
170
+ challenge: challenge({ amount: '1000000' }),
171
+ config: {
172
+ account: {
173
+ ...account,
174
+ signTypedData,
175
+ } as unknown as Account,
176
+ assets: tokens.popular,
177
+ maxAmount: '1',
178
+ },
179
+ context: {},
180
+ })
181
+
182
+ expect(signTypedData).toHaveBeenCalledOnce()
183
+ expect(Header.decodePaymentSignature(credential).accepted.asset).toBe(
184
+ Assets.baseSepolia.USDC.address,
185
+ )
186
+ })
187
+
188
+ test('pins viem token currency policy to available networks', async () => {
189
+ const signTypedData = vi.fn(async () => '0x1234')
190
+
191
+ await expect(
192
+ createCredential({
193
+ challenge: challenge({ network: 'eip155:999999' }),
194
+ config: {
195
+ account: {
196
+ ...account,
197
+ signTypedData,
198
+ } as unknown as Account,
199
+ currencies: tokens.popular,
200
+ maxAmount: '1',
201
+ },
202
+ context: {},
203
+ }),
204
+ ).rejects.toThrow(
205
+ 'x402 exact currency is not allowed: 0x036CbD53842c5426634e7929541eC2318f3dCF7e.',
206
+ )
207
+ expect(signTypedData).not.toHaveBeenCalled()
208
+ })
209
+
81
210
  test('signs EIP-3009 exact payment payloads', async () => {
82
211
  const signTypedData = vi.fn(async () => '0x1234')
83
212
  const config = {
@@ -99,9 +99,9 @@ export type Config = {
99
99
  /** Optional allowlist of supported EVM chain IDs. */
100
100
  networks?: readonly number[] | undefined
101
101
  /** Optional allowlist of supported currencies. */
102
- currencies?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined
102
+ currencies?: readonly Assets.Currency[] | undefined
103
103
  /** Legacy alias for `currencies`. */
104
- assets?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined
104
+ assets?: readonly Assets.Currency[] | undefined
105
105
  }
106
106
 
107
107
  const authorizationTypes = {
@@ -125,12 +125,12 @@ function assertPolicy(parameters: Config, accepted: Types.PaymentRequirements) {
125
125
  throw new Error(`x402 exact chain ID is not allowed: ${chainId}.`)
126
126
 
127
127
  const currencies = parameters.currencies ?? parameters.assets
128
- if (currencies?.some((currency) => !Assets.isAsset(currency)) && !parameters.networks?.length)
128
+ if (currencies?.some((currency) => Assets.isRawAddress(currency)) && !parameters.networks?.length)
129
129
  throw new Error('x402 exact raw currency allowlists require networks.')
130
130
  if (currencies) {
131
131
  const acceptedCurrency = getAddress(accepted.asset as `0x${string}`)
132
132
  const allowed = currencies.some((currency) =>
133
- currencyMatches(currency, acceptedCurrency, accepted.network),
133
+ Assets.matches(currency, acceptedCurrency, accepted.network),
134
134
  )
135
135
  if (!allowed) throw new Error(`x402 exact currency is not allowed: ${acceptedCurrency}.`)
136
136
  }
@@ -149,19 +149,6 @@ function assertPolicy(parameters: Config, accepted: Types.PaymentRequirements) {
149
149
  }
150
150
  }
151
151
 
152
- function addressOf(currency: `0x${string}` | Assets.KnownAsset): `0x${string}` {
153
- return Assets.isAsset(currency) ? currency.address : currency
154
- }
155
-
156
- function currencyMatches(
157
- currency: `0x${string}` | Assets.KnownAsset,
158
- acceptedCurrency: `0x${string}`,
159
- network: Types.EvmNetwork,
160
- ): boolean {
161
- if (getAddress(addressOf(currency)) !== acceptedCurrency) return false
162
- return !Assets.isAsset(currency) || currency.network === network
163
- }
164
-
165
152
  function decimalsOfAcceptedCurrency(
166
153
  parameters: Config,
167
154
  accepted: Types.PaymentRequirements,
@@ -169,9 +156,10 @@ function decimalsOfAcceptedCurrency(
169
156
  const currencies = parameters.currencies ?? parameters.assets
170
157
  const acceptedCurrency = getAddress(accepted.asset as `0x${string}`)
171
158
  const currency = currencies?.find((currency) =>
172
- currencyMatches(currency, acceptedCurrency, accepted.network),
159
+ Assets.matches(currency, acceptedCurrency, accepted.network),
173
160
  )
174
- if (currency && Assets.isAsset(currency)) return currency.decimals
161
+ const resolved = currency ? Assets.resolve(currency, accepted.network) : undefined
162
+ if (resolved?.decimals !== undefined) return resolved.decimals
175
163
  return parameters.decimals
176
164
  }
177
165