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.
- package/CHANGELOG.md +19 -0
- package/dist/cli/internal.js +2 -2
- package/dist/cli/internal.js.map +1 -1
- package/dist/cli/plugins/evm.d.ts +2 -0
- package/dist/cli/plugins/evm.d.ts.map +1 -0
- package/dist/cli/plugins/evm.js +51 -0
- package/dist/cli/plugins/evm.js.map +1 -0
- package/dist/cli/plugins/index.d.ts +1 -0
- package/dist/cli/plugins/index.d.ts.map +1 -1
- package/dist/cli/plugins/index.js +1 -0
- package/dist/cli/plugins/index.js.map +1 -1
- package/dist/cli/plugins/stripe.d.ts.map +1 -1
- package/dist/cli/plugins/stripe.js +3 -2
- package/dist/cli/plugins/stripe.js.map +1 -1
- package/dist/cli/validate/challenge.d.ts +2 -0
- package/dist/cli/validate/challenge.d.ts.map +1 -1
- package/dist/cli/validate/challenge.js +191 -84
- package/dist/cli/validate/challenge.js.map +1 -1
- package/dist/cli/validate/discovery.d.ts +1 -0
- package/dist/cli/validate/discovery.d.ts.map +1 -1
- package/dist/cli/validate/discovery.js +99 -35
- package/dist/cli/validate/discovery.js.map +1 -1
- package/dist/cli/validate/helpers.d.ts +14 -1
- package/dist/cli/validate/helpers.d.ts.map +1 -1
- package/dist/cli/validate/helpers.js +0 -13
- package/dist/cli/validate/helpers.js.map +1 -1
- package/dist/cli/validate/index.d.ts +1 -0
- package/dist/cli/validate/index.d.ts.map +1 -1
- package/dist/cli/validate/index.js +102 -153
- package/dist/cli/validate/index.js.map +1 -1
- package/dist/cli/validate/payment.d.ts +4 -1
- package/dist/cli/validate/payment.d.ts.map +1 -1
- package/dist/cli/validate/payment.js +312 -106
- package/dist/cli/validate/payment.js.map +1 -1
- package/dist/evm/Chains.d.ts +4 -0
- package/dist/evm/Chains.d.ts.map +1 -1
- package/dist/evm/Chains.js +4 -0
- package/dist/evm/Chains.js.map +1 -1
- package/dist/internal/AcceptPayment.d.ts.map +1 -1
- package/dist/internal/AcceptPayment.js +7 -1
- package/dist/internal/AcceptPayment.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/server/internal/request-body.d.ts.map +1 -1
- package/dist/tempo/server/internal/request-body.js +2 -0
- package/dist/tempo/server/internal/request-body.js.map +1 -1
- package/dist/tempo/session/server/ChannelStore.js +4 -4
- package/dist/tempo/session/server/ChannelStore.js.map +1 -1
- package/dist/tempo/session/server/CredentialVerification.d.ts +8 -0
- package/dist/tempo/session/server/CredentialVerification.d.ts.map +1 -1
- package/dist/tempo/session/server/CredentialVerification.js +21 -1
- package/dist/tempo/session/server/CredentialVerification.js.map +1 -1
- package/dist/tempo/session/server/Session.d.ts.map +1 -1
- package/dist/tempo/session/server/Session.js +1 -0
- package/dist/tempo/session/server/Session.js.map +1 -1
- package/dist/validation/core.d.ts +75 -0
- package/dist/validation/core.d.ts.map +1 -0
- package/dist/validation/core.js +269 -0
- package/dist/validation/core.js.map +1 -0
- package/dist/validation/index.d.ts +3 -0
- package/dist/validation/index.d.ts.map +1 -0
- package/dist/validation/index.js +2 -0
- package/dist/validation/index.js.map +1 -0
- package/dist/x402/Assets.d.ts +9 -0
- package/dist/x402/Assets.d.ts.map +1 -1
- package/dist/x402/Assets.js +38 -0
- package/dist/x402/Assets.js.map +1 -1
- package/package.json +9 -4
- package/src/cli/internal.ts +2 -2
- package/src/cli/plugins/evm.ts +55 -0
- package/src/cli/plugins/index.ts +1 -0
- package/src/cli/plugins/stripe.ts +3 -2
- package/src/cli/validate/challenge.ts +345 -127
- package/src/cli/validate/discovery.test.ts +307 -0
- package/src/cli/validate/discovery.ts +104 -38
- package/src/cli/validate/helpers.ts +15 -13
- package/src/cli/validate/index.ts +121 -242
- package/src/cli/validate/payment.ts +413 -144
- package/src/cli/validate.test.ts +237 -3
- package/src/client/internal/Fetch.test.ts +142 -2
- package/src/evm/Chains.ts +6 -0
- package/src/evm/PublicInterface.test-d.ts +7 -0
- package/src/internal/AcceptPayment.test.ts +45 -0
- package/src/internal/AcceptPayment.ts +11 -1
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/server/internal/request-body.test.ts +9 -0
- package/src/tempo/server/internal/request-body.ts +2 -0
- package/src/tempo/session/server/ChannelStore.test.ts +17 -0
- package/src/tempo/session/server/ChannelStore.ts +4 -4
- package/src/tempo/session/server/CredentialVerification.test.ts +118 -1
- package/src/tempo/session/server/CredentialVerification.ts +30 -0
- package/src/tempo/session/server/Session.integration.test.ts +5 -0
- package/src/tempo/session/server/Session.test.ts +119 -53
- package/src/tempo/session/server/Session.ts +1 -0
- package/src/validation/core.ts +374 -0
- package/src/validation/index.ts +11 -0
- package/src/x402/Assets.test.ts +42 -0
- package/src/x402/Assets.ts +40 -0
- 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
|
|
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
|
|
21
|
-
if (typeof
|
|
22
|
-
|
|
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<{
|
|
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
|
-
|
|
110
|
-
|
|
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
|
|
121
|
-
let
|
|
134
|
+
// Parse all challenges
|
|
135
|
+
let challenges: Challenge.Challenge[]
|
|
122
136
|
try {
|
|
123
|
-
|
|
137
|
+
challenges = Challenge.fromResponseList(response)
|
|
124
138
|
} catch (error) {
|
|
125
|
-
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
136
|
-
'Every challenge must include a unique id field.
|
|
200
|
+
`${missing.length} missing`,
|
|
201
|
+
'Every challenge must include a unique id field.',
|
|
137
202
|
),
|
|
138
203
|
)
|
|
139
|
-
|
|
204
|
+
}
|
|
205
|
+
}
|
|
140
206
|
|
|
141
|
-
|
|
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
|
-
|
|
146
|
-
"Set realm to your server's hostname.
|
|
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
|
|
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 (
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
'Set
|
|
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
|
-
|
|
309
|
+
} else {
|
|
160
310
|
results.push(
|
|
161
311
|
fail(
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
'Set
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
'
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
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
|
+
}
|