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.
@@ -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 Assets from '../Assets.js'
@@ -96,4 +97,251 @@ describe('evm charge client', () => {
96
97
  'EVM raw currency allowlists require networks.',
97
98
  )
98
99
  })
100
+
101
+ test('accepts viem token sets for currency policy and decimals', async () => {
102
+ const signTypedData = vi.fn(async () => '0x1234')
103
+ const client = charge({
104
+ account: {
105
+ ...account,
106
+ signTypedData,
107
+ } as unknown as Account,
108
+ authorization: { name: 'USD Coin', version: '2' },
109
+ currencies: tokens.popular,
110
+ maxAmount: '1',
111
+ })
112
+ const challenge = Challenge.from({
113
+ id: 'native',
114
+ intent: 'charge',
115
+ method: 'evm',
116
+ realm: 'api.example.com',
117
+ request: {
118
+ amount: '1000000',
119
+ currency: Assets.baseSepolia.USDC.address,
120
+ methodDetails: {
121
+ chainId: 84532,
122
+ credentialTypes: ['authorization'],
123
+ decimals: 18,
124
+ },
125
+ recipient: '0x2222222222222222222222222222222222222222',
126
+ },
127
+ })
128
+
129
+ await client.createCredential({ challenge } as never)
130
+
131
+ expect(signTypedData).toHaveBeenCalledWith(
132
+ expect.objectContaining({
133
+ domain: expect.objectContaining({
134
+ name: 'USD Coin',
135
+ verifyingContract: Assets.baseSepolia.USDC.address,
136
+ version: '2',
137
+ }),
138
+ }),
139
+ )
140
+ })
141
+
142
+ test('uses viem token decimals for native authorization maxAmount policy', async () => {
143
+ const signTypedData = vi.fn(async () => '0x1234')
144
+ const client = charge({
145
+ account: {
146
+ ...account,
147
+ signTypedData,
148
+ } as unknown as Account,
149
+ authorization: { name: 'USD Coin', version: '2' },
150
+ currencies: tokens.popular,
151
+ maxAmount: '1',
152
+ })
153
+ const challenge = Challenge.from({
154
+ id: 'native',
155
+ intent: 'charge',
156
+ method: 'evm',
157
+ realm: 'api.example.com',
158
+ request: {
159
+ amount: '1000001',
160
+ currency: Assets.baseSepolia.USDC.address,
161
+ methodDetails: {
162
+ chainId: 84532,
163
+ credentialTypes: ['authorization'],
164
+ decimals: 18,
165
+ },
166
+ recipient: '0x2222222222222222222222222222222222222222',
167
+ },
168
+ })
169
+
170
+ await expect(client.createCredential({ challenge } as never)).rejects.toThrow(
171
+ 'EVM charge amount exceeds maxAmount.',
172
+ )
173
+ expect(signTypedData).not.toHaveBeenCalled()
174
+ })
175
+
176
+ test('requires authorization metadata when signing viem token currencies', async () => {
177
+ const signTypedData = vi.fn(async () => '0x1234')
178
+ const client = charge({
179
+ account: {
180
+ ...account,
181
+ signTypedData,
182
+ } as unknown as Account,
183
+ currencies: tokens.popular,
184
+ maxAmount: '1',
185
+ })
186
+ const challenge = Challenge.from({
187
+ id: 'native',
188
+ intent: 'charge',
189
+ method: 'evm',
190
+ realm: 'api.example.com',
191
+ request: {
192
+ amount: '1000000',
193
+ currency: Assets.baseSepolia.USDC.address,
194
+ methodDetails: {
195
+ chainId: 84532,
196
+ credentialTypes: ['authorization'],
197
+ decimals: 18,
198
+ },
199
+ recipient: '0x2222222222222222222222222222222222222222',
200
+ },
201
+ })
202
+
203
+ await expect(client.createCredential({ challenge } as never)).rejects.toThrow(
204
+ 'EVM authorization requires token name and version.',
205
+ )
206
+ expect(signTypedData).not.toHaveBeenCalled()
207
+ })
208
+
209
+ test('accepts viem token sets through legacy assets policy', async () => {
210
+ const signTypedData = vi.fn(async () => '0x1234')
211
+ const client = charge({
212
+ account: {
213
+ ...account,
214
+ signTypedData,
215
+ } as unknown as Account,
216
+ assets: tokens.popular,
217
+ authorization: { name: 'USD Coin', version: '2' },
218
+ maxAmount: '1',
219
+ })
220
+ const challenge = Challenge.from({
221
+ id: 'native',
222
+ intent: 'charge',
223
+ method: 'evm',
224
+ realm: 'api.example.com',
225
+ request: {
226
+ amount: '1000000',
227
+ currency: Assets.baseSepolia.USDC.address,
228
+ methodDetails: {
229
+ chainId: 84532,
230
+ credentialTypes: ['authorization'],
231
+ decimals: 18,
232
+ },
233
+ recipient: '0x2222222222222222222222222222222222222222',
234
+ },
235
+ })
236
+
237
+ await client.createCredential({ challenge } as never)
238
+
239
+ expect(signTypedData).toHaveBeenCalledOnce()
240
+ })
241
+
242
+ test('uses known asset metadata for native authorization policy and signing domain', async () => {
243
+ const signTypedData = vi.fn(async () => '0x1234')
244
+ const client = charge({
245
+ account: {
246
+ ...account,
247
+ signTypedData,
248
+ } as unknown as Account,
249
+ currencies: [Assets.baseSepolia.USDC],
250
+ maxAmount: '0.01',
251
+ })
252
+ const challenge = Challenge.from({
253
+ id: 'native',
254
+ intent: 'charge',
255
+ method: 'evm',
256
+ realm: 'api.example.com',
257
+ request: {
258
+ amount: '10000',
259
+ currency: Assets.baseSepolia.USDC.address,
260
+ methodDetails: {
261
+ chainId: 84532,
262
+ credentialTypes: ['authorization'],
263
+ decimals: 18,
264
+ },
265
+ recipient: '0x2222222222222222222222222222222222222222',
266
+ },
267
+ })
268
+
269
+ await client.createCredential({ challenge } as never)
270
+
271
+ expect(signTypedData).toHaveBeenCalledOnce()
272
+ expect(signTypedData).toHaveBeenCalledWith(
273
+ expect.objectContaining({
274
+ domain: expect.objectContaining({
275
+ name: 'USDC',
276
+ version: '2',
277
+ }),
278
+ }),
279
+ )
280
+ })
281
+
282
+ test('uses known asset decimals for native authorization maxAmount policy', async () => {
283
+ const signTypedData = vi.fn(async () => '0x1234')
284
+ const client = charge({
285
+ account: {
286
+ ...account,
287
+ signTypedData,
288
+ } as unknown as Account,
289
+ currencies: [Assets.baseSepolia.USDC],
290
+ maxAmount: '0.01',
291
+ })
292
+ const challenge = Challenge.from({
293
+ id: 'native',
294
+ intent: 'charge',
295
+ method: 'evm',
296
+ realm: 'api.example.com',
297
+ request: {
298
+ amount: '10001',
299
+ currency: Assets.baseSepolia.USDC.address,
300
+ methodDetails: {
301
+ chainId: 84532,
302
+ credentialTypes: ['authorization'],
303
+ decimals: 18,
304
+ },
305
+ recipient: '0x2222222222222222222222222222222222222222',
306
+ },
307
+ })
308
+
309
+ await expect(client.createCredential({ challenge } as never)).rejects.toThrow(
310
+ 'EVM charge amount exceeds maxAmount.',
311
+ )
312
+ expect(signTypedData).not.toHaveBeenCalled()
313
+ })
314
+
315
+ test('pins known asset native authorization policy to its network', async () => {
316
+ const signTypedData = vi.fn(async () => '0x1234')
317
+ const client = charge({
318
+ account: {
319
+ ...account,
320
+ signTypedData,
321
+ } as unknown as Account,
322
+ currencies: [Assets.baseSepolia.USDC],
323
+ maxAmount: '0.01',
324
+ })
325
+ const challenge = Challenge.from({
326
+ id: 'native',
327
+ intent: 'charge',
328
+ method: 'evm',
329
+ realm: 'api.example.com',
330
+ request: {
331
+ amount: '10000',
332
+ currency: Assets.baseSepolia.USDC.address,
333
+ methodDetails: {
334
+ chainId: 8453,
335
+ credentialTypes: ['authorization'],
336
+ decimals: 6,
337
+ },
338
+ recipient: '0x2222222222222222222222222222222222222222',
339
+ },
340
+ })
341
+
342
+ await expect(client.createCredential({ challenge } as never)).rejects.toThrow(
343
+ 'EVM currency is not allowed: 0x036CbD53842c5426634e7929541eC2318f3dCF7e.',
344
+ )
345
+ expect(signTypedData).not.toHaveBeenCalled()
346
+ })
99
347
  })
