[SDK] Fix: Parse x402 payment header (#8617)
Co-authored-by: Joaquim Verges <[email protected]>
This commit is contained in:
co-authored by
Joaquim Verges
parent
8d4b6a1fea
commit
5766c90fb4
@@ -0,0 +1,5 @@
|
||||
---
|
||||
"thirdweb": patch
|
||||
---
|
||||
|
||||
Support for x402 payment-required headers
|
||||
@@ -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"
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user