mppx 0.8.4 → 0.8.6

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/Receipt.d.ts +20 -2
  3. package/dist/Receipt.d.ts.map +1 -1
  4. package/dist/Receipt.js +19 -12
  5. package/dist/Receipt.js.map +1 -1
  6. package/dist/cli/cli.d.ts.map +1 -1
  7. package/dist/cli/cli.js +6 -4
  8. package/dist/cli/cli.js.map +1 -1
  9. package/dist/cli/plugins/tempo.d.ts.map +1 -1
  10. package/dist/cli/plugins/tempo.js +4 -2
  11. package/dist/cli/plugins/tempo.js.map +1 -1
  12. package/dist/client/internal/Fetch.d.ts.map +1 -1
  13. package/dist/client/internal/Fetch.js +6 -1
  14. package/dist/client/internal/Fetch.js.map +1 -1
  15. package/dist/evm/client/Charge.d.ts +2 -2
  16. package/dist/evm/client/Charge.d.ts.map +1 -1
  17. package/dist/evm/client/Charge.js +11 -17
  18. package/dist/evm/client/Charge.js.map +1 -1
  19. package/dist/evm/server/Charge.d.ts +5 -5
  20. package/dist/evm/server/Charge.d.ts.map +1 -1
  21. package/dist/evm/server/Charge.js +15 -7
  22. package/dist/evm/server/Charge.js.map +1 -1
  23. package/dist/tempo/client/Charge.d.ts.map +1 -1
  24. package/dist/tempo/client/Charge.js +6 -2
  25. package/dist/tempo/client/Charge.js.map +1 -1
  26. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  27. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  28. package/dist/tempo/server/internal/html.gen.js +1 -1
  29. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  30. package/dist/tempo/session/precompile/Chain.d.ts +26 -2
  31. package/dist/tempo/session/precompile/Chain.d.ts.map +1 -1
  32. package/dist/tempo/session/precompile/Chain.js +38 -5
  33. package/dist/tempo/session/precompile/Chain.js.map +1 -1
  34. package/dist/x402/Assets.d.ts +40 -0
  35. package/dist/x402/Assets.d.ts.map +1 -1
  36. package/dist/x402/Assets.js +73 -0
  37. package/dist/x402/Assets.js.map +1 -1
  38. package/dist/x402/client/Exact.d.ts +2 -2
  39. package/dist/x402/client/Exact.d.ts.map +1 -1
  40. package/dist/x402/client/Exact.js +6 -13
  41. package/dist/x402/client/Exact.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/Receipt.test.ts +53 -0
  44. package/src/Receipt.ts +26 -13
  45. package/src/cli/cli.test.ts +41 -5
  46. package/src/cli/cli.ts +7 -4
  47. package/src/cli/plugins/tempo.ts +6 -2
  48. package/src/client/internal/Fetch.test.ts +44 -0
  49. package/src/client/internal/Fetch.ts +5 -1
  50. package/src/evm/PublicInterface.test-d.ts +15 -2
  51. package/src/evm/client/Charge.test.ts +248 -0
  52. package/src/evm/client/Charge.ts +12 -23
  53. package/src/evm/server/Charge.test.ts +140 -0
  54. package/src/evm/server/Charge.ts +19 -12
  55. package/src/tempo/client/Charge.test.ts +61 -0
  56. package/src/tempo/client/Charge.ts +6 -1
  57. package/src/tempo/server/internal/html.gen.ts +1 -1
  58. package/src/tempo/session/precompile/Chain.test.ts +121 -2
  59. package/src/tempo/session/precompile/Chain.ts +56 -6
  60. package/src/x402/Assets.test.ts +211 -0
  61. package/src/x402/Assets.ts +116 -0
  62. package/src/x402/client/Exact.test.ts +129 -0
  63. package/src/x402/client/Exact.ts +7 -19
package/src/Receipt.ts CHANGED
@@ -3,17 +3,7 @@ import { Base64 } from 'ox'
3
3
  import * as Constants from './Constants.js'
4
4
  import * as z from './zod.js'
5
5
 
