[SDK] Add x402 payment protocol utilities (#8076)

This commit is contained in:
Joaquim Verges
2025-09-19 05:00:05 -07:00
committed by GitHub
parent fddb791f0a
commit 5967fb8afa
20 changed files with 1971 additions and 166 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"thirdweb": minor
---
x402 utilities
+1
View File
@@ -48,6 +48,7 @@
"thirdweb": "workspace:*",
"use-debounce": "^10.0.5",
"use-stick-to-bottom": "^1.1.1",
"x402-next": "^0.6.1",
"zod": "3.25.75"
},
"devDependencies": {
@@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
// Allow streaming responses up to 5 minutes
export const maxDuration = 300;
export async function GET(_req: Request) {
return NextResponse.json({
success: true,
message: "Congratulations! You have accessed the protected route.",
});
}
+4
View File
@@ -206,6 +206,10 @@ const payments: ShadcnSidebarLink = {
href: "/payments/transactions",
label: "Onchain Transaction",
},
{
href: "/payments/x402",
label: "x402",
},
],
};
@@ -1,19 +0,0 @@
import { OverviewPage } from "@/components/blocks/OverviewPage";
import { PayIcon } from "../../icons/PayIcon";
import { paymentsFeatureCards } from "../data/pages-metadata";
export default function Page() {
return (
<OverviewPage
icon={PayIcon}
title="Payments"
description={
<>
Allow developers and users to receive and spend any token on any EVM
chain
</>
}
featureCards={paymentsFeatureCards}
/>
);
}
@@ -0,0 +1,102 @@
"use client";
import { useMutation } from "@tanstack/react-query";
import { CodeClient } from "@workspace/ui/components/code/code.client";
import { CodeIcon, LockIcon } from "lucide-react";
import { baseSepolia } from "thirdweb/chains";
import {
ConnectButton,
getDefaultToken,
useActiveAccount,
useActiveWallet,
} from "thirdweb/react";
import { wrapFetchWithPayment } from "thirdweb/x402";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { THIRDWEB_CLIENT } from "../../../../lib/client";
const chain = baseSepolia;
const token = getDefaultToken(chain, "USDC");
export function X402ClientPreview() {
const activeWallet = useActiveWallet();
const activeAccount = useActiveAccount();
const paidApiCall = useMutation({
mutationFn: async () => {
if (!activeWallet) {
throw new Error("No active wallet");
}
const fetchWithPay = wrapFetchWithPayment(
fetch,
THIRDWEB_CLIENT,
activeWallet,
);
const response = await fetchWithPay("/api/paywall");
return response.json();
},
});
const handlePayClick = async () => {
paidApiCall.mutate();
};
return (
<div className="flex flex-col gap-4 w-full p-4 md:p-12 max-w-lg mx-auto">
<ConnectButton
client={THIRDWEB_CLIENT}
chain={chain}
detailsButton={{
displayBalanceToken: {
[chain.id]: token!.address,
},
}}
supportedTokens={{
[chain.id]: [token!],
}}
/>
<Card className="p-6">
<div className="flex items-center gap-3 mb-4">
<LockIcon className="w-5 h-5 text-muted-foreground" />
<span className="text-lg font-medium">Paid API Call</span>
<span className="text-xl font-bold text-red-600">$0.01</span>
</div>
<Button
onClick={handlePayClick}
className="w-full mb-4"
size="lg"
disabled={paidApiCall.isPending || !activeAccount}
>
Pay Now
</Button>
<p className="text-sm text-muted-foreground">
{" "}
<a
className="underline"
href={"https://faucet.circle.com/"}
target="_blank"
rel="noopener noreferrer"
>
Click here to get USDC on {chain.name}
</a>
</p>
</Card>
<Card className="p-6">
<div className="flex items-center gap-3 mb-2">
<CodeIcon className="w-5 h-5 text-muted-foreground" />
<span className="text-lg font-medium">API Call Response</span>
</div>
{paidApiCall.isPending && <div className="text-center">Loading...</div>}
{paidApiCall.isError && (
<div className="text-center">Error: {paidApiCall.error.message}</div>
)}
{paidApiCall.data && (
<CodeClient
code={JSON.stringify(paidApiCall.data, null, 2)}
lang="json"
/>
)}
</Card>
</div>
);
}
@@ -0,0 +1,127 @@
import { CodeServer } from "@workspace/ui/components/code/code.server";
import { CircleDollarSignIcon, Code2Icon } from "lucide-react";
import { CodeExample, TabName } from "@/components/code/code-example";
import ThirdwebProvider from "@/components/thirdweb-provider";
import { PageLayout } from "../../../components/blocks/APIHeader";
import { createMetadata } from "../../../lib/metadata";
import { X402ClientPreview } from "./components/x402-client-preview";
const title = "x402 Payments";
const description =
"Use the x402 payment protocol to pay for API calls using any web3 wallet.";
const ogDescription =
"Use the x402 payment protocol to pay for API calls using any web3 wallet.";
export const metadata = createMetadata({
title,
description: ogDescription,
image: {
icon: "payments",
title,
},
});
export default function Page() {
return (
<ThirdwebProvider>
<PageLayout
icon={CircleDollarSignIcon}
title={title}
description={description}
docsLink="https://portal.thirdweb.com/payments/x402?utm_source=playground"
>
<X402Example />
<div className="h-8" />
<ServerCodeExample />
</PageLayout>
</ThirdwebProvider>
);
}
function ServerCodeExample() {
return (
<>
<div className="mb-4">
<h2 className="font-semibold text-xl tracking-tight">
Next.js Server Code Example
</h2>
<p className="max-w-4xl text-muted-foreground text-balance text-sm md:text-base">
Use any x402 middleware + the thirdweb facilitator to settle
transactions with our server wallet.
</p>
</div>
<div className="overflow-hidden rounded-lg border bg-card">
<div className="flex grow flex-col border-b md:border-r md:border-b-0">
<TabName icon={Code2Icon} name="Server Code" />
<CodeServer
className="h-full rounded-none border-none"
code={`// src/middleware.ts
import { facilitator } from "thirdweb/x402";
import { createThirdwebClient } from "thirdweb";
import { paymentMiddleware } from "x402-next";
const client = createThirdwebClient({ secretKey: "your-secret-key" });
export const middleware = paymentMiddleware(
"0xYourWalletAddress",
{
"/api/paid-endpoint": {
price: "$0.01",
network: "base-sepolia",
config: {
description: "Access to paid content",
},
},
},
facilitator({
client,
serverWalletAddress: "0xYourServerWalletAddress",
}),
);
// Configure which paths the middleware should run on
export const config = {
matcher: ["/api/paid-endpoint"],
};
`}
lang="tsx"
/>
</div>
</div>
</>
);
}
function X402Example() {
return (
<CodeExample
header={{
title: "Client Code Example",
description:
"Wrap your fetch requests with the `wrapFetchWithPayment` function to enable x402 payments.",
}}
code={`import { createThirdwebClient } from "thirdweb";
import { wrapFetchWithPayment } from "thirdweb/x402";
import { useActiveWallet } from "thirdweb/react";
const client = createThirdwebClient({ clientId: "your-client-id" });
export default function Page() {
const wallet = useActiveWallet();
const onClick = async () => {
const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
const response = await fetchWithPay('/api/paid-endpoint');
}
return (
<Button onClick={onClick}>Pay Now</Button>
);
}`}
lang="tsx"
preview={<X402ClientPreview />}
/>
);
}
+36
View File
@@ -0,0 +1,36 @@
import { createThirdwebClient } from "thirdweb";
import { facilitator } from "thirdweb/x402";
import { paymentMiddleware } from "x402-next";
const client = createThirdwebClient({
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
});
const BACKEND_WALLET_ADDRESS = process.env.ENGINE_BACKEND_WALLET as string;
const ENGINE_VAULT_ACCESS_TOKEN = process.env
.ENGINE_VAULT_ACCESS_TOKEN as string;
const API_URL = `https://${process.env.NEXT_PUBLIC_API_URL || "api.thirdweb.com"}`;
export const middleware = paymentMiddleware(
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
{
"/api/paywall": {
price: "$0.01",
network: "base-sepolia",
config: {
description: "Access to paid content",
},
},
},
facilitator({
baseUrl: `${API_URL}/v1/payments/x402`,
client,
serverWalletAddress: BACKEND_WALLET_ADDRESS,
vaultAccessToken: ENGINE_VAULT_ACCESS_TOKEN,
}),
);
// Configure which paths the middleware should run on
export const config = {
matcher: ["/api/paywall"],
};
+4
View File
@@ -61,6 +61,10 @@ export const sidebar: SideBar = {
href: `${paymentsSlug}/custom-data`,
name: "Custom Data",
},
{
href: `${paymentsSlug}/x402`,
name: "x402",
},
],
name: "Guides",
},
@@ -0,0 +1,80 @@
import { ArticleIconCard } from "@doc";
import { ReactIcon } from "@/icons";
# x402 payments
Implement paid API calls using the x402 protocol. Every request is paid for by the user with a micro payment onchain.
<ArticleIconCard
title="x402 Playground"
description="Try out a x402 payment in our live playground"
icon={ReactIcon}
href="https://playground.thirdweb.com/payments/x402"
/>
## Client Side
`wrapFetchWithPayment` wraps the native fetch API to automatically handle `402 Payment Required` responses from any API call. It will:
1. Make the initial request
2. If a 402 response is received, parse the payment requirements
3. Verify the payment amount is within the allowed maximum
4. Sign a payment authorization
5. Create a payment header using the provided wallet signature
6. Retry the request with the payment header
Here's an example:
```typescript
import { wrapFetchWithPayment } from "thirdweb/x402";
import { createThirdwebClient } from "thirdweb";
import { createWallet } from "thirdweb/wallets";
const client = createThirdwebClient({ clientId: "your-client-id" });
const wallet = createWallet("io.metamask"); // or any other wallet
await wallet.connect({ client })
const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
// Make a request that may require payment
const response = await fetchWithPay('https://api.example.com/paid-endpoint');
```
## Server Side
To make your API calls payable, you can use any x402 middleware library like x402-hono, x402-next, x402-express, etc.
Then, use the `facilitator` configuratino function settle transactions with your thirdweb server wallet gaslessly and pass it to the middleware.
Here's an example with Next.js:
```typescript
import { createThirdwebClient } from "thirdweb";
import { facilitator } from "thirdweb/x402";
import { paymentMiddleware } from "x402-next";
const client = createThirdwebClient({
secretKey: process.env.THIRDWEB_SECRET_KEY as string,
});
export const middleware = paymentMiddleware(
"0xdd99b75f095d0c4d5112aCe938e4e6ed962fb024",
{
"/api/paid-endpoint": {
price: "$0.01",
network: "base-sepolia",
config: {
description: "Access to paid content",
},
},
},
facilitator({
client,
serverWalletAddress: "0x1234567890123456789012345678901234567890",
}),
);
// Configure which paths the middleware should run on
export const config = {
matcher: ["/api/paid-endpoint"],
};
```
+9
View File
@@ -40,6 +40,7 @@
"toml": "3.0.0",
"uqr": "0.1.2",
"viem": "2.33.2",
"x402": "0.6.1",
"zod": "3.25.75"
},
"devDependencies": {
@@ -226,6 +227,11 @@
"react-native": "./dist/esm/exports/wallets/in-app.native.js",
"import": "./dist/esm/exports/wallets/in-app.js",
"default": "./dist/cjs/exports/wallets/in-app.js"
},
"./x402": {
"types": "./dist/types/exports/x402.d.ts",
"import": "./dist/esm/exports/x402.js",
"default": "./dist/cjs/exports/x402.js"
}
},
"files": [
@@ -414,6 +420,9 @@
],
"insight": [
"./dist/types/exports/insight.d.ts"
],
"x402": [
"./dist/types/exports/x402.d.ts"
]
}
},
+5
View File
@@ -0,0 +1,5 @@
export {
facilitator,
type ThirdwebX402FacilitatorConfig,
} from "../x402/facilitator.js";
export { wrapFetchWithPayment } from "../x402/fetchWithPayment.js";
@@ -322,7 +322,7 @@ type UIOptionsResult =
*
* Refer to the [`BuyWidgetConnectOptions`](https://portal.thirdweb.com/references/typescript/v5/BuyWidgetConnectOptions) type for more details.
*
* @bridge Widgets
* @bridge
*/
export function BuyWidget(props: BuyWidgetProps) {
const localeQuery = useConnectLocale(props.locale || "en_US");
@@ -318,7 +318,7 @@ type UIOptionsResult =
*
* Refer to the [`CheckoutWidgetConnectOptions`](https://portal.thirdweb.com/references/typescript/v5/CheckoutWidgetConnectOptions) type for more details.
*
* @bridge Widgets
* @bridge
*/
export function CheckoutWidget(props: CheckoutWidgetProps) {
const localeQuery = useConnectLocale(props.locale || "en_US");
@@ -326,7 +326,7 @@ type UIOptionsResult =
*
* Refer to the [`TransactionWidgetConnectOptions`](https://portal.thirdweb.com/references/typescript/v5/TransactionWidgetConnectOptions) type for more details.
*
* @bridge Widgets
* @bridge
*/
export function TransactionWidget(props: TransactionWidgetProps) {
const localeQuery = useConnectLocale(props.locale || "en_US");
@@ -234,6 +234,7 @@ export type SwapWidgetProps = {
* }} />
* ```
*
* @bridge
*/
export function SwapWidget(props: SwapWidgetProps) {
return (
+87
View File
@@ -0,0 +1,87 @@
import type { FacilitatorConfig } from "x402/types";
import type { ThirdwebClient } from "../client/client.js";
export type ThirdwebX402FacilitatorConfig = {
client: ThirdwebClient;
serverWalletAddress: string;
vaultAccessToken?: string;
baseUrl?: string;
};
const DEFAULT_BASE_URL = "https://api.thirdweb.com/v1/payments/x402";
/**
* Creates a facilitator for the x402 payment protocol.
* Use this with any x402 middleware to enable settling transactions with your thirdweb server wallet.
*
* @param config - The configuration for the facilitator
* @returns a x402 compatible FacilitatorConfig
*
* @example
* ```ts
* import { facilitator } from "thirdweb/x402";
* import { createThirdwebClient } from "thirdweb";
*
* const client = createThirdwebClient({
* secretKey: "your-secret-key",
* });
* const thirdwebX402Facilitator = facilitator({
* client: client,
* serverWalletAddress: "0x1234567890123456789012345678901234567890",
* });
*
* // add the facilitator to any x402 payment middleware
* const middleware = paymentMiddleware(
* "0x1234567890123456789012345678901234567890",
* {
* "/api/paywall": {
* price: "$0.01",
* network: "base-sepolia",
* config: {
* description: "Access to paid content",
* },
* },
* },
* thirdwebX402Facilitator,
* );
* ```
*
* @bridge x402
*/
export function facilitator(
config: ThirdwebX402FacilitatorConfig,
): FacilitatorConfig {
const secretKey = config.client.secretKey;
if (!secretKey) {
throw new Error("Client secret key is required for the x402 facilitator");
}
const serverWalletAddress = config.serverWalletAddress;
if (!serverWalletAddress) {
throw new Error(
"Server wallet address is required for the x402 facilitator",
);
}
return {
url: (config.baseUrl ?? DEFAULT_BASE_URL) as `${string}://${string}`,
createAuthHeaders: async () => {
return {
verify: {
"x-secret-key": secretKey,
},
settle: {
"x-secret-key": secretKey,
"x-settlement-wallet-address": serverWalletAddress,
...(config.vaultAccessToken
? { "x-vault-access-token": config.vaultAccessToken }
: {}),
},
supported: {
"x-secret-key": secretKey,
},
list: {
"x-secret-key": secretKey,
},
};
},
};
}
@@ -0,0 +1,173 @@
import { createPaymentHeader } from "x402/client";
import {
ChainIdToNetwork,
EvmNetworkToChainId,
type PaymentRequirements,
PaymentRequirementsSchema,
type Signer,
} from "x402/types";
import { viemAdapter } from "../adapters/viem.js";
import { getCachedChain } from "../chains/utils.js";
import type { ThirdwebClient } from "../client/client.js";
import type { Wallet } from "../wallets/interfaces/wallet.js";
/**
* Enables the payment of APIs using the x402 payment protocol.
*
* This function wraps the native fetch API to automatically handle 402 Payment Required responses
* by creating and sending a payment header. It will:
* 1. Make the initial request
* 2. If a 402 response is received, parse the payment requirements
* 3. Verify the payment amount is within the allowed maximum
* 4. Create a payment header using the provided wallet client
* 5. Retry the request with the payment header
*
* @param fetch - The fetch function to wrap (typically globalThis.fetch)
* @param client - The thirdweb client used to access RPC infrastructure
* @param wallet - The wallet used to sign payment messages
* @param maxValue - The maximum allowed payment amount in base units (defaults to 1 USDC)
* @returns A wrapped fetch function that handles 402 responses automatically
*
* @example
* ```typescript
* import { wrapFetchWithPayment } from "thirdweb/x402";
* import { createThirdwebClient } from "thirdweb";
* import { createWallet } from "thirdweb/wallets";
*
* const client = createThirdwebClient({ clientId: "your-client-id" });
* const wallet = createWallet("io.metamask");
* await wallet.connect({ client })
*
* const fetchWithPay = wrapFetchWithPayment(fetch, client, wallet);
*
* // Make a request that may require payment
* const response = await fetchWithPay('https://api.example.com/paid-endpoint');
* ```
*
* @throws {Error} If the payment amount exceeds the maximum allowed value
* @throws {Error} If a payment has already been attempted for this request
* @throws {Error} If there's an error creating the payment header
*
* @bridge x402
*/
export function wrapFetchWithPayment(
fetch: typeof globalThis.fetch,
client: ThirdwebClient,
wallet: Wallet,
maxValue: bigint = BigInt(1 * 10 ** 6), // Default to 1 USDC
) {
return async (input: RequestInfo, init?: RequestInit) => {
const response = await fetch(input, init);
if (response.status !== 402) {
return response;
}
const { x402Version, accepts } = (await response.json()) as {
x402Version: number;
accepts: unknown[];
};
const parsedPaymentRequirements = accepts
.map((x) => PaymentRequirementsSchema.parse(x))
.filter((x) => x.scheme === "exact"); // TODO (402): accept other schemes
const account = wallet.getAccount();
let chain = wallet.getChain();
if (!account || !chain) {
throw new Error(
"Wallet not connected. Please connect your wallet to continue.",
);
}
const selectedPaymentRequirements = defaultPaymentRequirementsSelector(
parsedPaymentRequirements,
chain.id,
"exact",
);
if (BigInt(selectedPaymentRequirements.maxAmountRequired) > maxValue) {
throw new Error("Payment amount exceeds maximum allowed");
}
const paymentChainId = EvmNetworkToChainId.get(
selectedPaymentRequirements.network,
);
if (!paymentChainId) {
throw new Error(
`No chain found for the selected payment requirement: ${selectedPaymentRequirements.network}`,
);
}
// switch to the payment chain if it's not the current chain
if (paymentChainId !== chain.id) {
await wallet.switchChain(getCachedChain(paymentChainId));
chain = wallet.getChain();
if (!chain) {
throw new Error(`Failed to switch chain (${paymentChainId})`);
}
}
const walletClient = viemAdapter.wallet.toViem({
wallet: wallet,
chain,
client,
}) as Signer;
const paymentHeader = await createPaymentHeader(
walletClient,
x402Version,
selectedPaymentRequirements,
);
const initParams = init || {};
if ((initParams as { __is402Retry?: boolean }).__is402Retry) {
throw new Error("Payment already attempted");
}
const newInit = {
...initParams,
headers: {
...(initParams.headers || {}),
"X-PAYMENT": paymentHeader,
"Access-Control-Expose-Headers": "X-PAYMENT-RESPONSE",
},
__is402Retry: true,
};
const secondResponse = await fetch(input, newInit);
return secondResponse;
};
}
function defaultPaymentRequirementsSelector(
paymentRequirements: PaymentRequirements[],
chainId: number,
scheme: "exact",
) {
if (!paymentRequirements.length) {
throw new Error(
"No valid payment requirements found in server 402 response",
);
}
const currentWalletNetwork = ChainIdToNetwork[chainId];
// find the payment requirements matching the connected wallet chain
const matchingPaymentRequirements = paymentRequirements.find(
(x) => x.network === currentWalletNetwork && x.scheme === scheme,
);
if (matchingPaymentRequirements) {
return matchingPaymentRequirements;
} else {
// if no matching payment requirements, use the first payment requirement
// and switch the wallet to that chain
const firstPaymentRequirement = paymentRequirements.find(
(x) => x.scheme === scheme,
);
if (!firstPaymentRequirement) {
throw new Error("No suitable payment requirements found");
}
return firstPaymentRequirement;
}
}
+1324 -144
View File
File diff suppressed because it is too large Load Diff