Skip to content

Commit e379f00

Browse files
[SDK] Add verifyPayment() backend utility for arbitrary chain x402 payments
1 parent 51177fb commit e379f00

File tree

13 files changed

+881
-441
lines changed

13 files changed

+881
-441
lines changed

.changeset/some-moons-burn.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+
Accept arbitrary chain ids for x402 payments with new verifyPayment() backend utility

apps/playground-web/package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,6 @@
4848
"thirdweb": "workspace:*",
4949
"use-debounce": "^10.0.5",
5050
"use-stick-to-bottom": "^1.1.1",
51-
"x402-next": "^0.6.1",
5251
"zod": "3.25.75"
5352
},
5453
"devDependencies": {

apps/playground-web/src/app/payments/x402/components/x402-client-preview.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { useMutation } from "@tanstack/react-query";
44
import { CodeClient } from "@workspace/ui/components/code/code.client";
55
import { CodeIcon, LockIcon } from "lucide-react";
6-
import { baseSepolia } from "thirdweb/chains";
6+
import { arbitrumSepolia } from "thirdweb/chains";
77
import {
88
ConnectButton,
99
getDefaultToken,
@@ -15,7 +15,7 @@ import { Button } from "@/components/ui/button";
1515
import { Card } from "@/components/ui/card";
1616
import { THIRDWEB_CLIENT } from "../../../../lib/client";
1717

18-
const chain = baseSepolia;
18+
const chain = arbitrumSepolia;
1919
const token = getDefaultToken(chain, "USDC");
2020

2121
export function X402ClientPreview() {

apps/playground-web/src/middleware.ts

Lines changed: 45 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,60 @@
11
import { createThirdwebClient } from "thirdweb";
2-
import { facilitator } from "thirdweb/x402";
3-
import { paymentMiddleware } from "x402-next";
2+
import { facilitator, verifyPayment } from "thirdweb/x402";
3+
import { NextRequest, NextResponse } from "next/server";
4+
import { arbitrumSepolia } from "thirdweb/chains";
45

56
const client = createThirdwebClient({
67
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
78
});
89

10+
const chain = arbitrumSepolia;
911
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
1012
const ENGINE_VAULT_ACCESS_TOKEN = process.env
1113
.ENGINE_VAULT_ACCESS_TOKEN as string;
1214
const API_URL = `https://${process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com"}`;
1315

14-
export const middleware = paymentMiddleware(
15-
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
16-
{
17-
"/api/paywall": {
18-
price: "$0.01",
19-
network: "base-sepolia",
20-
config: {
21-
description: "Access to paid content",
22-
},
16+
const twFacilitator = facilitator({
17+
baseUrl: `${API_URL}/v1/payments/x402`,
18+
client,
19+
serverWalletAddress: BACKEND_WALLET_ADDRESS,
20+
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
21+
});
22+
23+
export async function middleware(request: NextRequest) {
24+
const pathname = request.nextUrl.pathname;
25+
const method = request.method.toUpperCase();
26+
const resourceUrl = `${request.nextUrl.protocol}//${request.nextUrl.host}${pathname}`;
27+
const paymentData = request.headers.get("X-PAYMENT");
28+
29+
const result = await verifyPayment({
30+
resourceUrl,
31+
method,
32+
paymentData,
33+
payTo: "0x2247d5d238d0f9d37184d8332aE0289d1aD9991b",
34+
network: `eip155:${chain.id}`,
35+
price: "$0.01",
36+
routeConfig: {
37+
description: "Access to paid content",
2338
},
24-
},
25-
facilitator({
26-
baseUrl: `${API_URL}/v1/payments/x402`,
27-
client,
28-
serverWalletAddress: BACKEND_WALLET_ADDRESS,
29-
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
30-
}),
31-
);
39+
facilitator: twFacilitator,
40+
});
41+
42+
if (result.status === 200) {
43+
// payment successful, execute the request
44+
const response = NextResponse.next();
45+
response.headers.set(
46+
"X-PAYMENT-RESPONSE",
47+
result.responseHeaders["X-PAYMENT-RESPONSE"] ?? ""
48+
);
49+
return response;
50+
}
51+
52+
// otherwise, request payment
53+
return NextResponse.json(result.responseBody, {
54+
status: result.status,
55+
headers: result.responseHeaders,
56+
});
57+
}
3258

3359
// Configure which paths the middleware should run on
3460
export const config = {

packages/thirdweb/src/exports/x402.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,9 @@ export {
33
type ThirdwebX402FacilitatorConfig,
44
} from "../x402/facilitator.js";
55
export { wrapFetchWithPayment } from "../x402/fetchWithPayment.js";
6+
export { decodePayment, encodePayment } from "../x402/encode.js";
7+
export {
8+
verifyPayment,
9+
type VerifyPaymentArgs,
10+
type VerifyPaymentResult,
11+
} from "../x402/verify-payment.js";

packages/thirdweb/src/react/core/utils/defaultTokens.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -289,15 +289,9 @@ const DEFAULT_TOKENS = {
289289
symbol: "USDC",
290290
},
291291
],
292-
"421613": [
292+
"421614": [
293293
{
294-
address: "0xe39Ab88f8A4777030A534146A9Ca3B52bd5D43A3",
295-
icon: wrappedEthIcon,
296-
name: "Wrapped Ether",
297-
symbol: "WETH",
298-
},
299-
{
300-
address: "0xfd064A18f3BF249cf1f87FC203E90D8f650f2d63",
294+
address: "0x75faf114eafb1BDbe2F0316DF893fd58CE46AA4d",
301295
icon: usdcIcon,
302296
name: "USD Coin",
303297
symbol: "USDC",
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
import {
2+
type ExactEvmPayload,
3+
} from "x402/types";
4+
import { RequestedPaymentPayloadSchema, type RequestedPaymentPayload } from "./schemas.js";
5+
6+
/**
7+
* Encodes a payment payload into a base64 string, ensuring bigint values are properly stringified
8+
*
9+
* @param payment - The payment payload to encode
10+
* @returns A base64 encoded string representation of the payment payload
11+
*/
12+
export function encodePayment(payment: RequestedPaymentPayload): string {
13+
let safe: RequestedPaymentPayload;
14+
15+
// evm
16+
const evmPayload = payment.payload as ExactEvmPayload;
17+
safe = {
18+
...payment,
19+
payload: {
20+
...evmPayload,
21+
authorization: Object.fromEntries(
22+
Object.entries(evmPayload.authorization).map(([key, value]) => [
23+
key,
24+
typeof value === "bigint" ? (value as bigint).toString() : value,
25+
])
26+
) as ExactEvmPayload["authorization"],
27+
},
28+
};
29+
return safeBase64Encode(JSON.stringify(safe));
30+
}
31+
32+
/**
33+
* Decodes a base64 encoded payment string back into a PaymentPayload object
34+
*
35+
* @param payment - The base64 encoded payment string to decode
36+
* @returns The decoded and validated PaymentPayload object
37+
*/
38+
export function decodePayment(payment: string): RequestedPaymentPayload {
39+
const decoded = safeBase64Decode(payment);
40+
const parsed = JSON.parse(decoded);
41+
42+
let obj: RequestedPaymentPayload;
43+
obj = {
44+
...parsed,
45+
payload: parsed.payload as ExactEvmPayload,
46+
};
47+
const validated = RequestedPaymentPayloadSchema.parse(obj);
48+
return validated;
49+
}
50+
51+
export const Base64EncodedRegex = /^[A-Za-z0-9+/]*={0,2}$/;
52+
53+
/**
54+
* Encodes a string to base64 format
55+
*
56+
* @param data - The string to be encoded to base64
57+
* @returns The base64 encoded string
58+
*/
59+
export function safeBase64Encode(data: string): string {
60+
if (
61+
typeof globalThis !== "undefined" &&
62+
typeof globalThis.btoa === "function"
63+
) {
64+
return globalThis.btoa(data);
65+
}
66+
return Buffer.from(data).toString("base64");
67+
}
68+
69+
/**
70+
* Decodes a base64 string back to its original format
71+
*
72+
* @param data - The base64 encoded string to be decoded
73+
* @returns The decoded string in UTF-8 format
74+
*/
75+
export function safeBase64Decode(data: string): string {
76+
if (
77+
typeof globalThis !== "undefined" &&
78+
typeof globalThis.atob === "function"
79+
) {
80+
return globalThis.atob(data);
81+
}
82+
return Buffer.from(data, "base64").toString("utf-8");
83+
}

packages/thirdweb/src/x402/facilitator.ts

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,15 @@
1-
import type { FacilitatorConfig } from "x402/types";
1+
import type {
2+
SupportedPaymentKindsResponse,
3+
VerifyResponse,
4+
} from "x402/types";
25
import type { ThirdwebClient } from "../client/client.js";
6+
import type {
7+
FacilitatorSettleResponse,
8+
RequestedPaymentPayload,
9+
RequestedPaymentRequirements,
10+
} from "./schemas.js";
11+
import { stringify } from "../utils/json.js";
12+
import { withCache } from "../utils/promise/withCache.js";
313

414
export type ThirdwebX402FacilitatorConfig = {
515
client: ThirdwebClient;
@@ -48,20 +58,18 @@ const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402";
4858
*
4959
* @bridge x402
5060
*/
51-
export function facilitator(
52-
config: ThirdwebX402FacilitatorConfig,
53-
): FacilitatorConfig {
61+
export function facilitator(config: ThirdwebX402FacilitatorConfig) {
5462
const secretKey = config.client.secretKey;
5563
if (!secretKey) {
5664
throw new Error("Client secret key is required for the x402 facilitator");
5765
}
5866
const serverWalletAddress = config.serverWalletAddress;
5967
if (!serverWalletAddress) {
6068
throw new Error(
61-
"Server wallet address is required for the x402 facilitator",
69+
"Server wallet address is required for the x402 facilitator"
6270
);
6371
}
64-
return {
72+
const facilitator = {
6573
url: (config.baseUrl ?? DEFAULT_BASE_URL) as `${string}://${string}`,
6674
createAuthHeaders: async () => {
6775
return {
@@ -83,5 +91,108 @@ export function facilitator(
8391
},
8492
};
8593
},
94+
/**
95+
* Verifies a payment payload with the facilitator service
96+
*
97+
* @param payload - The payment payload to verify
98+
* @param paymentRequirements - The payment requirements to verify against
99+
* @returns A promise that resolves to the verification response
100+
*/
101+
async verify(
102+
payload: RequestedPaymentPayload,
103+
paymentRequirements: RequestedPaymentRequirements
104+
): Promise<VerifyResponse> {
105+
const url = config.baseUrl ?? DEFAULT_BASE_URL;
106+
107+
let headers = { "Content-Type": "application/json" };
108+
const authHeaders = await facilitator.createAuthHeaders();
109+
headers = { ...headers, ...authHeaders.verify };
110+
111+
const res = await fetch(`${url}/verify`, {
112+
method: "POST",
113+
headers,
114+
body: stringify({
115+
x402Version: payload.x402Version,
116+
paymentPayload: payload,
117+
paymentRequirements: paymentRequirements,
118+
}),
119+
});
120+
121+
if (res.status !== 200) {
122+
const text = `${res.statusText} ${await res.text()}`;
123+
throw new Error(`Failed to verify payment: ${res.status} ${text}`);
124+
}
125+
126+
const data = await res.json();
127+
return data as VerifyResponse;
128+
},
129+
130+
/**
131+
* Settles a payment with the facilitator service
132+
*
133+
* @param payload - The payment payload to settle
134+
* @param paymentRequirements - The payment requirements for the settlement
135+
* @returns A promise that resolves to the settlement response
136+
*/
137+
async settle(
138+
payload: RequestedPaymentPayload,
139+
paymentRequirements: RequestedPaymentRequirements
140+
): Promise<FacilitatorSettleResponse> {
141+
const url = config.baseUrl ?? DEFAULT_BASE_URL;
142+
143+
let headers = { "Content-Type": "application/json" };
144+
const authHeaders = await facilitator.createAuthHeaders();
145+
headers = { ...headers, ...authHeaders.settle };
146+
147+
const res = await fetch(`${url}/settle`, {
148+
method: "POST",
149+
headers,
150+
body: JSON.stringify({
151+
x402Version: payload.x402Version,
152+
paymentPayload: payload,
153+
paymentRequirements: paymentRequirements,
154+
}),
155+
});
156+
157+
if (res.status !== 200) {
158+
const text = `${res.statusText} ${await res.text()}`;
159+
throw new Error(`Failed to settle payment: ${res.status} ${text}`);
160+
}
161+
162+
const data = await res.json();
163+
return data as FacilitatorSettleResponse;
164+
},
165+
166+
/**
167+
* Gets the supported payment kinds from the facilitator service.
168+
*
169+
* @returns A promise that resolves to the supported payment kinds
170+
*/
171+
async supported(): Promise<SupportedPaymentKindsResponse> {
172+
const url = config.baseUrl ?? DEFAULT_BASE_URL;
173+
return withCache(
174+
async () => {
175+
let headers = { "Content-Type": "application/json" };
176+
const authHeaders = await facilitator.createAuthHeaders();
177+
headers = { ...headers, ...authHeaders.supported };
178+
const res = await fetch(`${url}/supported`, { headers });
179+
180+
if (res.status !== 200) {
181+
throw new Error(
182+
`Failed to get supported payment kinds: ${res.statusText}`
183+
);
184+
}
185+
186+
const data = await res.json();
187+
return data as SupportedPaymentKindsResponse;
188+
},
189+
{
190+
cacheKey: "supported-payment-kinds",
191+
cacheTime: 1000 * 60 * 60 * 24, // 24 hours
192+
}
193+
);
194+
},
86195
};
196+
197+
return facilitator;
87198
}

0 commit comments

Comments
 (0)