diff --git a/.changeset/social-ads-arrive.md b/.changeset/social-ads-arrive.md new file mode 100644 index 00000000000..8be6221121d --- /dev/null +++ b/.changeset/social-ads-arrive.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Support for x402 payment-required headers diff --git a/packages/thirdweb/src/x402/encode.ts b/packages/thirdweb/src/x402/encode.ts index 9a89e5e60ca..4c95fb446c2 100644 --- a/packages/thirdweb/src/x402/encode.ts +++ b/packages/thirdweb/src/x402/encode.ts @@ -66,7 +66,7 @@ export function safeBase64Encode(data: string): string { * @param data - The base64 encoded string to be decoded * @returns The decoded string in UTF-8 format */ -function safeBase64Decode(data: string): string { +export function safeBase64Decode(data: string): string { if ( typeof globalThis !== "undefined" && typeof globalThis.atob === "function" diff --git a/packages/thirdweb/src/x402/fetchWithPayment.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.test.ts new file mode 100644 index 00000000000..9207e6f264f --- /dev/null +++ b/packages/thirdweb/src/x402/fetchWithPayment.test.ts @@ -0,0 +1,304 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { safeBase64Decode, safeBase64Encode } from "./encode.js"; +import { wrapFetchWithPayment } from "./fetchWithPayment.js"; + +// Mock the createPaymentHeader function +vi.mock("./sign.js", () => ({ + createPaymentHeader: vi.fn().mockResolvedValue("mock-payment-header"), +})); + +// Mock webLocalStorage +vi.mock("../utils/storage/webStorage.js", () => ({ + webLocalStorage: { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + }, +})); + +describe("wrapFetchWithPayment", () => { + const mockPaymentRequirements = { + scheme: "exact", + network: "eip155:1", + maxAmountRequired: "1000000", + resource: "https://api.example.com/resource", + description: "Test payment", + mimeType: "application/json", + payTo: "0x1234567890123456789012345678901234567890", + maxTimeoutSeconds: 300, + asset: "0x0000000000000000000000000000000000000001", + extra: { + name: "Test Token", + version: "1", + }, + }; + + const mock402ResponseData = { + x402Version: 1, + accepts: [mockPaymentRequirements], + error: undefined, + }; + + const mockClient = { + clientId: "test-client-id", + } as Parameters[1]; + + const mockAccount = { + address: "0x1234567890123456789012345678901234567890", + signTypedData: vi.fn(), + }; + + const mockWallet = { + getAccount: vi.fn().mockReturnValue(mockAccount), + getChain: vi.fn().mockReturnValue({ id: 1 }), + switchChain: vi.fn(), + } as unknown as Parameters[2]; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should pass through non-402 responses unchanged", async () => { + const mockResponse = new Response(JSON.stringify({ data: "test" }), { + status: 200, + }); + const mockFetch = vi.fn().mockResolvedValue(mockResponse); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("should parse payment requirements from payment-required header when present", async () => { + const encodedPaymentInfo = safeBase64Encode( + JSON.stringify(mock402ResponseData), + ); + + const mock402Response = new Response(null, { + status: 402, + headers: { + "payment-required": encodedPaymentInfo, + }, + }); + + const mockSuccessResponse = new Response( + JSON.stringify({ success: true }), + { + status: 200, + }, + ); + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(mock402Response) + .mockResolvedValueOnce(mockSuccessResponse); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify the second call includes the X-PAYMENT header + const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; + expect(secondCallInit.headers).toHaveProperty("X-PAYMENT"); + }); + + it("should parse payment requirements from JSON body when payment-required header is absent", async () => { + const mock402Response = new Response(JSON.stringify(mock402ResponseData), { + status: 402, + }); + + const mockSuccessResponse = new Response( + JSON.stringify({ success: true }), + { + status: 200, + }, + ); + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(mock402Response) + .mockResolvedValueOnce(mockSuccessResponse); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify the second call includes the X-PAYMENT header + const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; + expect(secondCallInit.headers).toHaveProperty("X-PAYMENT"); + }); + + it("should prefer payment-required header over JSON body when both are present", async () => { + const headerPaymentRequirements = { + ...mockPaymentRequirements, + maxAmountRequired: "500000", // Different amount to verify header is used + }; + const headerResponseData = { + x402Version: 1, + accepts: [headerPaymentRequirements], + }; + + const bodyResponseData = { + x402Version: 1, + accepts: [{ ...mockPaymentRequirements, maxAmountRequired: "2000000" }], + }; + + const encodedPaymentInfo = safeBase64Encode( + JSON.stringify(headerResponseData), + ); + + // Create response with both header and body + const mock402Response = new Response(JSON.stringify(bodyResponseData), { + status: 402, + headers: { + "payment-required": encodedPaymentInfo, + }, + }); + + const mockSuccessResponse = new Response( + JSON.stringify({ success: true }), + { + status: 200, + }, + ); + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(mock402Response) + .mockResolvedValueOnce(mockSuccessResponse); + + // Use maxValue to verify which payment requirements are used + // If header is used (500000), it should pass + // If body is used (2000000), it would exceed maxValue + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { + maxValue: BigInt(1000000), + }, + ); + + const response = await wrappedFetch("https://api.example.com/resource"); + + // Should succeed because header value (500000) is under maxValue (1000000) + expect(response.status).toBe(200); + }); + + it("should parse payment requirements from payment-required header", async () => { + const encodedPaymentInfo = safeBase64Encode( + JSON.stringify(mock402ResponseData), + ); + + const mock402Response = new Response(null, { + status: 402, + headers: { + "payment-required": encodedPaymentInfo, + }, + }); + + const mockSuccessResponse = new Response( + JSON.stringify({ success: true }), + { + status: 200, + }, + ); + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(mock402Response) + .mockResolvedValueOnce(mockSuccessResponse); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify the second call includes the X-PAYMENT header + const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; + expect(secondCallInit.headers).toHaveProperty("X-PAYMENT"); + }); + + it("should correctly decode a raw base64 encoded payment-required header", async () => { + // This is an actual base64 encoded payment requirements header + // 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"}}]} + const rawBase64Header = + "eyJ4NDAyVmVyc2lvbiI6MSwiYWNjZXB0cyI6W3sic2NoZW1lIjoiZXhhY3QiLCJuZXR3b3JrIjoiZWlwMTU1Ojg0NTMiLCJtYXhBbW91bnRSZXF1aXJlZCI6IjEwMDAwMCIsInJlc291cmNlIjoiaHR0cHM6Ly9leGFtcGxlLmNvbS9hcGkiLCJkZXNjcmlwdGlvbiI6IkFQSSBhY2Nlc3MiLCJtaW1lVHlwZSI6ImFwcGxpY2F0aW9uL2pzb24iLCJwYXlUbyI6IjB4ZDhkQTZCRjI2OTY0YUY5RDdlRWQ5ZTAzRTUzNDE1RDM3YUE5NjA0NSIsIm1heFRpbWVvdXRTZWNvbmRzIjozMDAsImFzc2V0IjoiMHg4MzM1ODlmQ0Q2ZURiNkUwOGY0YzdDMzJENGY3MWI1NGJkQTAyOTEzIiwiZXh0cmEiOnsibmFtZSI6IlVTRCBDb2luIiwidmVyc2lvbiI6IjIifX1dfQ=="; + + // Verify the base64 decodes to valid JSON + const decoded = safeBase64Decode(rawBase64Header); + const parsed = JSON.parse(decoded); + + expect(parsed.x402Version).toBe(1); + expect(parsed.accepts).toHaveLength(1); + expect(parsed.accepts[0].network).toBe("eip155:8453"); + expect(parsed.accepts[0].maxAmountRequired).toBe("100000"); + expect(parsed.accepts[0].payTo).toBe( + "0xd8dA6BF26964aF9D7eEd9e03E53415D37aA96045", + ); + + // Now test the full flow with this raw header + const mock402Response = new Response(null, { + status: 402, + headers: { + "payment-required": rawBase64Header, + }, + }); + + const mockSuccessResponse = new Response( + JSON.stringify({ success: true }), + { + status: 200, + }, + ); + + const mockFetch = vi + .fn() + .mockResolvedValueOnce(mock402Response) + .mockResolvedValueOnce(mockSuccessResponse); + + // Use a wallet on Base (chain 8453) to match the payment requirements + const baseWallet = { + getAccount: vi.fn().mockReturnValue(mockAccount), + getChain: vi.fn().mockReturnValue({ id: 8453 }), + switchChain: vi.fn(), + } as unknown as Parameters[2]; + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + baseWallet, + ); + const response = await wrappedFetch("https://example.com/api"); + + expect(response.status).toBe(200); + expect(mockFetch).toHaveBeenCalledTimes(2); + + // Verify the retry request was made with X-PAYMENT header + const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; + expect(secondCallInit.headers).toHaveProperty("X-PAYMENT"); + }); +}); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 589637c3bad..9100a12a61b 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -4,6 +4,7 @@ import { getAddress } from "../utils/address.js"; import type { AsyncStorage } from "../utils/storage/AsyncStorage.js"; import { webLocalStorage } from "../utils/storage/webStorage.js"; import type { Wallet } from "../wallets/interfaces/wallet.js"; +import { safeBase64Decode } from "./encode.js"; import { clearPermitSignatureFromCache } from "./permitSignatureStorage.js"; import { extractEvmChainId, @@ -75,14 +76,50 @@ export function wrapFetchWithPayment( return response; } - const { x402Version, accepts, error } = (await response.json()) as { - x402Version: number; - accepts: unknown[]; - error?: string; - }; - const parsedPaymentRequirements = accepts.map((x) => - RequestedPaymentRequirementsSchema.parse(x), - ); + let x402Version: number; + let parsedPaymentRequirements: RequestedPaymentRequirements[]; + let error: string | undefined; + + // Check payment-required header first before falling back to JSON body + const paymentRequiredHeader = response.headers.get("payment-required"); + if (paymentRequiredHeader) { + const decoded = safeBase64Decode(paymentRequiredHeader); + const parsed = JSON.parse(decoded) as { + x402Version: number; + accepts: unknown[]; + error?: string; + }; + + if (!Array.isArray(parsed.accepts)) { + throw new Error( + `402 response has no usable x402 payment requirements. ${parsed.error ?? ""}`, + ); + } + + x402Version = parsed.x402Version; + parsedPaymentRequirements = parsed.accepts.map((x) => + RequestedPaymentRequirementsSchema.parse(x), + ); + error = parsed.error; + } else { + const body = (await response.json()) as { + x402Version: number; + accepts: unknown[]; + error?: string; + }; + + if (!Array.isArray(body.accepts)) { + throw new Error( + `402 response has no usable x402 payment requirements. ${body.error ?? ""}`, + ); + } + + x402Version = body.x402Version; + parsedPaymentRequirements = body.accepts.map((x) => + RequestedPaymentRequirementsSchema.parse(x), + ); + error = body.error; + } const account = wallet.getAccount(); let chain = wallet.getChain();