@@ -106,9 +106,9 @@ export declare namespace charge {
106
106
  /** Optional allowlist of supported EVM chain IDs. */
107
107
  networks?: readonly number[] | undefined
108
108
  /** Optional allowlist of supported currencies. */
109
- currencies?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined
109
+ currencies?: readonly Assets.Currency[] | undefined
110
110
  /** Legacy alias for `currencies`. */
111
- assets?: readonly (`0x${string}` | Assets.KnownAsset)[] | undefined
111
+ assets?: readonly Assets.Currency[] | undefined
112
112
  }
113
113
  }
114
114
 
@@ -127,12 +127,12 @@ function assertPolicy(parameters: charge.Parameters, request: Types.ChargeReques
127
127
  throw new Error(`EVM chain ID is not allowed: ${chainId}.`)
128
128
 
129
129
  const currencies = parameters.currencies ?? parameters.assets
130
- if (currencies?.some((currency) => !Assets.isAsset(currency)) && !parameters.networks?.length)
130
+ if (currencies?.some((currency) => Assets.isRawAddress(currency)) && !parameters.networks?.length)
131
131
  throw new Error('EVM raw currency allowlists require networks.')
132
132
  if (currencies) {
133
133
  const acceptedCurrency = getAddress(request.currency as `0x${string}`)
134
134
  const allowed = currencies.some((currency) =>
135
- currencyMatches(currency, acceptedCurrency, network),
135
+ Assets.matches(currency, acceptedCurrency, network),
136
136
  )
137
137
  if (!allowed) throw new Error(`EVM currency is not allowed: ${acceptedCurrency}.`)
138
138
  }
@@ -159,30 +159,18 @@ function resolveAuthorization(
159
159
  const acceptedCurrency = getAddress(request.currency as `0x${string}`)
160
160
  const network = Types.networkOf(request.methodDetails.chainId)
161
161
  const currency = currencies?.find((currency) =>
162
- currencyMatches(currency, acceptedCurrency, network),
162
+ Assets.matches(currency, acceptedCurrency, network),
163
163
  )
164
- if (currency && Assets.isAsset(currency) && currency.transfer.type === Types.eip3009)
164
+ const resolved = currency ? Assets.resolve(currency, network) : undefined
165
+ if (resolved?.transfer?.type === Types.eip3009)
165
166
  return {
166
- name: currency.transfer.name,
167
- version: currency.transfer.version,
167
+ name: resolved.transfer.name,
168
+ version: resolved.transfer.version,
168
169
  }
169
170
  if (parameters.authorization) return parameters.authorization
170
171
  throw new Error('EVM authorization requires token name and version.')
171
172
  }
172
173
 
173
- function addressOf(currency: `0x${string}` | Assets.KnownAsset): `0x${string}` {
174
- return Assets.isAsset(currency) ? currency.address : currency
175
- }
176
-
177
- function currencyMatches(
178
- currency: `0x${string}` | Assets.KnownAsset,
179
- acceptedCurrency: `0x${string}`,
180
- network: Types.EvmNetwork,
181
- ): boolean {
182
- if (getAddress(addressOf(currency)) !== acceptedCurrency) return false
183
- return !Assets.isAsset(currency) || currency.network === network
184
- }
185
-
186
174
  function decimalsOfAcceptedCurrency(
187
175
  parameters: charge.Parameters,
188
176
  request: Types.ChargeRequest,
@@ -191,8 +179,9 @@ function decimalsOfAcceptedCurrency(
191
179
  const acceptedCurrency = getAddress(request.currency as `0x${string}`)
192
180
  const network = Types.networkOf(request.methodDetails.chainId)
193
181
  const currency = currencies?.find((currency) =>
194
- currencyMatches(currency, acceptedCurrency, network),
182
+ Assets.matches(currency, acceptedCurrency, network),
195
183
  )
196
- if (currency && Assets.isAsset(currency)) return currency.decimals
184
+ const resolved = currency ? Assets.resolve(currency, network) : undefined
185
+ if (resolved?.decimals !== undefined) return resolved.decimals
197
186
  return parameters.decimals
198
187
  }
@@ -4,6 +4,7 @@ import { Types as evm_Types } from 'mppx/evm'
4
4
  import { evm, Mppx } from 'mppx/server'
5
5
  import { Header as x402_Header, Types as x402_Types, type PaymentPayload } from 'mppx/x402'
6
6
  import { privateKeyToAccount } from 'viem/accounts'
7
+ import { usdc } from 'viem/tokens'
7
8
  import { describe, expect, test } from 'vp/test'
8
9
 
9
10
  const currency = '0x036CbD53842c5426634e7929541eC2318f3dCF7e'
@@ -14,6 +15,145 @@ const account = privateKeyToAccount(
14
15
  )
15
16
 
16
17
  describe('evm charge server', () => {
18
+ test('resolves viem token currency config', async () => {
19
+ const mppx = Mppx.create({
20
+ methods: [
21
+ evm({
22
+ authorization: { name: 'USD Coin', version: '2' },
23
+ chainId: 84532,
24
+ currency: usdc,
25
+ recipient,
26
+ x402: {
27
+ facilitator: {
28
+ async verify() {
29
+ return { isValid: true }
30
+ },
31
+ async settle() {
32
+ return {
33
+ network: evm_Types.networkOf(84532),
34
+ success: true,
35
+ transaction,
36
+ }
37
+ },
38
+ },
39
+ },
40
+ }),
41
+ ],
42
+ secretKey: 'test-secret-key-test-secret-key-32',
43
+ })
44
+ const route = mppx.evm.charge({ amount: '0.25' })
45
+
46
+ const first = await route(new Request('https://example.com/paid'))
47
+
48
+ expect(first.status).toBe(402)
49
+ if (first.status !== 402) throw new Error()
50
+ const challenge = Challenge.fromResponse(first.challenge)
51
+ expect(challenge.request).toEqual({
52
+ amount: '250000',
53
+ currency,
54
+ methodDetails: {
55
+ chainId: 84532,
56
+ credentialTypes: ['authorization'],
57
+ decimals: 6,
58
+ },
59
+ recipient,
60
+ })
61
+ })
62
+
63
+ test('requires chain ID for viem token currency config', () => {
64
+ expect(() =>
65
+ evm({
66
+ authorization: { name: 'USD Coin', version: '2' },
67
+ currency: usdc,
68
+ recipient,
69
+ settle: async () => ({ reference: transaction }),
70
+ }),
71
+ ).toThrow('EVM authorization requires `chainId`.')
72
+ })
73
+
74
+ test('rejects viem token currency unavailable on configured chain', () => {
75
+ expect(() =>
76
+ evm({
77
+ authorization: { name: 'USD Coin', version: '2' },
78
+ chainId: 999_999,
79
+ currency: usdc,
80
+ recipient,
81
+ settle: async () => ({ reference: transaction }),
82
+ }),
83
+ ).toThrow('EVM currency is not available on chain ID 999999.')
84
+ })
85
+
86
+ test('requires authorization metadata for viem token currency config', () => {
87
+ expect(() =>
88
+ evm({
89
+ chainId: 84532,
90
+ currency: usdc,
91
+ recipient,
92
+ settle: async () => ({ reference: transaction }),
93
+ }),
94
+ ).toThrow('EVM authorization requires `authorization` metadata.')
95
+ })
96
+
97
+ test('rejects known asset currency configured for a different chain', () => {
98
+ expect(() =>
99
+ evm({
100
+ chainId: 8453,
101
+ currency: evm.assets.baseSepolia.USDC,
102
+ recipient,
103
+ settle: async () => ({ reference: transaction }),
104
+ }),
105
+ ).toThrow('EVM currency is not available on chain ID 8453.')
106
+ })
107
+
108
+ test('infers native charge defaults from known asset metadata', async () => {
109
+ const mppx = Mppx.create({
110
+ methods: [
111
+ evm({
112
+ currency: evm.assets.base.USDC,
113
+ recipient,
114
+ settle: async () => ({ reference: transaction }),
115
+ }),
116
+ ],
117
+ secretKey: 'test-secret-key-test-secret-key-32',
118
+ })
119
+ const route = mppx.evm.charge({ amount: '0.25' })
120
+
121
+ const response = await route(new Request('https://example.com/paid'))
122
+
123
+ expect(response.status).toBe(402)
124
+ if (response.status !== 402) throw new Error()
125
+ const challenge = Challenge.fromResponse(response.challenge)
126
+ expect(challenge.request).toEqual({
127
+ amount: '250000',
128
+ currency: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
129
+ methodDetails: {
130
+ chainId: 8453,
131
+ credentialTypes: ['authorization'],
132
+ decimals: 6,
133
+ },
134
+ recipient,
135
+ })
136
+ })
137
+
138
+ test('requires authorization metadata for known assets without EIP-3009 transfer metadata', () => {
139
+ const permit2Asset = evm.assets.define({
140
+ address: currency,
141
+ decimals: 6,
142
+ network: 'eip155:84532',
143
+ transfer: {
144
+ type: 'permit2',
145
+ },
146
+ })
147
+
148
+ expect(() =>
149
+ evm({
150
+ currency: permit2Asset,
151
+ recipient,
152
+ settle: async () => ({ reference: transaction }),
153
+ }),
154
+ ).toThrow('EVM authorization requires `authorization` metadata.')
155
+ })
156
+
17
157
  test('settles native Payment-auth authorization credentials', async () => {
18
158
  let facilitated:
19
159
  | {
@@ -110,11 +110,11 @@ export declare namespace charge {
110
110
  type NativeConfig = BaseConfig & CurrencyConfig & RecipientConfig
111
111
 
112
112
  type BaseConfig = {
113
- /** EIP-3009 token domain metadata. Required for custom currency addresses; inferred for known assets. */
113
+ /** EIP-3009 token domain metadata. Required for raw addresses and viem tokens; inferred for known assets. */
114
114
  authorization?: Types.AuthorizationConfig | undefined
115
- /** EVM chain ID. Required for custom currency addresses; inferred for known assets. */
115
+ /** EVM chain ID. Required for raw addresses and viem tokens; inferred for known assets. */
116
116
  chainId?: number | undefined
117
- /** Token decimal places. Required for custom currency addresses; inferred for known assets. */
117
+ /** Token decimal places. Required for raw addresses; inferred for known assets and viem tokens. */
118
118
  decimals?: number | undefined
119
119
  /** Custom settlement override. If omitted, `x402.facilitator` is used. */
120
120
  settle?: SettleAuthorization | undefined
@@ -123,8 +123,8 @@ export declare namespace charge {
123
123
  }
124
124
 
125
125
  type CurrencyConfig = {
126
- /** Token contract address or known EVM asset metadata. */
127
- currency: `0x${string}` | Assets.KnownAsset
126
+ /** Token contract address, known EVM asset metadata, or viem token definition. */
127
+ currency: Assets.Currency
128
128
  }
129
129
 
130
130
  type RecipientConfig = {
@@ -197,17 +197,24 @@ function resolveConfig(config: charge.NativeConfig): ResolvedConfig {
197
197
  let decimals = config.decimals
198
198
 
199
199
  if (Assets.isAsset(currency)) {
200
- address = currency.address
201
- chainId ??= Number(currency.network.slice('eip155:'.length))
202
- decimals ??= currency.decimals
203
- if (currency.transfer.type === Types.eip3009) {
200
+ chainId ??= Assets.toChainId(currency.network)
201
+ }
202
+
203
+ if (chainId !== undefined) {
204
+ const resolved = Assets.resolve(currency, Types.networkOf(chainId))
205
+ if (!resolved) throw new Error(`EVM currency is not available on chain ID ${chainId}.`)
206
+ address = resolved.address
207
+ decimals ??= resolved.decimals
208
+ if (resolved.transfer?.type === Types.eip3009) {
204
209
  authorization ??= {
205
- name: currency.transfer.name,
206
- version: currency.transfer.version,
210
+ name: resolved.transfer.name,
211
+ version: resolved.transfer.version,
207
212
  }
208
213
  }
209
- } else {
214
+ } else if (Assets.isRawAddress(currency)) {
210
215
  address = currency
216
+ } else {
217
+ throw new Error('EVM authorization requires `chainId`.')
211
218
  }
212
219
 
213
220
  if (!authorization) throw new Error('EVM authorization requires `authorization` metadata.')