diff --git a/.changeset/replay-x402-request-bodies.md b/.changeset/replay-x402-request-bodies.md new file mode 100644 index 00000000000..fbdd3d4101a --- /dev/null +++ b/.changeset/replay-x402-request-bodies.md @@ -0,0 +1,8 @@ +--- +"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. + +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 new file mode 100644 index 00000000000..fe838a4f88e --- /dev/null +++ b/packages/thirdweb/src/x402/fetchWithPayment.node.test.ts @@ -0,0 +1,244 @@ +import { generatePrivateKey } from "viem/accounts"; +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"; +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; +afterEach(() => vi.unstubAllGlobals()); +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; +}; + +/** 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: { + 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([ + { 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, + 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, + }); + if (standard) { + vi.stubGlobal("Request", standardRequest()); + } + const response = await wrapFetchWithPayment(nativeFetch, client, wallet, { + storage, + })(input, Object.freeze(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); + }, + ); + + 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.requestBody.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts new file mode 100644 index 00000000000..c2ec941275e --- /dev/null +++ b/packages/thirdweb/src/x402/fetchWithPayment.requestBody.test.ts @@ -0,0 +1,401 @@ +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 NativeHeaders = Headers; +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" }); + // 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(() => { + vi.unstubAllGlobals(); + 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.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; + 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, { + 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, + }); + 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); + 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(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"); + 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); +}); + +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 5243e4a6691..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. * @@ -31,6 +33,11 @@ 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. + * 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 * @param wallet - The wallet used to sign payment messages @@ -79,7 +86,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) { @@ -186,17 +197,62 @@ 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 secondResponse = await fetch(input, newInit); + 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, paymentInit) + : undefined; + 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, { + ...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(() => {}); + } + } // If payment was rejected (still 402), clear cached signature if (secondResponse.status === 402) { @@ -210,6 +266,25 @@ export function wrapFetchWithPayment( return secondResponse; }; + + return async (input: RequestInfo, init?: RequestInit): Promise => { + const retryRequest = + typeof input !== "string" && + input.body !== null && + input.body !== undefined && + !input.bodyUsed && + !input.body.locked + ? input.clone() + : 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(() => {}); + } + } + }; } /**