From 8d1750f0f488e4bc0bad9ea6622da84a6b030a90 Mon Sep 17 00:00:00 2001 From: 0xFirekeeper <0xFirekeeper@gmail.com> Date: Fri, 11 Sep 2026 01:13:02 +0700 Subject: [PATCH] [SDK] Accept x402 v2 payment requirements that specify amount Co-Authored-By: Claude Fable 5.1 --- .changeset/x402-v2-amount.md | 5 + .../x402/components/X402EmptyState.tsx | 4 +- .../app/x402/components/X402RightSection.tsx | 6 +- apps/portal/src/app/x402/server/page.mdx | 8 +- .../hooks/x402/useFetchWithPaymentCore.ts | 7 +- .../native/hooks/x402/useFetchWithPayment.ts | 2 +- .../web/hooks/x402/useFetchWithPayment.tsx | 4 +- .../react/web/ui/x402/PaymentErrorModal.tsx | 13 +- packages/thirdweb/src/x402/encode.ts | 48 +- .../src/x402/fetchWithPayment.test.ts | 1277 +++++++++++++++-- .../thirdweb/src/x402/fetchWithPayment.ts | 126 +- packages/thirdweb/src/x402/schemas.test.ts | 123 ++ packages/thirdweb/src/x402/schemas.ts | 205 ++- packages/thirdweb/src/x402/sign.ts | 96 +- 14 files changed, 1734 insertions(+), 190 deletions(-) create mode 100644 .changeset/x402-v2-amount.md create mode 100644 packages/thirdweb/src/x402/schemas.test.ts diff --git a/.changeset/x402-v2-amount.md b/.changeset/x402-v2-amount.md new file mode 100644 index 00000000000..d700ac8fe78 --- /dev/null +++ b/.changeset/x402-v2-amount.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Accept x402 v2 payment requirements that specify `amount`, and enforce `maxValue: 0n` as a cap. diff --git a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/components/X402EmptyState.tsx b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/components/X402EmptyState.tsx index aad46b13b37..d8a60e19e6e 100644 --- a/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/components/X402EmptyState.tsx +++ b/apps/dashboard/src/app/(app)/team/[team_slug]/[project_slug]/(sidebar)/x402/components/X402EmptyState.tsx @@ -44,7 +44,9 @@ export async function GET(request: Request) { const result = await settlePayment({ resourceUrl: "https://api.example.com/premium-content", method: "GET", - paymentData: request.headers.get("x-payment"), + paymentData: + request.headers.get("PAYMENT-SIGNATURE") || + request.headers.get("X-PAYMENT"), network: arbitrumSepolia, price: "$0.01", facilitator: thirdwebX402Facilitator, diff --git a/apps/playground-web/src/app/x402/components/X402RightSection.tsx b/apps/playground-web/src/app/x402/components/X402RightSection.tsx index f0fd5235dc9..b75e23925c7 100644 --- a/apps/playground-web/src/app/x402/components/X402RightSection.tsx +++ b/apps/playground-web/src/app/x402/components/X402RightSection.tsx @@ -94,11 +94,13 @@ const client = createThirdwebClient({ const thirdwebFacilitator = facilitator({ client, serverWalletAddress: "0xYourServerWalletAddress", - waitUtil: "${props.options.waitUntil}", + waitUntil: "${props.options.waitUntil}", }); export async function POST(request: Request) { - const paymentData = request.headers.get("x-payment"); + const paymentData = + request.headers.get("PAYMENT-SIGNATURE") || + request.headers.get("X-PAYMENT"); // verify and process the payment const result = await settlePayment({ diff --git a/apps/portal/src/app/x402/server/page.mdx b/apps/portal/src/app/x402/server/page.mdx index 8bd38516156..3ff77e9ca6e 100644 --- a/apps/portal/src/app/x402/server/page.mdx +++ b/apps/portal/src/app/x402/server/page.mdx @@ -267,7 +267,7 @@ Protect individual API endpoints with x402 payments: const result = await settlePayment({ resourceUrl: `${req.protocol}://${req.get('host')}${req.originalUrl}`, method: req.method, - paymentData: req.headers["x-payment"], + paymentData: req.headers["payment-signature"] ?? req.headers["x-payment"], payTo: "0x1234567890123456789012345678901234567890", network: arbitrumSepolia, price: "$0.05", @@ -320,7 +320,7 @@ Protect individual API endpoints with x402 payments: const result = await settlePayment({ resourceUrl: new URL(c.req.url).toString(), method: c.req.method, - paymentData: c.req.header("x-payment"), + paymentData: c.req.header("payment-signature") ?? c.req.header("x-payment"), payTo: "0x1234567890123456789012345678901234567890", network: arbitrumSepolia, price: "$0.05", @@ -445,7 +445,7 @@ Protect multiple endpoints with a shared middleware: const result = await settlePayment({ resourceUrl: `${req.protocol}://${req.get('host')}${req.originalUrl}`, method: req.method, - paymentData: req.headers["x-payment"], + paymentData: req.headers["payment-signature"] ?? req.headers["x-payment"], payTo: "0x1234567890123456789012345678901234567890", network: arbitrumSepolia, price: "$0.05", @@ -500,7 +500,7 @@ Protect multiple endpoints with a shared middleware: const result = await settlePayment({ resourceUrl: new URL(c.req.url).toString(), method: c.req.method, - paymentData: c.req.header("x-payment"), + paymentData: c.req.header("payment-signature") ?? c.req.header("x-payment"), payTo: "0x1234567890123456789012345678901234567890", network: arbitrumSepolia, price: "$0.05", diff --git a/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts b/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts index fc51df92634..e7c5c2e4e54 100644 --- a/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts +++ b/packages/thirdweb/src/react/core/hooks/x402/useFetchWithPaymentCore.ts @@ -4,7 +4,10 @@ import { useMutation } from "@tanstack/react-query"; import type { ThirdwebClient } from "../../../../client/client.js"; import type { AsyncStorage } from "../../../../utils/storage/AsyncStorage.js"; import type { Wallet } from "../../../../wallets/interfaces/wallet.js"; -import { wrapFetchWithPayment } from "../../../../x402/fetchWithPayment.js"; +import { + getRequestUrl, + wrapFetchWithPayment, +} from "../../../../x402/fetchWithPayment.js"; import type { RequestedPaymentRequirements } from "../../../../x402/schemas.js"; import type { PaymentRequiredResult } from "../../../../x402/types.js"; import { useActiveWallet } from "../wallets/useActiveWallet.js"; @@ -24,6 +27,7 @@ export type UseFetchWithPaymentOptions = { type ShowErrorModalCallback = (data: { errorData: PaymentRequiredResult["responseBody"]; + requestUrl?: string; onRetry: () => void; onCancel: () => void; }) => void; @@ -107,6 +111,7 @@ export function useFetchWithPaymentCore( return new Promise((resolve, reject) => { showErrorModal({ errorData: errorBody, + requestUrl: getRequestUrl(input), onRetry: async () => { // Retry the entire fetch+error handling logic recursively // Pass currentWallet to avoid re-showing connect modal with stale wallet state diff --git a/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts b/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts index c2f481b6df5..ab29147e76e 100644 --- a/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts +++ b/packages/thirdweb/src/react/native/hooks/x402/useFetchWithPayment.ts @@ -27,7 +27,7 @@ export type { UseFetchWithPaymentOptions }; * * @param client - The thirdweb client used to access RPC infrastructure * @param options - Optional configuration for payment handling - * @param options.maxValue - The maximum allowed payment amount in base units + * @param options.maxValue - The maximum allowed payment amount in base units. `0n` only allows zero-amount payments * @param options.paymentRequirementsSelector - Custom function to select payment requirements from available options * @param options.parseAs - How to parse the response: "json" (default), "text", or "raw" * @param options.storage - Storage for caching permit signatures (for "upto" scheme). Provide your own AsyncStorage implementation for React Native. diff --git a/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx b/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx index 89526b02953..6f870a944a8 100644 --- a/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx +++ b/packages/thirdweb/src/react/web/hooks/x402/useFetchWithPayment.tsx @@ -78,7 +78,7 @@ type UseFetchWithPaymentConfig = UseFetchWithPaymentOptions & { * * @param client - The thirdweb client used to access RPC infrastructure * @param options - Optional configuration for payment handling - * @param options.maxValue - The maximum allowed payment amount in base units + * @param options.maxValue - The maximum allowed payment amount in base units. `0n` only allows zero-amount payments * @param options.paymentRequirementsSelector - Custom function to select payment requirements from available options * @param options.parseAs - How to parse the response: "json" (default), "text", or "raw" * @param options.uiEnabled - Whether to show the UI for connection, funding or payment retries (defaults to true). Set to false to handle errors yourself @@ -196,6 +196,7 @@ export function useFetchWithPayment( const showErrorModal = showModal ? (data: { errorData: Parameters[0]["errorData"]; + requestUrl?: string; onRetry: () => void; onCancel: () => void; }) => { @@ -203,6 +204,7 @@ export function useFetchWithPayment( { setRootEl(null); data.onCancel(); diff --git a/packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx b/packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx index 4737ef27501..63a92466e53 100644 --- a/packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx +++ b/packages/thirdweb/src/react/web/ui/x402/PaymentErrorModal.tsx @@ -5,6 +5,7 @@ import type { ThirdwebClient } from "../../../../client/client.js"; import { extractEvmChainId, networkToCaip2ChainId, + parsePaymentRequirementsForDisplay, type RequestedPaymentRequirements, } from "../../../../x402/schemas.js"; import type { PaymentRequiredResult } from "../../../../x402/types.js"; @@ -25,6 +26,7 @@ import { Text } from "../components/text.js"; type PaymentErrorModalProps = { client: ThirdwebClient; errorData: PaymentRequiredResult["responseBody"]; + requestUrl?: string; onRetry: () => void; onCancel: () => void; theme: Theme | "light" | "dark"; @@ -54,6 +56,7 @@ export function PaymentErrorModal(props: PaymentErrorModalProps) { const { client, errorData, + requestUrl, onRetry, onCancel, theme, @@ -66,13 +69,15 @@ export function PaymentErrorModal(props: PaymentErrorModalProps) { // Extract chain and token info from errorData for BuyWidget const getBuyWidgetConfig = () => { - if (!errorData.accepts || errorData.accepts.length === 0) { + // Get payment requirements from errorData + const parsedPaymentRequirements = parsePaymentRequirementsForDisplay( + errorData, + requestUrl, + ); + if (parsedPaymentRequirements.length === 0) { return null; } - // Get payment requirements from errorData - const parsedPaymentRequirements = errorData.accepts; - // Get the current chain from wallet const currentChain = wallet?.getChain(); const currentChainId = currentChain?.id; diff --git a/packages/thirdweb/src/x402/encode.ts b/packages/thirdweb/src/x402/encode.ts index b9bb5d8c0bd..05e82d679e2 100644 --- a/packages/thirdweb/src/x402/encode.ts +++ b/packages/thirdweb/src/x402/encode.ts @@ -1,4 +1,6 @@ import type { ExactEvmPayload } from "x402/types"; +import { cachedTextDecoder } from "../utils/text-decoder.js"; +import { cachedTextEncoder } from "../utils/text-encoder.js"; import type { RequestedPaymentPayload, RequestedPaymentRequirements, @@ -27,7 +29,7 @@ export function encodePayment(payment: RequestedPaymentPayload): string { ) as ExactEvmPayload["authorization"], }, }; - return safeBase64Encode(JSON.stringify(safe)); + return base64EncodeUtf8(JSON.stringify(safe)); } /** @@ -37,7 +39,7 @@ export function encodePayment(payment: RequestedPaymentPayload): string { * @returns The decoded and validated PaymentPayload object */ export function decodePayment(payment: string): RequestedPaymentPayload { - const decoded = safeBase64Decode(payment); + const decoded = base64DecodeUtf8(payment); const parsed = JSON.parse(decoded); const obj: RequestedPaymentPayload = { @@ -93,3 +95,45 @@ export function safeBase64Decode(data: string): string { } return Buffer.from(data, "base64").toString("utf-8"); } + +/** + * Encodes a string as UTF-8 and then to base64 + * + * @param data - The string to encode + * @returns The base64 encoded UTF-8 bytes + */ +function base64EncodeUtf8(data: string): string { + if ( + typeof globalThis !== "undefined" && + typeof globalThis.btoa === "function" + ) { + const bytes = cachedTextEncoder().encode(data); + let binary = ""; + for (const byte of bytes) { + binary += String.fromCharCode(byte); + } + return globalThis.btoa(binary); + } + return Buffer.from(data, "utf-8").toString("base64"); +} + +/** + * Decodes a base64 string and interprets the bytes as UTF-8 + * + * @param data - The base64 encoded string + * @returns The decoded string + */ +export function base64DecodeUtf8(data: string): string { + if ( + typeof globalThis !== "undefined" && + typeof globalThis.atob === "function" + ) { + const binary = globalThis.atob(data); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return cachedTextDecoder().decode(bytes); + } + return Buffer.from(data, "base64").toString("utf-8"); +} diff --git a/packages/thirdweb/src/x402/fetchWithPayment.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.test.ts index ff925da2ef1..98fa6ebb5a2 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.test.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.test.ts @@ -1,13 +1,39 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; +import { allowance } from "../extensions/erc20/__generated__/IERC20/read/allowance.js"; +import { nonces } from "../extensions/erc20/__generated__/IERC20Permit/read/nonces.js"; +import { getAddress } from "../utils/address.js"; +import type { AsyncStorage } from "../utils/storage/AsyncStorage.js"; +import { webLocalStorage } from "../utils/storage/webStorage.js"; import { safeBase64Decode, safeBase64Encode } from "./encode.js"; import { wrapFetchWithPayment } from "./fetchWithPayment.js"; import { getPaymentRequestHeader } from "./headers.js"; +import { + getPermitSignatureFromCache, + savePermitSignatureToCache, +} from "./permitSignatureStorage.js"; -// Mock the createPaymentHeader function -vi.mock("./sign.js", () => ({ - createPaymentHeader: vi.fn().mockResolvedValue("mock-payment-header"), +vi.mock("./common.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getSupportedSignatureType: vi.fn( + async (args: { eip712Extras?: { primaryType?: string } }) => + args.eip712Extras?.primaryType ?? "TransferWithAuthorization", + ), + }; +}); + +vi.mock("../extensions/erc20/__generated__/IERC20/read/allowance.js", () => ({ + allowance: vi.fn(), })); +vi.mock( + "../extensions/erc20/__generated__/IERC20Permit/read/nonces.js", + () => ({ + nonces: vi.fn(), + }), +); + // Mock webLocalStorage vi.mock("../utils/storage/webStorage.js", () => ({ webLocalStorage: { @@ -17,6 +43,34 @@ vi.mock("../utils/storage/webStorage.js", () => ({ }, })); +const SIGNATURE = `0x${"11".repeat(65)}`; +const CACHED_SIGNATURE = `0x${"22".repeat(65)}`; +const OWNER = "0xabcdefabcdefabcdefabcdefabcdefabcdefabcd"; +const PAY_TO = "0x1234567890123456789012345678901234567890"; +const OTHER_ADDRESS = "0x9999999999999999999999999999999999999999"; +const ASSET = "0x0000000000000000000000000000000000000001"; + +function base64Utf8(data: unknown): string { + return Buffer.from(JSON.stringify(data), "utf-8").toString("base64"); +} + +function paymentRequiredResponse( + data: unknown, + mode: "header" | "body", +): Response { + if (mode === "header") { + return new Response(null, { + status: 402, + headers: { "payment-required": base64Utf8(data) }, + }); + } + return new Response(JSON.stringify(data), { status: 402 }); +} + +function successResponse(): Response { + return new Response(JSON.stringify({ success: true }), { status: 200 }); +} + describe("wrapFetchWithPayment", () => { const mockPaymentRequirements = { scheme: "exact", @@ -25,9 +79,9 @@ describe("wrapFetchWithPayment", () => { resource: "https://api.example.com/resource", description: "Test payment", mimeType: "application/json", - payTo: "0x1234567890123456789012345678901234567890", + payTo: PAY_TO, maxTimeoutSeconds: 300, - asset: "0x0000000000000000000000000000000000000001", + asset: ASSET, extra: { name: "Test Token", version: "1", @@ -40,12 +94,31 @@ describe("wrapFetchWithPayment", () => { error: undefined, }; + const canonicalV2Requirement = { + scheme: "exact", + network: "eip155:1", + amount: "12345", + payTo: PAY_TO, + maxTimeoutSeconds: 300, + asset: ASSET, + extra: { + name: "Test Token", + version: "1", + }, + }; + + const canonicalV2Resource = { + url: "https://api.example.com/resource", + description: "Test payment", + mimeType: "application/json", + }; + const mockClient = { clientId: "test-client-id", } as Parameters[1]; const mockAccount = { - address: "0x1234567890123456789012345678901234567890", + address: OWNER, signTypedData: vi.fn(), }; @@ -55,8 +128,36 @@ describe("wrapFetchWithPayment", () => { switchChain: vi.fn(), } as unknown as Parameters[2]; + function sentPayment( + mockFetch: ReturnType, + x402Version: number, + ) { + const init = mockFetch.mock.calls[1]?.[1] as RequestInit; + const headers = init.headers as Record; + const header = headers[getPaymentRequestHeader(x402Version)]; + expect(header).toBeDefined(); + return JSON.parse( + Buffer.from(header as string, "base64").toString("utf-8"), + ); + } + + function signedValues() { + return mockAccount.signTypedData.mock.calls.map( + (call) => (call[0] as { message: { value: bigint } }).message.value, + ); + } + + function mockFetchWith(...responses: Response[]) { + const mockFetch = vi.fn(); + for (const response of responses) { + mockFetch.mockResolvedValueOnce(response); + } + return mockFetch; + } + beforeEach(() => { vi.clearAllMocks(); + mockAccount.signTypedData.mockResolvedValue(SIGNATURE); }); it("should pass through non-402 responses unchanged", async () => { @@ -77,29 +178,11 @@ describe("wrapFetchWithPayment", () => { }); it("should parse payment requirements from payment-required header when present", async () => { - const encodedPaymentInfo = safeBase64Encode( - JSON.stringify(mock402ResponseData), + const mockFetch = mockFetchWith( + paymentRequiredResponse(mock402ResponseData, "header"), + successResponse(), ); - 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, @@ -118,22 +201,11 @@ describe("wrapFetchWithPayment", () => { }); 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 = mockFetchWith( + paymentRequiredResponse(mock402ResponseData, "body"), + successResponse(), ); - const mockFetch = vi - .fn() - .mockResolvedValueOnce(mock402Response) - .mockResolvedValueOnce(mockSuccessResponse); - const wrappedFetch = wrapFetchWithPayment( mockFetch, mockClient, @@ -178,17 +250,7 @@ describe("wrapFetchWithPayment", () => { }, }); - const mockSuccessResponse = new Response( - JSON.stringify({ success: true }), - { - status: 200, - }, - ); - - const mockFetch = vi - .fn() - .mockResolvedValueOnce(mock402Response) - .mockResolvedValueOnce(mockSuccessResponse); + const mockFetch = mockFetchWith(mock402Response, successResponse()); // Use maxValue to verify which payment requirements are used // If header is used (500000), it should pass @@ -206,47 +268,7 @@ describe("wrapFetchWithPayment", () => { // 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 payment header for the version - const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(secondCallInit.headers).toHaveProperty( - getPaymentRequestHeader(mock402ResponseData.x402Version), - ); + expect(signedValues()).toEqual([500000n]); }); it("should correctly decode a raw base64 encoded payment-required header", async () => { @@ -275,17 +297,7 @@ describe("wrapFetchWithPayment", () => { }, }); - const mockSuccessResponse = new Response( - JSON.stringify({ success: true }), - { - status: 200, - }, - ); - - const mockFetch = vi - .fn() - .mockResolvedValueOnce(mock402Response) - .mockResolvedValueOnce(mockSuccessResponse); + const mockFetch = mockFetchWith(mock402Response, successResponse()); // Use a wallet on Base (chain 8453) to match the payment requirements const baseWallet = { @@ -310,4 +322,1061 @@ describe("wrapFetchWithPayment", () => { getPaymentRequestHeader(parsed.x402Version), ); }); + + it("does not sign when a payment was already attempted for the request", async () => { + const mockFetch = mockFetchWith( + paymentRequiredResponse(mock402ResponseData, "header"), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + + await expect( + wrappedFetch("https://api.example.com/resource", { + __is402Retry: true, + } as RequestInit), + ).rejects.toThrow("Payment already attempted"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + describe("x402 v2 payment requirements", () => { + it("pays a canonical v2 header requirement that only specifies amount", async () => { + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [canonicalV2Requirement], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { maxValue: 12345n }, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + expect(signedValues()).toEqual([12345n]); + + const typedData = mockAccount.signTypedData.mock.calls[0]?.[0]; + expect(typedData.primaryType).toBe("TransferWithAuthorization"); + expect(typedData.domain.chainId).toBe(1); + expect(typedData.domain.verifyingContract).toBe(getAddress(ASSET)); + expect(typedData.message.from).toBe(getAddress(OWNER)); + expect(typedData.message.to).toBe(getAddress(PAY_TO)); + + const init = mockFetch.mock.calls[1]?.[1] as RequestInit; + expect(init.headers).toHaveProperty("PAYMENT-SIGNATURE"); + expect(init.headers).not.toHaveProperty("X-PAYMENT"); + + const payment = sentPayment(mockFetch, 2); + expect(payment.x402Version).toBe(2); + expect(payment.payload.authorization.value).toBe("12345"); + expect(payment.payload.signature).toBe(SIGNATURE); + }); + + it("includes the raw accepted requirement and top-level scheme/network in the v2 payload", async () => { + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [canonicalV2Requirement], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + await wrappedFetch("https://api.example.com/resource"); + + const payment = sentPayment(mockFetch, 2); + expect(payment.scheme).toBe("exact"); + expect(payment.network).toBe("eip155:1"); + expect(payment.accepted).toEqual(canonicalV2Requirement); + expect(payment.accepted).not.toHaveProperty("maxAmountRequired"); + expect(payment.resource).toEqual(canonicalV2Resource); + }); + + it("pays a canonical v2 JSON body requirement", async () => { + const paymentRequired = { + x402Version: 2, + accepts: [canonicalV2Requirement], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "body"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + const response = await wrappedFetch("https://api.example.com/paid"); + + expect(response.status).toBe(200); + expect(signedValues()).toEqual([12345n]); + + const payment = sentPayment(mockFetch, 2); + expect(payment.payload.authorization.value).toBe("12345"); + expect(payment.accepted).toEqual(canonicalV2Requirement); + expect(payment.resource.url).toBe("https://api.example.com/paid"); + }); + + it.each(["header", "body"] as const)( + "pays a v2 %s requirement with non-Latin-1 text", + async (mode) => { + const requirement = { + ...canonicalV2Requirement, + description: "Premium — content ✓", + }; + const resource = { + ...canonicalV2Resource, + description: "Données 日本語", + }; + const paymentRequired = { + x402Version: 2, + resource, + accepts: [requirement], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, mode), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + const payment = sentPayment(mockFetch, 2); + expect(payment.accepted).toEqual(requirement); + expect(payment.resource).toEqual(resource); + expect(signedValues()).toEqual([12345n]); + }, + ); + + it("pays a v2 envelope with v1-shaped requirements", async () => { + const paymentRequired = { + x402Version: 2, + error: "Payment required", + resource: { url: "https://api.example.com/resource" }, + accepts: [mockPaymentRequirements], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + expect(signedValues()).toEqual([1000000n]); + + const payment = sentPayment(mockFetch, 2); + expect(payment.payload.authorization.value).toBe("1000000"); + expect(payment.accepted).toEqual(mockPaymentRequirements); + expect(payment.resource).toEqual({ + url: "https://api.example.com/resource", + }); + }); + + it("pays a v1 body requirement with the v1 header and payload", async () => { + const paymentRequired = { + x402Version: 1, + accepts: [mockPaymentRequirements], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "body"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + const init = mockFetch.mock.calls[1]?.[1] as RequestInit; + expect(init.headers).toHaveProperty("X-PAYMENT"); + expect(init.headers).not.toHaveProperty("PAYMENT-SIGNATURE"); + + const payment = sentPayment(mockFetch, 1); + expect(Object.keys(payment).sort()).toEqual( + ["network", "payload", "scheme", "x402Version"].sort(), + ); + expect(payment.x402Version).toBe(1); + expect(payment.payload.authorization.value).toBe("1000000"); + expect(signedValues()).toEqual([1000000n]); + }); + + it("defaults to x402 v2 when the response has no x402Version", async () => { + const mockFetch = mockFetchWith( + paymentRequiredResponse( + { resource: canonicalV2Resource, accepts: [canonicalV2Requirement] }, + "header", + ), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + await wrappedFetch("https://api.example.com/resource"); + + const init = mockFetch.mock.calls[1]?.[1] as RequestInit; + expect(init.headers).toHaveProperty("PAYMENT-SIGNATURE"); + const payment = sentPayment(mockFetch, 2); + expect(payment.x402Version).toBe(2); + expect(payment.accepted).toEqual(canonicalV2Requirement); + }); + + describe.each(["header", "body"] as const)("%s", (mode) => { + it.each([ + [1, { amount: "1", maxAmountRequired: "1000000000" }], + [1, { amount: "1000000000", maxAmountRequired: "1" }], + [2, { amount: "1", maxAmountRequired: "1000000000" }], + [2, { amount: "1000000000", maxAmountRequired: "1" }], + ])( + "rejects before signing when amounts differ (x402Version %i, %j)", + async (x402Version, amounts) => { + const paymentRequired = { + x402Version, + accepts: [{ ...mockPaymentRequirements, ...amounts }], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, mode), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + + await expect( + wrappedFetch("https://api.example.com/resource"), + ).rejects.toThrow("do not match"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }, + ); + }); + + it("rejects before signing when amounts differ with maxValue set", async () => { + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [ + { + ...canonicalV2Requirement, + amount: "1", + maxAmountRequired: "1000000000", + }, + ], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { maxValue: 10n }, + ); + + await expect( + wrappedFetch("https://api.example.com/resource"), + ).rejects.toThrow("do not match"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("rejects when a requirement that would not be selected has mismatched amounts", async () => { + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [ + canonicalV2Requirement, + { + ...canonicalV2Requirement, + network: "eip155:8453", + amount: "1", + maxAmountRequired: "1000000000", + }, + ], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + + await expect( + wrappedFetch("https://api.example.com/resource"), + ).rejects.toThrow("do not match"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("accepts amount and maxAmountRequired when they are equal", async () => { + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [{ ...canonicalV2Requirement, maxAmountRequired: "12345" }], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + expect(signedValues()).toEqual([12345n]); + }); + + it("signs the canonical form of a zero-padded amount", async () => { + const requirement = { + ...canonicalV2Requirement, + amount: "0100", + maxAmountRequired: "100", + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse( + { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [requirement], + }, + "header", + ), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { maxValue: 100n }, + ); + await wrappedFetch("https://api.example.com/resource"); + + expect(signedValues()).toEqual([100n]); + const payment = sentPayment(mockFetch, 2); + expect(payment.payload.authorization.value).toBe("100"); + expect(payment.accepted).toEqual(requirement); + }); + + it("pays the maximum uint256 amount", async () => { + const max = (2n ** 256n - 1n).toString(); + const mockFetch = mockFetchWith( + paymentRequiredResponse( + { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [{ ...canonicalV2Requirement, amount: max }], + }, + "header", + ), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + await wrappedFetch("https://api.example.com/resource"); + + expect(signedValues()).toEqual([2n ** 256n - 1n]); + expect(sentPayment(mockFetch, 2).payload.authorization.value).toBe(max); + }); + + it.each([ + ["canonical v2", { x402Version: 2, accepts: [canonicalV2Requirement] }], + ["v1-shaped", mock402ResponseData], + ])( + "blocks a %s payment when maxValue is 0n", + async (_name, paymentRequired) => { + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { maxValue: 0n }, + ); + + await expect( + wrappedFetch("https://api.example.com/resource"), + ).rejects.toThrow("Payment amount exceeds maximum allowed"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }, + ); + + it("blocks a canonical v2 amount above maxValue", async () => { + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [canonicalV2Requirement], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { maxValue: 12344n }, + ); + + await expect( + wrappedFetch("https://api.example.com/resource"), + ).rejects.toThrow("Payment amount exceeds maximum allowed"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + }); + + it.each([ + ["amount", { amount: "1e3" }], + ["maxAmountRequired", { amount: undefined, maxAmountRequired: "1e3" }], + ["amount", { amount: "-1" }], + ["amount", { amount: "1.5" }], + ["amount", { amount: "" }], + ["amount", { amount: 1000 }], + ["amount", { amount: (2n ** 256n).toString() }], + ["amount", { amount: "0x10" }], + ["maxAmountRequired", { amount: undefined, maxAmountRequired: "0x10" }], + ["amount", { amount: " 100" }], + ["amount", { amount: "100\n" }], + ["amount", { amount: "+100" }], + ["amount", { amount: "1".repeat(79) }], + ["maxAmountRequired", { maxAmountRequired: "" }], + ])("rejects a malformed %s (%j)", async (_field, override) => { + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [{ ...canonicalV2Requirement, ...override }], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + + await expect( + wrappedFetch("https://api.example.com/resource"), + ).rejects.toThrow("Invalid payment requirements"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + + it("rejects a requirement with no amount", async () => { + const { amount: _amount, ...withoutAmount } = canonicalV2Requirement; + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [withoutAmount], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + + await expect( + wrappedFetch("https://api.example.com/resource"), + ).rejects.toThrow("missing amount or maxAmountRequired"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + }); + + describe("resource resolution", () => { + it("keeps the requirement resource and echoes the top-level resource", async () => { + const requirement = { + ...mockPaymentRequirements, + resource: "https://api.example.com/a", + }; + const topLevelResource = { url: "https://api.example.com/b" }; + const mockFetch = mockFetchWith( + paymentRequiredResponse( + { + x402Version: 2, + resource: topLevelResource, + accepts: [requirement], + }, + "header", + ), + successResponse(), + ); + const selector = vi.fn( + (requirements: Array<{ resource: string }>) => requirements[0], + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { paymentRequirementsSelector: selector as never }, + ); + await wrappedFetch("https://api.example.com/c"); + + expect(selector.mock.calls[0]?.[0]?.[0]?.resource).toBe( + "https://api.example.com/a", + ); + const payment = sentPayment(mockFetch, 2); + expect(payment.resource).toEqual(topLevelResource); + expect(payment.accepted).toEqual(requirement); + }); + + it.each([ + [ + "a Request", + () => new Request("https://api.example.com/request"), + "https://api.example.com/request", + ], + [ + "a URL", + () => new URL("https://api.example.com/url"), + "https://api.example.com/url", + ], + ])( + "falls back to the URL of %s", + async (_name, createInput, expectedUrl) => { + const mockFetch = mockFetchWith( + paymentRequiredResponse( + { x402Version: 2, accepts: [canonicalV2Requirement] }, + "body", + ), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + await wrappedFetch(createInput() as unknown as RequestInfo); + + expect(sentPayment(mockFetch, 2).resource.url).toBe(expectedUrl); + }, + ); + + it("rejects before signing when no resource URL can be resolved", async () => { + const mockFetch = mockFetchWith( + paymentRequiredResponse( + { x402Version: 2, accepts: [canonicalV2Requirement] }, + "body", + ), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + + await expect(wrappedFetch("/paid")).rejects.toThrow(); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + expect(mockFetch).toHaveBeenCalledTimes(1); + }); + }); + + describe("custom selector", () => { + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [canonicalV2Requirement], + }; + + it("validates requirements returned by a custom selector", async () => { + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { + paymentRequirementsSelector: (requirements) => { + const [first] = requirements; + return first ? { ...first, maxAmountRequired: "1e3" } : undefined; + }, + }, + ); + + await expect( + wrappedFetch("https://api.example.com/resource"), + ).rejects.toThrow("Invalid payment requirements"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + }); + + it("validates a parsed requirement modified in place by a custom selector", async () => { + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { + paymentRequirementsSelector: (requirements) => { + const [first] = requirements; + if (first) { + first.maxAmountRequired = "0x10"; + } + return first; + }, + }, + ); + + await expect( + wrappedFetch("https://api.example.com/resource"), + ).rejects.toThrow("Invalid payment requirements"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + }); + + it("caps the amount of a copy returned by a custom selector", async () => { + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { + maxValue: 15000n, + paymentRequirementsSelector: (requirements) => { + const [first] = requirements; + return first + ? { ...first, maxAmountRequired: "20000" } + : undefined; + }, + }, + ); + + await expect( + wrappedFetch("https://api.example.com/resource"), + ).rejects.toThrow("Payment amount exceeds maximum allowed"); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + }); + + it("signs and echoes a copy returned by a custom selector", async () => { + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { + paymentRequirementsSelector: (requirements) => { + const [first] = requirements; + return first + ? { ...first, maxAmountRequired: "20000" } + : undefined; + }, + }, + ); + await wrappedFetch("https://api.example.com/resource"); + + expect(signedValues()).toEqual([20000n]); + const payment = sentPayment(mockFetch, 2); + expect(payment.payload.authorization.value).toBe("20000"); + expect(payment.accepted.maxAmountRequired).toBe("20000"); + }); + + it("exposes the normalised amount as maxAmountRequired to selectors", async () => { + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + const selector = vi.fn( + (requirements: Array<{ maxAmountRequired: string }>) => + requirements[0], + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { + paymentRequirementsSelector: selector as never, + }, + ); + await wrappedFetch("https://api.example.com/resource"); + + expect(selector.mock.calls[0]?.[0]).toEqual([ + expect.objectContaining({ + maxAmountRequired: "12345", + resource: canonicalV2Resource.url, + description: "", + mimeType: "", + }), + ]); + expect(signedValues()).toEqual([12345n]); + expect(sentPayment(mockFetch, 2).accepted).toEqual( + canonicalV2Requirement, + ); + }); + }); + }); + + describe("upto permit cache", () => { + const uptoRequirement = { + ...canonicalV2Requirement, + scheme: "upto", + amount: "1000", + extra: { + name: "Test Token", + version: "1", + primaryType: "Permit", + }, + }; + + const cacheParams = { + chainId: 1, + asset: ASSET, + owner: OWNER, + spender: PAY_TO, + }; + + function createMemoryStorage(): AsyncStorage { + const store = new Map(); + return { + getItem: async (key) => store.get(key) ?? null, + setItem: async (key, value) => { + store.set(key, value); + }, + removeItem: async (key) => { + store.delete(key); + }, + }; + } + + async function seedCachedPermit( + storage: AsyncStorage, + value: string, + overrides: { + deadlineOffset?: number; + from?: string; + to?: string; + envelope?: Record; + } = {}, + ) { + const deadline = String( + Math.floor(Date.now() / 1000) + (overrides.deadlineOffset ?? 3600), + ); + await savePermitSignatureToCache( + storage, + cacheParams, + { + x402Version: 1, + scheme: "upto", + network: "eip155:1", + ...overrides.envelope, + payload: { + signature: CACHED_SIGNATURE, + authorization: { + from: overrides.from ?? OWNER, + to: overrides.to ?? PAY_TO, + value, + validAfter: "0", + validBefore: deadline, + nonce: `0x${"00".repeat(32)}`, + }, + }, + } as Parameters[2], + deadline, + value, + ); + } + + beforeEach(() => { + vi.mocked(allowance).mockResolvedValue(10n ** 18n); + vi.mocked(nonces).mockResolvedValue(0n); + }); + + it("reuses a cached permit that covers the amount, in the current envelope", async () => { + const storage = createMemoryStorage(); + await seedCachedPermit(storage, "5000"); + + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [uptoRequirement], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { storage }, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + + const payment = sentPayment(mockFetch, 2); + expect(payment.x402Version).toBe(2); + expect(payment.scheme).toBe("upto"); + expect(payment.network).toBe("eip155:1"); + expect(payment.accepted).toEqual(uptoRequirement); + expect(payment.resource).toEqual(canonicalV2Resource); + expect(payment.payload.signature).toBe(CACHED_SIGNATURE); + }); + + it("signs a new permit when the cached permit is below the amount", async () => { + const storage = createMemoryStorage(); + await seedCachedPermit(storage, "999"); + + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [uptoRequirement], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { storage }, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(200); + expect(signedValues()).toEqual([1000n]); + + const payment = sentPayment(mockFetch, 2); + expect(payment.payload.signature).toBe(SIGNATURE); + expect(payment.payload.authorization.value).toBe("1000"); + expect(payment.accepted).toEqual(uptoRequirement); + + const cached = await getPermitSignatureFromCache(storage, cacheParams); + expect(cached?.maxAmount).toBe("1000"); + expect(cached?.payload.x402Version).toBe(2); + expect(cached?.payload.payload).toMatchObject({ + signature: SIGNATURE, + authorization: { value: "1000" }, + }); + }); + + it.each([ + { name: "a cached value equal to the amount", reused: true }, + { + name: "a minAmountRequired covered by the allowance", + allowance: 600n, + minAmountRequired: "500", + reused: true, + }, + { + name: "a cached value above maxValue", + maxValue: 1000n, + cachedValue: "5000", + reused: false, + }, + { name: "an expired deadline", deadlineOffset: -60, reused: false }, + { name: "an allowance below the amount", allowance: 999n, reused: false }, + { + name: "a minAmountRequired of zero", + allowance: 0n, + minAmountRequired: "0", + reused: false, + }, + { name: "a malformed cached value", cachedValue: "1e3", reused: false }, + { + name: "a permit for another spender", + to: OTHER_ADDRESS, + reused: false, + }, + { + name: "a permit from another owner", + from: OTHER_ADDRESS, + reused: false, + }, + ])("handles $name", async (testCase) => { + const storage = createMemoryStorage(); + await seedCachedPermit(storage, testCase.cachedValue ?? "1000", { + deadlineOffset: testCase.deadlineOffset, + from: testCase.from, + to: testCase.to, + }); + if (testCase.allowance !== undefined) { + vi.mocked(allowance).mockResolvedValue(testCase.allowance); + } + + const requirement = testCase.minAmountRequired + ? { + ...uptoRequirement, + extra: { + ...uptoRequirement.extra, + minAmountRequired: testCase.minAmountRequired, + }, + } + : uptoRequirement; + const mockFetch = mockFetchWith( + paymentRequiredResponse( + { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [requirement], + }, + "header", + ), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { storage, maxValue: testCase.maxValue }, + ); + await wrappedFetch("https://api.example.com/resource"); + + const payment = sentPayment(mockFetch, 2); + if (testCase.reused) { + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + expect(payment.payload.signature).toBe(CACHED_SIGNATURE); + } else { + expect(signedValues()).toEqual([1000n]); + expect(payment.payload.signature).toBe(SIGNATURE); + expect(payment.payload.authorization.value).toBe("1000"); + } + }); + + it("reuses a v2 cached permit in a v1 envelope for a v1 requirement", async () => { + const storage = createMemoryStorage(); + await seedCachedPermit(storage, "5000", { + envelope: { + x402Version: 2, + accepted: uptoRequirement, + resource: canonicalV2Resource, + }, + }); + + const v1Requirement = { + ...mockPaymentRequirements, + scheme: "upto", + maxAmountRequired: "1000", + extra: uptoRequirement.extra, + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse( + { x402Version: 1, accepts: [v1Requirement] }, + "body", + ), + successResponse(), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + { storage }, + ); + await wrappedFetch("https://api.example.com/resource"); + + expect(mockAccount.signTypedData).not.toHaveBeenCalled(); + const payment = sentPayment(mockFetch, 1); + expect(Object.keys(payment).sort()).toEqual( + ["network", "payload", "scheme", "x402Version"].sort(), + ); + expect(payment.x402Version).toBe(1); + expect(payment.payload.signature).toBe(CACHED_SIGNATURE); + }); + + it("writes and clears the permit with the default storage when payment is rejected", async () => { + const paymentRequired = { + x402Version: 2, + resource: canonicalV2Resource, + accepts: [uptoRequirement], + }; + const mockFetch = mockFetchWith( + paymentRequiredResponse(paymentRequired, "header"), + paymentRequiredResponse(paymentRequired, "header"), + ); + + const wrappedFetch = wrapFetchWithPayment( + mockFetch, + mockClient, + mockWallet, + ); + const response = await wrappedFetch("https://api.example.com/resource"); + + expect(response.status).toBe(402); + const expectedKey = `x402:permit:1:${ASSET.toLowerCase()}:${OWNER.toLowerCase()}:${PAY_TO.toLowerCase()}`; + expect(vi.mocked(webLocalStorage.setItem).mock.calls[0]?.[0]).toBe( + expectedKey, + ); + expect(vi.mocked(webLocalStorage.removeItem)).toHaveBeenCalledTimes(1); + expect(vi.mocked(webLocalStorage.removeItem).mock.calls[0]?.[0]).toBe( + expectedKey, + ); + }); + }); }); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 2a529c4fe91..5243e4a6691 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -4,7 +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 { base64DecodeUtf8 } from "./encode.js"; import { getPaymentRequestHeader, getPaymentResponseHeader, @@ -13,8 +13,9 @@ import { clearPermitSignatureFromCache } from "./permitSignatureStorage.js"; import { extractEvmChainId, networkToCaip2ChainId, + normalizePaymentRequirements, + parsePaymentRequired, type RequestedPaymentRequirements, - RequestedPaymentRequirementsSchema, } from "./schemas.js"; import { createPaymentHeader } from "./sign.js"; import { x402Version as defaultX402Version } from "./types.js"; @@ -33,7 +34,7 @@ import { x402Version as defaultX402Version } from "./types.js"; * @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 - * @param maxValue - The maximum allowed payment amount in base units + * @param maxValue - The maximum allowed payment amount in base units. `0n` only allows zero-amount payments * @returns A wrapped fetch function that handles 402 responses automatically * * @example @@ -64,6 +65,10 @@ export function wrapFetchWithPayment( wallet: Wallet, options?: { maxValue?: bigint; + /** + * Selects the payment requirement to pay. Return one of the provided requirements; + * the returned requirement is validated again before payment. + */ paymentRequirementsSelector?: ( paymentRequirements: RequestedPaymentRequirements[], ) => RequestedPaymentRequirements | undefined; @@ -81,49 +86,25 @@ export function wrapFetchWithPayment( return response; } - let x402Version: number; - let parsedPaymentRequirements: RequestedPaymentRequirements[]; - let error: string | undefined; + const initParams = init || {}; + + if ((initParams as { __is402Retry?: boolean }).__is402Retry) { + throw new Error("Payment already attempted"); + } // 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 ?? defaultX402Version; - 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 ?? ""}`, - ); - } + const paymentRequired: unknown = paymentRequiredHeader + ? JSON.parse(base64DecodeUtf8(paymentRequiredHeader)) + : await response.json(); - x402Version = body.x402Version ?? defaultX402Version; - parsedPaymentRequirements = body.accepts.map((x) => - RequestedPaymentRequirementsSchema.parse(x), - ); - error = body.error; - } + const parsed = parsePaymentRequired(paymentRequired, getRequestUrl(input)); + const x402Version = parsed.x402Version ?? defaultX402Version; + const error = parsed.error; + const normalizedPaymentRequirements = parsed.accepts; + const parsedPaymentRequirements = normalizedPaymentRequirements.map( + (x) => x.requirements, + ); const account = wallet.getAccount(); let chain = wallet.getChain(); @@ -133,7 +114,7 @@ export function wrapFetchWithPayment( "Wallet not connected. Please connect your wallet to continue.", ); } - const selectedPaymentRequirements = options?.paymentRequirementsSelector + const selected = options?.paymentRequirementsSelector ? options.paymentRequirementsSelector(parsedPaymentRequirements) : defaultPaymentRequirementsSelector( parsedPaymentRequirements, @@ -141,14 +122,22 @@ export function wrapFetchWithPayment( error, ); - if (!selectedPaymentRequirements) { + if (!selected) { throw new Error( `No suitable payment requirements found for chain ${chain.id}. ${error}`, ); } + const { requirements: selectedPaymentRequirements } = + normalizePaymentRequirements(selected, { + resourceUrl: parsed.resourceUrl, + }); + const acceptedRequirement = + normalizedPaymentRequirements.find((x) => x.requirements === selected) + ?.raw ?? selected; + if ( - options?.maxValue && + options?.maxValue !== undefined && BigInt(selectedPaymentRequirements.maxAmountRequired) > options.maxValue ) { throw new Error( @@ -176,23 +165,27 @@ export function wrapFetchWithPayment( } } + const storage = options?.storage ?? webLocalStorage; const paymentHeader = await createPaymentHeader( client, account, selectedPaymentRequirements, x402Version, - options?.storage ?? webLocalStorage, + storage, + { + accepted: acceptedRequirement, + resource: parsed.resource ?? { + url: selectedPaymentRequirements.resource, + description: selectedPaymentRequirements.description, + mimeType: selectedPaymentRequirements.mimeType, + }, + }, + options?.maxValue, ); const paymentRequestHeaderName = getPaymentRequestHeader(x402Version); const paymentResponseHeaderName = getPaymentResponseHeader(x402Version); - const initParams = init || {}; - - if ((initParams as { __is402Retry?: boolean }).__is402Retry) { - throw new Error("Payment already attempted"); - } - const newInit = { ...initParams, headers: { @@ -206,8 +199,8 @@ export function wrapFetchWithPayment( const secondResponse = await fetch(input, newInit); // If payment was rejected (still 402), clear cached signature - if (secondResponse.status === 402 && options?.storage) { - await clearPermitSignatureFromCache(options.storage, { + if (secondResponse.status === 402) { + await clearPermitSignatureFromCache(storage, { chainId: paymentChainId, asset: selectedPaymentRequirements.asset, owner: getAddress(account.address), @@ -219,6 +212,31 @@ export function wrapFetchWithPayment( }; } +/** + * Resolves the absolute URL of a fetch input, if it can be determined. + * @internal + */ +export function getRequestUrl(input: RequestInfo | URL): string | undefined { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.href + : (input as Partial | undefined)?.url; + if (typeof url !== "string") { + return undefined; + } + try { + const base = + typeof globalThis.location !== "undefined" + ? globalThis.location.href + : undefined; + return new URL(url, base).toString(); + } catch { + return undefined; + } +} + function defaultPaymentRequirementsSelector( paymentRequirements: RequestedPaymentRequirements[], chainId: number, diff --git a/packages/thirdweb/src/x402/schemas.test.ts b/packages/thirdweb/src/x402/schemas.test.ts new file mode 100644 index 00000000000..2761afd1d90 --- /dev/null +++ b/packages/thirdweb/src/x402/schemas.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from "vitest"; +import { + normalizePaymentRequirements, + parsePaymentRequirementsForDisplay, +} from "./schemas.js"; + +const PAY_TO = "0x1234567890123456789012345678901234567890"; +const ASSET = "0x0000000000000000000000000000000000000001"; +const REQUEST_URL = "https://api.example.com/paid"; + +const canonicalV2Requirement = { + scheme: "exact", + network: "eip155:8453", + amount: "1000", + payTo: PAY_TO, + maxTimeoutSeconds: 300, + asset: ASSET, + extra: { name: "Test Token", version: "1" }, +}; + +describe("normalizePaymentRequirements", () => { + it("canonicalises the amount", () => { + const { requirements } = normalizePaymentRequirements( + { ...canonicalV2Requirement, amount: "0100" }, + { resourceUrl: REQUEST_URL }, + ); + expect(requirements.maxAmountRequired).toBe("100"); + }); + + it("accepts amount and maxAmountRequired with the same value", () => { + const { requirements } = normalizePaymentRequirements( + { ...canonicalV2Requirement, amount: "0100", maxAmountRequired: "100" }, + { resourceUrl: REQUEST_URL }, + ); + expect(requirements.maxAmountRequired).toBe("100"); + }); + + it.each([ + { amount: "1", maxAmountRequired: "1000000000" }, + { amount: "1000000000", maxAmountRequired: "1" }, + ])("rejects mismatched amounts (%j)", (amounts) => { + expect(() => + normalizePaymentRequirements( + { ...canonicalV2Requirement, ...amounts }, + { resourceUrl: REQUEST_URL }, + ), + ).toThrow("do not match"); + }); + + it("keeps the raw requirement", () => { + const raw = { ...canonicalV2Requirement }; + expect( + normalizePaymentRequirements(raw, { resourceUrl: REQUEST_URL }).raw, + ).toBe(raw); + }); +}); + +describe("parsePaymentRequirementsForDisplay", () => { + it("parses a canonical v2 requirement without a resource using the request URL", () => { + expect( + parsePaymentRequirementsForDisplay( + { x402Version: 2, accepts: [canonicalV2Requirement] }, + REQUEST_URL, + ), + ).toEqual([ + expect.objectContaining({ + network: "eip155:8453", + asset: ASSET, + maxAmountRequired: "1000", + resource: REQUEST_URL, + }), + ]); + }); + + it("prefers the top-level resource URL", () => { + expect( + parsePaymentRequirementsForDisplay( + { + x402Version: 2, + resource: { url: "https://api.example.com/resource" }, + accepts: [canonicalV2Requirement], + }, + REQUEST_URL, + ), + ).toEqual([ + expect.objectContaining({ resource: "https://api.example.com/resource" }), + ]); + }); + + it("skips invalid entries", () => { + const requirements = parsePaymentRequirementsForDisplay( + { + x402Version: 2, + accepts: [ + { ...canonicalV2Requirement, amount: "1", maxAmountRequired: "2" }, + { ...canonicalV2Requirement, amount: "1e3" }, + { ...canonicalV2Requirement, network: "eip155:1" }, + ], + }, + REQUEST_URL, + ); + expect(requirements).toHaveLength(1); + expect(requirements[0]?.network).toBe("eip155:1"); + }); + + it.each([[undefined], [null], [{}], [{ accepts: "invalid" }]])( + "returns no requirements for %j", + (paymentRequired) => { + expect( + parsePaymentRequirementsForDisplay(paymentRequired, REQUEST_URL), + ).toEqual([]); + }, + ); + + it("returns no requirements when no resource URL can be resolved", () => { + expect( + parsePaymentRequirementsForDisplay({ + x402Version: 2, + accepts: [canonicalV2Requirement], + }), + ).toEqual([]); + }); +}); diff --git a/packages/thirdweb/src/x402/schemas.ts b/packages/thirdweb/src/x402/schemas.ts index 15e3fd404ed..8af0276824d 100644 --- a/packages/thirdweb/src/x402/schemas.ts +++ b/packages/thirdweb/src/x402/schemas.ts @@ -23,7 +23,12 @@ const RequestedPaymentPayloadSchema = PaymentPayloadSchema.extend({ export type RequestedPaymentPayload = z.infer< typeof RequestedPaymentPayloadSchema ->; +> & { + /** x402 v2: the payment requirement accepted by the client, as sent by the server */ + accepted?: Record; + /** x402 v2: the resource being paid for */ + resource?: Record; +}; export type UnsignedPaymentPayload = Omit< RequestedPaymentPayload, "payload" @@ -31,16 +36,204 @@ export type UnsignedPaymentPayload = Omit< payload: Omit & { signature: undefined }; }; -export const RequestedPaymentRequirementsSchema = - PaymentRequirementsSchema.extend({ - network: FacilitatorNetworkSchema, - scheme: PaymentSchemeSchema, - }); +const RequestedPaymentRequirementsSchema = PaymentRequirementsSchema.extend({ + network: FacilitatorNetworkSchema, + scheme: PaymentSchemeSchema, +}); export type RequestedPaymentRequirements = z.infer< typeof RequestedPaymentRequirementsSchema >; +const MAX_UINT256 = 2n ** 256n - 1n; +const ATOMIC_AMOUNT_REGEX = /^\d{1,78}$/; + +/** + * Returns true if the value is a base-10 integer string that fits in a uint256. + * @internal + */ +export function isAtomicAmount(value: unknown): value is string { + return ( + typeof value === "string" && + ATOMIC_AMOUNT_REGEX.test(value) && + BigInt(value) <= MAX_UINT256 + ); +} + +/** + * Reads an amount field and returns it in canonical form (no leading zeros). + */ +function readAtomicAmount( + requirement: Record, + field: "amount" | "maxAmountRequired", +): string | undefined { + const value = requirement[field]; + if (value === undefined) { + return undefined; + } + if (!isAtomicAmount(value)) { + throw new Error( + `Invalid payment requirements: ${field} must be an integer string in base units`, + ); + } + return BigInt(value).toString(); +} + +/** + * Payment requirements normalised from a server 402 response. + * @internal + */ +type NormalizedPaymentRequirements = { + /** The normalised requirement */ + requirements: RequestedPaymentRequirements; + /** The requirement exactly as sent by the server */ + raw: Record; +}; + +/** + * Extracts the top-level resource object of a 402 payment required response, if any. + */ +function getPaymentRequiredResource( + paymentRequired: unknown, +): Record | undefined { + if (!isRecord(paymentRequired)) { + return undefined; + } + const resource = paymentRequired.resource; + if (isRecord(resource) && typeof resource.url === "string") { + return resource; + } + return undefined; +} + +/** + * Normalises a payment requirement from a 402 response (x402 v1, v2, or v2 envelopes with v1 requirements). + * + * @param requirement - The raw payment requirement + * @param context - Fallback values for fields that v2 moves out of the requirement + * @returns The normalised requirement and the raw requirement + * @throws If the amount is missing, malformed, or ambiguous, or the requirement is otherwise invalid + * @internal + */ +export function normalizePaymentRequirements( + requirement: unknown, + context: { resourceUrl?: string } = {}, +): NormalizedPaymentRequirements { + if (!isRecord(requirement)) { + throw new Error("Invalid payment requirements: expected an object"); + } + + const amount = readAtomicAmount(requirement, "amount"); + const maxAmountRequired = readAtomicAmount(requirement, "maxAmountRequired"); + if ( + amount !== undefined && + maxAmountRequired !== undefined && + amount !== maxAmountRequired + ) { + throw new Error( + `Invalid payment requirements: amount (${amount}) and maxAmountRequired (${maxAmountRequired}) do not match`, + ); + } + const normalizedAmount = amount ?? maxAmountRequired; + if (normalizedAmount === undefined) { + throw new Error( + "Invalid payment requirements: missing amount or maxAmountRequired", + ); + } + + const requirements = RequestedPaymentRequirementsSchema.parse({ + ...requirement, + maxAmountRequired: normalizedAmount, + resource: + typeof requirement.resource === "string" + ? requirement.resource + : context.resourceUrl, + description: + typeof requirement.description === "string" + ? requirement.description + : "", + mimeType: + typeof requirement.mimeType === "string" ? requirement.mimeType : "", + }); + + return { requirements, raw: requirement }; +} + +/** + * Parses a decoded 402 payment required object (PAYMENT-REQUIRED header or JSON body). + * Every entry of `accepts` is normalised with {@link normalizePaymentRequirements}. + * + * @param paymentRequired - The decoded payment required object + * @param requestUrl - The URL of the request, used when the response carries no resource URL + * @returns The x402 version, error, top-level resource, resolved resource URL and normalised requirements + * @throws If the object has no accepts array or any requirement is invalid + * @internal + */ +export function parsePaymentRequired( + paymentRequired: unknown, + requestUrl?: string, +): { + x402Version: number | undefined; + error: string | undefined; + resource: Record | undefined; + resourceUrl: string | undefined; + accepts: NormalizedPaymentRequirements[]; +} { + const data = isRecord(paymentRequired) ? paymentRequired : {}; + const error = typeof data.error === "string" ? data.error : undefined; + if (!Array.isArray(data.accepts)) { + throw new Error( + `402 response has no usable x402 payment requirements. ${error ?? ""}`, + ); + } + const resource = getPaymentRequiredResource(data); + const resourceUrl = + typeof resource?.url === "string" ? resource.url : requestUrl; + return { + x402Version: + typeof data.x402Version === "number" ? data.x402Version : undefined, + error, + resource, + resourceUrl, + accepts: data.accepts.map((requirement) => + normalizePaymentRequirements(requirement, { resourceUrl }), + ), + }; +} + +/** + * Parses the payment requirements of a 402 response for display only, skipping invalid entries. + * + * @param paymentRequired - The decoded payment required object + * @param requestUrl - The URL of the request, used when the response carries no resource URL + * @returns The normalised requirements that passed validation + * @internal + */ +export function parsePaymentRequirementsForDisplay( + paymentRequired: unknown, + requestUrl?: string, +): RequestedPaymentRequirements[] { + if (!isRecord(paymentRequired) || !Array.isArray(paymentRequired.accepts)) { + return []; + } + const resource = getPaymentRequiredResource(paymentRequired); + const resourceUrl = + typeof resource?.url === "string" ? resource.url : requestUrl; + return paymentRequired.accepts.flatMap((requirement) => { + try { + return [ + normalizePaymentRequirements(requirement, { resourceUrl }).requirements, + ]; + } catch { + return []; + } + }); +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + const FacilitatorSettleResponseSchema = SettleResponseSchema.extend({ network: FacilitatorNetworkSchema, errorMessage: z.string().optional(), diff --git a/packages/thirdweb/src/x402/sign.ts b/packages/thirdweb/src/x402/sign.ts index 24ed29680f7..298790bebf9 100644 --- a/packages/thirdweb/src/x402/sign.ts +++ b/packages/thirdweb/src/x402/sign.ts @@ -1,5 +1,5 @@ import { hexToBigInt } from "viem"; -import type { ExactEvmPayloadAuthorization } from "x402/types"; +import type { ExactEvmPayload, ExactEvmPayloadAuthorization } from "x402/types"; import { getCachedChain } from "../chains/utils.js"; import type { ThirdwebClient } from "../client/client.js"; import { getContract } from "../contract/contract.js"; @@ -18,6 +18,7 @@ import { } from "./permitSignatureStorage.js"; import { extractEvmChainId, + isAtomicAmount, networkToCaip2ChainId, type RequestedPaymentPayload, type RequestedPaymentRequirements, @@ -25,12 +26,45 @@ import { } from "./schemas.js"; import type { ERC20TokenAmount } from "./types.js"; +/** + * x402 v2 payload fields echoed back to the server. + */ +type PaymentPayloadV2Context = { + /** The selected payment requirement exactly as sent by the server */ + accepted: Record; + /** The resource being paid for */ + resource?: Record; +}; + +/** + * Builds the payload fields shared by every payment payload for the given requirements. + * v2 payloads additionally carry `accepted` and `resource`. + */ +function paymentPayloadEnvelope( + x402Version: number, + paymentRequirements: RequestedPaymentRequirements, + v2Context: PaymentPayloadV2Context | undefined, +): Omit { + return { + x402Version, + scheme: paymentRequirements.scheme, + network: paymentRequirements.network, + ...(x402Version === 2 && v2Context + ? { + accepted: v2Context.accepted, + ...(v2Context.resource ? { resource: v2Context.resource } : {}), + } + : {}), + }; +} + /** * Prepares an unsigned payment header with the given sender address and payment requirements. * * @param from - The sender's address from which the payment will be made * @param x402Version - The version of the X402 protocol to use * @param paymentRequirements - The payment requirements containing scheme and network information + * @param v2Context - The x402 v2 fields to include in the payload * @returns An unsigned payment payload containing authorization details */ function preparePaymentHeader( @@ -38,6 +72,7 @@ function preparePaymentHeader( x402Version: number, paymentRequirements: RequestedPaymentRequirements, nonce: Hex, + v2Context: PaymentPayloadV2Context | undefined, ): UnsignedPaymentPayload { const validAfter = BigInt( Math.floor(Date.now() / 1000) - 86400, // 24h before in case weird block timestamp behavior @@ -47,9 +82,7 @@ function preparePaymentHeader( ).toString(); return { - x402Version, - scheme: paymentRequirements.scheme, - network: paymentRequirements.network, + ...paymentPayloadEnvelope(x402Version, paymentRequirements, v2Context), payload: { signature: undefined, authorization: { @@ -71,6 +104,7 @@ function preparePaymentHeader( * @param paymentRequirements - The payment requirements containing scheme and network information * @param unsignedPaymentHeader - The unsigned payment payload to be signed * @param storage - Optional storage for caching permit signatures (for "upto" scheme) + * @param v2Context - The x402 v2 fields to include in the payload * @returns A promise that resolves to the signed payment payload */ async function signPaymentHeader( @@ -79,6 +113,8 @@ async function signPaymentHeader( paymentRequirements: RequestedPaymentRequirements, x402Version: number, storage?: AsyncStorage, + v2Context?: PaymentPayloadV2Context, + maxValue?: bigint, ): Promise { const from = getAddress(account.address); const caip2ChainId = networkToCaip2ChainId(paymentRequirements.network); @@ -114,8 +150,23 @@ async function signPaymentHeader( // Try to reuse cached signature for "upto" scheme if (shouldCache && storage) { const cached = await getPermitSignatureFromCache(storage, cacheParams); + const cachedPayload = cached?.payload?.payload as + | ExactEvmPayload + | undefined; + const cachedAuthorization = cachedPayload?.authorization; + const cachedValue = cachedAuthorization?.value; + const amount = BigInt(paymentRequirements.maxAmountRequired); - if (cached) { + if ( + cached && + cachedPayload && + isSameAddress(cachedAuthorization?.from, from) && + isSameAddress(cachedAuthorization?.to, spender) && + isAtomicAmount(cached.deadline) && + isAtomicAmount(cachedValue) && + BigInt(cachedValue) >= amount && + (maxValue === undefined || BigInt(cachedValue) <= maxValue) + ) { // Validate deadline hasn't passed const now = BigInt(Math.floor(Date.now() / 1000)); if (BigInt(cached.deadline) > now) { @@ -130,19 +181,30 @@ async function signPaymentHeader( spender, }); - // Determine threshold - use minAmountRequired if present, else maxAmountRequired + // Determine threshold - use minAmountRequired if it is within (0, amount], else the amount const extra = paymentRequirements.extra as | (ERC20TokenAmount["asset"]["eip712"] & { minAmountRequired?: string; }) | undefined; - const threshold = extra?.minAmountRequired + const minAmount = isAtomicAmount(extra?.minAmountRequired) ? BigInt(extra.minAmountRequired) - : BigInt(paymentRequirements.maxAmountRequired); + : undefined; + const threshold = + minAmount !== undefined && minAmount > 0n && minAmount <= amount + ? minAmount + : amount; - // If allowance >= threshold, reuse signature + // If allowance >= threshold, reuse the signature in the current envelope if (currentAllowance >= threshold) { - return cached.payload; + return { + ...paymentPayloadEnvelope( + x402Version, + paymentRequirements, + v2Context, + ), + payload: cachedPayload, + }; } } } @@ -162,6 +224,7 @@ async function signPaymentHeader( x402Version, paymentRequirements, toHex(nonce, { size: 32 }), // permit nonce + v2Context, ); const { signature } = await signERC2612Permit( account, @@ -198,6 +261,7 @@ async function signPaymentHeader( x402Version, paymentRequirements, nonce, // random nonce + v2Context, ); const { signature } = await signERC3009Authorization( account, @@ -226,6 +290,8 @@ async function signPaymentHeader( * @param x402Version - The version of the X402 protocol to use * @param paymentRequirements - The payment requirements containing scheme and network information * @param storage - Optional storage for caching permit signatures (for "upto" scheme) + * @param v2Context - The x402 v2 fields to include in the payload + * @param maxValue - The maximum allowed payment amount * @returns A promise that resolves to the encoded payment header string */ export async function createPaymentHeader( @@ -234,6 +300,8 @@ export async function createPaymentHeader( paymentRequirements: RequestedPaymentRequirements, x402Version: number, storage?: AsyncStorage, + v2Context?: PaymentPayloadV2Context, + maxValue?: bigint, ): Promise { const payment = await signPaymentHeader( client, @@ -241,10 +309,18 @@ export async function createPaymentHeader( paymentRequirements, x402Version, storage, + v2Context, + maxValue, ); return encodePayment(payment); } +function isSameAddress(value: unknown, address: string): boolean { + return ( + typeof value === "string" && value.toLowerCase() === address.toLowerCase() + ); +} + /** * Signs an EIP-3009 authorization for USDC transfer *