Skip to content

Commit 36ff327

Browse files
support x402 v2 headers (#8623)
1 parent f68c2c6 commit 36ff327

17 files changed

Lines changed: 229 additions & 75 deletions

File tree

.changeset/eight-pants-drum.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"thirdweb": patch
3+
---
4+
5+
Support x402 v2

apps/playground-web/src/app/api/paywall/route.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ export async function GET(request: NextRequest) {
2727
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
2828
});
2929

30-
const paymentData = request.headers.get("X-PAYMENT");
30+
const paymentData =
31+
request.headers.get("PAYMENT-SIGNATURE") ||
32+
request.headers.get("X-PAYMENT");
3133
const queryParams = request.nextUrl.searchParams;
3234

3335
const chainId = queryParams.get("chainId");

apps/portal/src/app/x402/facilitator/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ The payment token must support either:
103103
});
104104

105105
export async function GET(request: Request) {
106-
const paymentData = request.headers.get("x-payment");
106+
const paymentData = request.headers.get("PAYMENT-SIGNATURE") || request.headers.get("X-PAYMENT");
107107

108108
const result = await settlePayment({
109109
resourceUrl: "https://api.example.com/premium-content",

apps/portal/src/app/x402/page.mdx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ const thirdwebX402Facilitator = facilitator({
113113
});
114114

115115
export async function GET(request: Request) {
116-
const paymentData = request.headers.get("x-payment");
116+
const paymentData = request.headers.get("PAYMENT-SIGNATURE") || request.headers.get("X-PAYMENT");
117117

118118
const result = await settlePayment({
119119
resourceUrl: "https://api.example.com/premium-content",

apps/portal/src/app/x402/server/page.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,7 +211,7 @@ Protect individual API endpoints with x402 payments:
211211
});
212212

213213
export async function GET(request: Request) {
214-
const paymentData = request.headers.get("x-payment");
214+
const paymentData = request.headers.get("PAYMENT-SIGNATURE") || request.headers.get("X-PAYMENT");
215215

216216
// Verify and process the payment
217217
const result = await settlePayment({
@@ -383,7 +383,7 @@ Protect multiple endpoints with a shared middleware:
383383
export async function middleware(request: NextRequest) {
384384
const method = request.method.toUpperCase();
385385
const resourceUrl = request.nextUrl.toString();
386-
const paymentData = request.headers.get("x-payment");
386+
const paymentData = request.headers.get("PAYMENT-SIGNATURE") || request.headers.get("X-PAYMENT");
387387

388388
const result = await settlePayment({
389389
resourceUrl,

packages/nexus/src/settle-payment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import { stringify } from "./utils.js";
3232
* });
3333
*
3434
* export async function GET(request: Request) {
35-
* const paymentData = request.headers.get("x-payment");
35+
* const paymentData = request.headers.get("PAYMENT-SIGNATURE") || request.headers.get("X-PAYMENT");
3636
*
3737
* // verify and process the payment
3838
* const result = await settlePayment({

packages/nexus/src/verify-payment.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ import {
2424
* });
2525
*
2626
* export async function GET(request: Request) {
27-
* const paymentData = request.headers.get("x-payment");
27+
* const paymentData = request.headers.get("PAYMENT-SIGNATURE") || request.headers.get("X-PAYMENT");
2828
*
2929
* const paymentArgs = {
3030
* resourceUrl: "https://api.example.com/premium-content",

packages/thirdweb/src/x402/common.ts

Lines changed: 78 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { resolveContractAbi } from "../contract/actions/resolve-abi.js";
66
import { getContract } from "../contract/contract.js";
77
import { isPermitSupported } from "../extensions/erc20/__generated__/IERC20Permit/write/permit.js";
88
import { isTransferWithAuthorizationSupported } from "../extensions/erc20/__generated__/USDC/write/transferWithAuthorization.js";
9-
import { decodePayment } from "./encode.js";
9+
import { decodePayment, encodePaymentRequired } from "./encode.js";
1010
import {
1111
networkToCaip2ChainId,
1212
type RequestedPaymentPayload,
@@ -16,6 +16,8 @@ import {
1616
type ERC20TokenAmount,
1717
type PaymentArgs,
1818
type PaymentRequiredResult,
19+
type PaymentRequiredResultV1,
20+
type PaymentRequiredResultV2,
1921
type SupportedSignatureType,
2022
x402Version,
2123
} from "./types.js";
@@ -27,6 +29,50 @@ type GetPaymentRequirementsResult = {
2729
decodedPayment: RequestedPaymentPayload;
2830
};
2931

32+
/**
33+
* Formats a payment required response in x402 v2 format (header-based)
34+
*/
35+
function formatPaymentRequiredResponseV2(
36+
paymentRequirements: RequestedPaymentRequirements[],
37+
error: string,
38+
resourceUrl: string,
39+
): PaymentRequiredResultV2 {
40+
const paymentRequired = {
41+
x402Version: 2,
42+
error,
43+
accepts: paymentRequirements,
44+
resource: { url: resourceUrl },
45+
};
46+
47+
return {
48+
status: 402,
49+
responseHeaders: {
50+
"PAYMENT-REQUIRED": encodePaymentRequired(paymentRequired),
51+
},
52+
responseBody: {} as Record<string, never>,
53+
};
54+
}
55+
56+
/**
57+
* Formats a payment required response in x402 v1 format (body-based)
58+
*/
59+
function formatPaymentRequiredResponseV1(
60+
paymentRequirements: RequestedPaymentRequirements[],
61+
error: string,
62+
): PaymentRequiredResultV1 {
63+
return {
64+
status: 402,
65+
responseHeaders: {
66+
"Content-Type": "application/json",
67+
},
68+
responseBody: {
69+
x402Version: 1,
70+
error,
71+
accepts: paymentRequirements,
72+
},
73+
};
74+
}
75+
3076
/**
3177
* Decodes a payment request and returns the payment requirements, selected payment requirements, and decoded payment
3278
* @param args
@@ -35,37 +81,36 @@ type GetPaymentRequirementsResult = {
3581
export async function decodePaymentRequest(
3682
args: PaymentArgs,
3783
): Promise<GetPaymentRequirementsResult | PaymentRequiredResult> {
38-
const { facilitator, routeConfig = {}, paymentData } = args;
84+
const { facilitator, routeConfig = {}, paymentData, resourceUrl } = args;
3985
const { errorMessages } = routeConfig;
4086

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

43-
// Check for payment header, if none, return the payment requirements
91+
// Check for payment header, if none, return the payment requirements in v2 format (default)
4492
if (!paymentData) {
45-
return paymentRequirementsResult;
93+
return formatPaymentRequiredResponseV2(
94+
paymentRequirements,
95+
"Payment required",
96+
resourceUrl,
97+
);
4698
}
4799

48-
const paymentRequirements = paymentRequirementsResult.responseBody.accepts;
49-
50100
// decode b64 payment
51101
let decodedPayment: RequestedPaymentPayload;
52102
try {
53103
decodedPayment = decodePayment(paymentData);
54-
decodedPayment.x402Version = x402Version;
104+
// Preserve version provided by the client, default to the current protocol version if missing
105+
decodedPayment.x402Version ??= x402Version;
55106
} catch (error) {
56-
return {
57-
status: 402,
58-
responseHeaders: {
59-
"Content-Type": "application/json",
60-
},
61-
responseBody: {
62-
x402Version,
63-
error:
64-
errorMessages?.invalidPayment ||
65-
(error instanceof Error ? error.message : "Invalid payment"),
66-
accepts: paymentRequirements,
67-
},
68-
};
107+
// Decode error - default to v2 format since we can't determine client version
108+
return formatPaymentRequiredResponseV2(
109+
paymentRequirements,
110+
errorMessages?.invalidPayment ||
111+
(error instanceof Error ? error.message : "Invalid payment"),
112+
resourceUrl,
113+
);
69114
}
70115

71116
const selectedPaymentRequirements = paymentRequirements.find(
@@ -75,19 +120,19 @@ export async function decodePaymentRequest(
75120
networkToCaip2ChainId(decodedPayment.network),
76121
);
77122
if (!selectedPaymentRequirements) {
78-
return {
79-
status: 402,
80-
responseHeaders: {
81-
"Content-Type": "application/json",
82-
},
83-
responseBody: {
84-
x402Version,
85-
error:
86-
errorMessages?.noMatchingRequirements ||
87-
"Unable to find matching payment requirements",
88-
accepts: paymentRequirements,
89-
},
90-
};
123+
// Use the client's version for the response format
124+
const errorMessage =
125+
errorMessages?.noMatchingRequirements ||
126+
"Unable to find matching payment requirements";
127+
128+
if (decodedPayment.x402Version === 1) {
129+
return formatPaymentRequiredResponseV1(paymentRequirements, errorMessage);
130+
}
131+
return formatPaymentRequiredResponseV2(
132+
paymentRequirements,
133+
errorMessage,
134+
resourceUrl,
135+
);
91136
}
92137

93138
return {

packages/thirdweb/src/x402/encode.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import type { ExactEvmPayload } from "x402/types";
2-
import type { RequestedPaymentPayload } from "./schemas.js";
2+
import type {
3+
RequestedPaymentPayload,
4+
RequestedPaymentRequirements,
5+
} from "./schemas.js";
36

47
/**
58
* Encodes a payment payload into a base64 string, ensuring bigint values are properly stringified
@@ -44,6 +47,21 @@ export function decodePayment(payment: string): RequestedPaymentPayload {
4447
return obj;
4548
}
4649

50+
/**
51+
* Encodes a payment required object into a base64 string for the PAYMENT-REQUIRED header (x402 v2)
52+
*
53+
* @param paymentRequired - The payment required object to encode
54+
* @returns A base64 encoded string representation of the payment required object
55+
*/
56+
export function encodePaymentRequired(paymentRequired: {
57+
x402Version: number;
58+
error?: string;
59+
accepts: RequestedPaymentRequirements[];
60+
resource?: { url: string; description?: string; mimeType?: string };
61+
}): string {
62+
return safeBase64Encode(JSON.stringify(paymentRequired));
63+
}
64+
4765
/**
4866
* Encodes a string to base64 format
4967
*

packages/thirdweb/src/x402/facilitator.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import {
1010
type RequestedPaymentPayload,
1111
type RequestedPaymentRequirements,
1212
} from "./schemas.js";
13-
import type { PaymentArgs, PaymentRequiredResult } from "./types.js";
13+
import {
14+
type PaymentArgs,
15+
type PaymentRequiredResultV1,
16+
x402Version,
17+
} from "./types.js";
1418

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

@@ -50,7 +54,7 @@ export type ThirdwebX402Facilitator = {
5054
}) => Promise<FacilitatorSupportedResponse>;
5155
accepts: (
5256
args: Omit<PaymentArgs, "facilitator">,
53-
) => Promise<PaymentRequiredResult>;
57+
) => Promise<PaymentRequiredResultV1>;
5458
};
5559

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

265269
async accepts(
266270
args: Omit<PaymentArgs, "facilitator">,
267-
): Promise<PaymentRequiredResult> {
271+
): Promise<PaymentRequiredResultV1> {
268272
const url = config.baseUrl ?? DEFAULT_BASE_URL;
269273
let headers = { "Content-Type": "application/json" };
270274
const authHeaders = await facilitator.createAuthHeaders();
@@ -284,6 +288,7 @@ export function facilitator(
284288
serverWalletAddress: facilitator.address,
285289
recipientAddress: args.payTo,
286290
extraMetadata: args.extraMetadata,
291+
x402Version: args.x402Version ?? x402Version,
287292
}),
288293
});
289294
if (res.status !== 402) {
@@ -294,7 +299,7 @@ export function facilitator(
294299
return {
295300
status: res.status as 402,
296301
responseBody:
297-
(await res.json()) as PaymentRequiredResult["responseBody"],
302+
(await res.json()) as PaymentRequiredResultV1["responseBody"],
298303
responseHeaders: {
299304
"Content-Type": "application/json",
300305
},

0 commit comments

Comments
 (0)