x402-surface-check 0.2.9 → 0.2.11
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/README.md +4 -0
- package/bin/x402-surface-check.mjs +50 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,12 +20,14 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
|
|
|
20
20
|
- MPP `WWW-Authenticate: Payment` and x402 V2 `WWW-Authenticate: X402 requirements=...` challenges
|
|
21
21
|
- Atomic-unit `amount` / `maxAmountRequired` fields, plus legacy decimal `amount` + `token` x402 v1 challenges
|
|
22
22
|
- `asset` or token metadata, `network`, and `payTo`
|
|
23
|
+
- OpenAPI-declared `x-payment-info.price.amount` drift versus the live 402 challenge price
|
|
23
24
|
- Placeholder recipients such as zero addresses and Solana system-program values
|
|
24
25
|
- Testnet or staging rails such as Base Sepolia and Solana devnet
|
|
25
26
|
- HTTPS resource URLs and stable resource metadata
|
|
26
27
|
- Browser CORS allowance for `X-PAYMENT`
|
|
27
28
|
- Over-broad public method surfaces
|
|
28
29
|
- Auth, validation, and free/trial responses that appear before a payment challenge, without piling on missing-field findings when no challenge was actually returned
|
|
30
|
+
- Operational health/status endpoints, without treating expected free health checks as paid-route failures
|
|
29
31
|
- Object-valued document metadata such as facilitator objects, without `[object Object]` report artifacts
|
|
30
32
|
|
|
31
33
|
## Public Proof
|
|
@@ -35,6 +37,8 @@ Recent public no-payment checks have found and verified real launch fixes:
|
|
|
35
37
|
- TensorFeed: parameter-required premium routes moved behind canonical x402 V2 challenges, then verified clean. https://github.com/solana-foundation/pay-skills/pull/68#issuecomment-4455360068
|
|
36
38
|
- x402jp: weather routes that returned 500 now return structured Base x402 challenges. https://github.com/solana-foundation/pay-skills/pull/58#issuecomment-4455401355
|
|
37
39
|
- Spraay: resource echo and browser payment-header behavior verified clean. https://github.com/solana-foundation/pay-skills/pull/60#issuecomment-4455519760
|
|
40
|
+
- UZPROOF: schemes-style Solana x402 challenge and browser payment-header behavior verified clean. https://github.com/solana-foundation/pay-skills/pull/28#issuecomment-4455613892
|
|
41
|
+
- HYRE Agent: OpenAPI-declared prices found 10x below live 402 challenge prices. https://github.com/solana-foundation/pay-skills/pull/19#issuecomment-4455641258
|
|
38
42
|
- Agent Trust Bench: live discovery URL and browser-compatibility notes for adversarial agent-payment resources. https://github.com/solana-foundation/pay-skills/pull/23#issuecomment-4455484414
|
|
39
43
|
|
|
40
44
|
Field notes and browser version: https://tateprograms.com/x402-surface-check.html
|
|
@@ -98,6 +98,11 @@ function moneyFromDecimal(amount) {
|
|
|
98
98
|
})}`
|
|
99
99
|
}
|
|
100
100
|
|
|
101
|
+
function numberFromDecimal(amount) {
|
|
102
|
+
const numeric = Number(amount)
|
|
103
|
+
return Number.isFinite(numeric) ? numeric : null
|
|
104
|
+
}
|
|
105
|
+
|
|
101
106
|
function uniqueEntries(entries, limit) {
|
|
102
107
|
const seen = new Set()
|
|
103
108
|
return entries
|
|
@@ -127,6 +132,15 @@ function endpointUrl(rawPath, baseUrl, sourceUrl) {
|
|
|
127
132
|
return new URL(value, base).toString()
|
|
128
133
|
}
|
|
129
134
|
|
|
135
|
+
function operationExpectedPrice(operation) {
|
|
136
|
+
const price = operation?.['x-payment-info']?.price
|
|
137
|
+
?? operation?.['x-payment']?.price
|
|
138
|
+
?? operation?.payment?.price
|
|
139
|
+
const amount = price?.amount ?? price?.amountUsd ?? price?.usd
|
|
140
|
+
const numeric = numberFromDecimal(amount)
|
|
141
|
+
return numeric === null ? null : numeric
|
|
142
|
+
}
|
|
143
|
+
|
|
130
144
|
function endpointEntries(document, sourceUrl, limit) {
|
|
131
145
|
const entries = []
|
|
132
146
|
const baseUrl = documentBaseUrl(document, sourceUrl)
|
|
@@ -189,6 +203,7 @@ function endpointEntries(document, sourceUrl, limit) {
|
|
|
189
203
|
name: operation.operationId ?? `${method.toUpperCase()} ${path}`,
|
|
190
204
|
url,
|
|
191
205
|
method: method.toUpperCase(),
|
|
206
|
+
expectedPriceUsd: operationExpectedPrice(operation),
|
|
192
207
|
})
|
|
193
208
|
}
|
|
194
209
|
}
|
|
@@ -437,6 +452,14 @@ function challengePrice(accept, result) {
|
|
|
437
452
|
: moneyFromAtomic(amount, acceptDecimals(accept))
|
|
438
453
|
}
|
|
439
454
|
|
|
455
|
+
function challengePriceUsd(accept, result) {
|
|
456
|
+
const amount = acceptAmountValue(accept)
|
|
457
|
+
if (usesDecimalAmount(accept, result)) return numberFromDecimal(amount)
|
|
458
|
+
const numeric = Number(amount)
|
|
459
|
+
if (!Number.isFinite(numeric)) return null
|
|
460
|
+
return numeric / (10 ** acceptDecimals(accept))
|
|
461
|
+
}
|
|
462
|
+
|
|
440
463
|
function hasPaymentChallenge(result) {
|
|
441
464
|
const challenge = result.body.json
|
|
442
465
|
return challengeAccepts(result).length > 0 || Boolean(challenge?.resource || challenge?.payment || result.headers?.['www-authenticate'])
|
|
@@ -457,6 +480,8 @@ function challengeSummary(result) {
|
|
|
457
480
|
network: firstAccept.network ?? '',
|
|
458
481
|
amount,
|
|
459
482
|
price: hasChallenge ? challengePrice(firstAccept, result) : '',
|
|
483
|
+
priceUsd: hasChallenge ? challengePriceUsd(firstAccept, result) : null,
|
|
484
|
+
expectedPriceUsd: typeof result.expectedPriceUsd === 'number' ? result.expectedPriceUsd : null,
|
|
460
485
|
payTo: firstAccept.payTo ?? '',
|
|
461
486
|
asset: acceptAssetValue(firstAccept),
|
|
462
487
|
timeout: firstAccept.maxTimeoutSeconds ?? '',
|
|
@@ -476,11 +501,21 @@ function looksLikePlaceholderPayTo(payTo) {
|
|
|
476
501
|
return false
|
|
477
502
|
}
|
|
478
503
|
|
|
504
|
+
function entryKey(entry) {
|
|
505
|
+
return `${entry.method ?? 'POST'} ${entry.url}`
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function looksLikeOperationalHealthEndpoint(result) {
|
|
509
|
+
const value = `${result.name ?? ''} ${new URL(result.url).pathname}`.toLowerCase()
|
|
510
|
+
return /(^|[/_\s-])(health|healthz|ready|readiness|live|liveness|status)([/_\s-]|$)/.test(value)
|
|
511
|
+
}
|
|
512
|
+
|
|
479
513
|
function findingList(documentResult, challengeResults, preflightResults, entries) {
|
|
480
514
|
const document = documentResult.body.json ?? {}
|
|
481
515
|
const findings = []
|
|
482
516
|
const networks = valueList(document.networks)
|
|
483
517
|
const challengeNetworks = new Set()
|
|
518
|
+
const challengesByEntry = new Map(challengeResults.map(result => [entryKey(result), result]))
|
|
484
519
|
|
|
485
520
|
if (documentResult.status < 200 || documentResult.status >= 300) {
|
|
486
521
|
findings.push(`P1 - Document returned HTTP ${documentResult.status}; expected a successful JSON response.`)
|
|
@@ -501,7 +536,9 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
501
536
|
|
|
502
537
|
if (result.status !== 402) {
|
|
503
538
|
if (result.status >= 200 && result.status < 300) {
|
|
504
|
-
|
|
539
|
+
if (!looksLikeOperationalHealthEndpoint(result)) {
|
|
540
|
+
findings.push(`P3 - ${result.name} returned ${result.status} without a payment challenge for a no-payment ${result.method ?? 'POST'} probe; document this as free/trial access or move the 402 challenge before content.`)
|
|
541
|
+
}
|
|
505
542
|
}
|
|
506
543
|
else if (result.status === 400 || result.status === 422) {
|
|
507
544
|
findings.push(`P1 - ${result.name} returned validation HTTP ${result.status} before a payment challenge for a no-payment ${result.method ?? 'POST'} probe.`)
|
|
@@ -524,6 +561,12 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
524
561
|
if (!summary.amount || !summary.payTo || !summary.asset) {
|
|
525
562
|
findings.push(`P1 - ${result.name} challenge is missing amount/maxAmountRequired, payTo, or asset metadata.`)
|
|
526
563
|
}
|
|
564
|
+
if (summary.expectedPriceUsd !== null && summary.priceUsd !== null) {
|
|
565
|
+
const delta = Math.abs(summary.expectedPriceUsd - summary.priceUsd)
|
|
566
|
+
if (delta > 0.000001) {
|
|
567
|
+
findings.push(`P1 - ${result.name} documented price ${moneyFromDecimal(summary.expectedPriceUsd)} does not match live 402 challenge price ${moneyFromDecimal(summary.priceUsd)}.`)
|
|
568
|
+
}
|
|
569
|
+
}
|
|
527
570
|
for (const accept of challengeAccepts(result)) {
|
|
528
571
|
if (looksLikePlaceholderPayTo(accept.payTo)) {
|
|
529
572
|
findings.push(`P1 - ${result.name} challenge advertises placeholder-looking payTo ${accept.payTo}; production listings should not ask agents to pay placeholder recipients.`)
|
|
@@ -538,9 +581,14 @@ function findingList(documentResult, challengeResults, preflightResults, entries
|
|
|
538
581
|
}
|
|
539
582
|
|
|
540
583
|
for (const result of preflightResults) {
|
|
584
|
+
const challengeResult = challengesByEntry.get(entryKey(result))
|
|
585
|
+
if (!challengeResult || !hasPaymentChallenge(challengeResult)) continue
|
|
541
586
|
const allowed = result.headers['access-control-allow-headers'] ?? ''
|
|
542
587
|
if (allowed !== '*' && !/x-payment/i.test(allowed)) {
|
|
543
|
-
|
|
588
|
+
const observed = result.status >= 400
|
|
589
|
+
? `HTTP ${result.status}; allow headers: ${allowed || 'none'}`
|
|
590
|
+
: `allow headers: ${allowed || 'none'}`
|
|
591
|
+
findings.push(`P1 - ${result.name} CORS preflight does not allow X-PAYMENT; observed ${observed}.`)
|
|
544
592
|
}
|
|
545
593
|
const allowedMethods = result.headers['access-control-allow-methods'] ?? ''
|
|
546
594
|
if (/delete|put|patch/i.test(allowedMethods)) {
|