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

Filter by extension

Filter by extension

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

Support x402 v2
3 changes: 2 additions & 1 deletion packages/thirdweb/src/x402/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 20 additions & 11 deletions packages/thirdweb/src/x402/fetchWithPayment.test.ts
Original file line number Diff line number Diff line change
@@ -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", () => ({
Expand Down Expand Up @@ -34,7 +35,7 @@ describe("wrapFetchWithPayment", () => {
};

const mock402ResponseData = {
x402Version: 1,
x402Version: 2,
accepts: [mockPaymentRequirements],
error: undefined,
};
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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 () => {
Expand All @@ -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" }],
};

Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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),
);
});
});
16 changes: 12 additions & 4 deletions packages/thirdweb/src/x402/fetchWithPayment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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.
Expand Down Expand Up @@ -96,7 +101,7 @@ export function wrapFetchWithPayment(
);
}

x402Version = parsed.x402Version;
x402Version = parsed.x402Version ?? defaultX402Version;
parsedPaymentRequirements = parsed.accepts.map((x) =>
RequestedPaymentRequirementsSchema.parse(x),
);
Expand All @@ -114,7 +119,7 @@ export function wrapFetchWithPayment(
);
}

x402Version = body.x402Version;
x402Version = body.x402Version ?? defaultX402Version;
parsedPaymentRequirements = body.accepts.map((x) =>
RequestedPaymentRequirementsSchema.parse(x),
);
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
};
Expand Down
26 changes: 26 additions & 0 deletions packages/thirdweb/src/x402/headers.ts
Original file line number Diff line number Diff line change
@@ -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;
}
2 changes: 1 addition & 1 deletion packages/thirdweb/src/x402/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions packages/thirdweb/src/x402/settle-payment.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -151,6 +155,9 @@ export async function settlePayment(
decodePaymentResult;

try {
const paymentResponseHeaderName = getPaymentResponseHeader(
decodedPayment.x402Version,
);
const settlement = await facilitator.settle(
decodedPayment,
selectedPaymentRequirements,
Expand All @@ -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 {
Expand All @@ -174,7 +181,7 @@ export async function settlePayment(
"Content-Type": "application/json",
},
responseBody: {
x402Version,
x402Version: decodedPayment.x402Version ?? x402Version,
error,
errorMessage:
errorMessages?.settlementFailed || settlement.errorMessage,
Expand All @@ -190,7 +197,7 @@ export async function settlePayment(
"Content-Type": "application/json",
},
responseBody: {
x402Version,
x402Version: decodedPayment.x402Version ?? x402Version,
error: "Settlement error",
errorMessage:
errorMessages?.settlementFailed ||
Expand Down
8 changes: 6 additions & 2 deletions packages/thirdweb/src/x402/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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;
Expand Down
8 changes: 5 additions & 3 deletions packages/thirdweb/src/x402/verify-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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,
Expand All @@ -137,7 +139,7 @@ export async function verifyPayment(
"Content-Type": "application/json",
},
responseBody: {
x402Version,
x402Version: decodedPayment.x402Version ?? x402Version,
error: "Verification error",
errorMessage:
errorMessages?.verificationFailed ||
Expand Down
Loading