mppx 0.8.6 → 0.8.8

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 (108) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/dist/cli/internal.js +2 -2
  3. package/dist/cli/internal.js.map +1 -1
  4. package/dist/cli/plugins/evm.d.ts +2 -0
  5. package/dist/cli/plugins/evm.d.ts.map +1 -0
  6. package/dist/cli/plugins/evm.js +51 -0
  7. package/dist/cli/plugins/evm.js.map +1 -0
  8. package/dist/cli/plugins/index.d.ts +1 -0
  9. package/dist/cli/plugins/index.d.ts.map +1 -1
  10. package/dist/cli/plugins/index.js +1 -0
  11. package/dist/cli/plugins/index.js.map +1 -1
  12. package/dist/cli/plugins/stripe.d.ts.map +1 -1
  13. package/dist/cli/plugins/stripe.js +3 -2
  14. package/dist/cli/plugins/stripe.js.map +1 -1
  15. package/dist/cli/validate/challenge.d.ts +2 -0
  16. package/dist/cli/validate/challenge.d.ts.map +1 -1
  17. package/dist/cli/validate/challenge.js +191 -84
  18. package/dist/cli/validate/challenge.js.map +1 -1
  19. package/dist/cli/validate/discovery.d.ts +1 -0
  20. package/dist/cli/validate/discovery.d.ts.map +1 -1
  21. package/dist/cli/validate/discovery.js +99 -35
  22. package/dist/cli/validate/discovery.js.map +1 -1
  23. package/dist/cli/validate/helpers.d.ts +14 -1
  24. package/dist/cli/validate/helpers.d.ts.map +1 -1
  25. package/dist/cli/validate/helpers.js +0 -13
  26. package/dist/cli/validate/helpers.js.map +1 -1
  27. package/dist/cli/validate/index.d.ts +1 -0
  28. package/dist/cli/validate/index.d.ts.map +1 -1
  29. package/dist/cli/validate/index.js +102 -153
  30. package/dist/cli/validate/index.js.map +1 -1
  31. package/dist/cli/validate/payment.d.ts +4 -1
  32. package/dist/cli/validate/payment.d.ts.map +1 -1
  33. package/dist/cli/validate/payment.js +312 -106
  34. package/dist/cli/validate/payment.js.map +1 -1
  35. package/dist/evm/Chains.d.ts +4 -0
  36. package/dist/evm/Chains.d.ts.map +1 -1
  37. package/dist/evm/Chains.js +4 -0
  38. package/dist/evm/Chains.js.map +1 -1
  39. package/dist/internal/AcceptPayment.d.ts.map +1 -1
  40. package/dist/internal/AcceptPayment.js +7 -1
  41. package/dist/internal/AcceptPayment.js.map +1 -1
  42. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  43. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  44. package/dist/stripe/server/internal/html.gen.js +1 -1
  45. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  46. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  47. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  48. package/dist/tempo/server/internal/html.gen.js +1 -1
  49. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  50. package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
  51. package/dist/tempo/server/internal/request-body.js +2 -0
  52. package/dist/tempo/server/internal/request-body.js.map +1 -1
  53. package/dist/tempo/session/server/ChannelStore.js +4 -4
  54. package/dist/tempo/session/server/ChannelStore.js.map +1 -1
  55. package/dist/tempo/session/server/CredentialVerification.d.ts +8 -0
  56. package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
  57. package/dist/tempo/session/server/CredentialVerification.js +21 -1
  58. package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
  59. package/dist/tempo/session/server/Session.d.ts.map +1 -1
  60. package/dist/tempo/session/server/Session.js +1 -0
  61. package/dist/tempo/session/server/Session.js.map +1 -1
  62. package/dist/validation/core.d.ts +75 -0
  63. package/dist/validation/core.d.ts.map +1 -0
  64. package/dist/validation/core.js +269 -0
  65. package/dist/validation/core.js.map +1 -0
  66. package/dist/validation/index.d.ts +3 -0
  67. package/dist/validation/index.d.ts.map +1 -0
  68. package/dist/validation/index.js +2 -0
  69. package/dist/validation/index.js.map +1 -0
  70. package/dist/x402/Assets.d.ts +9 -0
  71. package/dist/x402/Assets.d.ts.map +1 -1
  72. package/dist/x402/Assets.js +38 -0
  73. package/dist/x402/Assets.js.map +1 -1
  74. package/package.json +9 -4
  75. package/src/cli/internal.ts +2 -2
  76. package/src/cli/plugins/evm.ts +55 -0
  77. package/src/cli/plugins/index.ts +1 -0
  78. package/src/cli/plugins/stripe.ts +3 -2
  79. package/src/cli/validate/challenge.ts +345 -127
  80. package/src/cli/validate/discovery.test.ts +307 -0
  81. package/src/cli/validate/discovery.ts +104 -38
  82. package/src/cli/validate/helpers.ts +15 -13
  83. package/src/cli/validate/index.ts +121 -242
  84. package/src/cli/validate/payment.ts +413 -144
  85. package/src/cli/validate.test.ts +237 -3
  86. package/src/client/internal/Fetch.test.ts +142 -2
  87. package/src/evm/Chains.ts +6 -0
  88. package/src/evm/PublicInterface.test-d.ts +7 -0
  89. package/src/internal/AcceptPayment.test.ts +45 -0
  90. package/src/internal/AcceptPayment.ts +11 -1
  91. package/src/stripe/server/internal/html/package.json +1 -1
  92. package/src/stripe/server/internal/html.gen.ts +1 -1
  93. package/src/tempo/server/internal/html/package.json +1 -1
  94. package/src/tempo/server/internal/html.gen.ts +1 -1
  95. package/src/tempo/server/internal/request-body.test.ts +9 -0
  96. package/src/tempo/server/internal/request-body.ts +2 -0
  97. package/src/tempo/session/server/ChannelStore.test.ts +17 -0
  98. package/src/tempo/session/server/ChannelStore.ts +4 -4
  99. package/src/tempo/session/server/CredentialVerification.test.ts +118 -1
  100. package/src/tempo/session/server/CredentialVerification.ts +30 -0
  101. package/src/tempo/session/server/Session.integration.test.ts +5 -0
  102. package/src/tempo/session/server/Session.test.ts +119 -53
  103. package/src/tempo/session/server/Session.ts +1 -0
  104. package/src/validation/core.ts +374 -0
  105. package/src/validation/index.ts +11 -0
  106. package/src/x402/Assets.test.ts +42 -0
  107. package/src/x402/Assets.ts +40 -0
  108. package/src/x402/Exact.localnet.test.ts +475 -0
