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

Filter by extension

Filter by extension

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

Support x402 v2
4 changes: 3 additions & 1 deletion apps/playground-web/src/app/api/paywall/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ export async function GET(request: NextRequest) {
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
});

const paymentData = request.headers.get("X-PAYMENT");
const paymentData =
request.headers.get("PAYMENT-SIGNATURE") ||
request.headers.get("X-PAYMENT");
const queryParams = request.nextUrl.searchParams;

const chainId = queryParams.get("chainId");
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/app/x402/facilitator/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,7 @@ The payment token must support either:
});

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 result = await settlePayment({
resourceUrl: "https://api.example.com/premium-content",
Expand Down
2 changes: 1 addition & 1 deletion apps/portal/src/app/x402/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ const thirdwebX402Facilitator = facilitator({
});

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 result = await settlePayment({
resourceUrl: "https://api.example.com/premium-content",
Expand Down
4 changes: 2 additions & 2 deletions apps/portal/src/app/x402/server/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ Protect individual API endpoints with x402 payments:
});

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 @@ -383,7 +383,7 @@ Protect multiple endpoints with a shared middleware:
export async function middleware(request: NextRequest) {
const method = request.method.toUpperCase();
const resourceUrl = request.nextUrl.toString();
const paymentData = request.headers.get("x-payment");
const paymentData = request.headers.get("PAYMENT-SIGNATURE") || request.headers.get("X-PAYMENT");

const result = await settlePayment({
resourceUrl,
Expand Down
2 changes: 1 addition & 1 deletion packages/nexus/src/settle-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ import { stringify } from "./utils.js";
* });
*
* 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
2 changes: 1 addition & 1 deletion packages/nexus/src/verify-payment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ 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
111 changes: 78 additions & 33 deletions packages/thirdweb/src/x402/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { resolveContractAbi } from "../contract/actions/resolve-abi.js";
import { getContract } from "../contract/contract.js";
import { isPermitSupported } from "../extensions/erc20/__generated__/IERC20Permit/write/permit.js";
import { isTransferWithAuthorizationSupported } from "../extensions/erc20/__generated__/USDC/write/transferWithAuthorization.js";
import { decodePayment } from "./encode.js";
import { decodePayment, encodePaymentRequired } from "./encode.js";
import {
networkToCaip2ChainId,
type RequestedPaymentPayload,
Expand All @@ -16,6 +16,8 @@ import {
type ERC20TokenAmount,
type PaymentArgs,
type PaymentRequiredResult,
type PaymentRequiredResultV1,
type PaymentRequiredResultV2,
type SupportedSignatureType,
x402Version,
} from "./types.js";
Expand All @@ -27,6 +29,50 @@ type GetPaymentRequirementsResult = {
decodedPayment: RequestedPaymentPayload;
};

/**
* Formats a payment required response in x402 v2 format (header-based)
*/
function formatPaymentRequiredResponseV2(
paymentRequirements: RequestedPaymentRequirements[],
error: string,
resourceUrl: string,
): PaymentRequiredResultV2 {
const paymentRequired = {
x402Version: 2,
error,
accepts: paymentRequirements,
resource: { url: resourceUrl },
};

return {
status: 402,
responseHeaders: {
"PAYMENT-REQUIRED": encodePaymentRequired(paymentRequired),
},
responseBody: {} as Record<string, never>,
};
}

/**
* Formats a payment required response in x402 v1 format (body-based)
*/
function formatPaymentRequiredResponseV1(
paymentRequirements: RequestedPaymentRequirements[],
error: string,
): PaymentRequiredResultV1 {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version: 1,
error,
accepts: paymentRequirements,
},
};
}

/**
* Decodes a payment request and returns the payment requirements, selected payment requirements, and decoded payment
* @param args
Expand All @@ -35,37 +81,36 @@ type GetPaymentRequirementsResult = {
export async function decodePaymentRequest(
args: PaymentArgs,
): Promise<GetPaymentRequirementsResult | PaymentRequiredResult> {
const { facilitator, routeConfig = {}, paymentData } = args;
const { facilitator, routeConfig = {}, paymentData, resourceUrl } = args;
const { errorMessages } = routeConfig;

// facilitator.accepts() returns v1 format from API - extract payment requirements
const paymentRequirementsResult = await facilitator.accepts(args);
const paymentRequirements = paymentRequirementsResult.responseBody.accepts;

// Check for payment header, if none, return the payment requirements
// Check for payment header, if none, return the payment requirements in v2 format (default)
if (!paymentData) {
return paymentRequirementsResult;
return formatPaymentRequiredResponseV2(
paymentRequirements,
"Payment required",
resourceUrl,
);
}

const paymentRequirements = paymentRequirementsResult.responseBody.accepts;

// decode b64 payment
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,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error:
errorMessages?.invalidPayment ||
(error instanceof Error ? error.message : "Invalid payment"),
accepts: paymentRequirements,
},
};
// Decode error - default to v2 format since we can't determine client version
return formatPaymentRequiredResponseV2(
paymentRequirements,
errorMessages?.invalidPayment ||
(error instanceof Error ? error.message : "Invalid payment"),
resourceUrl,
);
}

const selectedPaymentRequirements = paymentRequirements.find(
Expand All @@ -75,19 +120,19 @@ export async function decodePaymentRequest(
networkToCaip2ChainId(decodedPayment.network),
);
if (!selectedPaymentRequirements) {
return {
status: 402,
responseHeaders: {
"Content-Type": "application/json",
},
responseBody: {
x402Version,
error:
errorMessages?.noMatchingRequirements ||
"Unable to find matching payment requirements",
accepts: paymentRequirements,
},
};
// Use the client's version for the response format
const errorMessage =
errorMessages?.noMatchingRequirements ||
"Unable to find matching payment requirements";

if (decodedPayment.x402Version === 1) {
return formatPaymentRequiredResponseV1(paymentRequirements, errorMessage);
}
return formatPaymentRequiredResponseV2(
paymentRequirements,
errorMessage,
resourceUrl,
);
}

return {
Expand Down
20 changes: 19 additions & 1 deletion packages/thirdweb/src/x402/encode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import type { ExactEvmPayload } from "x402/types";
import type { RequestedPaymentPayload } from "./schemas.js";
import type {
RequestedPaymentPayload,
RequestedPaymentRequirements,
} from "./schemas.js";

/**
* Encodes a payment payload into a base64 string, ensuring bigint values are properly stringified
Expand Down Expand Up @@ -44,6 +47,21 @@
return obj;
}

/**
* Encodes a payment required object into a base64 string for the PAYMENT-REQUIRED header (x402 v2)
*
* @param paymentRequired - The payment required object to encode
* @returns A base64 encoded string representation of the payment required object
*/
export function encodePaymentRequired(paymentRequired: {
x402Version: number;
error?: string;
accepts: RequestedPaymentRequirements[];
resource?: { url: string; description?: string; mimeType?: string };
}): string {
return safeBase64Encode(JSON.stringify(paymentRequired));
}

Check warning on line 63 in packages/thirdweb/src/x402/encode.ts

View check run for this annotation

Codecov / codecov/patch

packages/thirdweb/src/x402/encode.ts#L61-L63

Added lines #L61 - L63 were not covered by tests

/**
* Encodes a string to base64 format
*
Expand Down
13 changes: 9 additions & 4 deletions packages/thirdweb/src/x402/facilitator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ import {
type RequestedPaymentPayload,
type RequestedPaymentRequirements,
} from "./schemas.js";
import type { PaymentArgs, PaymentRequiredResult } from "./types.js";
import {
type PaymentArgs,
type PaymentRequiredResultV1,
x402Version,
} from "./types.js";

export type WaitUntil = "simulated" | "submitted" | "confirmed";

Expand Down Expand Up @@ -50,7 +54,7 @@ export type ThirdwebX402Facilitator = {
}) => Promise<FacilitatorSupportedResponse>;
accepts: (
args: Omit<PaymentArgs, "facilitator">,
) => Promise<PaymentRequiredResult>;
) => Promise<PaymentRequiredResultV1>;
};

const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402";
Expand Down Expand Up @@ -264,7 +268,7 @@ export function facilitator(

async accepts(
args: Omit<PaymentArgs, "facilitator">,
): Promise<PaymentRequiredResult> {
): Promise<PaymentRequiredResultV1> {
const url = config.baseUrl ?? DEFAULT_BASE_URL;
let headers = { "Content-Type": "application/json" };
const authHeaders = await facilitator.createAuthHeaders();
Expand All @@ -284,6 +288,7 @@ export function facilitator(
serverWalletAddress: facilitator.address,
recipientAddress: args.payTo,
extraMetadata: args.extraMetadata,
x402Version: args.x402Version ?? x402Version,
}),
});
if (res.status !== 402) {
Expand All @@ -294,7 +299,7 @@ export function facilitator(
return {
status: res.status as 402,
responseBody:
(await res.json()) as PaymentRequiredResult["responseBody"],
(await res.json()) as PaymentRequiredResultV1["responseBody"],
responseHeaders: {
"Content-Type": "application/json",
},
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),
);
});
});
Loading
Loading