6
- /**
7
- * Schema for a payment receipt.
8
- *
9
- * @example
10
- * ```ts
11
- * import { Receipt } from 'mppx'
12
- *
13
- * const receipt = Receipt.Schema.parse(data)
14
- * ```
15
- */
16
- export const Schema = z.object({
6
+ const shape = {
17
7
  /** Payment method used (e.g., "tempo", "stripe"). */
18
8
  method: z.string(),
19
9
  /** Method-specific reference (e.g., transaction hash). */
@@ -26,11 +16,34 @@ export const Schema = z.object({
26
16
  status: z.literal('success'),
27
17
  /** RFC 3339 settlement timestamp. */
28
18
  timestamp: z.datetime(),
29
- })
19
+ }
20
+
21
+ /** Base-field schema used only to derive the {@link Receipt} type without an index signature. */
22
+ const BaseSchema = z.object(shape)
23
+
24
+ /**
25
+ * Schema for a payment receipt.
26
+ *
27
+ * Method specifications may define additional receipt fields beyond the
28
+ * base set (per the core spec's Payment-Receipt section); unknown fields
29
+ * are preserved through parse/serialize round-trips rather than stripped.
30
+ *
31
+ * @example
32
+ * ```ts
33
+ * import { Receipt } from 'mppx'
34
+ *
35
+ * const receipt = Receipt.Schema.parse(data)
36
+ * ```
37
+ */
38
+ export const Schema = z.looseObject(shape)
30
39
 
31
40
  /**
32
41
  * Payment receipt returned after verification.
33
42
  *
43
+ * Method-specific extension fields are preserved at runtime but not part of
44
+ * this base type; method packages can type them via intersection
45
+ * (e.g. `Receipt.Receipt & { originTxHash: string }`).
46
+ *
34
47
  * @example
35
48
  * ```ts
36
49
  * import { Receipt } from 'mppx'
@@ -43,7 +56,7 @@ export const Schema = z.object({
43
56
  * }
44
57
  * ```
45
58
  */
46
- export type Receipt = z.infer<typeof Schema>
59
+ export type Receipt = z.infer<typeof BaseSchema>
47
60
 
48
61
  /**
49
62
  * Deserializes a Payment-Receipt header value to a receipt.
@@ -43,7 +43,7 @@ afterAll(() => {
43
43
  })
44
44
 
45
45
  async function serve(argv: string[], options?: { env?: Record<string, string | undefined> }) {
46
- let output = ''
46
+ const stdoutChunks: Buffer[] = []
47
47
  let stderr = ''
48
48
  let exitCode: number | undefined
49
49
  const saved: Record<string, string | undefined> = {}
@@ -58,7 +58,9 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
58
58
  const origLog = console.log
59
59
  const origError = console.error
60
60
  process.stdout.write = ((chunk: unknown) => {
61
- output += typeof chunk === 'string' ? chunk : String(chunk)
61
+ if (typeof chunk === 'string') stdoutChunks.push(Buffer.from(chunk))
62
+ else if (chunk instanceof Uint8Array) stdoutChunks.push(Buffer.from(chunk))
63
+ else stdoutChunks.push(Buffer.from(String(chunk)))
62
64
  return true
63
65
  }) as typeof process.stdout.write
64
66
  process.stderr.write = ((chunk: unknown) => {
@@ -66,7 +68,7 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
66
68
  return true
67
69
  }) as typeof process.stderr.write
68
70
  console.log = (...args: unknown[]) => {
69
- output += `${args.map(String).join(' ')}\n`
71
+ stdoutChunks.push(Buffer.from(`${args.map(String).join(' ')}\n`))
70
72
  }
71
73
  console.error = (...args: unknown[]) => {
72
74
  stderr += `${args.map(String).join(' ')}\n`
@@ -74,7 +76,7 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
74
76
  try {
75
77
  await cli.serve(argv, {
76
78
  stdout(s: string) {
77
- output += s
79
+ stdoutChunks.push(Buffer.from(s))
78
80
  },
79
81
  exit(code: number) {
80
82
  exitCode = code
@@ -90,7 +92,8 @@ async function serve(argv: string[], options?: { env?: Record<string, string | u
90
92
  else process.env[key] = value
91
93
  }
92
94
  }
93
- return { output, stderr, exitCode }
95
+ const stdoutBytes = Buffer.concat(stdoutChunks)
96
+ return { output: stdoutBytes.toString(), stderr, exitCode, stdoutBytes }
94
97
  }
95
98
 
96
99
  function createMockChargeMethod(name: string) {
@@ -430,6 +433,24 @@ describe('services', () => {
430
433
  })
431
434
  })
432
435
 
436
+ describe('request output', () => {
437
+ test('writes non-402 response bodies to stdout without text decoding', async () => {
438
+ const body = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x0a, 0x0a])
439
+ const httpServer = await Http.createServer((_req, res) => {
440
+ res.writeHead(200, { 'Content-Type': 'application/pdf' })
441
+ res.end(body)
442
+ })
443
+
444
+ try {
445
+ const { exitCode, stdoutBytes } = await serve([httpServer.url, '-s'])
446
+ expect(exitCode).toBeUndefined()
447
+ expect(stdoutBytes).toEqual(body)
448
+ } finally {
449
+ httpServer.close()
450
+ }
451
+ })
452
+ })
453
+
433
454
  describe('basic charge (examples/basic)', () => {
434
455
  test('happy path: makes payment and receives response', { timeout: 120_000 }, async () => {
435
456
  const { Actions } = await import('viem/tempo')
@@ -1819,6 +1840,21 @@ test('mppx --help', async () => {
1819
1840
  expect(output).toContain('sign')
1820
1841
  })
1821
1842
 
1843
+ describe('account fund help', () => {
1844
+ test('only advertises testnet network funding', async () => {
1845
+ const { output } = await serve(['account', 'fund', '--help'])
1846
+ expect(output).toContain('--network <testnet>')
1847
+ expect(output).not.toContain('mainnet|testnet')
1848
+ })
1849
+
1850
+ test('rejects mainnet network funding', async () => {
1851
+ const { exitCode, output } = await serve(['account', 'fund', '--network', 'mainnet'])
1852
+ expect(exitCode).toBe(1)
1853
+ expect(output).toContain('Invalid input')
1854
+ expect(output).toContain('testnet')
1855
+ })
1856
+ })
1857
+
1822
1858
  // ---------------------------------------------------------------------------
1823
1859
  // sign
1824
1860
  // ---------------------------------------------------------------------------
package/src/cli/cli.ts CHANGED
@@ -99,6 +99,10 @@ function outputResult<Data>(
99
99
  return undefined as unknown as Data
100
100
  }
101
101
 
102
+ async function writeResponseBody(response: Response) {
103
+ process.stdout.write(Buffer.from(await response.arrayBuffer()))
104
+ }
105
+
102
106
  function canReadCommandStdin() {
103
107
  if (process.stdin.isTTY !== false) return false
104
108
  return process.stdin.listenerCount('data') === 0 && process.stdin.listenerCount('readable') === 0
@@ -371,7 +375,7 @@ const cli = Cli.create('mppx', {
371
375
  exitCode: 22,
372
376
  })
373
377
  printResponseHeaders(challengeResponse, headerOpts)
374
- console.log((await challengeResponse.text()).replace(/\n+$/, ''))
378
+ await writeResponseBody(challengeResponse)
375
379
  return
376
380
  }
377
381
 
@@ -625,8 +629,7 @@ const cli = Cli.create('mppx', {
625
629
  } catch {}
626
630
  }
627
631
 
628
- const body = (await credentialResponse.text()).replace(/\n+$/, '')
629
- console.log(body)
632
+ await writeResponseBody(credentialResponse)
630
633
  }
631
634
  } catch (err) {
632
635
  // Re-throw IncurError so incur's error handler formats it properly
@@ -864,7 +867,7 @@ const account = Cli.create('account', {
864
867
  description: 'Fund account with testnet tokens',
865
868
  options: z.object({
866
869
  account: z.string().optional().describe('Account name (env: MPPX_ACCOUNT)'),
867
- network: z.enum(['mainnet', 'testnet']).optional().describe('Tempo network'),
870
+ network: z.enum(['testnet']).optional().describe('Tempo network'),
868
871
  rpcUrl: z.string().optional().describe('RPC endpoint (env: MPPX_RPC_URL)'),
869
872
  }),
870
873
  output: z.object({ account: z.string(), chain: z.string(), transactions: z.array(z.string()) }),
@@ -42,6 +42,11 @@ const booleanOption = z.union([
42
42
  z.literal('true').transform(() => true),
43
43
  z.literal('false').transform(() => false),
44
44
  ])
45
+
46
+ async function writeResponseBody(response: Response) {
47
+ process.stdout.write(Buffer.from(await response.arrayBuffer()))
48
+ }
49
+
45
50
  const tempoOptionSchema = z.object({
46
51
  autoSwap: z.optional(booleanOption),
47
52
  channel: z.optional(z.coerce.string()),
@@ -425,8 +430,7 @@ export function tempo() {
425
430
  })
426
431
  } else {
427
432
  // Non-SSE: print body, then close channel
428
- const body = (await credentialResponse.text()).replace(/\n+$/, '')
429
- console.log(body)
433
+ await writeResponseBody(credentialResponse)
430
434
 
431
435
  if (channelId && escrowContract && chainId) {
432
436
  if (confirmEnabled) info('\n')
@@ -1742,6 +1742,50 @@ describe('Fetch.from: 402 retry path', () => {
1742
1742
  expect(createCredential).toHaveBeenCalledOnce()
1743
1743
  expect(response.status).toBe(402)
1744
1744
  })
1745
+
1746
+ test('returns a challenge-less 402 after a rejected payment', async () => {
1747
+ let callCount = 0
1748
+ const createCredential = vi.fn(async () => 'credential')
1749
+ const mockFetch: typeof globalThis.fetch = async () => {
1750
+ callCount++
1751
+ if (callCount === 1) return make402()
1752
+ return new Response(JSON.stringify({ title: 'Verification Failed' }), {
1753
+ status: 402,
1754
+ headers: { 'Content-Type': 'application/json' },
1755
+ })
1756
+ }
1757
+
1758
+ const fetch = Fetch.from({
1759
+ fetch: mockFetch,
1760
+ methods: [{ ...noopMethod, createCredential }],
1761
+ })
1762
+
1763
+ const response = await fetch('https://example.com/api')
1764
+ expect(callCount).toBe(2)
1765
+ expect(createCredential).toHaveBeenCalledOnce()
1766
+ expect(response.status).toBe(402)
1767
+ await expect(response.json()).resolves.toEqual({ title: 'Verification Failed' })
1768
+ })
1769
+
1770
+ test('returns a post-payment 402 with only unsupported challenges', async () => {
1771
+ let callCount = 0
1772
+ const createCredential = vi.fn(async () => 'credential')
1773
+ const mockFetch: typeof globalThis.fetch = async () => {
1774
+ callCount++
1775
+ if (callCount === 1) return make402()
1776
+ return make402({ method: 'stripe', intent: 'charge' })
1777
+ }
1778
+
1779
+ const fetch = Fetch.from({
1780
+ fetch: mockFetch,
1781
+ methods: [{ ...noopMethod, createCredential }],
1782
+ })
1783
+
1784
+ const response = await fetch('https://example.com/api')
1785
+ expect(callCount).toBe(2)
1786
+ expect(createCredential).toHaveBeenCalledOnce()
1787
+ expect(response.status).toBe(402)
1788
+ })
1745
1789
  })
1746
1790
 
1747
1791
  describe('Fetch.from: acceptPaymentPolicy', () => {
@@ -221,10 +221,14 @@ export function from<const methods extends readonly Method.AnyClient[]>(
221
221
  orderChallenges,
222
222
  )
223
223
  const selected = orderedCandidates[0]
224
- if (!selected)
224
+ if (!selected) {
225
+ // A post-payment 402 with no actionable challenge (e.g. rejected credential)
226
+ // is the server's final answer, not a new challenge round.
227
+ if (retry > 0) return response
225
228
  throw new Error(
226
229
  `No method found for challenges: ${challenges.map((c) => `${c.method}.${c.intent}`).join(', ')}. Available: ${methods.map((m) => `${m.name}.${m.intent}`).join(', ')}`,
227
230
  )
231
+ }
228
232
 
229
233
  const selectedChallenge = selected.challenge
230
234
  challenge = selectedChallenge
@@ -7,6 +7,7 @@ import {
7
7
  } from 'mppx/evm/client'
8
8
  import { assets as serverAssets, charge as serverCharge, evm as serverEvm } from 'mppx/evm/server'
9
9
  import type { Account } from 'viem'
10
+ import { tokens, usdc } from 'viem/tokens'
10
11
  import { describe, expectTypeOf, test } from 'vp/test'
11
12
 
12
13
  import { Mppx as ClientMppx } from '../client/index.js'
@@ -30,6 +31,12 @@ const facilitator = {
30
31
  describe('evm public interface', () => {
31
32
  test('exports EVM asset metadata from root and subpaths', () => {
32
33
  expectTypeOf(evmRoot.assets.base.USDC).toMatchTypeOf<typeof serverAssets.base.USDC>()
34
+ expectTypeOf(
35
+ evmRoot.assets.fromToken(usdc, {
36
+ chainId: clientChains.base,
37
+ transfer: { type: 'eip3009', version: '2' },
38
+ }),
39
+ ).toMatchTypeOf<typeof serverAssets.base.USDC>()
33
40
  expectTypeOf(clientAssets.baseSepolia.USDC).toMatchTypeOf<
34
41
  typeof serverAssets.baseSepolia.USDC
35
42
  >()
@@ -53,6 +60,13 @@ describe('evm public interface', () => {
53
60
 
54
61
  const mppx = ServerMppx.create({
55
62
  methods: [
63
+ serverEvm({
64
+ authorization: { name: 'USD Coin', version: '2' },
65
+ chainId: clientChains.base,
66
+ currency: usdc,
67
+ recipient,
68
+ x402: { facilitator },
69
+ }),
56
70
  serverEvm({
57
71
  currency: serverAssets.base.USDC,
58
72
  recipient,
@@ -90,9 +104,8 @@ describe('evm public interface', () => {
90
104
  methods: [
91
105
  clientEvm({
92
106
  account,
93
- currencies: [clientAssets.baseSepolia.USDC],
107
+ currencies: tokens.popular,
94
108
  maxAmount: '0.01',
95
- networks: [clientEvm.chains.baseSepolia],
96
109
  }),
97
110
  ],
98
111
  polyfill: false,
@@ -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
  }