Skip to content

Commit fa52b08

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

File tree

14 files changed

+896
-431
lines changed

14 files changed

+896
-431
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 @@
1+
import { type NextRequest, NextResponse } from "next/server";
12
import { createThirdwebClient } from "thirdweb";
2-
import { facilitator } from "thirdweb/x402";
3-
import { paymentMiddleware } from "x402-next";
3+
import { arbitrumSepolia } from "thirdweb/chains";
4+
import { facilitator, verifyPayment } from "thirdweb/x402";
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 = {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ const response = await fetchWithPay('https://api.example.com/paid-endpoint');
4343

4444
To make your API calls payable, you can use any x402 middleware library like x402-hono, x402-next, x402-express, etc.
4545

46-
Then, use the `facilitator` configuratino function settle transactions with your thirdweb server wallet gaslessly and pass it to the middleware.
46+
Then, use the `facilitator` configuration function settle transactions with your thirdweb server wallet gaslessly and pass it to the middleware.
4747

4848
Here's an example with Next.js:
4949

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

packages/thirdweb/src/x402/facilitator.ts

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

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

0 commit comments

Comments
 (0)