Skip to content

Commit e323157

Browse files
feat: add x402 challenge detection and human-in-the-loop payment
Detect x402 on-chain payment challenges (X-Payment-Required: x402 header) alongside existing L402 Lightning challenges. When an x402 challenge is detected, parse the payment details (receiver, network, asset, amount) and present them to the agent for human-in-the-loop payment. After the user completes payment, the txHash parameter allows retrying the request with the X-Payment header for server verification.
1 parent 6152744 commit e323157

10 files changed

Lines changed: 654 additions & 7 deletions

File tree

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ src/
5252
wallet/ # Payment implementations (NWC, Cashu melt, human)
5353
store/ # Persistent JSON stores (credentials, Cashu tokens)
5454
l402/ # L402 protocol utilities (parse, detect, cache, bolt11)
55+
x402/ # x402 protocol utilities (parse, payment deeplinks)
5556
tests/ # Tests mirror src/ structure (tests/tools/, tests/wallet/, etc.)
5657
e2e/ # Integration tests against in-process toll-booth
5758
```

‎package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
".": "./build/index.js",
1212
"./tools/*": "./build/tools/*.js",
1313
"./l402/*": "./build/l402/*.js",
14+
"./x402/*": "./build/x402/*.js",
1415
"./store/*": "./build/store/*.js",
1516
"./fetch/*": "./build/fetch/*.js",
1617
"./spend-tracker": "./build/spend-tracker.js"

‎src/index.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import { registerBuyCreditsTool } from './tools/buy-credits.js'
2525
import { registerRedeemCashuTool } from './tools/redeem-cashu.js'
2626
import { registerSearchTool } from './tools/search.js'
2727
import { createNostrSubscriber } from './tools/nostr-subscribe.js'
28+
import { isX402Challenge, parseX402Challenge } from './x402/parse.js'
29+
import { formatX402PaymentRequest } from './x402/payment.js'
2830
import { createResilientFetch, withTransportFallback } from './fetch/resilient-fetch.js'
2931
import { selectTransports } from './fetch/transport.js'
3032
import { resolveHns as resolveHnsBase } from './fetch/hns-resolve.js'
@@ -180,6 +182,9 @@ registerFetchTool(server, {
180182
challengeCache,
181183
generateQr,
182184
walletMethod: () => getWallet()?.method,
185+
isX402: isX402Challenge,
186+
parseX402: parseX402Challenge,
187+
formatX402: formatX402PaymentRequest,
183188
})
184189

185190
registerPayTool(server, {

‎src/tools/fetch.ts‎

Lines changed: 38 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type { ResilientFetchOptions } from '../fetch/resilient-fetch.js'
88
import type { SpendTracker } from '../spend-tracker.js'
99
import type { ChallengeCache } from '../l402/challenge-cache.js'
1010
import type { WalletMethod } from '../wallet/types.js'
11+
import type { X402Challenge } from '../x402/parse.js'
1112
import { safeErrorMessage } from './safe-error.js'
1213
import { filterResponseHeaders } from './safe-headers.js'
1314

@@ -35,6 +36,12 @@ export interface FetchDeps {
3536
challengeCache: ChallengeCache
3637
generateQr: (invoice: string) => Promise<{ png: string; text: string }>
3738
walletMethod: () => WalletMethod | undefined
39+
/** Detects x402 challenge from response headers. */
40+
isX402: (headers: Headers) => boolean
41+
/** Parses x402 challenge details from the response body. */
42+
parseX402: (body: unknown) => X402Challenge | null
43+
/** Formats x402 challenge as a payment request for the agent. */
44+
formatX402: (challenge: X402Challenge) => { json: Record<string, unknown>; message: string }
3845
}
3946

4047
function parseBalance(value: string | null): number | null {
@@ -43,9 +50,9 @@ function parseBalance(value: string | null): number | null {
4350
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
4451
}
4552

46-
/** Makes an HTTP request with automatic L402 payment and credential reuse. Pays the invoice if within budget, stores the credential, and retries. */
53+
/** Makes an HTTP request with automatic L402/x402 payment and credential reuse. Pays the invoice if within budget, stores the credential, and retries. */
4754
export async function handleFetch(
48-
args: { url: string; urls?: string[]; method?: string; headers?: Record<string, string>; body?: string; autoPay?: boolean; pubkey?: string },
55+
args: { url: string; urls?: string[]; method?: string; headers?: Record<string, string>; body?: string; autoPay?: boolean; pubkey?: string; txHash?: string },
4956
deps: FetchDeps,
5057
) {
5158
// When multiple URLs are provided (from l402_search results), use transport fallback.
@@ -73,6 +80,13 @@ export async function handleFetch(
7380
deps.credentialStore.updateLastUsed(credKey)
7481
}
7582

83+
// x402 retry: when the caller provides a transaction hash from a completed
84+
// on-chain payment, attach it as the X-Payment header so the server can
85+
// verify and grant access.
86+
if (args.txHash) {
87+
reqHeaders['X-Payment'] = args.txHash
88+
}
89+
7690
// Build a unified fetch helper: multi-URL (transport fallback) or single-URL
7791
const doFetch = (url: string, init: RequestInit) => {
7892
const allUrls = args.urls?.length ? args.urls : [url]
@@ -111,13 +125,29 @@ export async function handleFetch(
111125
}
112126
}
113127

114-
// 402 response - parse the challenge
115-
const authHeader = response.headers.get('www-authenticate') ?? ''
116-
const challenge = deps.parseL402(authHeader)
117-
128+
// 402 response - parse the challenge body (shared by L402 and x402 paths)
118129
let challengeBody: Record<string, unknown> = {}
119130
try { challengeBody = await response.json() as Record<string, unknown> } catch { /* non-JSON 402 body */ }
120131

132+
// x402 challenge: on-chain stablecoin payment (human-in-the-loop)
133+
if (deps.isX402(response.headers)) {
134+
const x402 = deps.parseX402(challengeBody)
135+
if (x402) {
136+
const { json } = deps.formatX402(x402)
137+
return {
138+
content: [{
139+
type: 'text' as const,
140+
text: JSON.stringify(json, null, 2),
141+
}],
142+
}
143+
}
144+
// Header said x402 but body was unparseable — fall through to generic 402
145+
}
146+
147+
// L402 challenge: Lightning payment
148+
const authHeader = response.headers.get('www-authenticate') ?? ''
149+
const challenge = deps.parseL402(authHeader)
150+
121151
const decoded = challenge ? deps.decodeBolt11(challenge.invoice) : { costSats: null, paymentHash: null, expiry: 3600 }
122152
const serverInfo = deps.detectServer(response.headers, challengeBody)
123153

@@ -320,7 +350,7 @@ export function registerFetchTool(server: McpServer, deps: FetchDeps): void {
320350
server.registerTool(
321351
'l402_fetch',
322352
{
323-
description: 'Fetch a URL with automatic payment handling. Use this to access any paid API or service. Manages credentials, pays automatically when autoPay is true and cost is within budget, and retries. For human wallets, returns a payment page URL or QR code. Set autoPay to true for seamless access. When a 402 is returned with tiers, present the pricing options to the user and use l402_buy_credits to purchase their chosen tier.',
353+
description: 'Fetch a URL with automatic payment handling (L402 Lightning + x402 on-chain). Manages credentials, pays automatically when autoPay is true and cost is within budget, and retries. For human wallets, returns a payment page URL or QR code. For x402 services, returns payment details (receiver address, network, asset, amount) — the user pays in their wallet and provides the transaction hash. Set autoPay to true for seamless access. When a 402 is returned with tiers, present the pricing options to the user and use l402_buy_credits to purchase their chosen tier.',
324354
inputSchema: {
325355
url: z.url().describe('The primary URL to request. When using search results, pass the first URL here and all URLs in the urls field.'),
326356
urls: z.array(z.url()).max(10).optional().describe('All transport URLs from l402_search results (clearnet, onion, HNS). When present, transports are tried in preference order with automatic fallback on connection failure.'),
@@ -329,6 +359,7 @@ export function registerFetchTool(server: McpServer, deps: FetchDeps): void {
329359
body: z.string().max(1_000_000).optional().describe('Request body (for POST/PUT)'),
330360
autoPay: z.boolean().optional().default(false).describe('Automatically pay if within MAX_AUTO_PAY_SATS budget'),
331361
pubkey: z.string().max(128).optional().describe('Service pubkey from l402_search results — used to share credentials across all transport URLs for the same service'),
362+
txHash: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional().describe('Transaction hash from a completed x402 on-chain payment. When provided, retries the request with X-Payment header for server verification.'),
332363
},
333364
},
334365
async (args) => handleFetch(args, deps),

‎src/x402/parse.ts‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
/** Chain IDs for supported EVM networks. */
2+
const CHAIN_IDS: Record<string, number> = {
3+
base: 8453,
4+
'base-sepolia': 84532,
5+
ethereum: 1,
6+
optimism: 10,
7+
arbitrum: 42161,
8+
polygon: 137,
9+
}
10+
11+
/** Decimal places per asset for converting human-readable amounts to smallest unit. */
12+
const ASSET_DECIMALS: Record<string, number> = {
13+
usdc: 6,
14+
usdt: 6,
15+
dai: 18,
16+
eth: 18,
17+
}
18+
19+
export interface X402Challenge {
20+
receiver: string
21+
network: string
22+
asset: string
23+
amountUsd: number
24+
/** EVM chain ID for the network (e.g. 8453 for Base). */
25+
chainId: number | null
26+
/** Amount in smallest asset unit (e.g. 1000000 for 1 USDC). */
27+
amountSmallestUnit: bigint | null
28+
}
29+
30+
const ETH_ADDRESS_RE = /^0x[0-9a-fA-F]{40}$/
31+
32+
/** Detects whether a 402 response contains an x402 challenge via the X-Payment-Required header. */
33+
export function isX402Challenge(headers: Headers): boolean {
34+
const value = headers.get('x-payment-required')
35+
return value?.toLowerCase() === 'x402'
36+
}
37+
38+
/**
39+
* Parses an x402 challenge from the response body.
40+
* Expected shape: `{ x402: { receiver, network, asset, amount_usd } }`.
41+
*/
42+
export function parseX402Challenge(body: unknown): X402Challenge | null {
43+
if (body === null || typeof body !== 'object') return null
44+
45+
const root = body as Record<string, unknown>
46+
const x402 = root.x402
47+
if (x402 === null || typeof x402 !== 'object') return null
48+
49+
const data = x402 as Record<string, unknown>
50+
51+
const receiver = typeof data.receiver === 'string' ? data.receiver.trim() : null
52+
const network = typeof data.network === 'string' ? data.network.trim().toLowerCase() : null
53+
const asset = typeof data.asset === 'string' ? data.asset.trim().toLowerCase() : null
54+
55+
// Accept both amount_usd and amountUsd
56+
const rawAmount = data.amount_usd ?? data.amountUsd
57+
const amountUsd = typeof rawAmount === 'number' && Number.isFinite(rawAmount) && rawAmount > 0
58+
? rawAmount
59+
: null
60+
61+
if (!receiver || !network || !asset || amountUsd === null) return null
62+
63+
// Validate Ethereum address format
64+
if (!ETH_ADDRESS_RE.test(receiver)) return null
65+
66+
const chainId = CHAIN_IDS[network] ?? null
67+
const decimals = ASSET_DECIMALS[asset]
68+
const amountSmallestUnit = decimals !== undefined
69+
? BigInt(Math.round(amountUsd * 10 ** decimals))
70+
: null
71+
72+
return {
73+
receiver,
74+
network,
75+
asset,
76+
amountUsd,
77+
chainId,
78+
amountSmallestUnit,
79+
}
80+
}
81+
82+
/** Builds an EIP-681 payment deeplink for wallet apps. */
83+
export function buildPaymentDeeplink(challenge: X402Challenge): string | null {
84+
if (!challenge.chainId) return null
85+
86+
// For ERC-20 tokens (not native ETH), use the transfer function call format
87+
// For now, return a simple ethereum: URI that most wallets understand
88+
return `ethereum:${challenge.receiver}@${challenge.chainId}`
89+
}

‎src/x402/payment.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import type { X402Challenge } from './parse.js'
2+
import { buildPaymentDeeplink } from './parse.js'
3+
4+
/** Format the x402 challenge as a human-readable payment request for the agent to present. */
5+
export function formatX402PaymentRequest(challenge: X402Challenge): {
6+
/** Structured JSON for the agent to parse. */
7+
json: Record<string, unknown>
8+
/** Human-readable summary. */
9+
message: string
10+
} {
11+
const deeplink = buildPaymentDeeplink(challenge)
12+
13+
const json: Record<string, unknown> = {
14+
status: 402,
15+
protocol: 'x402',
16+
receiver: challenge.receiver,
17+
network: challenge.network,
18+
asset: challenge.asset.toUpperCase(),
19+
amountUsd: challenge.amountUsd,
20+
...(challenge.chainId !== null ? { chainId: challenge.chainId } : {}),
21+
...(deeplink ? { paymentDeeplink: deeplink } : {}),
22+
message: `Payment required: $${challenge.amountUsd} ${challenge.asset.toUpperCase()} on ${challenge.network}. `
23+
+ `Send to ${challenge.receiver}. `
24+
+ `After payment, provide the transaction hash to retry the request.`,
25+
}
26+
27+
return {
28+
json,
29+
message: json.message as string,
30+
}
31+
}
32+
33+
/** Validate a transaction hash format (0x-prefixed hex, 32 bytes). */
34+
export function isValidTxHash(hash: string): boolean {
35+
return /^0x[0-9a-fA-F]{64}$/.test(hash)
36+
}

‎tests/tools/fetch-security.test.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ function makeDeps(overrides: Partial<FetchDeps> = {}): FetchDeps {
1313
updateLastUsed: vi.fn(),
1414
} as unknown as FetchDeps['credentialStore'],
1515
fetchFn: vi.fn() as unknown as typeof fetch,
16+
transportFetch: vi.fn() as unknown as FetchDeps['transportFetch'],
1617
payInvoice: vi.fn().mockResolvedValue({ paid: false, method: 'none' }),
1718
maxAutoPaySats: 100,
1819
maxSpendPerMinuteSats: 10000,
@@ -23,6 +24,9 @@ function makeDeps(overrides: Partial<FetchDeps> = {}): FetchDeps {
2324
challengeCache: new ChallengeCache(),
2425
generateQr: vi.fn().mockResolvedValue({ png: 'data:image/png;base64,test', text: '█▀▀█' }),
2526
walletMethod: () => undefined,
27+
isX402: vi.fn().mockReturnValue(false),
28+
parseX402: vi.fn().mockReturnValue(null),
29+
formatX402: vi.fn().mockReturnValue({ json: {}, message: '' }),
2630
...overrides,
2731
}
2832
}

0 commit comments

Comments
 (0)