Skip to content

Commit 24d9f4f

Browse files
committed
Parse x402 v2 authenticate headers
1 parent 9337ec4 commit 24d9f4f

5 files changed

Lines changed: 74 additions & 13 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ npx --yes x402-surface-check --endpoint --method POST https://x402.rpc.ankr.com/
1717
- Manifest endpoint discovery from `items[]`, `endpoints[]`, `x402Endpoints`, category arrays, resource strings, and OpenAPI paths
1818
- No-payment HTTP 402 challenge shape
1919
- x402 v1 and v2 price fields
20-
- MPP `WWW-Authenticate: Payment` challenges
20+
- MPP `WWW-Authenticate: Payment` and x402 V2 `WWW-Authenticate: X402 requirements=...` challenges
2121
- `amount` / `maxAmountRequired`, `asset`, `network`, and `payTo`
2222
- Placeholder recipients such as zero addresses and Solana system-program values
2323
- Testnet or staging rails such as Base Sepolia and Solana devnet

bin/x402-surface-check.mjs

Lines changed: 32 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -228,17 +228,25 @@ function parseEncodedChallenge(value) {
228228
}
229229
}
230230

231-
function parsePaymentAuthenticate(value) {
232-
if (!value || !/^Payment\s+/i.test(value)) return null
231+
function authenticateParams(value, scheme) {
232+
const header = String(value ?? '').replace(/^www-authenticate:\s*/i, '').trim()
233+
if (!header || !new RegExp(`^${scheme}\\s+`, 'i').test(header)) return null
233234
const params = {}
234235
const pattern = /([a-zA-Z][\w-]*)="([^"]*)"/g
235-
let match = pattern.exec(value)
236+
let match = pattern.exec(header)
236237

237238
while (match) {
238239
params[match[1]] = match[2]
239-
match = pattern.exec(value)
240+
match = pattern.exec(header)
240241
}
241242

243+
return params
244+
}
245+
246+
function parsePaymentAuthenticate(value) {
247+
const params = authenticateParams(value, 'Payment')
248+
if (!params) return null
249+
242250
const request = parseEncodedChallenge(params.request)
243251
if (!request) return null
244252

@@ -264,6 +272,19 @@ function parsePaymentAuthenticate(value) {
264272
}
265273
}
266274

275+
function parseX402Authenticate(value) {
276+
const params = authenticateParams(value, 'X402')
277+
if (!params) return null
278+
279+
const requirements = parseEncodedChallenge(params.requirements ?? params.request)
280+
if (!requirements || !Array.isArray(requirements.accepts)) return null
281+
282+
return {
283+
protocol: requirements.protocol ?? 'x402',
284+
...requirements,
285+
}
286+
}
287+
267288
async function fetchDocument(url) {
268289
const response = await fetch(url, {
269290
headers: {
@@ -296,16 +317,18 @@ async function probeEndpoint(entry) {
296317
const headerChallenge = parseEncodedChallenge(
297318
response.headers.get('payment-required') ?? response.headers.get('x-payment-required'),
298319
)
299-
const paymentChallenge = parsePaymentAuthenticate(response.headers.get('www-authenticate'))
320+
const authenticateChallenge = parsePaymentAuthenticate(response.headers.get('www-authenticate'))
321+
?? parseX402Authenticate(response.headers.get('www-authenticate'))
300322

301323
if (!body.json?.accepts?.length) {
302324
if (headerChallenge) {
303325
body.json = headerChallenge
304326
}
305-
else if (paymentChallenge) {
306-
paymentChallenge.resource.url = entry.url
307-
paymentChallenge.accepts[0].resource = entry.url
308-
body.json = paymentChallenge
327+
else if (authenticateChallenge) {
328+
authenticateChallenge.resource = authenticateChallenge.resource ?? { url: entry.url }
329+
authenticateChallenge.resource.url = authenticateChallenge.resource.url || entry.url
330+
authenticateChallenge.accepts[0].resource = authenticateChallenge.accepts[0].resource || entry.url
331+
body.json = authenticateChallenge
309332
}
310333
}
311334

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "x402-surface-check",
3-
"version": "0.2.2",
3+
"version": "0.2.3",
44
"description": "No-payment x402 public-surface checker for manifests, OpenAPI specs, and HTTP 402 challenges.",
55
"type": "module",
66
"bin": {

test/smoke.mjs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,31 @@ const server = createServer((request, response) => {
149149
return
150150
}
151151

152+
if (request.url === '/x402v2/header') {
153+
const requirements = Buffer.from(JSON.stringify({
154+
x402Version: 2,
155+
error: 'payment_required',
156+
resource: {
157+
url: `${serverUrl}/x402v2/header`,
158+
description: 'Header-only x402 V2 fixture',
159+
mimeType: 'application/json',
160+
},
161+
accepts: [{
162+
scheme: 'exact',
163+
network: 'eip155:8453',
164+
amount: '20000',
165+
asset: '0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913',
166+
payTo: '0x549c82e6bfc54bdae9a2073744cbc2af5d1fc6d1',
167+
maxTimeoutSeconds: 60,
168+
}],
169+
})).toString('base64url')
170+
response.statusCode = 402
171+
response.setHeader('content-type', 'application/json')
172+
response.setHeader('www-authenticate', `X402 requirements="${requirements}"`)
173+
response.end(JSON.stringify({ error: 'payment_required' }))
174+
return
175+
}
176+
152177
response.statusCode = 404
153178
response.end('not found')
154179
})
@@ -211,6 +236,19 @@ try {
211236
assert.match(mpp.stdout, /mpp/)
212237
assert.match(mpp.stdout, /mainnet/)
213238
assert.match(mpp.stdout, /\$0\.001/)
239+
240+
const x402Header = await execFileAsync('node', [
241+
'bin/x402-surface-check.mjs',
242+
'--endpoint',
243+
'--method',
244+
'GET',
245+
`${serverUrl}/x402v2/header`,
246+
], { cwd: new URL('..', import.meta.url) })
247+
248+
assert.match(x402Header.stdout, /direct endpoint/)
249+
assert.match(x402Header.stdout, /x402/)
250+
assert.match(x402Header.stdout, /eip155:8453/)
251+
assert.match(x402Header.stdout, /\$0\.02/)
214252
}
215253
finally {
216254
await new Promise(resolve => server.close(resolve))

0 commit comments

Comments
 (0)