Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/social-ads-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"thirdweb": patch
---

Support for x402 payment-required headers
2 changes: 1 addition & 1 deletion packages/thirdweb/src/x402/encode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
304 changes: 304 additions & 0 deletions packages/thirdweb/src/x402/fetchWithPayment.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof wrapFetchWithPayment>[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<typeof wrapFetchWithPayment>[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<typeof wrapFetchWithPayment>[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");
});
});
53 changes: 45 additions & 8 deletions packages/thirdweb/src/x402/fetchWithPayment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Comment on lines +85 to +103
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Missing error handling for malformed header parsing.

If the payment-required header contains invalid base64 or malformed JSON, safeBase64Decode or JSON.parse will throw, preventing fallback to the body path. Consider wrapping header parsing in try-catch to gracefully fall back to body parsing when the header is present but malformed.

🛠️ Suggested fix
     // Check payment-required header first before falling back to JSON body
     const paymentRequiredHeader = response.headers.get("payment-required");
     if (paymentRequiredHeader) {
+      try {
         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 {
+      } catch (headerError) {
+        // Header parsing failed, fall through to body parsing
+      }
+    }
+
+    // Fall back to body if header was absent or parsing failed
+    if (!parsedPaymentRequirements) {
       const body = (await response.json()) as {
         x402Version: number;
         accepts: unknown[];
         error?: string;
       };
       // ... rest of body parsing
     }

Note: This would require initializing parsedPaymentRequirements as undefined initially and checking it before body parsing.

🤖 Prompt for AI Agents
In @packages/thirdweb/src/x402/fetchWithPayment.ts around lines 85 - 103, Wrap
the existing header parsing (the block that reads paymentRequiredHeader, calls
safeBase64Decode, JSON.parse, and maps to
RequestedPaymentRequirementsSchema.parse) in a try-catch so malformed
base64/JSON or invalid schema does not throw and instead leaves
parsedPaymentRequirements undefined; initialize parsedPaymentRequirements (and
x402Version/error as needed) before the header block, catch any errors from
safeBase64Decode/JSON.parse/RequestedPaymentRequirementsSchema.parse and
swallow/log them as non-fatal, then only proceed to parse the body if
parsedPaymentRequirements is still undefined, and finally throw only if both
header and body parsing fail.

} 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();
Expand Down
Loading