@@ -1,11 +1,13 @@
1
+ import type { Chain } from 'viem'
2
+ import * as viemChains from 'viem/chains'
3
+
1
4
  import * as Challenge from '../../Challenge.js'
2
5
  import * as Constants from '../../Constants.js'
3
- import { chainId as tempoChainIds } from '../../tempo/internal/defaults.js'
6
+ import * as x402Header from '../../x402/Header.js'
4
7
  import { pc } from '../utils.js'
5
- import { extractRequestBodyFromDiscovery } from './discovery.js'
8
+ import { buildUrl, extractRequestBodyFromDiscovery } from './discovery.js'
6
9
  import type { CheckResult, EndpointSpec } from './helpers.js'
7
10
  import {
8
- buildUrl,
9
11
  check,
10
12
  fail,
11
13
  fetchWithTimeout,
@@ -15,13 +17,13 @@ import {
15
17
  warn,
16
18
  } from './helpers.js'
17
19
 
20
+ const allChains = Object.values(viemChains) as Chain[]
21
+
18
22
  function detectTestnet(challenge: Challenge.Challenge): boolean {
19
23
  const request = challenge.request as Record<string, unknown>
20
- const methodDetails = request.methodDetails as Record<string, unknown> | undefined
21
- if (typeof methodDetails?.chainId === 'number') {
22
- return methodDetails.chainId !== tempoChainIds.mainnet
23
- }
24
- return false
24
+ const md = request.methodDetails as Record<string, unknown> | undefined
25
+ if (typeof md?.chainId !== 'number') return false
26
+ return allChains.find((c) => c.id === md.chainId)?.testnet === true
25
27
  }
26
28
 
27
29
  export async function validateChallenge(
@@ -33,7 +35,11 @@ export async function validateChallenge(
33
35
  query?: string[] | undefined
34
36
  discoveryDoc?: Record<string, unknown> | undefined
35
37
  },
36
- ): Promise<{ results: CheckResult[]; resolvedBody?: string | undefined }> {
38
+ ): Promise<{
39
+ results: CheckResult[]
40
+ resolvedBody?: string | undefined
41
+ challenges?: Challenge.Challenge[] | undefined
42
+ }> {
37
43
  const results: CheckResult[] = []
38
44
  const url = buildUrl(baseUrl, endpoint, options?.query)
39
45
  const fetchHeaders: Record<string, string> = {}
@@ -106,9 +112,17 @@ export async function validateChallenge(
106
112
  // Check WWW-Authenticate header
107
113
  const wwwAuth = response.headers.get(Constants.Headers.wwwAuthenticate)
108
114
  if (!wwwAuth) {
109
- results.push(
110
- skip('Not an MPP endpoint', 'No WWW-Authenticate header (may be x402 or other protocol)'),
111
- )
115
+ const x402Results = checkX402Headers(response)
116
+ if (x402Results.length > 0) {
117
+ results.push(
118
+ skip('Not an MPP endpoint', 'No WWW-Authenticate header — x402 protocol detected'),
119
+ )
120
+ results.push(...x402Results)
121
+ } else {
122
+ results.push(
123
+ skip('Not an MPP endpoint', 'No WWW-Authenticate header (may be x402 or other protocol)'),
124
+ )
125
+ }
112
126
  return { results }
113
127
  }
114
128
  if (!wwwAuth.startsWith(`${Constants.Schemes.payment} `)) {
@@ -117,160 +131,334 @@ export async function validateChallenge(
117
131
  }
118
132
  results.push(check('WWW-Authenticate header present', 'Payment scheme'))
119
133
 
120
- // Parse challenge
121
- let challenge: Challenge.Challenge
134
+ // Parse all challenges
135
+ let challenges: Challenge.Challenge[]
122
136
  try {
123
- challenge = Challenge.fromResponse(response)
137
+ challenges = Challenge.fromResponseList(response)
124
138
  } catch (error) {
125
- results.push(fail('Challenge parseable', (error as Error).message))
139
+ const msg = (error as Error).message
140
+ results.push(
141
+ fail(
142
+ 'Challenge parseable',
143
+ msg,
144
+ 'The Payment challenge must include a request="<base64url>" parameter containing JSON-encoded payment details.',
145
+ ),
146
+ )
147
+ const rawParams = wwwAuth.slice(wwwAuth.indexOf(' ') + 1)
148
+ results.push(
149
+ warn('Received', rawParams.length > 200 ? rawParams.slice(0, 200) + '...' : rawParams),
150
+ )
126
151
  return { results }
127
152
  }
128
- results.push(check('Challenge parseable', `${challenge.method}/${challenge.intent}`))
129
153
 
130
- // Validate required fields
131
- if (!challenge.id)
154
+ if (challenges.length === 0) {
155
+ results.push(fail('Challenge parseable', 'No Payment challenges found in header'))
156
+ return { results }
157
+ }
158
+
159
+ const methodList = challenges.map((c) => `${c.method}/${c.intent}`).join(', ')
160
+ results.push(
161
+ check(
162
+ 'Challenge parseable',
163
+ challenges.length === 1 ? methodList : `${challenges.length} methods: ${methodList}`,
164
+ ),
165
+ )
166
+
167
+ // Validate common fields across all challenges
168
+ const serverHost = new URL(baseUrl).hostname
169
+ validateIds(challenges, results)
170
+ validateRealms(challenges, results)
171
+ validateExpiration(challenges, results)
172
+ validateRealmMatchesHost(challenges, serverHost, results)
173
+
174
+ // Method-specific validation
175
+ const hasMultipleMethods = challenges.length > 1
176
+ for (const ch of challenges) {
177
+ const request = ch.request as Record<string, unknown>
178
+ validateMethodFields(ch, request, results, hasMultipleMethods)
179
+ }
180
+
181
+ if (verbose) {
182
+ for (const ch of challenges) {
183
+ console.log(
184
+ pc.dim(` Challenge (${ch.method}/${ch.intent}): ${JSON.stringify(ch, null, 2)}`),
185
+ )
186
+ }
187
+ }
188
+
189
+ return { results, resolvedBody: fetchBody, challenges }
190
+ }
191
+
192
+ function validateIds(challenges: Challenge.Challenge[], results: CheckResult[]): void {
193
+ const missing = challenges.filter((ch) => !ch.id)
194
+ if (missing.length === 0) {
195
+ results.push(check('Challenge has id'))
196
+ } else {
132
197
  results.push(
133
198
  fail(
134
199
  'Challenge has id',
135
- undefined,
136
- 'Every challenge must include a unique id field. Generate a random string or hash per challenge.',
200
+ `${missing.length} missing`,
201
+ 'Every challenge must include a unique id field.',
137
202
  ),
138
203
  )
139
- else results.push(check('Challenge has id'))
204
+ }
205
+ }
140
206
 
141
- if (!challenge.realm)
207
+ function validateRealms(challenges: Challenge.Challenge[], results: CheckResult[]): void {
208
+ const missing = challenges.filter((ch) => !ch.realm)
209
+ if (missing.length === 0) {
210
+ results.push(check('Challenge has realm'))
211
+ } else {
142
212
  results.push(
143
213
  fail(
144
214
  'Challenge has realm',
145
- undefined,
146
- "Set realm to your server's hostname. It tells clients who they are paying.",
215
+ `${missing.length} missing`,
216
+ "Set realm to your server's hostname.",
217
+ ),
218
+ )
219
+ }
220
+ }
221
+
222
+ function validateExpiration(challenges: Challenge.Challenge[], results: CheckResult[]): void {
223
+ const now = new Date()
224
+ for (const ch of challenges) {
225
+ if (!ch.expires) {
226
+ results.push(
227
+ warn(
228
+ 'Challenge has expiration',
229
+ 'missing expires field',
230
+ 'Add an expires field (ISO 8601) to prevent replay attacks.',
231
+ ),
232
+ )
233
+ return
234
+ }
235
+ if (new Date(ch.expires) <= now) {
236
+ results.push(
237
+ fail(
238
+ 'Challenge expires in the future',
239
+ `Expired at ${ch.expires}`,
240
+ 'The expires timestamp must be in the future.',
241
+ ),
242
+ )
243
+ return
244
+ }
245
+ }
246
+ const soonest = Math.min(
247
+ ...challenges.map((ch) => new Date(ch.expires!).getTime() - now.getTime()),
248
+ )
249
+ results.push(check('Challenge expires in the future', `${Math.round(soonest / 60000)}m from now`))
250
+ }
251
+
252
+ function validateRealmMatchesHost(
253
+ challenges: Challenge.Challenge[],
254
+ serverHost: string,
255
+ results: CheckResult[],
256
+ ): void {
257
+ const realms = [...new Set(challenges.map((ch) => ch.realm ?? ''))]
258
+ const badRealms = realms.filter((r) => r && r !== serverHost && !serverHost.endsWith(`.${r}`))
259
+ if (badRealms.length > 0) {
260
+ results.push(
261
+ warn(
262
+ 'Realm matches server hostname',
263
+ `realm="${badRealms[0]}" vs host="${serverHost}"`,
264
+ 'Set the realm to your production hostname (or base domain) in the challenge.',
147
265
  ),
148
266
  )
149
- else results.push(check('Challenge has realm'))
267
+ } else {
268
+ results.push(check('Realm matches server hostname'))
269
+ }
270
+ }
271
+
272
+ function validateMethodFields(
273
+ challenge: Challenge.Challenge,
274
+ request: Record<string, unknown>,
275
+ results: CheckResult[],
276
+ hasMultipleMethods: boolean,
277
+ ): void {
278
+ const tag = hasMultipleMethods ? `[${challenge.method}] ` : ''
150
279
 
151
- if (!challenge.method)
280
+ if (challenge.method === Constants.Methods.tempo)
281
+ validateTempoFields(request, tag, results, challenge)
282
+ else if (challenge.method === Constants.Methods.stripe)
283
+ validateStripeFields(request, tag, results)
284
+ else if (challenge.method === Constants.Methods.evm)
285
+ validateEvmFields(challenge, request, tag, results)
286
+ }
287
+
288
+ function validateTempoFields(
289
+ request: Record<string, unknown>,
290
+ tag: string,
291
+ results: CheckResult[],
292
+ challenge: Challenge.Challenge,
293
+ ): void {
294
+ const methodDetails = request.methodDetails as Record<string, unknown> | undefined
295
+ const hasSplits = Array.isArray(methodDetails?.splits) && methodDetails.splits.length > 0
296
+
297
+ if (isValidAddress(request.recipient)) {
298
+ results.push(check(`${tag}Valid recipient address`))
299
+ } else if (request.recipient === undefined && hasSplits) {
300
+ results.push(check(`${tag}Uses splits (no single recipient)`))
301
+ } else if (request.recipient === undefined) {
152
302
  results.push(
153
303
  fail(
154
- 'Challenge has method',
155
- undefined,
156
- 'Set method to the payment method (e.g. "tempo", "stripe").',
304
+ `${tag}Valid recipient address`,
305
+ 'Missing recipient (and no splits defined)',
306
+ 'Set request.recipient to a valid 0x address, or use methodDetails.splits for multiple recipients.',
157
307
  ),
158
308
  )
159
- if (!challenge.intent)
309
+ } else {
160
310
  results.push(
161
311
  fail(
162
- 'Challenge has intent',
163
- undefined,
164
- 'Set intent to the payment type (e.g. "charge", "session").',
312
+ `${tag}Valid recipient address`,
313
+ `Got: ${String(request.recipient)}`,
314
+ 'Set request.recipient to a valid 0x-prefixed 40-hex-char address.',
165
315
  ),
166
316
  )
317
+ }
167
318
 
168
- // Semantic checks
169
- if (challenge.expires) {
170
- const expiresDate = new Date(challenge.expires)
171
- const now = new Date()
172
- if (expiresDate <= now) {
173
- results.push(
174
- fail(
175
- 'Challenge expires in the future',
176
- `Expired at ${challenge.expires}`,
177
- 'The expires timestamp must be in the future when the challenge is issued. Use a 5-10 minute window from the current time.',
178
- ),
179
- )
180
- } else {
181
- const diffMs = expiresDate.getTime() - now.getTime()
182
- const diffMin = Math.round(diffMs / 60000)
183
- results.push(check('Challenge expires in the future', `${diffMin}m from now`))
184
- }
319
+ if (isValidAddress(request.currency)) {
320
+ const network = detectTestnet(challenge) ? 'testnet' : 'mainnet'
321
+ results.push(check(`${tag}Valid currency address`, network))
185
322
  } else {
186
323
  results.push(
187
- warn(
188
- 'Challenge has expiration',
189
- 'No expires field set',
190
- 'Add an expires field (ISO 8601 datetime) to prevent replay attacks. Recommended: 5 minutes from issuance.',
324
+ fail(
325
+ `${tag}Valid currency address`,
326
+ `Got: ${String(request.currency)}`,
327
+ 'Set request.currency to a valid token address.',
191
328
  ),
192
329
  )
193
330
  }
194
331
 
195
- // Realm check (allow subdomain matches)
196
- try {
197
- const serverHost = new URL(baseUrl).hostname
198
- const realm = challenge.realm ?? ''
199
- const matches = serverHost === realm || serverHost.endsWith(`.${realm}`)
200
- if (matches) {
201
- results.push(check('Realm matches server hostname'))
202
- } else {
203
- results.push(
204
- warn(
205
- 'Realm matches server hostname',
206
- `realm="${realm}" vs host="${serverHost}"`,
207
- 'Set the realm to your production hostname (or base domain) in the challenge. Clients use realm to verify they are paying the right server.',
208
- ),
209
- )
210
- }
211
- } catch {}
332
+ validateAmount(
333
+ request,
334
+ tag,
335
+ results,
336
+ 'token\'s smallest unit (e.g. "10000" = $0.01 for 6-decimal tokens)',
337
+ )
338
+ }
212
339
 
213
- // Method-specific validation
214
- const request = challenge.request as Record<string, unknown>
215
- if (challenge.method === Constants.Methods.tempo) {
216
- if (isValidAddress(request.recipient)) {
217
- results.push(check('Valid recipient address'))
218
- } else {
219
- results.push(
220
- fail(
221
- 'Valid recipient address',
222
- `Got: ${String(request.recipient)}`,
223
- 'Set request.recipient to a valid 0x-prefixed 40-hex-char address. This is where payment will be sent.',
224
- ),
225
- )
226
- }
340
+ function validateStripeFields(
341
+ request: Record<string, unknown>,
342
+ tag: string,
343
+ results: CheckResult[],
344
+ ): void {
345
+ validateAmount(request, tag, results, 'currency\'s smallest unit (e.g. "100" = $1.00 for USD)')
227
346
 
228
- if (isValidAddress(request.currency)) {
229
- const isTestnet = detectTestnet(challenge)
230
- const network = isTestnet ? 'testnet' : 'mainnet'
231
- results.push(check('Valid currency address', `${network}`))
232
- } else {
233
- results.push(
234
- fail(
235
- 'Valid currency address',
236
- `Got: ${String(request.currency)}`,
237
- 'Set request.currency to a valid token address. Common: "0x20c0000000000000000000000000000000000000" (PathUSD).',
238
- ),
239
- )
240
- }
347
+ const currency = request.currency as string | undefined
348
+ const validCurrency = typeof currency === 'string' && /^[a-z]{3}$/i.test(currency)
349
+ if (validCurrency) {
350
+ results.push(check(`${tag}Valid currency code`, currency.toUpperCase()))
351
+ } else {
352
+ results.push(
353
+ fail(
354
+ `${tag}Valid currency code`,
355
+ currency ? `Got: ${currency}` : 'missing',
356
+ 'Must be a three-letter ISO currency code (e.g. "usd").',
357
+ ),
358
+ )
359
+ }
241
360
 
242
- if (isValidIntegerAmount(request.amount)) {
243
- results.push(check('Amount is valid integer string'))
244
- } else if (request.amount === undefined || request.amount === null) {
245
- results.push(
246
- warn(
247
- 'Amount is valid integer string',
248
- 'No amount (dynamic pricing?)',
249
- 'Set request.amount to a string of digits in the token\'s smallest unit (e.g. "10000" = $0.01 for 6-decimal tokens).',
250
- ),
251
- )
252
- } else {
253
- results.push(
254
- fail(
255
- 'Amount is valid integer string',
256
- `Got: ${String(request.amount)}`,
257
- 'request.amount must be a string of digits (no decimals, no prefix). Example: "10000" for $0.01 with 6-decimal tokens.',
258
- ),
259
- )
260
- }
261
- } else if (challenge.method === Constants.Methods.stripe) {
262
- if (request.amount !== undefined) {
263
- results.push(check('Stripe challenge has amount'))
264
- } else {
265
- results.push(warn('Stripe challenge has amount', 'No amount field'))
266
- }
361
+ const methodDetails = request.methodDetails as Record<string, unknown> | undefined
362
+ const networkId = methodDetails?.networkId as string | undefined
363
+ if (networkId) {
364
+ results.push(check(`${tag}Has networkId`, networkId.slice(0, 20)))
365
+ } else {
366
+ results.push(
367
+ fail(
368
+ `${tag}Has networkId`,
369
+ 'Missing methodDetails.networkId',
370
+ 'Set methodDetails.networkId to your Stripe Business Network profile ID.',
371
+ ),
372
+ )
267
373
  }
268
374
 
269
- if (verbose) {
270
- console.log(pc.dim(` Challenge: ${JSON.stringify(challenge, null, 2)}`))
375
+ const pmTypes = methodDetails?.paymentMethodTypes as string[] | undefined
376
+ if (Array.isArray(pmTypes) && pmTypes.length > 0) {
377
+ results.push(check(`${tag}Has paymentMethodTypes`, pmTypes.join(', ')))
378
+ } else {
379
+ results.push(
380
+ fail(
381
+ `${tag}Has paymentMethodTypes`,
382
+ 'Missing or empty',
383
+ 'Set methodDetails.paymentMethodTypes to an array (e.g. ["card"]).',
384
+ ),
385
+ )
271
386
  }
387
+ }
272
388
 
273
- return { results, resolvedBody: fetchBody }
389
+ function validateEvmFields(
390
+ challenge: Challenge.Challenge,
391
+ request: Record<string, unknown>,
392
+ tag: string,
393
+ results: CheckResult[],
394
+ ): void {
395
+ if (isValidAddress(request.recipient)) {
396
+ results.push(check(`${tag}Valid recipient address`))
397
+ } else {
398
+ results.push(
399
+ fail(
400
+ `${tag}Valid recipient address`,
401
+ `Got: ${String(request.recipient)}`,
402
+ 'Set request.recipient to a valid 0x-prefixed Ethereum address.',
403
+ ),
404
+ )
405
+ }
406
+
407
+ if (isValidAddress(request.currency)) {
408
+ const network = detectTestnet(challenge) ? 'testnet' : 'mainnet'
409
+ results.push(check(`${tag}Valid currency address`, network))
410
+ } else {
411
+ results.push(
412
+ fail(
413
+ `${tag}Valid currency address`,
414
+ `Got: ${String(request.currency)}`,
415
+ 'Set request.currency to a valid ERC-20 token contract address.',
416
+ ),
417
+ )
418
+ }
419
+
420
+ validateAmount(request, tag, results, "token's smallest unit")
421
+
422
+ const methodDetails = request.methodDetails as Record<string, unknown> | undefined
423
+ const chainId = methodDetails?.chainId as number | undefined
424
+ if (typeof chainId === 'number' && chainId > 0) {
425
+ results.push(check(`${tag}Has chainId`, String(chainId)))
426
+ } else {
427
+ results.push(
428
+ fail(
429
+ `${tag}Has chainId`,
430
+ 'Missing methodDetails.chainId',
431
+ 'Set methodDetails.chainId to the EVM chain ID.',
432
+ ),
433
+ )
434
+ }
435
+ }
436
+
437
+ function validateAmount(
438
+ request: Record<string, unknown>,
439
+ tag: string,
440
+ results: CheckResult[],
441
+ unitHint: string,
442
+ ): void {
443
+ if (isValidIntegerAmount(request.amount)) {
444
+ results.push(check(`${tag}Amount is valid integer string`))
445
+ } else if (request.amount === undefined || request.amount === null) {
446
+ results.push(
447
+ fail(
448
+ `${tag}Amount is valid integer string`,
449
+ 'Missing amount',
450
+ `Set request.amount to a string of digits in the ${unitHint}.`,
451
+ ),
452
+ )
453
+ } else {
454
+ results.push(
455
+ fail(
456
+ `${tag}Amount is valid integer string`,
457
+ `Got: ${String(request.amount)}`,
458
+ 'request.amount must be a string of digits (no decimals, no prefix).',
459
+ ),
460
+ )
461
+ }
274
462
  }
275
463
 
276
464
  export async function validateErrorHandling(
@@ -333,3 +521,33 @@ export async function validateErrorHandling(
333
521
 
334
522
  return results
335
523
  }
524
+
525
+ function checkX402Headers(response: Response): CheckResult[] {
526
+ const results: CheckResult[] = []
527
+ const paymentRequiredRaw =
528
+ response.headers.get('payment-required') ?? response.headers.get('x-payment-required')
529
+ if (!paymentRequiredRaw) return results
530
+
531
+ const headerName = response.headers.get('payment-required')
532
+ ? 'PAYMENT-REQUIRED'
533
+ : 'X-Payment-Required'
534
+
535
+ try {
536
+ const decoded = x402Header.decodePaymentRequired(paymentRequiredRaw)
537
+ results.push(
538
+ check(
539
+ 'x402 payment challenge',
540
+ `${headerName} with valid x402 v${decoded.x402Version} payload`,
541
+ ),
542
+ )
543
+ } catch {
544
+ results.push(
545
+ warn(
546
+ 'x402 payment challenge',
547
+ `${headerName} header present but failed schema validation`,
548
+ 'Ensure the PAYMENT-REQUIRED header contains a valid base64-encoded JSON payload matching the x402 schema.',
549
+ ),
550
+ )
551
+ }
552
+ return results
553
+ }