Skip to content

Commit 5766c90

Browse files
[SDK] Fix: Parse x402 payment header (#8617)
Co-authored-by: Joaquim Verges <joaquim.verges@gmail.com>
1 parent 8d4b6a1 commit 5766c90

4 files changed

Lines changed: 355 additions & 9 deletions

File tree

.changeset/social-ads-arrive.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"thirdweb": patch
3+
---
4+
5+
Support for x402 payment-required headers

packages/thirdweb/src/x402/encode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,7 @@ export function safeBase64Encode(data: string): string {
6666
* @param data - The base64 encoded string to be decoded
6767
* @returns The decoded string in UTF-8 format
6868
*/
69-
function safeBase64Decode(data: string): string {
69+
export function safeBase64Decode(data: string): string {
7070
if (
7171
typeof globalThis !== "undefined" &&
7272
typeof globalThis.atob === "function"
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
import { safeBase64Decode, safeBase64Encode } from "./encode.js";
3+
import { wrapFetchWithPayment } from "./fetchWithPayment.js";
4+
5+
// Mock the createPaymentHeader function
6+
vi.mock("./sign.js", () => ({
7+
createPaymentHeader: vi.fn().mockResolvedValue("mock-payment-header"),
8+
}));
9+
10+
// Mock webLocalStorage
11+
vi.mock("../utils/storage/webStorage.js", () => ({
12+
webLocalStorage: {
13+
getItem: vi.fn(),
14+
setItem: vi.fn(),
15+
removeItem: vi.fn(),
16+
},
17+
}));
18+
19+
describe("wrapFetchWithPayment", () => {
20+
const mockPaymentRequirements = {
21+
scheme: "exact",
22+
network: "eip155:1",
23+
maxAmountRequired: "1000000",
24+
resource: "https://api.example.com/resource",
25+
description: "Test payment",
26+
mimeType: "application/json",
27+
payTo: "0x1234567890123456789012345678901234567890",
28+
maxTimeoutSeconds: 300,
29+
asset: "0x0000000000000000000000000000000000000001",
30+
extra: {
31+
name: "Test Token",
32+
version: "1",
33+
},
34+
};
35+
36+
const mock402ResponseData = {
37+
x402Version: 1,
38+
accepts: [mockPaymentRequirements],
39+
error: undefined,
40+
};
41+
42+
const mockClient = {
43+
clientId: "test-client-id",
44+
} as Parameters<typeof wrapFetchWithPayment>[1];
45+
46+
const mockAccount = {
47+
address: "0x1234567890123456789012345678901234567890",
48+
signTypedData: vi.fn(),
49+
};
50+
51+
const mockWallet = {
52+
getAccount: vi.fn().mockReturnValue(mockAccount),
53+
getChain: vi.fn().mockReturnValue({ id: 1 }),
54+
switchChain: vi.fn(),
55+
} as unknown as Parameters<typeof wrapFetchWithPayment>[2];
56+
57+
beforeEach(() => {
58+
vi.clearAllMocks();
59+
});
60+
61+
it("should pass through non-402 responses unchanged", async () => {
62+
const mockResponse = new Response(JSON.stringify({ data: "test" }), {
63+
status: 200,
64+
});
65+
const mockFetch = vi.fn().mockResolvedValue(mockResponse);
66+
67+
const wrappedFetch = wrapFetchWithPayment(
68+
mockFetch,
69+
mockClient,
70+
mockWallet,
71+
);
72+
const response = await wrappedFetch("https://api.example.com/resource");
73+
74+
expect(response.status).toBe(200);
75+
expect(mockFetch).toHaveBeenCalledTimes(1);
76+
});
77+
78+
it("should parse payment requirements from payment-required header when present", async () => {
79+
const encodedPaymentInfo = safeBase64Encode(
80+
JSON.stringify(mock402ResponseData),
81+
);
82+
83+
const mock402Response = new Response(null, {
84+
status: 402,
85+
headers: {
86+
"payment-required": encodedPaymentInfo,
87+
},
88+
});
89+
90+
const mockSuccessResponse = new Response(
91+
JSON.stringify({ success: true }),
92+
{
93+
status: 200,
94+
},
95+
);
96+
97+
const mockFetch = vi
98+
.fn()
99+
.mockResolvedValueOnce(mock402Response)
100+
.mockResolvedValueOnce(mockSuccessResponse);
101+
102+
const wrappedFetch = wrapFetchWithPayment(
103+
mockFetch,
104+
mockClient,
105+
mockWallet,
106+
);
107+
const response = await wrappedFetch("https://api.example.com/resource");
108+
109+
expect(response.status).toBe(200);
110+
expect(mockFetch).toHaveBeenCalledTimes(2);
111+
112+
// Verify the second call includes the X-PAYMENT header
113+
const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit;
114+
expect(secondCallInit.headers).toHaveProperty("X-PAYMENT");
115+
});
116+
117+
it("should parse payment requirements from JSON body when payment-required header is absent", async () => {
118+
const mock402Response = new Response(JSON.stringify(mock402ResponseData), {
119+
status: 402,
120+
});
121+
122+
const mockSuccessResponse = new Response(
123+
JSON.stringify({ success: true }),
124+
{
125+
status: 200,
126+
},
127+
);
128+
129+
const mockFetch = vi
130+
.fn()
131+
.mockResolvedValueOnce(mock402Response)
132+
.mockResolvedValueOnce(mockSuccessResponse);
133+
134+
const wrappedFetch = wrapFetchWithPayment(
135+
mockFetch,
136+
mockClient,
137+
mockWallet,
138+
);
139+
const response = await wrappedFetch("https://api.example.com/resource");
140+
141+
expect(response.status).toBe(200);
142+
expect(mockFetch).toHaveBeenCalledTimes(2);
143+
144+
// Verify the second call includes the X-PAYMENT header
145+
const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit;
146+
expect(secondCallInit.headers).toHaveProperty("X-PAYMENT");
147+
});
148+
149+
it("should prefer payment-required header over JSON body when both are present", async () => {
150+
const headerPaymentRequirements = {
151+
...mockPaymentRequirements,
152+
maxAmountRequired: "500000", // Different amount to verify header is used
153+
};
154+
const headerResponseData = {
155+
x402Version: 1,
156+
accepts: [headerPaymentRequirements],
157+
};
158+
159+
const bodyResponseData = {
160+
x402Version: 1,
161+
accepts: [{ ...mockPaymentRequirements, maxAmountRequired: "2000000" }],
162+
};
163+
164+
const encodedPaymentInfo = safeBase64Encode(
165+
JSON.stringify(headerResponseData),
166+
);
167+
168+
// Create response with both header and body
169+
const mock402Response = new Response(JSON.stringify(bodyResponseData), {
170+
status: 402,
171+
headers: {
172+
"payment-required": encodedPaymentInfo,
173+
},
174+
});
175+
176+
const mockSuccessResponse = new Response(
177+
JSON.stringify({ success: true }),
178+
{
179+
status: 200,
180+
},
181+
);
182+
183+
const mockFetch = vi
184+
.fn()
185+
.mockResolvedValueOnce(mock402Response)
186+
.mockResolvedValueOnce(mockSuccessResponse);
187+
188+
// Use maxValue to verify which payment requirements are used
189+
// If header is used (500000), it should pass
190+
// If body is used (2000000), it would exceed maxValue
191+
const wrappedFetch = wrapFetchWithPayment(
192+
mockFetch,
193+
mockClient,
194+
mockWallet,
195+
{
196+
maxValue: BigInt(1000000),
197+
},
198+
);
199+
200+
const response = await wrappedFetch("https://api.example.com/resource");
201+
202+
// Should succeed because header value (500000) is under maxValue (1000000)
203+
expect(response.status).toBe(200);
204+
});
205+
206+
it("should parse payment requirements from payment-required header", async () => {
207+
const encodedPaymentInfo = safeBase64Encode(
208+
JSON.stringify(mock402ResponseData),
209+
);
210+
211+
const mock402Response = new Response(null, {
212+
status: 402,
213+
headers: {
214+
"payment-required": encodedPaymentInfo,
215+
},
216+
});
217+
218+
const mockSuccessResponse = new Response(
219+
JSON.stringify({ success: true }),
220+
{
221+
status: 200,
222+
},
223+
);
224+
225+
const mockFetch = vi
226+
.fn()
227+
.mockResolvedValueOnce(mock402Response)
228+
.mockResolvedValueOnce(mockSuccessResponse);
229+
230+
const wrappedFetch = wrapFetchWithPayment(
231+
mockFetch,
232+
mockClient,
233+
mockWallet,
234+
);
235+
const response = await wrappedFetch("https://api.example.com/resource");
236+
237+
expect(response.status).toBe(200);
238+
expect(mockFetch).toHaveBeenCalledTimes(2);
239+
240+
// Verify the second call includes the X-PAYMENT header
241+
const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit;
242+
expect(secondCallInit.headers).toHaveProperty("X-PAYMENT");
243+
});
244+
245+
it("should correctly decode a raw base64 encoded payment-required header", async () => {
246+
// This is an actual base64 encoded payment requirements header
247+
// Original JSON: {"x402Version":1,"accepts":[{"scheme":"exact","network":"eip155:8453","maxAmountRequired":"100000","resource":"https://example.com/api","description":"API access","mimeType":"application/json","payTo":"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045","maxTimeoutSeconds":300,"asset":"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913","extra":{"name":"USD Coin","version":"2"}}]}
248+
const rawBase64Header =
249+
"eyJ4NDAyVmVyc2lvbiI6MSwiYWNjZXB0cyI6W3sic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMiLCJtYXhBbW91bnRSZXF1aXJlZCI6IjEwMDAwMCIsInJlc291cmNlIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9hcGkiLCJkZXNjcmlwdGlvbiI6IkFQSSBhY2Nlc3MiLCJtaW1lVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJwYXlUbyI6IjB4ZDhkQTZCRjI2OTY0YUY5RDdlRWQ5ZTAzRTUzNDE1RDM3YUE5NjA0NSIsIm1heFRpbWVvdXRTZWNvbmRzIjozMDAsImFzc2V0IjoiMHg4MzM1ODlmQ0Q2ZURiNkUwOGY0YzdDMzJENGY3MWI1NGJkQTAyOTEzIiwiZXh0cmEiOnsibmFtZSI6IlVTRCBDb2luIiwidmVyc2lvbiI6IjIifX1dfQ==";
250+
251+
// Verify the base64 decodes to valid JSON
252+
const decoded = safeBase64Decode(rawBase64Header);
253+
const parsed = JSON.parse(decoded);
254+
255+
expect(parsed.x402Version).toBe(1);
256+
expect(parsed.accepts).toHaveLength(1);
257+
expect(parsed.accepts[0].network).toBe("eip155:8453");
258+
expect(parsed.accepts[0].maxAmountRequired).toBe("100000");
259+
expect(parsed.accepts[0].payTo).toBe(
260+
"0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045",
261+
);
262+
263+
// Now test the full flow with this raw header
264+
const mock402Response = new Response(null, {
265+
status: 402,
266+
headers: {
267+
"payment-required": rawBase64Header,
268+
},
269+
});
270+
271+
const mockSuccessResponse = new Response(
272+
JSON.stringify({ success: true }),
273+
{
274+
status: 200,
275+
},
276+
);
277+
278+
const mockFetch = vi
279+
.fn()
280+
.mockResolvedValueOnce(mock402Response)
281+
.mockResolvedValueOnce(mockSuccessResponse);
282+
283+
// Use a wallet on Base (chain 8453) to match the payment requirements
284+
const baseWallet = {
285+
getAccount: vi.fn().mockReturnValue(mockAccount),
286+
getChain: vi.fn().mockReturnValue({ id: 8453 }),
287+
switchChain: vi.fn(),
288+
} as unknown as Parameters<typeof wrapFetchWithPayment>[2];
289+
290+
const wrappedFetch = wrapFetchWithPayment(
291+
mockFetch,
292+
mockClient,
293+
baseWallet,
294+
);
295+
const response = await wrappedFetch("https://example.com/api");
296+
297+
expect(response.status).toBe(200);
298+
expect(mockFetch).toHaveBeenCalledTimes(2);
299+
300+
// Verify the retry request was made with X-PAYMENT header
301+
const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit;
302+
expect(secondCallInit.headers).toHaveProperty("X-PAYMENT");
303+
});
304+
});

packages/thirdweb/src/x402/fetchWithPayment.ts

Lines changed: 45 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { getAddress } from "../utils/address.js";
44
import type { AsyncStorage } from "../utils/storage/AsyncStorage.js";
55
import { webLocalStorage } from "../utils/storage/webStorage.js";
66
import type { Wallet } from "../wallets/interfaces/wallet.js";
7+
import { safeBase64Decode } from "./encode.js";
78
import { clearPermitSignatureFromCache } from "./permitSignatureStorage.js";
89
import {
910
extractEvmChainId,
@@ -75,14 +76,50 @@ export function wrapFetchWithPayment(
7576
return response;
7677
}
7778

78-
const { x402Version, accepts, error } = (await response.json()) as {
79-
x402Version: number;
80-
accepts: unknown[];
81-
error?: string;
82-
};
83-
const parsedPaymentRequirements = accepts.map((x) =>
84-
RequestedPaymentRequirementsSchema.parse(x),
85-
);
79+
let x402Version: number;
80+
let parsedPaymentRequirements: RequestedPaymentRequirements[];
81+
let error: string | undefined;
82+
83+
// Check payment-required header first before falling back to JSON body
84+
const paymentRequiredHeader = response.headers.get("payment-required");
85+
if (paymentRequiredHeader) {
86+
const decoded = safeBase64Decode(paymentRequiredHeader);
87+
const parsed = JSON.parse(decoded) as {
88+
x402Version: number;
89+
accepts: unknown[];
90+
error?: string;
91+
};
92+
93+
if (!Array.isArray(parsed.accepts)) {
94+
throw new Error(
95+
`402 response has no usable x402 payment requirements. ${parsed.error ?? ""}`,
96+
);
97+
}
98+
99+
x402Version = parsed.x402Version;
100+
parsedPaymentRequirements = parsed.accepts.map((x) =>
101+
RequestedPaymentRequirementsSchema.parse(x),
102+
);
103+
error = parsed.error;
104+
} else {
105+
const body = (await response.json()) as {
106+
x402Version: number;
107+
accepts: unknown[];
108+
error?: string;
109+
};
110+
111+
if (!Array.isArray(body.accepts)) {
112+
throw new Error(
113+
`402 response has no usable x402 payment requirements. ${body.error ?? ""}`,
114+
);
115+
}
116+
117+
x402Version = body.x402Version;
118+
parsedPaymentRequirements = body.accepts.map((x) =>
119+
RequestedPaymentRequirementsSchema.parse(x),
120+
);
121+
error = body.error;
122+
}
86123

87124
const account = wallet.getAccount();
88125
let chain = wallet.getChain();

0 commit comments

Comments
 (0)