From 155a10e91416c788d6e96a3795514e7eff81a32f Mon Sep 17 00:00:00 2001 From: snuff12 <62332131+snuff12@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:41:26 +0900 Subject: [PATCH 1/6] fix(x402): reserve Request bodies before payment retry --- .changeset/replay-x402-request-bodies.md | 5 + .../x402/fetchWithPayment.requestBody.test.ts | 238 ++++++++++++++++++ .../thirdweb/src/x402/fetchWithPayment.ts | 42 +++- 3 files changed, 283 insertions(+), 2 deletions(-) create mode 100644 .changeset/replay-x402-request-bodies.md create mode 100644 packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts diff --git a/.changeset/replay-x402-request-bodies.md b/.changeset/replay-x402-request-bodies.md new file mode 100644 index 00000000000..d5d7faf8ccd --- /dev/null +++ b/.changeset/replay-x402-request-bodies.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Fixed x402 payment retries for Request-owned bodies by reserving a bounded-body retry copy before the first fetch and releasing unused copies. diff --git a/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts new file mode 100644 index 00000000000..c749882953a --- /dev/null +++ b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts @@ -0,0 +1,238 @@ +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"; + +const URL = "https://x402-body.example/resource"; +const BODY = '{"message":"bounded request body"}'; +const NativeRequest = Request; +const server = setupServer(); +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 () => {}, +}; + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => { + server.resetHandlers(); + vi.restoreAllMocks(); +}); +afterAll(() => server.close()); + +function merchant(version: 1 | 2, non402 = false): string[] { + const received: string[] = []; + server.use( + http.all(URL, async ({ request }) => { + received.push(await request.text()); + if (non402 || received.length > 1) { + return HttpResponse.json({ success: true }); + } + const requirement = { + 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: [ + { + ...requirement, + maxAmountRequired: "1", + resource: URL, + description: "fixture", + mimeType: "application/json", + }, + ], + } + : { + x402Version: 2, + resource: { url: URL }, + accepts: [{ ...requirement, 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; +} + +function transport(observed: Request[] = []): typeof fetch { + return (input, init) => { + // Keep native Request semantics before MSW intercepts the transport. + const request = new NativeRequest(input, init); + observed.push(request); + return fetch(request); + }; +} + +describe.each([1, 2] as const)("x402 v%i Request body retries", (version) => { + it("replays a fresh JSON Request after the first fetch consumes it", async () => { + const received = merchant(version); + const input = new NativeRequest(URL, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: BODY, + }); + expect(input.bodyUsed).toBe(false); + const response = await wrapFetchWithPayment(transport(), client, wallet, { + storage, + })(input, { headers: { "Content-Type": "application/json" } }); + expect(response.status).toBe(200); + expect(received).toEqual([BODY, BODY]); + expect(input.bodyUsed).toBe(true); + }); + + it("inherits a Request body when init.body is null", async () => { + const received = merchant(version); + await wrapFetchWithPayment(transport(), client, wallet, { storage })( + new NativeRequest(URL, { method: "POST", body: BODY }), + { body: null }, + ); + expect(received).toEqual([BODY, BODY]); + }); + + it("preserves effective Request options and caller cancellation", async () => { + const received = merchant(version); + const controller = new AbortController(); + const observed: Request[] = []; + const input = new NativeRequest(URL, { + method: "POST", + body: BODY, + cache: "no-store", + credentials: "include", + redirect: "manual", + referrer: "https://client.example/context", + referrerPolicy: "origin", + signal: controller.signal, + }); + await wrapFetchWithPayment(transport(observed), client, wallet, { + storage, + })(input); + expect(received).toEqual([BODY, BODY]); + expect(observed).toHaveLength(2); + for (const request of observed) { + expect(request.cache).toBe("no-store"); + expect(request.credentials).toBe("include"); + expect(request.redirect).toBe("manual"); + expect(request.referrer).toBe("https://client.example/context"); + expect(request.referrerPolicy).toBe("origin"); + } + controller.abort(); + expect(observed.every((request) => request.signal.aborted)).toBe(true); + }); + + it("keeps non-402 responses as a single dispatch without signing", async () => { + const received = merchant(version, true); + const sign = vi.spyOn(account, "signTypedData"); + const response = await wrapFetchWithPayment(transport(), client, wallet, { + storage, + })(new NativeRequest(URL, { method: "POST", body: BODY })); + expect(response.status).toBe(200); + expect(received).toEqual([BODY]); + expect(sign).not.toHaveBeenCalled(); + }); + + it("preserves URL input with a replayable init.body", async () => { + const received = merchant(version); + await wrapFetchWithPayment(transport(), client, wallet, { storage })(URL, { + method: "POST", + body: BODY, + }); + expect(received).toEqual([BODY, BODY]); + }); + + it.each([false, true])( + "allows an explicit init.body override when the input was consumed: %s", + async (consumed) => { + const received = merchant(version); + const input = new NativeRequest(URL, { + method: "POST", + body: "original", + }); + if (consumed) await input.text(); + await wrapFetchWithPayment(transport(), client, wallet, { storage })( + input, + { + body: BODY, + }, + ); + expect(received).toEqual([BODY, BODY]); + }, + ); + + it("rejects failed payment signing without dispatching a retry", async () => { + const received = merchant(version); + const error = new Error("signing declined"); + vi.spyOn(account, "signTypedData").mockRejectedValueOnce(error); + await expect( + wrapFetchWithPayment(transport(), client, wallet, { storage })( + new NativeRequest(URL, { method: "POST", body: BODY }), + ), + ).rejects.toBe(error); + expect(received).toEqual([BODY]); + }); + + it("rejects a payment over maxValue without dispatching a retry", async () => { + const received = merchant(version); + await expect( + wrapFetchWithPayment(transport(), client, wallet, { + storage, + maxValue: 0n, + })(new NativeRequest(URL, { method: "POST", body: BODY })), + ).rejects.toThrow("exceeds maximum"); + expect(received).toEqual([BODY]); + }); +}); + +it("propagates an initial fetch rejection without consuming the caller body", async () => { + const input = new NativeRequest(URL, { method: "POST", body: BODY }); + const error = new Error("transport unavailable"); + const failingFetch: typeof fetch = async () => { + throw error; + }; + await expect( + wrapFetchWithPayment(failingFetch, client, wallet, { storage })(input), + ).rejects.toBe(error); + expect(input.bodyUsed).toBe(false); + expect(await input.text()).toBe(BODY); +}); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 5243e4a6691..96f41d511ff 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -31,6 +31,10 @@ import { x402Version as defaultX402Version } from "./types.js"; * 4. Create a payment header using the provided wallet client * 5. Retry the request with the payment header * + * A Request-owned body is cloned before dispatch for one payment retry. + * Request.clone() can buffer its unread branch; use bounded bodies. Bodies + * supplied through init.body must remain replayable for a retry. + * * @param fetch - The fetch function to wrap (typically globalThis.fetch) * @param client - The thirdweb client used to access RPC infrastructure * @param wallet - The wallet used to sign payment messages @@ -79,7 +83,11 @@ export function wrapFetchWithPayment( storage?: AsyncStorage; }, ) { - return async (input: RequestInfo, init?: RequestInit) => { + const fetchWithPayment = async ( + input: RequestInfo, + init?: RequestInit, + retryRequest?: Request, + ): Promise => { const response = await fetch(input, init); if (response.status !== 402) { @@ -196,7 +204,19 @@ export function wrapFetchWithPayment( __is402Retry: true, }; - const secondResponse = await fetch(input, newInit); + if (retryRequest) { + retryRequest.headers.set(paymentRequestHeaderName, paymentHeader); + retryRequest.headers.set( + "Access-Control-Expose-Headers", + paymentResponseHeaderName, + ); + } + const retryMetadata: RequestInit & { __is402Retry: boolean } = { + __is402Retry: true, + }; + const secondResponse = retryRequest + ? await fetch(retryRequest, retryMetadata) + : await fetch(input, newInit); // If payment was rejected (still 402), clear cached signature if (secondResponse.status === 402) { @@ -210,6 +230,24 @@ export function wrapFetchWithPayment( return secondResponse; }; + + return async (input: RequestInfo, init?: RequestInit): Promise => { + const retryRequest = + typeof input !== "string" && + input.body !== null && + input.body !== undefined && + (init?.body === undefined || init.body === null) + ? new Request(input.clone(), init) + : undefined; + try { + return await fetchWithPayment(input, init, retryRequest); + } finally { + if (retryRequest?.body && !retryRequest.bodyUsed) { + // Tee cancellation may wait for the first upload; do not await it here. + void retryRequest.body.cancel().catch(() => {}); + } + } + }; } /** From 4e8d11036683bd7525f6778edcedfa69b4d5c8ee Mon Sep 17 00:00:00 2001 From: snuff12 <62332131+snuff12@users.noreply.github.com> Date: Mon, 2 Nov 2026 15:54:00 +0900 Subject: [PATCH 2/6] fix(x402): release retry source when Request construction fails --- .../x402/fetchWithPayment.requestBody.test.ts | 31 +++++++++++++++++++ .../thirdweb/src/x402/fetchWithPayment.ts | 23 ++++++++------ 2 files changed, 45 insertions(+), 9 deletions(-) diff --git a/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts index c749882953a..1f960f5aecf 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts @@ -236,3 +236,34 @@ it("propagates an initial fetch rejection without consuming the caller body", as expect(input.bodyUsed).toBe(false); expect(await input.text()).toBe(BODY); }); + +it("releases the intermediate clone when Request options are invalid", async () => { + const cancel = vi.fn(); + const body = new ReadableStream({ cancel }); + const requestInit: RequestInit & { duplex: "half" } = { + method: "POST", + body, + duplex: "half", + }; + const input = new NativeRequest(URL, requestInit); + await expect( + wrapFetchWithPayment(transport(), client, wallet, { storage })(input, { + method: "GET", + }), + ).rejects.toThrow(); + let timeout: ReturnType | undefined; + try { + await Promise.race([ + input.body?.cancel(), + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("cancel did not finish")), + 500, + ); + }), + ]); + } finally { + clearTimeout(timeout); + } + expect(cancel).toHaveBeenCalledOnce(); +}); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 96f41d511ff..1ae6189c71e 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -232,19 +232,24 @@ export function wrapFetchWithPayment( }; return async (input: RequestInfo, init?: RequestInit): Promise => { - const retryRequest = - typeof input !== "string" && - input.body !== null && - input.body !== undefined && - (init?.body === undefined || init.body === null) - ? new Request(input.clone(), init) - : undefined; + let retrySource: Request | undefined; + let retryRequest: Request | undefined; try { + if ( + typeof input !== "string" && + input.body !== null && + input.body !== undefined && + (init?.body === undefined || init.body === null) + ) { + retrySource = input.clone(); + retryRequest = new Request(retrySource, init); + } return await fetchWithPayment(input, init, retryRequest); } finally { - if (retryRequest?.body && !retryRequest.bodyUsed) { + const unusedRequest = retryRequest ?? retrySource; + if (unusedRequest?.body && !unusedRequest.bodyUsed) { // Tee cancellation may wait for the first upload; do not await it here. - void retryRequest.body.cancel().catch(() => {}); + void unusedRequest.body.cancel().catch(() => {}); } } }; From 1411d0fdc45a6435657eae1c4c27052212ff6fea Mon Sep 17 00:00:00 2001 From: snuff12 <62332131+snuff12@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:14:55 +0900 Subject: [PATCH 3/6] fix(x402): defer retry options until a payment is required --- .../x402/fetchWithPayment.requestBody.test.ts | 52 ++++++++++++++++++- .../thirdweb/src/x402/fetchWithPayment.ts | 46 +++++++++------- 2 files changed, 76 insertions(+), 22 deletions(-) diff --git a/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts index 1f960f5aecf..82d285d2cd4 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts @@ -19,6 +19,7 @@ import { wrapFetchWithPayment } from "./fetchWithPayment.js"; const URL = "https://x402-body.example/resource"; const BODY = '{"message":"bounded request body"}'; const NativeRequest = Request; +const NativeHeaders = Headers; const server = setupServer(); const client = createThirdwebClient({ clientId: "fixture-client" }); const account = privateKeyToAccount({ @@ -35,12 +36,20 @@ const storage = { removeItem: async () => {}, }; -beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +beforeAll(() => { + server.listen({ onUnhandledRequest: "error" }); + // Keep native conversion counts when this test runs with header normalization. + vi.stubGlobal("Request", NativeRequest); + vi.stubGlobal("Headers", NativeHeaders); +}); afterEach(() => { server.resetHandlers(); vi.restoreAllMocks(); }); -afterAll(() => server.close()); +afterAll(() => { + vi.unstubAllGlobals(); + server.close(); +}); function merchant(version: 1 | 2, non402 = false): string[] { const received: string[] = []; @@ -106,6 +115,45 @@ function transport(observed: Request[] = []): typeof fetch { } describe.each([1, 2] as const)("x402 v%i Request body retries", (version) => { + it("does not pre-read an init.body override accessor", async () => { + const received = merchant(version, true); + let reads = 0; + const input = new NativeRequest(URL, { method: "POST", body: "original" }); + const init = { + get body(): string { + reads++; + if (reads > 1) throw new Error("body read twice"); + return BODY; + }, + }; + const response = await wrapFetchWithPayment(transport(), client, wallet, { + storage, + })(input, init); + expect(response.status).toBe(200); + expect(reads).toBe(1); + expect(received).toEqual([BODY]); + expect(input.bodyUsed).toBe(false); + await input.body?.cancel(); + }); + + it("keeps non-402 header accessors as a single native conversion", async () => { + const received = merchant(version, true); + 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(transport(), client, wallet, { + storage, + })(new NativeRequest(URL, { method: "POST", body: BODY }), { headers }); + expect(response.status).toBe(200); + expect(reads).toBe(1); + expect(received).toEqual([BODY]); + }); + it("replays a fresh JSON Request after the first fetch consumes it", async () => { const received = merchant(version); const input = new NativeRequest(URL, { diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 1ae6189c71e..98da7ab6ce6 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -204,9 +204,12 @@ export function wrapFetchWithPayment( __is402Retry: true, }; - if (retryRequest) { - retryRequest.headers.set(paymentRequestHeaderName, paymentHeader); - retryRequest.headers.set( + const paymentRequest = retryRequest + ? new Request(retryRequest, init) + : undefined; + if (paymentRequest) { + paymentRequest.headers.set(paymentRequestHeaderName, paymentHeader); + paymentRequest.headers.set( "Access-Control-Expose-Headers", paymentResponseHeaderName, ); @@ -214,9 +217,16 @@ export function wrapFetchWithPayment( const retryMetadata: RequestInit & { __is402Retry: boolean } = { __is402Retry: true, }; - const secondResponse = retryRequest - ? await fetch(retryRequest, retryMetadata) - : await fetch(input, newInit); + let secondResponse: Response; + try { + secondResponse = paymentRequest + ? await fetch(paymentRequest, retryMetadata) + : await fetch(input, newInit); + } finally { + if (paymentRequest?.body && !paymentRequest.bodyUsed) { + void paymentRequest.body.cancel().catch(() => {}); + } + } // If payment was rejected (still 402), clear cached signature if (secondResponse.status === 402) { @@ -232,24 +242,20 @@ export function wrapFetchWithPayment( }; return async (input: RequestInfo, init?: RequestInit): Promise => { - let retrySource: Request | undefined; - let retryRequest: Request | undefined; + const retryRequest = + typeof input !== "string" && + input.body !== null && + input.body !== undefined && + !input.bodyUsed && + !input.body.locked + ? input.clone() + : undefined; try { - if ( - typeof input !== "string" && - input.body !== null && - input.body !== undefined && - (init?.body === undefined || init.body === null) - ) { - retrySource = input.clone(); - retryRequest = new Request(retrySource, init); - } return await fetchWithPayment(input, init, retryRequest); } finally { - const unusedRequest = retryRequest ?? retrySource; - if (unusedRequest?.body && !unusedRequest.bodyUsed) { + if (retryRequest?.body && !retryRequest.bodyUsed) { // Tee cancellation may wait for the first upload; do not await it here. - void unusedRequest.body.cancel().catch(() => {}); + void retryRequest.body.cancel().catch(() => {}); } } }; From db97aeba06b6b59cba16a32fb156b22fc33e0a9d Mon Sep 17 00:00:00 2001 From: snuff12 <62332131+snuff12@users.noreply.github.com> Date: Mon, 2 Nov 2026 16:24:59 +0900 Subject: [PATCH 4/6] fix(x402): materialize retry options only in the used branch --- .../x402/fetchWithPayment.requestBody.test.ts | 40 +++++++++++++++++++ .../thirdweb/src/x402/fetchWithPayment.ts | 20 +++++----- 2 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts index 82d285d2cd4..f6e143dea87 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts @@ -115,6 +115,46 @@ function transport(observed: Request[] = []): typeof fetch { } describe.each([1, 2] as const)("x402 v%i Request body retries", (version) => { + it.each(["body", "headers"] as const)( + "keeps paid %s accessor reads within the native two-dispatch count", + async (field) => { + const received = merchant(version); + let reads = 0; + const read = (): void => { + if (++reads > 2) throw new Error("retry options read an extra time"); + }; + const init: RequestInit = + field === "body" + ? { + get body(): string { + read(); + return BODY; + }, + } + : { + body: BODY, + headers: { + get Authorization(): string { + read(); + return "Bearer fixture-user"; + }, + }, + }; + const input = new NativeRequest(URL, { + method: "POST", + body: "original", + }); + const response = await wrapFetchWithPayment(transport(), client, wallet, { + storage, + })(input, init); + expect(response.status).toBe(200); + expect(reads).toBeGreaterThanOrEqual(1); + expect(reads).toBeLessThanOrEqual(2); + expect(received).toEqual([BODY, BODY]); + await input.body?.cancel(); + }, + ); + it("does not pre-read an init.body override accessor", async () => { const received = merchant(version, true); let reads = 0; diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 98da7ab6ce6..7bc24e0a5cc 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -194,16 +194,6 @@ export function wrapFetchWithPayment( const paymentRequestHeaderName = getPaymentRequestHeader(x402Version); const paymentResponseHeaderName = getPaymentResponseHeader(x402Version); - const newInit = { - ...initParams, - headers: { - ...(initParams.headers || {}), - [paymentRequestHeaderName]: paymentHeader, - "Access-Control-Expose-Headers": paymentResponseHeaderName, - }, - __is402Retry: true, - }; - const paymentRequest = retryRequest ? new Request(retryRequest, init) : undefined; @@ -221,7 +211,15 @@ export function wrapFetchWithPayment( try { secondResponse = paymentRequest ? await fetch(paymentRequest, retryMetadata) - : await fetch(input, newInit); + : await fetch(input, { + ...initParams, + headers: { + ...(initParams.headers || {}), + [paymentRequestHeaderName]: paymentHeader, + "Access-Control-Expose-Headers": paymentResponseHeaderName, + }, + __is402Retry: true, + } as RequestInit); } finally { if (paymentRequest?.body && !paymentRequest.bodyUsed) { void paymentRequest.body.cancel().catch(() => {}); From 47054fcbde3f0c136767d61ef6097ebe5d9e688f Mon Sep 17 00:00:00 2001 From: snuff12 <62332131+snuff12@users.noreply.github.com> Date: Mon, 2 Nov 2026 17:49:39 +0900 Subject: [PATCH 5/6] fix(x402): preserve paid retry referrers across Node runtimes --- .changeset/replay-x402-request-bodies.md | 2 + .../src/x402/fetchWithPayment.node.test.ts | 188 ++++++++++++++++++ .../x402/fetchWithPayment.requestBody.test.ts | 48 ++++- .../thirdweb/src/x402/fetchWithPayment.ts | 4 + 4 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 packages/thirdweb/src/x402/fetchWithPayment.node.test.ts diff --git a/.changeset/replay-x402-request-bodies.md b/.changeset/replay-x402-request-bodies.md index d5d7faf8ccd..37214679c08 100644 --- a/.changeset/replay-x402-request-bodies.md +++ b/.changeset/replay-x402-request-bodies.md @@ -3,3 +3,5 @@ --- Fixed x402 payment retries for Request-owned bodies by reserving a bounded-body retry copy before the first fetch and releasing unused copies. + +Preserve explicit referrer options when retrying on newer Node runtimes. diff --git a/packages/thirdweb/src/x402/fetchWithPayment.node.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.node.test.ts new file mode 100644 index 00000000000..b19916514f1 --- /dev/null +++ b/packages/thirdweb/src/x402/fetchWithPayment.node.test.ts @@ -0,0 +1,188 @@ +import { generatePrivateKey } from "viem/accounts"; +import { describe, expect, it } 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"; + +const URL = "http://127.0.0.1:0/dispatcher-fixture"; +const BODY = "bounded upload"; +const REFERRER = "https://client.example/context"; +const nativeFetch = fetch; +const NativeRequest = Request; +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 () => {}, +}; + +type Handler = { + onConnect: (abort: () => void) => void; + onHeaders: ( + status: number, + headers: Buffer[], + resume: () => void, + statusText: string, + ) => void; + onData: (data: Buffer) => void; + onComplete: (trailers: Buffer[]) => void; + onError: (error: Error) => void; +}; + +/** Uses Node's native dispatcher API to record uploads and wire headers without sockets. */ +function merchant(version: 1 | 2): { + dispatcher: { + dispatch: ( + options: { + body?: AsyncIterable; + headers: string[] | Record; + }, + handler: Handler, + ) => boolean; + }; + received: string[]; + referrers: Array; +} { + const received: string[] = []; + const referrers: Array = []; + const requirement = { + 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: [ + { + ...requirement, + maxAmountRequired: "1", + resource: URL, + description: "fixture", + mimeType: "application/json", + }, + ], + } + : { + x402Version: 2, + resource: { url: URL }, + accepts: [{ ...requirement, amount: "1" }], + }; + return { + received, + referrers, + dispatcher: { + dispatch(options, handler): boolean { + handler.onConnect(() => {}); + void (async () => { + const chunks: Uint8Array[] = []; + if (options.body) + for await (const chunk of options.body) chunks.push(chunk); + received.push(Buffer.concat(chunks).toString()); + const requestHeaders = new Headers(); + if (Array.isArray(options.headers)) { + for (let i = 0; i < options.headers.length; i += 2) { + requestHeaders.append( + options.headers[i] ?? "", + options.headers[i + 1] ?? "", + ); + } + } else { + for (const [name, value] of Object.entries(options.headers)) { + requestHeaders.append(name, value); + } + } + referrers.push(requestHeaders.get("referer")); + const status = received.length === 1 ? 402 : 200; + const headers = [ + Buffer.from("content-type"), + Buffer.from("application/json"), + ]; + if (status === 402 && version === 2) { + headers.push( + Buffer.from("PAYMENT-REQUIRED"), + Buffer.from( + Buffer.from(JSON.stringify(challenge)).toString("base64"), + ), + ); + } + handler.onHeaders( + status, + headers, + () => {}, + status === 402 ? "Payment Required" : "OK", + ); + handler.onData( + Buffer.from( + status === 402 && version === 1 + ? JSON.stringify(challenge) + : "{}", + ), + ); + handler.onComplete([]); + })().catch((error) => + handler.onError( + error instanceof Error ? error : new Error(String(error)), + ), + ); + return true; + }, + }, + }; +} + +describe.each([1, 2] as const)("x402 v%i native Node dispatcher", (version) => { + it.each(["value", "getter"] as const)( + "retains the dispatcher and referrer with %s options on the paid retry", + async (source) => { + const { dispatcher, received, referrers } = merchant(version); + const options: RequestInit & { dispatcher: typeof dispatcher } = { + dispatcher, + referrer: REFERRER, + referrerPolicy: "origin", + }; + let reads = 0; + const init: RequestInit & { dispatcher: typeof dispatcher } = { + ...options, + get dispatcher() { + reads++; + return dispatcher; + }, + }; + const input = new NativeRequest(URL, { + method: "POST", + body: BODY, + }); + const response = await wrapFetchWithPayment(nativeFetch, client, wallet, { + storage, + })(input, source === "getter" ? init : options); + await response.text(); + expect(response.status).toBe(200); + expect(received).toEqual([BODY, BODY]); + expect(input.bodyUsed).toBe(true); + expect(referrers).toEqual([ + "https://client.example/", + "https://client.example/", + ]); + expect(reads).toBe(source === "getter" ? 2 : 0); + }, + ); +}); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts index f6e143dea87..c2ec941275e 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts @@ -233,6 +233,10 @@ describe.each([1, 2] as const)("x402 v%i Request body retries", (version) => { referrerPolicy: "origin", signal: controller.signal, }); + const nativeCopy = new NativeRequest(input.clone()); + const referrer = nativeCopy.referrer; + const referrerPolicy = nativeCopy.referrerPolicy; + void nativeCopy.body?.cancel().catch(() => {}); await wrapFetchWithPayment(transport(observed), client, wallet, { storage, })(input); @@ -242,13 +246,53 @@ describe.each([1, 2] as const)("x402 v%i Request body retries", (version) => { expect(request.cache).toBe("no-store"); expect(request.credentials).toBe("include"); expect(request.redirect).toBe("manual"); - expect(request.referrer).toBe("https://client.example/context"); - expect(request.referrerPolicy).toBe("origin"); + expect(request.referrer).toBe(referrer); + expect(request.referrerPolicy).toBe(referrerPolicy); } controller.abort(); expect(observed.every((request) => request.signal.aborted)).toBe(true); }); + it("does not dispatch the paid retry when canceled during payment signing", async () => { + const received = merchant(version); + const controller = new AbortController(); + const reason = new Error("caller canceled during payment signing"); + let resume = (): void => {}; + let started = (): void => {}; + const paused = new Promise((resolve) => { + resume = resolve; + }); + const signing = new Promise((resolve) => { + started = resolve; + }); + const sign = account.signTypedData.bind(account); + vi.spyOn(account, "signTypedData").mockImplementationOnce( + async (options) => { + started(); + await paused; + return sign(options); + }, + ); + const pending = wrapFetchWithPayment(transport(), client, wallet, { + storage, + })( + new NativeRequest(URL, { + method: "POST", + body: BODY, + signal: controller.signal, + }), + ); + const rejected = expect(pending).rejects.toBe(reason); + try { + await signing; + controller.abort(reason); + } finally { + resume(); + } + await rejected; + expect(received).toEqual([BODY]); + }); + it("keeps non-402 responses as a single dispatch without signing", async () => { const received = merchant(version, true); const sign = vi.spyOn(account, "signTypedData"); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 7bc24e0a5cc..811c7ec3424 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -34,6 +34,7 @@ import { x402Version as defaultX402Version } from "./types.js"; * A Request-owned body is cloned before dispatch for one payment retry. * Request.clone() can buffer its unread branch; use bounded bodies. Bodies * supplied through init.body must remain replayable for a retry. + * Pass Node dispatchers through init so they are reapplied to the retry Request. * * @param fetch - The fetch function to wrap (typically globalThis.fetch) * @param client - The thirdweb client used to access RPC infrastructure @@ -204,8 +205,11 @@ export function wrapFetchWithPayment( paymentResponseHeaderName, ); } + // Request retains init.dispatcher; referrer fields need explicit preservation on Node 24. const retryMetadata: RequestInit & { __is402Retry: boolean } = { __is402Retry: true, + referrer: paymentRequest?.referrer, + referrerPolicy: paymentRequest?.referrerPolicy, }; let secondResponse: Response; try { From c544bb48d991d982080163f18991f99fb54426ae Mon Sep 17 00:00:00 2001 From: snuff12 <62332131+snuff12@users.noreply.github.com> Date: Mon, 2 Nov 2026 18:26:20 +0900 Subject: [PATCH 6/6] fix(x402): forward fetch-only dispatchers without rereading accessors --- .changeset/replay-x402-request-bodies.md | 1 + .../src/x402/fetchWithPayment.node.test.ts | 66 +++++++++++++++++-- .../thirdweb/src/x402/fetchWithPayment.ts | 52 +++++++++++---- 3 files changed, 100 insertions(+), 19 deletions(-) diff --git a/.changeset/replay-x402-request-bodies.md b/.changeset/replay-x402-request-bodies.md index 37214679c08..fbdd3d4101a 100644 --- a/.changeset/replay-x402-request-bodies.md +++ b/.changeset/replay-x402-request-bodies.md @@ -5,3 +5,4 @@ Fixed x402 payment retries for Request-owned bodies by reserving a bounded-body retry copy before the first fetch and releasing unused copies. Preserve explicit referrer options when retrying on newer Node runtimes. +Keep Node fetch dispatchers on the paid retry, including runtimes whose Request constructor ignores the option. diff --git a/packages/thirdweb/src/x402/fetchWithPayment.node.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.node.test.ts index b19916514f1..fe838a4f88e 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.node.test.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.node.test.ts @@ -1,5 +1,5 @@ import { generatePrivateKey } from "viem/accounts"; -import { describe, expect, it } from "vitest"; +import { afterEach, 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"; @@ -11,6 +11,7 @@ const BODY = "bounded upload"; const REFERRER = "https://client.example/context"; const nativeFetch = fetch; const NativeRequest = Request; +afterEach(() => vi.unstubAllGlobals()); const client = createThirdwebClient({ clientId: "fixture-client" }); const account = privateKeyToAccount({ client, @@ -39,6 +40,29 @@ type Handler = { onError: (error: Error) => void; }; +/** Models a standards-only Request, which ignores the fetch-only dispatcher. */ +function standardRequest(created: Request[] = []): typeof Request { + return new Proxy(NativeRequest, { + construct: (_target, args) => { + const requestInit = args[1] as RequestInit | undefined; + const standardInit = + requestInit && + new Proxy( + {}, + { + get: (_options, property) => + property === "dispatcher" + ? undefined + : Reflect.get(requestInit, property, requestInit), + }, + ); + const request = new NativeRequest(args[0] as RequestInfo, standardInit); + created.push(request); + return request; + }, + }); +} + /** Uses Node's native dispatcher API to record uploads and wire headers without sockets. */ function merchant(version: 1 | 2): { dispatcher: { @@ -150,9 +174,14 @@ function merchant(version: 1 | 2): { } describe.each([1, 2] as const)("x402 v%i native Node dispatcher", (version) => { - it.each(["value", "getter"] as const)( - "retains the dispatcher and referrer with %s options on the paid retry", - async (source) => { + it.each([ + { source: "value", standard: false }, + { source: "getter", standard: false }, + { source: "value", standard: true }, + { source: "getter", standard: true }, + ] as const)( + "retains the dispatcher and referrer: $source, standard Request $standard", + async ({ source, standard }) => { const { dispatcher, received, referrers } = merchant(version); const options: RequestInit & { dispatcher: typeof dispatcher } = { dispatcher, @@ -171,9 +200,12 @@ describe.each([1, 2] as const)("x402 v%i native Node dispatcher", (version) => { method: "POST", body: BODY, }); + if (standard) { + vi.stubGlobal("Request", standardRequest()); + } const response = await wrapFetchWithPayment(nativeFetch, client, wallet, { storage, - })(input, source === "getter" ? init : options); + })(input, Object.freeze(source === "getter" ? init : options)); await response.text(); expect(response.status).toBe(200); expect(received).toEqual([BODY, BODY]); @@ -185,4 +217,28 @@ describe.each([1, 2] as const)("x402 v%i native Node dispatcher", (version) => { expect(reads).toBe(source === "getter" ? 2 : 0); }, ); + + it("releases the paid body if a fetch-only dispatcher getter rejects", async () => { + const { dispatcher, received } = merchant(version); + const created: Request[] = []; + vi.stubGlobal("Request", standardRequest(created)); + const reason = new Error("dispatcher unavailable on retry"); + let reads = 0; + const init: RequestInit & { dispatcher: typeof dispatcher } = { + get dispatcher() { + if (++reads > 1) throw reason; + return dispatcher; + }, + }; + await expect( + wrapFetchWithPayment(nativeFetch, client, wallet, { storage })( + new NativeRequest(URL, { method: "POST", body: BODY }), + init, + ), + ).rejects.toBe(reason); + expect(reads).toBe(2); + expect(received).toEqual([BODY]); + expect(created).toHaveLength(1); + expect(created[0]?.bodyUsed).toBe(true); + }); }); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 811c7ec3424..7293216285a 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -20,6 +20,8 @@ import { import { createPaymentHeader } from "./sign.js"; import { x402Version as defaultX402Version } from "./types.js"; +type RequestInitWithDispatcher = RequestInit & { dispatcher?: object }; + /** * Enables the payment of APIs using the x402 payment protocol. * @@ -195,24 +197,46 @@ export function wrapFetchWithPayment( const paymentRequestHeaderName = getPaymentRequestHeader(x402Version); const paymentResponseHeaderName = getPaymentResponseHeader(x402Version); + let dispatcher: object | undefined; + let dispatcherRead = false; + // Reuse the constructor's read so dispatcher accessors run only once per dispatch. + const paymentInit = + retryRequest && init + ? new Proxy(init, { + get: (target, property) => { + if (property === "dispatcher") { + dispatcher = (target as RequestInitWithDispatcher).dispatcher; + dispatcherRead = true; + return dispatcher; + } + return Reflect.get(target, property, target); + }, + }) + : init; const paymentRequest = retryRequest - ? new Request(retryRequest, init) + ? new Request(retryRequest, paymentInit) : undefined; - if (paymentRequest) { - paymentRequest.headers.set(paymentRequestHeaderName, paymentHeader); - paymentRequest.headers.set( - "Access-Control-Expose-Headers", - paymentResponseHeaderName, - ); - } - // Request retains init.dispatcher; referrer fields need explicit preservation on Node 24. - const retryMetadata: RequestInit & { __is402Retry: boolean } = { - __is402Retry: true, - referrer: paymentRequest?.referrer, - referrerPolicy: paymentRequest?.referrerPolicy, - }; let secondResponse: Response; try { + if (paymentRequest) { + paymentRequest.headers.set(paymentRequestHeaderName, paymentHeader); + paymentRequest.headers.set( + "Access-Control-Expose-Headers", + paymentResponseHeaderName, + ); + // Standard Request implementations do not read fetch-only dispatcher options. + if (!dispatcherRead) + dispatcher = (init as RequestInitWithDispatcher | undefined) + ?.dispatcher; + } + const retryMetadata: RequestInitWithDispatcher & { + __is402Retry: boolean; + } = { + __is402Retry: true, + referrer: paymentRequest?.referrer, + referrerPolicy: paymentRequest?.referrerPolicy, + ...(dispatcher !== undefined ? { dispatcher } : {}), + }; secondResponse = paymentRequest ? await fetch(paymentRequest, retryMetadata) : await fetch(input, {