-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathx402Client.example.ts
More file actions
320 lines (292 loc) · 15.1 KB
/
Copy pathx402Client.example.ts
File metadata and controls
320 lines (292 loc) · 15.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
/**
* x402Client.example.ts
* =====================
* A simplified, heavily-annotated x402 fetch wrapper.
*
* WHAT THIS FILE TEACHES:
* -----------------------
* • What HTTP 402 "Payment Required" means and how to handle it
* • The challenge-response pattern: request → 402 → pay → retry
* • How to parse a machine-readable payment requirement from JSON
* • How to build, sign, and attach a payment header before retrying
* • Why we need two header names (PAYMENT-SIGNATURE + X-PAYMENT)
* • A typed error taxonomy so callers handle payment failures precisely
*
* USAGE EXAMPLE:
* --------------
* ```typescript
* import { createX402Client, X402SessionRequired } from './x402Client.example'
*
* // Instantiate once at app startup (or in a React context provider).
* const x402 = createX402Client({
* usdcAddress: process.env.NEXT_PUBLIC_USDC_ARC_ADDRESS!
* })
*
* // Use exactly like window.fetch — the 402 dance is transparent.
* try {
* const response = await x402.fetch('/api/thesis/abc123')
* const data = await response.json()
* } catch (err) {
* if (err instanceof X402SessionRequired) {
* // No active session key — prompt user to approve one.
* showSessionApprovalModal(err.requirement)
* }
* }
* ```
*
* SIMPLIFIED vs PRODUCTION:
* This file intentionally omits retry logic, multi-scheme selection,
* and telemetry to keep the core x402 flow visible. A production
* implementation would add those layers on top.
*/
import { loadSessionKey, hasSessionBudget, recordSpend, generateNonce } from './sessionKey.example'
import { signTransferAuthorization, encodePaymentHeader, type TransferAuthorization } from './eip3009.example'
import {
ARC_TESTNET_CAIP2,
DEFAULT_EIP3009_EXTRA,
type PaymentRequirements,
} from './x402Types'
// ─────────────────────────────────────────────────────────────────────────────
// TYPES
// ─────────────────────────────────────────────────────────────────────────────
/**
* Configuration passed when creating the x402 client.
*
* Only the USDC contract address is needed here — everything else
* (amount, recipient, timeout) is negotiated dynamically from each
* server's 402 response. This makes the client chain-agnostic.
*/
export type X402ClientConfig = {
usdcAddress: string // USDC contract address on Arc (6 decimals)
}
// ─────────────────────────────────────────────────────────────────────────────
// CUSTOM ERRORS
// Typed errors let callers handle payment failures precisely, not generically.
// ─────────────────────────────────────────────────────────────────────────────
/**
* Thrown when no active session key exists in sessionStorage.
*
* Why a typed error? The caller needs to distinguish between:
* (a) "no session key" → show approval UI
* (b) "insufficient budget" → show top-up UI
* (c) "payment rejected" → show retry/error UI
*
* Contains the requirement so the UI can display the exact cost:
* "Approve up to $0.05 USDC to continue reading."
*/
export class X402SessionRequired extends Error {
constructor(public requirement: PaymentRequirements) {
super('x402: No active session key. User must approve one first.')
this.name = 'X402SessionRequired'
}
}
/**
* Thrown when a session key exists but has insufficient remaining budget.
*
* This is recoverable — the user can create a new session key with a
* higher budget. Contains both amounts so the UI can be specific:
* "You need $0.001 but only $0.0003 remains in this session."
*/
export class X402InsufficientBudget extends Error {
constructor(public required: number, public available: number) {
super(
`x402: Insufficient budget. ` +
`Need ${required} USDC, only ${available} USDC remaining in session.`
)
this.name = 'X402InsufficientBudget'
}
}
/**
* Thrown when the server rejects the payment we submitted.
*
* Common causes:
* - Wrong EIP-712 domain name ('USDC' instead of 'USD Coin')
* - Nonce already used (replay attempt detected)
* - validBefore already passed by the time server settled
* - Signature from wrong key (session key doesn't match 'from')
*/
export class X402PaymentFailed extends Error {
constructor(public status: number) {
super(`x402: Server rejected payment (HTTP ${status}). Check signature and domain.`)
this.name = 'X402PaymentFailed'
}
}
// ─────────────────────────────────────────────────────────────────────────────
// MAIN IMPLEMENTATION
// ─────────────────────────────────────────────────────────────────────────────
/**
* Create an x402-aware fetch wrapper.
*
* Design pattern: "Decorator"
* Wraps native fetch with transparent payment handling. Callers use the
* same API as window.fetch — they don't need to know the 402 handshake
* is happening underneath.
*
* @param config Global configuration (USDC contract address)
* @returns Object with a `fetch` method that handles 402 automatically
*/
export function createX402Client(config: X402ClientConfig) {
// Capture native fetch before any potential override.
// This prevents infinite recursion if this wrapper replaces globalThis.fetch.
const nativeFetch = globalThis.fetch
return {
/**
* Drop-in replacement for fetch() that handles x402 transparently.
*
* The full flow:
* 1. Make the original request (may return 402)
* 2. If 402: parse payment requirements from response body
* 3. Load session key from sessionStorage
* 4. Check budget: will this exceed the session spending cap?
* 5. Build an EIP-3009 transfer authorization
* 6. Sign it with the session key's private key (in-memory, no popup)
* 7. Encode the signature into a base64 HTTP header
* 8. Retry the original request with PAYMENT-SIGNATURE header
* 9. On success, decrement the session budget by the paid amount
*
* @param url Target URL (same signature as native fetch)
* @param options Fetch options: method, headers, body (same as native fetch)
*/
async fetch(url: string, options?: RequestInit): Promise<Response> {
// ── Step 1: Initial Request ────────────────────────────────────────
// Make the call exactly as the caller intended.
// If the server doesn't require payment, we return immediately.
// This wrapper adds ZERO overhead on non-x402 endpoints.
const initialResponse = await nativeFetch(url, options)
// Fast path: anything other than 402 → return as-is.
// This includes 200 (success), 401 (auth), 403 (forbidden), etc.
if (initialResponse.status !== 402) {
return initialResponse
}
// ── Step 2: Parse the 402 Challenge ───────────────────────────────
// The server's 402 body is machine-readable JSON describing exactly
// what payment it requires. This is the "challenge" in the handshake.
//
// `accepts` is an array: the server may accept multiple payment
// schemes or networks. We take the first one (simplification).
// A production client would find the best matching scheme.
const body = await initialResponse.json() as {
accepts?: PaymentRequirements[]
resource?: { url: string; description?: string; mimeType?: string }
}
const requirement = body.accepts?.[0]
if (!requirement) {
// Malformed 402: server returned the status but no requirements.
// This is a server-side bug — we can't proceed without knowing what to pay.
throw new Error('x402: Server returned 402 with no payment requirements in body.')
}
// ── Step 2b: Validate network matches expected ───────────────────
// The client must only pay on networks it supports. If the server
// requires a different network (e.g. Base Sepolia), the client
// cannot fulfill the request. Fail fast instead of signing an
// authorization for the wrong chain.
const SUPPORTED_NETWORKS: string[] = [ARC_TESTNET_CAIP2] // Arc Testnet only
if (!SUPPORTED_NETWORKS.includes(requirement.network)) {
throw new Error(
`x402: Server requires network "${requirement.network}" ` +
`but this client only supports: ${SUPPORTED_NETWORKS.join(', ')}`
)
}
// ── Step 3: Load the Session Key ──────────────────────────────────
// The session key is an ephemeral wallet stored in sessionStorage.
// It holds pre-funded USDC and signs EIP-3009 authorizations silently.
// loadSessionKey() returns null if: no key, key expired, or budget exhausted.
const sessionKey = loadSessionKey()
if (!sessionKey) {
// Throw X402SessionRequired (not a generic Error) so callers can
// detect this specific case and show the approval UI.
throw new X402SessionRequired(requirement)
}
// ── Step 4: Budget Check ──────────────────────────────────────────
// Convert the server's atomic units to a human-readable USDC float.
// CRITICAL: USDC has 6 decimals on Arc. Divide by 1_000_000, NOT 1e18.
// Using 1e18 here would accept payments ~10^12x larger than intended.
const amountUsdc = Number(requirement.amount) / 1_000_000
if (!hasSessionBudget(amountUsdc)) {
const available = sessionKey.config.maxAmountUsdc - sessionKey.spentUsdc
throw new X402InsufficientBudget(amountUsdc, available)
}
// ── Step 5: Build EIP-3009 Transfer Authorization ─────────────────
// We construct the parameters for a transferWithAuthorization call.
// This is NOT a transaction — it's a signed message. The server's
// settler wallet will submit the actual on-chain transaction.
//
// KEY CONSTRAINT (EIP-3009 spec):
// ecrecover(signature) MUST equal auth.from
// The private key that signs must control the 'from' address.
// Since the session key IS the 'from' address (and holds the USDC),
// this constraint is always satisfied in our architecture.
const transferAuth: TransferAuthorization = {
from: sessionKey.address, // session key = USDC source
to: requirement.payTo as `0x${string}`, // treasury from the 402 body
value: BigInt(requirement.amount), // atomic units (6 decimals)
validAfter: BigInt(0), // valid immediately after signing
// validBefore: DERIVE from server's timeout — never hardcode or use 0.
// The USDC contract checks block.timestamp < validBefore on-chain.
// Using 0n = "already expired" = immediate rejection.
validBefore: BigInt(
Math.floor(Date.now() / 1000) + requirement.maxTimeoutSeconds
),
// nonce: cryptographically random bytes32 per authorization.
// The USDC contract rejects any auth whose nonce was already used.
// This prevents replay attacks: a captured header cannot be reused.
nonce: generateNonce(),
}
// ── Step 6: Sign the Authorization ────────────────────────────────
// The session key signs the EIP-712 typed data in-memory.
// This produces a 65-byte secp256k1 ECDSA signature.
// No wallet popup. No network call. Completes in ~1ms.
const signed = await signTransferAuthorization(
transferAuth,
sessionKey,
config.usdcAddress
)
// ── Step 7: Encode Payment Header ─────────────────────────────────
// Serialize the signed authorization as JSON, then base64-encode it.
// Base64 is required because HTTP headers cannot contain raw binary
// or arbitrary special characters.
//
// x402 v2 spec: the PaymentPayload must include `accepted` echoing
// back the server's PaymentRequirements, and optionally `resource`.
const headerValue = encodePaymentHeader(signed, {
scheme: requirement.scheme,
network: requirement.network,
amount: requirement.amount,
asset: requirement.asset,
payTo: requirement.payTo,
maxTimeoutSeconds: requirement.maxTimeoutSeconds,
extra: DEFAULT_EIP3009_EXTRA,
}, body.resource)
// ── Step 8: Retry with Payment ────────────────────────────────────
// Re-send the original request with the payment proof attached.
// We include two header names for backwards compatibility:
// PAYMENT-SIGNATURE — x402 v2 spec (current standard)
// X-PAYMENT — x402 v1 spec (legacy fallback)
// Some deployed servers still check X-PAYMENT; including both
// ensures compatibility without extra round-trips.
const paidResponse = await nativeFetch(url, {
...options,
headers: {
...options?.headers,
'PAYMENT-SIGNATURE': headerValue, // x402 v2 primary header
'X-PAYMENT': headerValue, // x402 v1 fallback header
}
})
// ── Step 9: Accounting + Return ───────────────────────────────────
if (paidResponse.status >= 200 && paidResponse.status < 300) {
// Payment accepted and resource returned.
// Decrement the session budget so we don't overspend.
// This runs client-side as a soft cap; the server enforces
// the hard cap by rejecting invalid/expired authorizations.
recordSpend(amountUsdc)
return paidResponse
}
// Payment was rejected. Throw typed error with the status code.
// Debugging guide:
// 400 on retry → invalid payment (malformed header, bad signature, wrong amount)
// 402 on retry → payment required / replay detected / settlement failed
// 500 → server error during settlement
throw new X402PaymentFailed(paidResponse.status)
}
}
}