diff --git a/.changeset/preserve-x402-retry-headers.md b/.changeset/preserve-x402-retry-headers.md new file mode 100644 index 00000000000..153da36fc42 --- /dev/null +++ b/.changeset/preserve-x402-retry-headers.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Fixed x402 payment retries to preserve effective Request headers and native HeadersInit inputs, including explicit header overrides. diff --git a/packages/thirdweb/src/x402/fetchWithPayment.headers.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.headers.test.ts new file mode 100644 index 00000000000..9465cdc02b4 --- /dev/null +++ b/packages/thirdweb/src/x402/fetchWithPayment.headers.test.ts @@ -0,0 +1,374 @@ +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { generatePrivateKey } from "viem/accounts"; +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, + vi, +} from "vitest"; +import { getCachedChain } from "../chains/utils.js"; +import { createThirdwebClient } from "../client/client.js"; +import type { Wallet } from "../wallets/interfaces/wallet.js"; +import { privateKeyToAccount } from "../wallets/private-key.js"; +import { wrapFetchWithPayment } from "./fetchWithPayment.js"; +import { getPaymentRequestHeader } from "./headers.js"; + +const URL = "https://x402-headers.example/resource"; +const APP_HEADERS = { + Authorization: "Bearer fixture-user", + "X-Tenant": "fixture-tenant", +}; +const server = setupServer(); +const NativeRequest = Request; +const NativeHeaders = Headers; +const client = createThirdwebClient({ clientId: "fixture-client" }); +const account = privateKeyToAccount({ + client, + privateKey: generatePrivateKey(), +}); +const wallet = { + getAccount: () => account, + getChain: () => getCachedChain(84532), +} as Wallet; +const storage = { + getItem: async () => null, + setItem: async () => {}, + removeItem: async () => {}, +}; +// MSW 2.7 merges Request + init headers; normalize first to test native replacement semantics. +const fetchWithNativeRequest: typeof fetch = (input, init) => + fetch(new NativeRequest(input, init)); + +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); + // MSW's raw-header recorder re-reads accessors; keep native conversion semantics. + vi.stubGlobal("Headers", NativeHeaders); +}); +afterEach(() => server.resetHandlers()); +afterAll(() => { + vi.unstubAllGlobals(); + server.close(); +}); + +function snapshotHeaders(headers: Headers): Record { + const snapshot: Record = {}; + headers.forEach((value: string, name: string) => { + snapshot[name] = value; + }); + return snapshot; +} + +function merchant(version: 1 | 2, afterInitial?: () => void): Headers[] { + const received: Headers[] = []; + server.use( + http.get(URL, ({ request }) => { + received.push(new Headers(request.headers)); + if (received.length > 1) return HttpResponse.json({ success: true }); + afterInitial?.(); + const requirements = { + scheme: "exact", + network: version === 1 ? "base-sepolia" : "eip155:84532", + payTo: "0x2222222222222222222222222222222222222222", + asset: "0x3333333333333333333333333333333333333333", + maxTimeoutSeconds: 300, + extra: { + name: "Fixture Token", + version: "1", + primaryType: "TransferWithAuthorization", + }, + }; + const challenge = + version === 1 + ? { + x402Version: 1, + accepts: [ + { + ...requirements, + maxAmountRequired: "1", + resource: URL, + description: "fixture", + mimeType: "application/json", + }, + ], + } + : { + x402Version: 2, + resource: { url: URL }, + accepts: [{ ...requirements, amount: "1" }], + }; + return version === 1 + ? HttpResponse.json(challenge, { status: 402 }) + : new HttpResponse(null, { + status: 402, + headers: { + "PAYMENT-REQUIRED": Buffer.from( + JSON.stringify(challenge), + ).toString("base64"), + }, + }); + }), + ); + return received; +} + +describe.each([1, 2] as const)("x402 v%i retry headers", (version) => { + it.each([false, true])( + "preserves native RequestInit getter order when paid: %s", + async (paid) => { + const events: string[] = []; + let ready = false; + const received = paid ? merchant(version) : undefined; + if (!paid) + server.use(http.get(URL, () => HttpResponse.json({ success: true }))); + const init = Object.freeze({ + get method(): string { + events.push("method"); + ready = true; + return "GET"; + }, + get headers(): HeadersInit { + events.push("headers"); + if (!ready) throw new Error("headers read before method"); + return { + get Authorization(): string { + events.push("Authorization"); + return "Bearer after-method"; + }, + }; + }, + }); + const response = await wrapFetchWithPayment( + fetchWithNativeRequest, + client, + wallet, + { storage }, + )(URL, init); + expect(response.status).toBe(200); + expect(events.slice(0, 3)).toEqual([ + "method", + "headers", + "Authorization", + ]); + expect(events.filter((event) => event === "Authorization")).toHaveLength( + 1, + ); + if (received) + expect(received[1]?.get("Authorization")).toBe("Bearer after-method"); + }, + ); + + it("preserves inherited RequestInit accessor receivers", async () => { + const methods = new WeakMap(); + class Options { + headers = APP_HEADERS; + constructor() { + methods.set(this, "POST"); + } + get method(): string { + const method = methods.get(this); + if (!method) throw new Error("RequestInit getter receiver changed"); + return method; + } + } + let received: string | undefined; + server.use( + http.post(URL, ({ request }) => { + received = request.method; + return HttpResponse.json({ success: true }); + }), + ); + const response = await wrapFetchWithPayment( + fetchWithNativeRequest, + client, + wallet, + { storage }, + )(URL, Object.freeze(new Options())); + expect(response.status).toBe(200); + expect(received).toBe("POST"); + }); + + it("reads accessor-backed header values once for both dispatches", async () => { + const received = merchant(version); + let reads = 0; + const headers = { + get Authorization(): string { + reads++; + if (reads > 1) throw new Error("header read twice"); + return "Bearer fixture-user"; + }, + }; + const response = await wrapFetchWithPayment( + fetchWithNativeRequest, + client, + wallet, + { storage }, + )(URL, { headers }); + expect(response.status).toBe(200); + expect(reads).toBe(1); + expect(received.map((entry) => entry.get("Authorization"))).toEqual([ + "Bearer fixture-user", + "Bearer fixture-user", + ]); + }); + + it.each([false, true])( + "reads an init.headers accessor once when paid: %s", + async (paid) => { + let reads = 0; + let received: string | null = null; + const paidRequests = paid ? merchant(version) : undefined; + if (!paid) + server.use( + http.get(URL, ({ request }) => { + received = request.headers.get("Authorization"); + return HttpResponse.json({ success: true }); + }), + ); + const init = Object.freeze({ + get headers(): HeadersInit { + reads++; + if (reads > 1) throw new Error("init.headers read twice"); + return APP_HEADERS; + }, + }); + const response = await wrapFetchWithPayment( + fetchWithNativeRequest, + client, + wallet, + { storage }, + )(URL, init); + expect(response.status).toBe(200); + expect(reads).toBe(1); + if (paidRequests) { + expect(paidRequests).toHaveLength(2); + expect(paidRequests[1]?.get("Authorization")).toBe( + APP_HEADERS.Authorization, + ); + } else { + expect(received).toBe(APP_HEADERS.Authorization); + } + }, + ); + + it.each(["object", "Headers", "tuples", "Request"] as const)( + "preserves effective %s headers", + async (form) => { + const received = merchant(version); + const headers = + form === "Headers" + ? new Headers(APP_HEADERS) + : form === "tuples" + ? Object.entries(APP_HEADERS) + : APP_HEADERS; + const input = + form === "Request" ? new NativeRequest(URL, { headers }) : URL; + const init = form === "Request" ? undefined : { headers }; + const before = snapshotHeaders(new Headers(headers)); + const response = await wrapFetchWithPayment( + fetchWithNativeRequest, + client, + wallet, + { + storage, + }, + )(input, init); + expect(response.status).toBe(200); + expect(received).toHaveLength(2); + for (const requestHeaders of received) { + expect(requestHeaders.get("Authorization")).toBe( + APP_HEADERS.Authorization, + ); + expect(requestHeaders.get("X-Tenant")).toBe(APP_HEADERS["X-Tenant"]); + } + expect(received[1]?.has(getPaymentRequestHeader(version))).toBe(true); + expect(snapshotHeaders(new Headers(headers))).toEqual(before); + if (input instanceof NativeRequest) { + expect(snapshotHeaders(input.headers)).toEqual(before); + } + }, + ); + + it.each(["object", "Headers", "tuples"] as const)( + "keeps explicit %s overrides as full replacements", + async (form) => { + const received = merchant(version); + const replacement = { "X-Override": "replacement" }; + const headers = + form === "Headers" + ? new Headers(replacement) + : form === "tuples" + ? Object.entries(replacement) + : replacement; + await wrapFetchWithPayment(fetchWithNativeRequest, client, wallet, { + storage, + })(new NativeRequest(URL, { headers: APP_HEADERS }), { headers }); + for (const requestHeaders of received) { + expect(requestHeaders.get("Authorization")).toBeNull(); + expect(requestHeaders.get("X-Tenant")).toBeNull(); + expect(requestHeaders.get("X-Override")).toBe("replacement"); + } + expect(received).toHaveLength(2); + }, + ); + + it.each([ + { name: "object", headers: {} }, + { name: "Headers", headers: new Headers() }, + { name: "tuples", headers: [] }, + ])("keeps an explicit empty $name override", async ({ headers }) => { + const received = merchant(version); + await wrapFetchWithPayment(fetchWithNativeRequest, client, wallet, { + storage, + })(new NativeRequest(URL, { headers: APP_HEADERS }), { headers }); + expect(received).toHaveLength(2); + expect(received[1]?.get("Authorization")).toBeNull(); + expect(received[1]?.get("X-Tenant")).toBeNull(); + }); + + it("inherits Request headers when init.headers is undefined", async () => { + const received = merchant(version); + await wrapFetchWithPayment(fetchWithNativeRequest, client, wallet, { + storage, + })(new NativeRequest(URL, { headers: APP_HEADERS }), { + headers: undefined, + }); + expect(received[1]?.get("Authorization")).toBe(APP_HEADERS.Authorization); + }); + + it("snapshots headers before the first request", async () => { + const headers = new Headers(APP_HEADERS); + const received = merchant(version, () => headers.set("X-Tenant", "later")); + await wrapFetchWithPayment(fetchWithNativeRequest, client, wallet, { + storage, + })(URL, { + headers, + }); + expect(received[0]?.get("X-Tenant")).toBe("fixture-tenant"); + expect(received[1]?.get("X-Tenant")).toBe("fixture-tenant"); + expect(headers.get("X-Tenant")).toBe("later"); + }); + + it("replaces payment headers case-insensitively without mutating input", async () => { + const headers = new Headers(APP_HEADERS); + const paymentHeader = getPaymentRequestHeader(version); + headers.set(paymentHeader.toLowerCase(), "old-payment"); + headers.set("access-control-expose-headers", "old-response"); + const received = merchant(version); + await wrapFetchWithPayment(fetchWithNativeRequest, client, wallet, { + storage, + })(URL, { + headers, + }); + const payment = received[1]?.get(paymentHeader) ?? ""; + expect(payment).not.toContain("old-payment"); + expect( + JSON.parse(Buffer.from(payment, "base64").toString()).x402Version, + ).toBe(version); + expect(headers.get(paymentHeader)).toBe("old-payment"); + expect(headers.get("access-control-expose-headers")).toBe("old-response"); + }); +}); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.test.ts index 98fa6ebb5a2..3fba15d4623 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.test.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.test.ts @@ -133,9 +133,9 @@ describe("wrapFetchWithPayment", () => { x402Version: number, ) { const init = mockFetch.mock.calls[1]?.[1] as RequestInit; - const headers = init.headers as Record; - const header = headers[getPaymentRequestHeader(x402Version)]; - expect(header).toBeDefined(); + const headers = new Headers(init.headers); + const header = headers.get(getPaymentRequestHeader(x402Version)); + expect(header).not.toBeNull(); return JSON.parse( Buffer.from(header as string, "base64").toString("utf-8"), ); @@ -195,9 +195,11 @@ describe("wrapFetchWithPayment", () => { // Verify the second call includes the payment header for the version const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(secondCallInit.headers).toHaveProperty( - getPaymentRequestHeader(mock402ResponseData.x402Version), - ); + expect( + new Headers(secondCallInit.headers).has( + getPaymentRequestHeader(mock402ResponseData.x402Version), + ), + ).toBe(true); }); it("should parse payment requirements from JSON body when payment-required header is absent", async () => { @@ -218,9 +220,11 @@ describe("wrapFetchWithPayment", () => { // Verify the second call includes the payment header for the version const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(secondCallInit.headers).toHaveProperty( - getPaymentRequestHeader(mock402ResponseData.x402Version), - ); + expect( + new Headers(secondCallInit.headers).has( + getPaymentRequestHeader(mock402ResponseData.x402Version), + ), + ).toBe(true); }); it("should prefer payment-required header over JSON body when both are present", async () => { @@ -318,9 +322,11 @@ describe("wrapFetchWithPayment", () => { // Verify the retry request was made with the v1 payment header const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(secondCallInit.headers).toHaveProperty( - getPaymentRequestHeader(parsed.x402Version), - ); + expect( + new Headers(secondCallInit.headers).has( + getPaymentRequestHeader(parsed.x402Version), + ), + ).toBe(true); }); it("does not sign when a payment was already attempted for the request", async () => { @@ -374,8 +380,8 @@ describe("wrapFetchWithPayment", () => { expect(typedData.message.to).toBe(getAddress(PAY_TO)); const init = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(init.headers).toHaveProperty("PAYMENT-SIGNATURE"); - expect(init.headers).not.toHaveProperty("X-PAYMENT"); + expect(new Headers(init.headers).has("PAYMENT-SIGNATURE")).toBe(true); + expect(new Headers(init.headers).has("X-PAYMENT")).toBe(false); const payment = sentPayment(mockFetch, 2); expect(payment.x402Version).toBe(2); @@ -520,8 +526,8 @@ describe("wrapFetchWithPayment", () => { expect(response.status).toBe(200); const init = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(init.headers).toHaveProperty("X-PAYMENT"); - expect(init.headers).not.toHaveProperty("PAYMENT-SIGNATURE"); + expect(new Headers(init.headers).has("X-PAYMENT")).toBe(true); + expect(new Headers(init.headers).has("PAYMENT-SIGNATURE")).toBe(false); const payment = sentPayment(mockFetch, 1); expect(Object.keys(payment).sort()).toEqual( @@ -549,7 +555,7 @@ describe("wrapFetchWithPayment", () => { await wrappedFetch("https://api.example.com/resource"); const init = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(init.headers).toHaveProperty("PAYMENT-SIGNATURE"); + expect(new Headers(init.headers).has("PAYMENT-SIGNATURE")).toBe(true); const payment = sentPayment(mockFetch, 2); expect(payment.x402Version).toBe(2); expect(payment.accepted).toEqual(canonicalV2Requirement); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 5243e4a6691..2aa26145aab 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -80,13 +80,54 @@ export function wrapFetchWithPayment( }, ) { return async (input: RequestInfo, init?: RequestInit) => { - const response = await fetch(input, init); + let initialHeaders: Headers | undefined; + let normalizedHeaders: Headers | undefined; + let headersRead = false; + function readHeaders(): Headers | undefined { + if (!headersRead) { + const providedHeaders = init?.headers; + initialHeaders = new Headers( + providedHeaders !== undefined + ? providedHeaders + : typeof input === "string" + ? undefined + : input.headers, + ); + normalizedHeaders = + providedHeaders === undefined ? undefined : initialHeaders; + headersRead = true; + } + return normalizedHeaders; + } + let initialInit = init; + if (init) { + // Normalize on fetch's first headers read to preserve native getter order. + const descriptors = Object.getOwnPropertyDescriptors(init); + descriptors.headers = { + configurable: true, + enumerable: true, + get: readHeaders, + }; + // Keep other accessors bound to the caller's original init object. + initialInit = new Proxy( + Object.create(Object.getPrototypeOf(init), descriptors) as RequestInit, + { + get: (_target, property) => + property === "headers" + ? readHeaders() + : Reflect.get(init, property, init), + }, + ); + } + if (!init) readHeaders(); + const response = await fetch(input, initialInit); if (response.status !== 402) { return response; } - const initParams = init || {}; + readHeaders(); + const initParams = initialInit || {}; if ((initParams as { __is402Retry?: boolean }).__is402Retry) { throw new Error("Payment already attempted"); @@ -186,13 +227,13 @@ export function wrapFetchWithPayment( const paymentRequestHeaderName = getPaymentRequestHeader(x402Version); const paymentResponseHeaderName = getPaymentResponseHeader(x402Version); + const headers = new Headers(initialHeaders); + headers.set(paymentRequestHeaderName, paymentHeader); + headers.set("Access-Control-Expose-Headers", paymentResponseHeaderName); + const newInit = { ...initParams, - headers: { - ...(initParams.headers || {}), - [paymentRequestHeaderName]: paymentHeader, - "Access-Control-Expose-Headers": paymentResponseHeaderName, - }, + headers, __is402Retry: true, };