diff --git a/.changeset/eight-pants-drum.md b/.changeset/eight-pants-drum.md new file mode 100644 index 00000000000..3744dd06674 --- /dev/null +++ b/.changeset/eight-pants-drum.md @@ -0,0 +1,5 @@ +--- +"thirdweb": patch +--- + +Support x402 v2 diff --git a/packages/thirdweb/src/x402/common.ts b/packages/thirdweb/src/x402/common.ts index c15ba4873ee..5f05930dac8 100644 --- a/packages/thirdweb/src/x402/common.ts +++ b/packages/thirdweb/src/x402/common.ts @@ -51,7 +51,8 @@ export async function decodePaymentRequest( let decodedPayment: RequestedPaymentPayload; try { decodedPayment = decodePayment(paymentData); - decodedPayment.x402Version = x402Version; + // Preserve version provided by the client, default to the current protocol version if missing + decodedPayment.x402Version ??= x402Version; } catch (error) { return { status: 402, diff --git a/packages/thirdweb/src/x402/fetchWithPayment.test.ts b/packages/thirdweb/src/x402/fetchWithPayment.test.ts index 9207e6f264f..ff925da2ef1 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.test.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { safeBase64Decode, safeBase64Encode } from "./encode.js"; import { wrapFetchWithPayment } from "./fetchWithPayment.js"; +import { getPaymentRequestHeader } from "./headers.js"; // Mock the createPaymentHeader function vi.mock("./sign.js", () => ({ @@ -34,7 +35,7 @@ describe("wrapFetchWithPayment", () => { }; const mock402ResponseData = { - x402Version: 1, + x402Version: 2, accepts: [mockPaymentRequirements], error: undefined, }; @@ -109,9 +110,11 @@ describe("wrapFetchWithPayment", () => { expect(response.status).toBe(200); expect(mockFetch).toHaveBeenCalledTimes(2); - // Verify the second call includes the X-PAYMENT header + // Verify the second call includes the payment header for the version const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(secondCallInit.headers).toHaveProperty("X-PAYMENT"); + expect(secondCallInit.headers).toHaveProperty( + getPaymentRequestHeader(mock402ResponseData.x402Version), + ); }); it("should parse payment requirements from JSON body when payment-required header is absent", async () => { @@ -141,9 +144,11 @@ describe("wrapFetchWithPayment", () => { expect(response.status).toBe(200); expect(mockFetch).toHaveBeenCalledTimes(2); - // Verify the second call includes the X-PAYMENT header + // Verify the second call includes the payment header for the version const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(secondCallInit.headers).toHaveProperty("X-PAYMENT"); + expect(secondCallInit.headers).toHaveProperty( + getPaymentRequestHeader(mock402ResponseData.x402Version), + ); }); it("should prefer payment-required header over JSON body when both are present", async () => { @@ -152,12 +157,12 @@ describe("wrapFetchWithPayment", () => { maxAmountRequired: "500000", // Different amount to verify header is used }; const headerResponseData = { - x402Version: 1, + x402Version: 2, accepts: [headerPaymentRequirements], }; const bodyResponseData = { - x402Version: 1, + x402Version: 2, accepts: [{ ...mockPaymentRequirements, maxAmountRequired: "2000000" }], }; @@ -237,9 +242,11 @@ describe("wrapFetchWithPayment", () => { expect(response.status).toBe(200); expect(mockFetch).toHaveBeenCalledTimes(2); - // Verify the second call includes the X-PAYMENT header + // Verify the second call includes the payment header for the version const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(secondCallInit.headers).toHaveProperty("X-PAYMENT"); + expect(secondCallInit.headers).toHaveProperty( + getPaymentRequestHeader(mock402ResponseData.x402Version), + ); }); it("should correctly decode a raw base64 encoded payment-required header", async () => { @@ -297,8 +304,10 @@ describe("wrapFetchWithPayment", () => { expect(response.status).toBe(200); expect(mockFetch).toHaveBeenCalledTimes(2); - // Verify the retry request was made with X-PAYMENT header + // Verify the retry request was made with the v1 payment header const secondCallInit = mockFetch.mock.calls[1]?.[1] as RequestInit; - expect(secondCallInit.headers).toHaveProperty("X-PAYMENT"); + expect(secondCallInit.headers).toHaveProperty( + getPaymentRequestHeader(parsed.x402Version), + ); }); }); diff --git a/packages/thirdweb/src/x402/fetchWithPayment.ts b/packages/thirdweb/src/x402/fetchWithPayment.ts index 9100a12a61b..dd91fe6cbbb 100644 --- a/packages/thirdweb/src/x402/fetchWithPayment.ts +++ b/packages/thirdweb/src/x402/fetchWithPayment.ts @@ -5,6 +5,10 @@ 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 { + getPaymentRequestHeader, + getPaymentResponseHeader, +} from "./headers.js"; import { clearPermitSignatureFromCache } from "./permitSignatureStorage.js"; import { extractEvmChainId, @@ -13,6 +17,7 @@ import { RequestedPaymentRequirementsSchema, } from "./schemas.js"; import { createPaymentHeader } from "./sign.js"; +import { x402Version as defaultX402Version } from "./types.js"; /** * Enables the payment of APIs using the x402 payment protocol. @@ -96,7 +101,7 @@ export function wrapFetchWithPayment( ); } - x402Version = parsed.x402Version; + x402Version = parsed.x402Version ?? defaultX402Version; parsedPaymentRequirements = parsed.accepts.map((x) => RequestedPaymentRequirementsSchema.parse(x), ); @@ -114,7 +119,7 @@ export function wrapFetchWithPayment( ); } - x402Version = body.x402Version; + x402Version = body.x402Version ?? defaultX402Version; parsedPaymentRequirements = body.accepts.map((x) => RequestedPaymentRequirementsSchema.parse(x), ); @@ -180,6 +185,9 @@ export function wrapFetchWithPayment( options?.storage ?? webLocalStorage, ); + const paymentRequestHeaderName = getPaymentRequestHeader(x402Version); + const paymentResponseHeaderName = getPaymentResponseHeader(x402Version); + const initParams = init || {}; if ((initParams as { __is402Retry?: boolean }).__is402Retry) { @@ -190,8 +198,8 @@ export function wrapFetchWithPayment( ...initParams, headers: { ...(initParams.headers || {}), - "X-PAYMENT": paymentHeader, - "Access-Control-Expose-Headers": "X-PAYMENT-RESPONSE", + [paymentRequestHeaderName]: paymentHeader, + "Access-Control-Expose-Headers": paymentResponseHeaderName, }, __is402Retry: true, }; diff --git a/packages/thirdweb/src/x402/headers.ts b/packages/thirdweb/src/x402/headers.ts new file mode 100644 index 00000000000..47d29670a60 --- /dev/null +++ b/packages/thirdweb/src/x402/headers.ts @@ -0,0 +1,26 @@ +import { type X402Version, x402Version } from "./types.js"; + +const PAYMENT_HEADER_V1 = "X-PAYMENT"; +const PAYMENT_HEADER_V2 = "PAYMENT-SIGNATURE"; +const PAYMENT_RESPONSE_HEADER_V1 = "X-PAYMENT-RESPONSE"; +const PAYMENT_RESPONSE_HEADER_V2 = "PAYMENT-RESPONSE"; + +function resolveVersion(version?: number | X402Version): X402Version { + return version === 1 ? 1 : 2; +} + +export function getPaymentRequestHeader( + version?: number | X402Version, +): string { + const resolvedVersion = resolveVersion(version ?? x402Version); + return resolvedVersion === 1 ? PAYMENT_HEADER_V1 : PAYMENT_HEADER_V2; +} + +export function getPaymentResponseHeader( + version?: number | X402Version, +): string { + const resolvedVersion = resolveVersion(version ?? x402Version); + return resolvedVersion === 1 + ? PAYMENT_RESPONSE_HEADER_V1 + : PAYMENT_RESPONSE_HEADER_V2; +} diff --git a/packages/thirdweb/src/x402/schemas.ts b/packages/thirdweb/src/x402/schemas.ts index 6531f02b5d6..15e3fd404ed 100644 --- a/packages/thirdweb/src/x402/schemas.ts +++ b/packages/thirdweb/src/x402/schemas.ts @@ -82,7 +82,7 @@ const FacilitatorSupportedResponseSchema = SupportedPaymentKindsResponseSchema.extend({ kinds: z.array( z.object({ - x402Version: z.literal(1), + x402Version: z.union([z.literal(1), z.literal(2)]), scheme: PaymentSchemeSchema, network: FacilitatorNetworkSchema, extra: z diff --git a/packages/thirdweb/src/x402/settle-payment.ts b/packages/thirdweb/src/x402/settle-payment.ts index 50cb2b61fa2..e028c04a5d7 100644 --- a/packages/thirdweb/src/x402/settle-payment.ts +++ b/packages/thirdweb/src/x402/settle-payment.ts @@ -1,6 +1,7 @@ import { stringify } from "../utils/json.js"; import { decodePaymentRequest } from "./common.js"; import { safeBase64Encode } from "./encode.js"; +import { getPaymentResponseHeader } from "./headers.js"; import { type SettlePaymentArgs, type SettlePaymentResult, @@ -37,7 +38,9 @@ import { * }); * * export async function GET(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({ @@ -104,7 +107,8 @@ import { * 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, // or any other chain * price: "$0.05", @@ -151,6 +155,9 @@ export async function settlePayment( decodePaymentResult; try { + const paymentResponseHeaderName = getPaymentResponseHeader( + decodedPayment.x402Version, + ); const settlement = await facilitator.settle( decodedPayment, selectedPaymentRequirements, @@ -162,8 +169,8 @@ export async function settlePayment( status: 200, paymentReceipt: settlement, responseHeaders: { - "Access-Control-Expose-Headers": "X-PAYMENT-RESPONSE", - "X-PAYMENT-RESPONSE": safeBase64Encode(stringify(settlement)), + "Access-Control-Expose-Headers": paymentResponseHeaderName, + [paymentResponseHeaderName]: safeBase64Encode(stringify(settlement)), }, }; } else { @@ -174,7 +181,7 @@ export async function settlePayment( "Content-Type": "application/json", }, responseBody: { - x402Version, + x402Version: decodedPayment.x402Version ?? x402Version, error, errorMessage: errorMessages?.settlementFailed || settlement.errorMessage, @@ -190,7 +197,7 @@ export async function settlePayment( "Content-Type": "application/json", }, responseBody: { - x402Version, + x402Version: decodedPayment.x402Version ?? x402Version, error: "Settlement error", errorMessage: errorMessages?.settlementFailed || diff --git a/packages/thirdweb/src/x402/types.ts b/packages/thirdweb/src/x402/types.ts index 54c3887f36d..26e647932f6 100644 --- a/packages/thirdweb/src/x402/types.ts +++ b/packages/thirdweb/src/x402/types.ts @@ -11,7 +11,9 @@ import type { SupportedSignatureTypeSchema, } from "./schemas.js"; -export const x402Version = 1; +const supportedX402Versions = [1, 2] as const; +export type X402Version = (typeof supportedX402Versions)[number]; +export const x402Version: X402Version = 2; /** * Configuration object for verifying or processing X402 payments. @@ -23,7 +25,9 @@ export type PaymentArgs = { resourceUrl: string; /** The HTTP method used to access the resource */ method: "GET" | "POST" | ({} & string); - /** The payment data/proof provided by the client, typically from the X-PAYMENT header */ + /** + * The payment data/proof provided by the client, typically from the PAYMENT-SIGNATURE (v2) or X-PAYMENT (v1) header + */ paymentData?: string | null; /** The blockchain network where the payment should be processed */ network: FacilitatorNetwork | Chain; diff --git a/packages/thirdweb/src/x402/verify-payment.ts b/packages/thirdweb/src/x402/verify-payment.ts index 237e51bbd79..0ebf7436ab6 100644 --- a/packages/thirdweb/src/x402/verify-payment.ts +++ b/packages/thirdweb/src/x402/verify-payment.ts @@ -29,7 +29,9 @@ import { * }); * * export async function GET(request: Request) { - * const paymentData = request.headers.get("x-payment"); + * const paymentData = + * request.headers.get("payment-signature") ?? + * request.headers.get("x-payment"); * * const paymentArgs = { * resourceUrl: "https://api.example.com/premium-content", @@ -121,7 +123,7 @@ export async function verifyPayment( "Content-Type": "application/json", }, responseBody: { - x402Version, + x402Version: decodedPayment.x402Version ?? x402Version, error: error, errorMessage: errorMessages?.verificationFailed || verification.errorMessage, @@ -137,7 +139,7 @@ export async function verifyPayment( "Content-Type": "application/json", }, responseBody: { - x402Version, + x402Version: decodedPayment.x402Version ?? x402Version, error: "Verification error", errorMessage: errorMessages?.verificationFailed ||